diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 463d71d..e2b4bbd 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -7,8 +7,10 @@ use Appwrite\Enums\Compression; use Appwrite\Enums\PasswordHash; use Appwrite\Enums\Runtime; +use Appwrite\Enums\SmtpEncryption; use Appwrite\InputFile; use Appwrite\Services\Functions; +use Appwrite\Services\Messaging; use Appwrite\Services\Storage; use Appwrite\Services\Teams; use Appwrite\Services\Users; @@ -42,6 +44,10 @@ use Utopia\Migration\Resources\Functions\Deployment; use Utopia\Migration\Resources\Functions\EnvVar; use Utopia\Migration\Resources\Functions\Func; +use Utopia\Migration\Resources\Messaging\Message; +use Utopia\Migration\Resources\Messaging\Provider; +use Utopia\Migration\Resources\Messaging\Subscriber; +use Utopia\Migration\Resources\Messaging\Topic; use Utopia\Migration\Resources\Storage\Bucket; use Utopia\Migration\Resources\Storage\File; use Utopia\Migration\Transfer; @@ -54,6 +60,7 @@ class Appwrite extends Destination protected string $key; private Functions $functions; + private Messaging $messaging; private Storage $storage; private Teams $teams; private Users $users; @@ -87,6 +94,7 @@ public function __construct( ->setKey($key); $this->functions = new Functions($this->client); + $this->messaging = new Messaging($this->client); $this->storage = new Storage($this->client); $this->teams = new Teams($this->client); $this->users = new Users($this->client); @@ -128,6 +136,12 @@ public static function getSupportedResources(): array Resource::TYPE_FUNCTION, Resource::TYPE_DEPLOYMENT, Resource::TYPE_ENVIRONMENT_VARIABLE, + + // Messaging + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, ]; } @@ -199,6 +213,39 @@ public function report(array $resources = [], array $resourceIds = []): array $this->functions->create('', '', Runtime::NODE180()); } + // Messaging + if (\in_array(Resource::TYPE_PROVIDER, $resources)) { + $scope = 'providers.read'; + $this->messaging->listProviders(); + + $scope = 'providers.write'; + $this->messaging->createSendgridProvider('', ''); + } + + if (\in_array(Resource::TYPE_TOPIC, $resources)) { + $scope = 'topics.read'; + $this->messaging->listTopics(); + + $scope = 'topics.write'; + $this->messaging->createTopic('', ''); + } + + if (\in_array(Resource::TYPE_SUBSCRIBER, $resources)) { + $scope = 'subscribers.read'; + $this->messaging->listSubscribers(''); + + $scope = 'subscribers.write'; + $this->messaging->createSubscriber('', '', ''); + } + + if (\in_array(Resource::TYPE_MESSAGE, $resources)) { + $scope = 'messages.read'; + $this->messaging->listMessages(); + + $scope = 'messages.write'; + $this->messaging->createEmail('', '', '', draft: true); + } + } catch (AppwriteException $e) { if ($e->getCode() === 403) { throw new \Exception('Missing scope: ' . $scope, previous: $e); @@ -236,6 +283,7 @@ protected function import(array $resources, callable $callback): void Transfer::GROUP_STORAGE => $this->importFileResource($resource), Transfer::GROUP_AUTH => $this->importAuthResource($resource), Transfer::GROUP_FUNCTIONS => $this->importFunctionResource($resource), + Transfer::GROUP_MESSAGING => $this->importMessagingResource($resource), default => throw new \Exception('Invalid resource group'), }; } catch (\Throwable $e) { @@ -1501,4 +1549,344 @@ private function importDeployment(Deployment $deployment): Resource return $deployment; } + + /** + * @throws AppwriteException + * @throws \Exception + */ + public function importMessagingResource(Resource $resource): Resource + { + switch ($resource->getName()) { + case Resource::TYPE_PROVIDER: + /** @var Provider $resource */ + $this->createProvider($resource); + break; + case Resource::TYPE_TOPIC: + /** @var Topic $resource */ + $this->messaging->createTopic( + $resource->getId(), + $resource->getTopicName(), + $resource->getSubscribe(), + ); + break; + case Resource::TYPE_SUBSCRIBER: + /** @var Subscriber $resource */ + $targetId = $this->resolveTargetId($resource); + $this->messaging->createSubscriber( + $resource->getTopicId(), + $resource->getId(), + $targetId, + ); + break; + case Resource::TYPE_MESSAGE: + /** @var Message $resource */ + $this->createMessage($resource); + break; + default: + throw new \Exception('Unknown messaging resource type: ' . $resource->getName()); + } + + $resource->setStatus(Resource::STATUS_SUCCESS); + + return $resource; + } + + /** + * @throws AppwriteException + * @throws \Exception + */ + protected function createProvider(Provider $resource): bool + { + $credentials = $resource->getCredentials(); + $options = $resource->getOptions(); + $id = $resource->getId(); + $name = $resource->getProviderName(); + $enabled = $resource->getEnabled(); + + match ($resource->getProvider()) { + 'mailgun' => $this->messaging->createMailgunProvider( + $id, + $name, + $credentials['apiKey'] ?? null, + $credentials['domain'] ?? null, + $credentials['isEuRegion'] ?? null, + ($options['fromName'] ?? '') ?: null, + ($options['fromEmail'] ?? '') ?: null, + ($options['replyToName'] ?? '') ?: null, + ($options['replyToEmail'] ?? '') ?: null, + $enabled, + ), + 'sendgrid' => $this->messaging->createSendgridProvider( + $id, + $name, + $credentials['apiKey'] ?? null, + ($options['fromName'] ?? '') ?: null, + ($options['fromEmail'] ?? '') ?: null, + ($options['replyToName'] ?? '') ?: null, + ($options['replyToEmail'] ?? '') ?: null, + $enabled, + ), + 'resend' => $this->messaging->createResendProvider( + $id, + $name, + $credentials['apiKey'] ?? null, + ($options['fromName'] ?? '') ?: null, + ($options['fromEmail'] ?? '') ?: null, + ($options['replyToName'] ?? '') ?: null, + ($options['replyToEmail'] ?? '') ?: null, + $enabled, + ), + 'smtp' => $this->messaging->createSMTPProvider( + $id, + $name, + $credentials['host'] ?? '', + $credentials['port'] ?? null, + ($credentials['username'] ?? '') ?: null, + ($credentials['password'] ?? '') ?: null, + match ($options['encryption'] ?? '') { + 'ssl' => SmtpEncryption::SSL(), + 'tls' => SmtpEncryption::TLS(), + default => SmtpEncryption::NONE(), + }, + $options['autoTLS'] ?? null, + ($options['mailer'] ?? '') ?: null, + ($options['fromName'] ?? '') ?: null, + ($options['fromEmail'] ?? '') ?: null, + ($options['replyToName'] ?? '') ?: null, + ($options['replyToEmail'] ?? '') ?: null, + $enabled, + ), + 'msg91' => $this->messaging->createMsg91Provider( + $id, + $name, + $credentials['templateId'] ?? null, + $credentials['senderId'] ?? null, + $credentials['authKey'] ?? null, + $enabled, + ), + 'telesign' => $this->messaging->createTelesignProvider( + $id, + $name, + ($options['from'] ?? '') ?: null, + $credentials['customerId'] ?? null, + $credentials['apiKey'] ?? null, + $enabled, + ), + 'textmagic' => $this->messaging->createTextmagicProvider( + $id, + $name, + ($options['from'] ?? '') ?: null, + $credentials['username'] ?? null, + $credentials['apiKey'] ?? null, + $enabled, + ), + 'twilio' => $this->messaging->createTwilioProvider( + $id, + $name, + ($options['from'] ?? '') ?: null, + $credentials['accountSid'] ?? null, + $credentials['authToken'] ?? null, + $enabled, + ), + 'vonage' => $this->messaging->createVonageProvider( + $id, + $name, + ($options['from'] ?? '') ?: null, + $credentials['apiKey'] ?? null, + $credentials['apiSecret'] ?? null, + $enabled, + ), + 'fcm' => $this->messaging->createFCMProvider( + $id, + $name, + $credentials['serviceAccountJSON'] ?? null, + $enabled, + ), + 'apns' => $this->messaging->createAPNSProvider( + $id, + $name, + $credentials['authKey'] ?? null, + $credentials['authKeyId'] ?? null, + $credentials['teamId'] ?? null, + $credentials['bundleId'] ?? null, + $options['sandbox'] ?? null, + $enabled, + ), + default => throw new \Exception('Unknown provider: ' . $resource->getProvider()), + }; + + return true; + } + + /** + * @throws AppwriteException + * @throws \Exception + */ + protected function createMessage(Message $resource): bool + { + $resolvedTargets = $this->resolveMessageTargets($resource); + $status = $resource->getMessageStatus(); + + // Use SDK for scheduled messages so the platform schedule document is created. + // Fall back to draft if scheduledAt is missing or in the past. + if ($status === 'scheduled') { + $scheduledAt = $resource->getScheduledAt(); + + if (!empty($scheduledAt) && new \DateTime($scheduledAt) > new \DateTime()) { + return $this->createScheduledMessage($resource, $resolvedTargets); + } + + $status = 'draft'; + } + + // Processing messages have no worker on the destination, import as draft. + if ($status === 'processing') { + $status = 'draft'; + } + + $createdAt = $this->normalizeDateTime($resource->getCreatedAt()); + $updatedAt = $this->normalizeDateTime($resource->getUpdatedAt(), $createdAt); + + $this->database->createDocument('messages', new UtopiaDocument([ + '$id' => $resource->getId(), + '$createdAt' => $createdAt, + '$updatedAt' => $updatedAt, + 'providerType' => $resource->getProviderType(), + 'topics' => $resource->getTopics(), + 'users' => $resource->getUsers(), + 'targets' => $resolvedTargets, + 'scheduledAt' => null, + 'deliveredAt' => $resource->getDeliveredAt() ?: null, + 'deliveryErrors' => $resource->getDeliveryErrors(), + 'deliveredTotal' => $resource->getDeliveredTotal(), + 'data' => $resource->getData(), + 'status' => $status, + ])); + + return true; + } + + /** + * Create a scheduled message via SDK so the platform schedule document is created. + * + * @param array $resolvedTargets + * @throws AppwriteException + * @throws \Exception + */ + protected function createScheduledMessage(Message $resource, array $resolvedTargets): bool + { + $data = $resource->getData(); + $topics = $resource->getTopics() ?: null; + $users = $resource->getUsers() ?: null; + $targets = $resolvedTargets ?: null; + $scheduledAt = $resource->getScheduledAt(); + + match ($resource->getProviderType()) { + 'email' => $this->messaging->createEmail( + $resource->getId(), + $data['subject'] ?? '', + $data['content'] ?? '', + $topics, + $users, + $targets, + $data['cc'] ?? null, + $data['bcc'] ?? null, + null, + false, + $data['html'] ?? null, + $scheduledAt, + ), + 'sms' => $this->messaging->createSMS( + $resource->getId(), + $data['content'] ?? '', + $topics, + $users, + $targets, + false, + $scheduledAt, + ), + 'push' => $this->messaging->createPush( + $resource->getId(), + $data['title'] ?? null, + $data['body'] ?? null, + $topics, + $users, + $targets, + $data['data'] ?? null, + $data['action'] ?? null, + $data['image'] ?? null, + $data['icon'] ?? null, + $data['sound'] ?? null, + $data['color'] ?? null, + $data['tag'] ?? null, + $data['badge'] ?? null, + false, + $scheduledAt, + $data['contentAvailable'] ?? null, + $data['critical'] ?? null, + null, + ), + default => throw new \Exception('Unknown provider type: ' . $resource->getProviderType()), + }; + + return true; + } + + /** + * Resolve source target IDs to destination target IDs for a message. + * + * @return array + */ + private function resolveMessageTargets(Message $resource): array + { + $targetUserMap = $resource->getTargetUserMap(); + $providerType = $resource->getProviderType(); + $resolvedTargets = []; + $targetCache = []; + + foreach ($resource->getTargets() as $sourceTargetId) { + $userId = $targetUserMap[$sourceTargetId] ?? null; + + if ($userId === null) { + continue; + } + + try { + if (!isset($targetCache[$userId])) { + $targetCache[$userId] = $this->users->listTargets($userId); + } + + foreach ($targetCache[$userId]['targets'] as $target) { + if ($target['providerType'] === $providerType) { + $resolvedTargets[] = $target['$id']; + break; + } + } + } catch (\Throwable $e) { + // Skip targets for users that don't exist on the destination + continue; + } + } + + return $resolvedTargets; + } + + /** + * Resolve the destination target ID for a subscriber. + * + * User targets are auto-generated on the destination with new IDs, + * so we look up the matching target by userId and providerType. + */ + private function resolveTargetId(Subscriber $resource): string + { + $response = $this->users->listTargets($resource->getUserId()); + + foreach ($response['targets'] as $target) { + if ($target['providerType'] === $resource->getProviderType()) { + return $target['$id']; + } + } + + throw new \Exception('No matching target found for subscriber ' . $resource->getId() . ' with providerType ' . $resource->getProviderType()); + } } diff --git a/src/Migration/Destinations/Local.php b/src/Migration/Destinations/Local.php index 4c17ab2..c05354e 100644 --- a/src/Migration/Destinations/Local.php +++ b/src/Migration/Destinations/Local.php @@ -70,6 +70,12 @@ public static function getSupportedResources(): array Resource::TYPE_FUNCTION, Resource::TYPE_DEPLOYMENT, Resource::TYPE_ENVIRONMENT_VARIABLE, + + // Messaging + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, ]; } diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index 9645cc6..b9ab500 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -54,6 +54,15 @@ abstract class Resource implements \JsonSerializable public const TYPE_ENVIRONMENT_VARIABLE = 'environment-variable'; + // Messaging + public const TYPE_PROVIDER = 'provider'; + + public const TYPE_TOPIC = 'topic'; + + public const TYPE_SUBSCRIBER = 'subscriber'; + + public const TYPE_MESSAGE = 'message'; + // legacy terminologies public const TYPE_DOCUMENT = 'document'; public const TYPE_ATTRIBUTE = 'attribute'; @@ -80,6 +89,10 @@ abstract class Resource implements \JsonSerializable self::TYPE_ENVIRONMENT_VARIABLE, self::TYPE_TEAM, self::TYPE_MEMBERSHIP, + self::TYPE_PROVIDER, + self::TYPE_TOPIC, + self::TYPE_SUBSCRIBER, + self::TYPE_MESSAGE, // legacy self::TYPE_DOCUMENT, diff --git a/src/Migration/Resources/Messaging/Message.php b/src/Migration/Resources/Messaging/Message.php new file mode 100644 index 0000000..f17bed2 --- /dev/null +++ b/src/Migration/Resources/Messaging/Message.php @@ -0,0 +1,172 @@ + $topics + * @param array $users + * @param array $targets + * @param array $data + * @param string $messageStatus + * @param string $scheduledAt + * @param string $deliveredAt + * @param array $deliveryErrors + * @param int $deliveredTotal + * @param array $targetUserMap Source target ID => source user ID mapping for ID resolution + */ + public function __construct( + string $id, + private readonly string $providerType, + private readonly array $topics = [], + private readonly array $users = [], + private readonly array $targets = [], + private readonly array $data = [], + private readonly string $messageStatus = '', + private readonly string $scheduledAt = '', + private readonly string $deliveredAt = '', + private readonly array $deliveryErrors = [], + private readonly int $deliveredTotal = 0, + private readonly array $targetUserMap = [], + protected string $createdAt = '', + protected string $updatedAt = '', + ) { + $this->id = $id; + } + + /** + * @param array $array + * @return self + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + $array['providerType'] ?? '', + $array['topics'] ?? [], + $array['users'] ?? [], + $array['targets'] ?? [], + $array['data'] ?? [], + $array['messageStatus'] ?? $array['status'] ?? '', + $array['scheduledAt'] ?? '', + $array['deliveredAt'] ?? '', + $array['deliveryErrors'] ?? [], + $array['deliveredTotal'] ?? 0, + $array['targetUserMap'] ?? [], + $array['createdAt'] ?? '', + $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'providerType' => $this->providerType, + 'topics' => $this->topics, + 'users' => $this->users, + 'targets' => $this->targets, + 'data' => $this->data, + 'messageStatus' => $this->messageStatus, + 'scheduledAt' => $this->scheduledAt, + 'deliveredAt' => $this->deliveredAt, + 'deliveryErrors' => $this->deliveryErrors, + 'deliveredTotal' => $this->deliveredTotal, + 'targetUserMap' => $this->targetUserMap, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_MESSAGE; + } + + public function getGroup(): string + { + return Transfer::GROUP_MESSAGING; + } + + public function getProviderType(): string + { + return $this->providerType; + } + + /** + * @return array + */ + public function getTopics(): array + { + return $this->topics; + } + + /** + * @return array + */ + public function getUsers(): array + { + return $this->users; + } + + /** + * @return array + */ + public function getTargets(): array + { + return $this->targets; + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } + + public function getMessageStatus(): string + { + return $this->messageStatus; + } + + public function getScheduledAt(): string + { + return $this->scheduledAt; + } + + public function getDeliveredAt(): string + { + return $this->deliveredAt; + } + + /** + * @return array + */ + public function getDeliveryErrors(): array + { + return $this->deliveryErrors; + } + + public function getDeliveredTotal(): int + { + return $this->deliveredTotal; + } + + /** + * @return array + */ + public function getTargetUserMap(): array + { + return $this->targetUserMap; + } +} diff --git a/src/Migration/Resources/Messaging/Provider.php b/src/Migration/Resources/Messaging/Provider.php new file mode 100644 index 0000000..da1af48 --- /dev/null +++ b/src/Migration/Resources/Messaging/Provider.php @@ -0,0 +1,115 @@ + $credentials + * @param array $options + */ + public function __construct( + string $id, + private readonly string $name, + private readonly string $provider, + private readonly string $type, + private readonly bool $enabled = true, + private readonly array $credentials = [], + private readonly array $options = [], + protected string $createdAt = '', + protected string $updatedAt = '', + ) { + $this->id = $id; + } + + /** + * @param array $array + * @return self + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + $array['name'] ?? '', + $array['provider'] ?? '', + $array['type'] ?? '', + $array['enabled'] ?? true, + $array['credentials'] ?? [], + $array['options'] ?? [], + $array['createdAt'] ?? '', + $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'provider' => $this->provider, + 'type' => $this->type, + 'enabled' => $this->enabled, + 'credentials' => $this->credentials, + 'options' => $this->options, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_PROVIDER; + } + + public function getGroup(): string + { + return Transfer::GROUP_MESSAGING; + } + + public function getProviderName(): string + { + return $this->name; + } + + public function getProvider(): string + { + return $this->provider; + } + + public function getType(): string + { + return $this->type; + } + + public function getEnabled(): bool + { + return $this->enabled; + } + + /** + * @return array + */ + public function getCredentials(): array + { + return $this->credentials; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/Migration/Resources/Messaging/Subscriber.php b/src/Migration/Resources/Messaging/Subscriber.php new file mode 100644 index 0000000..91106eb --- /dev/null +++ b/src/Migration/Resources/Messaging/Subscriber.php @@ -0,0 +1,100 @@ +id = $id; + } + + /** + * @param array $array + * @return self + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + $array['topicId'] ?? '', + $array['targetId'] ?? '', + $array['userId'] ?? '', + $array['userName'] ?? '', + $array['providerType'] ?? '', + $array['createdAt'] ?? '', + $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'topicId' => $this->topicId, + 'targetId' => $this->targetId, + 'userId' => $this->userId, + 'userName' => $this->userName, + 'providerType' => $this->providerType, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_SUBSCRIBER; + } + + public function getGroup(): string + { + return Transfer::GROUP_MESSAGING; + } + + public function getTopicId(): string + { + return $this->topicId; + } + + public function getTargetId(): string + { + return $this->targetId; + } + + public function getUserId(): string + { + return $this->userId; + } + + public function getUserName(): string + { + return $this->userName; + } + + public function getProviderType(): string + { + return $this->providerType; + } +} diff --git a/src/Migration/Resources/Messaging/Topic.php b/src/Migration/Resources/Messaging/Topic.php new file mode 100644 index 0000000..0bbfbe6 --- /dev/null +++ b/src/Migration/Resources/Messaging/Topic.php @@ -0,0 +1,76 @@ + $subscribe + */ + public function __construct( + string $id, + private readonly string $name, + private readonly array $subscribe = [], + protected string $createdAt = '', + protected string $updatedAt = '', + ) { + $this->id = $id; + } + + /** + * @param array $array + * @return self + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + $array['name'] ?? '', + $array['subscribe'] ?? [], + $array['createdAt'] ?? '', + $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'subscribe' => $this->subscribe, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getName(): string + { + return Resource::TYPE_TOPIC; + } + + public function getGroup(): string + { + return Transfer::GROUP_MESSAGING; + } + + public function getTopicName(): string + { + return $this->name; + } + + /** + * @return array + */ + public function getSubscribe(): array + { + return $this->subscribe; + } +} diff --git a/src/Migration/Source.php b/src/Migration/Source.php index fb4a146..a247c4b 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -36,6 +36,11 @@ public function getFunctionsBatchSize(): int return static::$defaultBatchSize; } + public function getMessagingBatchSize(): int + { + return static::$defaultBatchSize; + } + /** * @param array $resources * @return void @@ -89,6 +94,7 @@ public function exportResources(array $resources): void Transfer::GROUP_DATABASES => Transfer::GROUP_DATABASES_RESOURCES, Transfer::GROUP_STORAGE => Transfer::GROUP_STORAGE_RESOURCES, Transfer::GROUP_FUNCTIONS => Transfer::GROUP_FUNCTIONS_RESOURCES, + Transfer::GROUP_MESSAGING => Transfer::GROUP_MESSAGING_RESOURCES, ]; foreach ($mapping as $group => $resources) { @@ -117,6 +123,9 @@ public function exportResources(array $resources): void case Transfer::GROUP_FUNCTIONS: $this->exportGroupFunctions($this->getFunctionsBatchSize(), $resources); break; + case Transfer::GROUP_MESSAGING: + $this->exportGroupMessaging($this->getMessagingBatchSize(), $resources); + break; } } } @@ -152,4 +161,12 @@ abstract protected function exportGroupStorage(int $batchSize, array $resources) * @param array $resources Resources to export */ abstract protected function exportGroupFunctions(int $batchSize, array $resources): void; + + /** + * Export Messaging Group + * + * @param int $batchSize + * @param array $resources Resources to export + */ + abstract protected function exportGroupMessaging(int $batchSize, array $resources): void; } diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 3f4a004..57c7010 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -7,6 +7,7 @@ use Appwrite\Query; use Appwrite\Services\Databases; use Appwrite\Services\Functions; +use Appwrite\Services\Messaging; use Appwrite\Services\Storage; use Appwrite\Services\Teams; use Appwrite\Services\Users; @@ -39,6 +40,10 @@ use Utopia\Migration\Resources\Functions\Deployment; use Utopia\Migration\Resources\Functions\EnvVar; use Utopia\Migration\Resources\Functions\Func; +use Utopia\Migration\Resources\Messaging\Message; +use Utopia\Migration\Resources\Messaging\Provider; +use Utopia\Migration\Resources\Messaging\Subscriber; +use Utopia\Migration\Resources\Messaging\Topic; use Utopia\Migration\Resources\Storage\Bucket; use Utopia\Migration\Resources\Storage\File; use Utopia\Migration\Source; @@ -64,6 +69,8 @@ class Appwrite extends Source private Functions $functions; + private Messaging $messaging; + private Reader $database; /** @@ -86,6 +93,7 @@ public function __construct( $this->teams = new Teams($this->client); $this->storage = new Storage($this->client); $this->functions = new Functions($this->client); + $this->messaging = new Messaging($this->client); $this->headers['x-appwrite-project'] = $this->project; $this->headers['x-appwrite-key'] = $this->key; @@ -142,6 +150,12 @@ public static function getSupportedResources(): array Resource::TYPE_DEPLOYMENT, Resource::TYPE_ENVIRONMENT_VARIABLE, + // Messaging + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, + // Settings ]; } @@ -179,6 +193,7 @@ public function report(array $resources = [], array $resourceIds = []): array $this->reportDatabases($resources, $report, $resourceIds); $this->reportStorage($resources, $report, $resourceIds); $this->reportFunctions($resources, $report, $resourceIds); + $this->reportMessaging($resources, $report, $resourceIds); $report['version'] = $this->call( 'GET', @@ -1614,6 +1629,366 @@ private function exportDeploymentData(Func $func, array $deployment): void } } + /** + * @param array $resources + * @param array $report + * @param array> $resourceIds + */ + private function reportMessaging(array $resources, array &$report, array $resourceIds = []): void + { + if (\in_array(Resource::TYPE_PROVIDER, $resources)) { + $providerQueries = $this->buildQueries( + resourceType: Resource::TYPE_PROVIDER, + resourceIds: $resourceIds, + limit: 1 + ); + $report[Resource::TYPE_PROVIDER] = $this->messaging->listProviders($providerQueries)['total']; + } + + if (\in_array(Resource::TYPE_TOPIC, $resources)) { + $topicQueries = $this->buildQueries( + resourceType: Resource::TYPE_TOPIC, + resourceIds: $resourceIds, + limit: 1 + ); + $report[Resource::TYPE_TOPIC] = $this->messaging->listTopics($topicQueries)['total']; + } + + if (\in_array(Resource::TYPE_SUBSCRIBER, $resources)) { + $subscriberTotal = 0; + $lastTopic = null; + + while (true) { + $topicQueries = [Query::limit(self::DEFAULT_PAGE_LIMIT)]; + if ($lastTopic) { + $topicQueries[] = Query::cursorAfter($lastTopic); + } + + $topicResponse = $this->messaging->listTopics($topicQueries); + if ($topicResponse['total'] == 0 || empty($topicResponse['topics'])) { + break; + } + + foreach ($topicResponse['topics'] as $topic) { + $subscriberTotal += $this->messaging->listSubscribers($topic['$id'], [Query::limit(1)])['total']; + $lastTopic = $topic['$id']; + } + + if (\count($topicResponse['topics']) < self::DEFAULT_PAGE_LIMIT) { + break; + } + } + + $report[Resource::TYPE_SUBSCRIBER] = $subscriberTotal; + } + + if (\in_array(Resource::TYPE_MESSAGE, $resources)) { + $messageQueries = $this->buildQueries( + resourceType: Resource::TYPE_MESSAGE, + resourceIds: $resourceIds, + limit: 1 + ); + $report[Resource::TYPE_MESSAGE] = $this->messaging->listMessages($messageQueries)['total']; + } + } + + protected function exportGroupMessaging(int $batchSize, array $resources): void + { + try { + if (\in_array(Resource::TYPE_PROVIDER, $resources)) { + $this->exportProviders($batchSize); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_PROVIDER, + Transfer::GROUP_MESSAGING, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + + try { + if (\in_array(Resource::TYPE_TOPIC, $resources)) { + $this->exportTopics($batchSize); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_TOPIC, + Transfer::GROUP_MESSAGING, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + + try { + if (\in_array(Resource::TYPE_SUBSCRIBER, $resources)) { + $this->exportSubscribers($batchSize); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_SUBSCRIBER, + Transfer::GROUP_MESSAGING, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + + try { + if (\in_array(Resource::TYPE_MESSAGE, $resources)) { + $this->exportMessages($batchSize); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_MESSAGE, + Transfer::GROUP_MESSAGING, + message: $e->getMessage(), + code: $e->getCode(), + previous: $e + )); + } + } + + private function exportProviders(int $batchSize): void + { + $lastDocument = null; + + while (true) { + $providers = []; + + $queries = [Query::limit($batchSize)]; + + if ($this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_PROVIDER) { + $queries[] = Query::equal('$id', $this->rootResourceId); + $queries[] = Query::limit(1); + } + + if ($lastDocument) { + $queries[] = Query::cursorAfter($lastDocument); + } + + $response = $this->messaging->listProviders($queries); + + if ($response['total'] == 0) { + break; + } + + foreach ($response['providers'] as $provider) { + $providers[] = new Provider( + $provider['$id'], + $provider['name'], + $provider['provider'], + $provider['type'], + $provider['enabled'], + $provider['credentials'] ?? [], + $provider['options'] ?? [], + $provider['$createdAt'] ?? '', + $provider['$updatedAt'] ?? '', + ); + + $lastDocument = $provider['$id']; + } + + $this->callback($providers); + + if (\count($providers) < $batchSize) { + break; + } + } + } + + private function exportTopics(int $batchSize): void + { + $lastDocument = null; + + while (true) { + $topics = []; + + $queries = [Query::limit($batchSize)]; + + if ($this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_TOPIC) { + $queries[] = Query::equal('$id', $this->rootResourceId); + $queries[] = Query::limit(1); + } + + if ($lastDocument) { + $queries[] = Query::cursorAfter($lastDocument); + } + + $response = $this->messaging->listTopics($queries); + + if ($response['total'] == 0) { + break; + } + + foreach ($response['topics'] as $topic) { + $topics[] = new Topic( + $topic['$id'], + $topic['name'], + $topic['subscribe'] ?? [], + $topic['$createdAt'] ?? '', + $topic['$updatedAt'] ?? '', + ); + + $lastDocument = $topic['$id']; + } + + $this->callback($topics); + + if (\count($topics) < $batchSize) { + break; + } + } + } + + private function exportSubscribers(int $batchSize): void + { + $topics = $this->cache->get(Topic::getName()); + + foreach ($topics as $topic) { + /** @var Topic $topic */ + $lastDocument = null; + + while (true) { + $subscribers = []; + + $queries = [Query::limit($batchSize)]; + + if ($lastDocument) { + $queries[] = Query::cursorAfter($lastDocument); + } + + $response = $this->messaging->listSubscribers($topic->getId(), $queries); + + if ($response['total'] == 0) { + break; + } + + foreach ($response['subscribers'] as $subscriber) { + $subscribers[] = new Subscriber( + $subscriber['$id'], + $subscriber['topicId'], + $subscriber['targetId'], + $subscriber['userId'] ?? '', + $subscriber['userName'] ?? '', + $subscriber['providerType'] ?? '', + $subscriber['$createdAt'] ?? '', + $subscriber['$updatedAt'] ?? '', + ); + + $lastDocument = $subscriber['$id']; + } + + $this->callback($subscribers); + + if (\count($subscribers) < $batchSize) { + break; + } + } + } + } + + private function exportMessages(int $batchSize): void + { + $targetUserMap = $this->buildTargetUserMap(); + $lastDocument = null; + + while (true) { + $messages = []; + + $queries = [Query::limit($batchSize)]; + + if ($this->rootResourceId !== '' && $this->rootResourceType === Resource::TYPE_MESSAGE) { + $queries[] = Query::equal('$id', $this->rootResourceId); + $queries[] = Query::limit(1); + } + + if ($lastDocument) { + $queries[] = Query::cursorAfter($lastDocument); + } + + $response = $this->messaging->listMessages($queries); + + if ($response['total'] == 0) { + break; + } + + foreach ($response['messages'] as $message) { + $messageTargetMap = []; + foreach ($message['targets'] ?? [] as $targetId) { + if (isset($targetUserMap[$targetId])) { + $messageTargetMap[$targetId] = $targetUserMap[$targetId]; + } + } + + $messages[] = new Message( + $message['$id'], + $message['providerType'] ?? '', + $message['topics'] ?? [], + $message['users'] ?? [], + $message['targets'] ?? [], + $message['data'] ?? [], + $message['status'] ?? '', + $message['scheduledAt'] ?? '', + $message['deliveredAt'] ?? '', + $message['deliveryErrors'] ?? [], + $message['deliveredTotal'] ?? 0, + $messageTargetMap, + $message['$createdAt'] ?? '', + $message['$updatedAt'] ?? '', + ); + + $lastDocument = $message['$id']; + } + + $this->callback($messages); + + if (\count($messages) < $batchSize) { + break; + } + } + } + + /** + * Build a map of source target ID => source user ID + * by iterating cached users and listing their targets. + * + * @return array + */ + private function buildTargetUserMap(): array + { + $map = []; + $users = $this->cache->get(User::getName()); + + foreach ($users as $user) { + /** @var User $user */ + $lastTarget = null; + + while (true) { + $queries = [Query::limit(self::DEFAULT_PAGE_LIMIT)]; + + if ($lastTarget !== null) { + $queries[] = Query::cursorAfter($lastTarget); + } + + $response = $this->users->listTargets($user->getId(), $queries); + + foreach ($response['targets'] as $target) { + $map[$target['$id']] = $user->getId(); + $lastTarget = $target['$id']; + } + + if (\count($response['targets']) < self::DEFAULT_PAGE_LIMIT) { + break; + } + } + } + + return $map; + } + /** * Build queries with optional filtering by resource IDs */ diff --git a/src/Migration/Sources/CSV.php b/src/Migration/Sources/CSV.php index b82e289..d919928 100644 --- a/src/Migration/Sources/CSV.php +++ b/src/Migration/Sources/CSV.php @@ -372,6 +372,14 @@ protected function exportGroupFunctions(int $batchSize, array $resources): void throw new \Exception('Not Implemented'); } + /** + * @throws \Exception + */ + protected function exportGroupMessaging(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } + /** * @param callable(resource $stream, string $delimiter): void $callback * @return void diff --git a/src/Migration/Sources/Firebase.php b/src/Migration/Sources/Firebase.php index 12117d6..05dcccd 100644 --- a/src/Migration/Sources/Firebase.php +++ b/src/Migration/Sources/Firebase.php @@ -808,4 +808,9 @@ protected function exportGroupFunctions(int $batchSize, array $resources): void { throw new \Exception('Not implemented'); } + + protected function exportGroupMessaging(int $batchSize, array $resources): void + { + throw new \Exception('Not implemented'); + } } diff --git a/src/Migration/Sources/JSON.php b/src/Migration/Sources/JSON.php index 779e267..51e24a7 100644 --- a/src/Migration/Sources/JSON.php +++ b/src/Migration/Sources/JSON.php @@ -201,6 +201,14 @@ protected function exportGroupFunctions(int $batchSize, array $resources): void throw new \Exception('Not Implemented'); } + /** + * @throws \Exception + */ + protected function exportGroupMessaging(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } + /** * @param callable(Items): void $callback * @throws \Exception|JsonMachineException diff --git a/src/Migration/Sources/NHost.php b/src/Migration/Sources/NHost.php index f65e700..2f2851d 100644 --- a/src/Migration/Sources/NHost.php +++ b/src/Migration/Sources/NHost.php @@ -848,4 +848,9 @@ protected function exportGroupFunctions(int $batchSize, array $resources): void { throw new \Exception('Not Implemented'); } + + protected function exportGroupMessaging(int $batchSize, array $resources): void + { + throw new \Exception('Not Implemented'); + } } diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 1633092..4a77d38 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -16,6 +16,8 @@ class Transfer public const GROUP_SETTINGS = 'settings'; + public const GROUP_MESSAGING = 'messaging'; + public const GROUP_AUTH_RESOURCES = [ Resource::TYPE_USER, Resource::TYPE_TEAM, @@ -44,6 +46,13 @@ class Transfer public const GROUP_SETTINGS_RESOURCES = []; + public const GROUP_MESSAGING_RESOURCES = [ + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, + ]; + public const ALL_PUBLIC_RESOURCES = [ Resource::TYPE_USER, Resource::TYPE_TEAM, @@ -58,6 +67,10 @@ class Transfer Resource::TYPE_INDEX, Resource::TYPE_COLUMN, Resource::TYPE_ROW, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, // legacy Resource::TYPE_DOCUMENT, @@ -71,6 +84,9 @@ class Transfer Resource::TYPE_FUNCTION, Resource::TYPE_USER, Resource::TYPE_TEAM, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_MESSAGE, ]; public const STORAGE_MAX_CHUNK_SIZE = 1024 * 1024 * 5; // 5MB @@ -330,6 +346,7 @@ public static function extractServices(array $services): array self::GROUP_AUTH => array_merge($resources, self::GROUP_AUTH_RESOURCES), self::GROUP_DATABASES => array_merge($resources, self::GROUP_DATABASES_RESOURCES), self::GROUP_SETTINGS => array_merge($resources, self::GROUP_SETTINGS_RESOURCES), + self::GROUP_MESSAGING => array_merge($resources, self::GROUP_MESSAGING_RESOURCES), default => throw new \Exception('No service group found'), }; } diff --git a/tests/Migration/Unit/Adapters/MockDestination.php b/tests/Migration/Unit/Adapters/MockDestination.php index 7c9806c..b2d2169 100644 --- a/tests/Migration/Unit/Adapters/MockDestination.php +++ b/tests/Migration/Unit/Adapters/MockDestination.php @@ -48,6 +48,10 @@ public static function getSupportedResources(): array Resource::TYPE_ENVIRONMENT_VARIABLE, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, ]; } diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index 41d352e..c643983 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -77,6 +77,10 @@ public static function getSupportedResources(): array Resource::TYPE_ENVIRONMENT_VARIABLE, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, + Resource::TYPE_PROVIDER, + Resource::TYPE_TOPIC, + Resource::TYPE_SUBSCRIBER, + Resource::TYPE_MESSAGE, // legacy Resource::TYPE_DOCUMENT, @@ -157,4 +161,15 @@ protected function exportGroupFunctions(int $batchSize, array $resources): void $this->handleResourceTransfer(Transfer::GROUP_FUNCTIONS, $resource); } } + + protected function exportGroupMessaging(int $batchSize, array $resources): void + { + foreach (Transfer::GROUP_MESSAGING_RESOURCES as $resource) { + if (!\in_array($resource, $resources)) { + continue; + } + + $this->handleResourceTransfer(Transfer::GROUP_MESSAGING, $resource); + } + } }