From f038da8678766795d55fbcc5d3ae5d3c1a438e3c Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 6 Feb 2026 11:20:27 +0000 Subject: [PATCH 1/6] add message resource type to messaging migration --- src/Migration/Destinations/Appwrite.php | 286 ++++++++++++++ src/Migration/Destinations/Local.php | 6 + src/Migration/Resource.php | 13 + src/Migration/Resources/Messaging/Message.php | 130 +++++++ .../Resources/Messaging/Provider.php | 115 ++++++ .../Resources/Messaging/Subscriber.php | 100 +++++ src/Migration/Resources/Messaging/Topic.php | 76 ++++ src/Migration/Source.php | 19 + src/Migration/Sources/Appwrite.php | 325 ++++++++++++++++ src/Migration/Transfer.php | 17 + .../Unit/Adapters/MockDestination.php | 4 + tests/Migration/Unit/Adapters/MockSource.php | 15 + .../Migration/Unit/General/MessagingTest.php | 365 ++++++++++++++++++ 13 files changed, 1471 insertions(+) create mode 100644 src/Migration/Resources/Messaging/Message.php create mode 100644 src/Migration/Resources/Messaging/Provider.php create mode 100644 src/Migration/Resources/Messaging/Subscriber.php create mode 100644 src/Migration/Resources/Messaging/Topic.php create mode 100644 tests/Migration/Unit/General/MessagingTest.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 463d71d7..5e67fa46 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,242 @@ 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; + } + + $resource->setStatus(Resource::STATUS_SUCCESS); + + return $resource; + } + + /** + * @throws AppwriteException + * @throws \Exception + */ + protected function createProvider(Provider $resource): void + { + $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()), + }; + } + + /** + * @throws AppwriteException + * @throws \Exception + */ + protected function createMessage(Message $resource): void + { + $id = $resource->getId(); + $data = $resource->getData(); + $topics = $resource->getTopics() ?: null; + $users = $resource->getUsers() ?: null; + $targets = $resource->getTargets() ?: null; + + match ($resource->getProviderType()) { + 'email' => $this->messaging->createEmail( + $id, + $data['subject'] ?? '', + $data['content'] ?? '', + $topics, + $users, + $targets, + !empty($data['cc']) ? $data['cc'] : null, + !empty($data['bcc']) ? $data['bcc'] : null, + null, + true, + $data['html'] ?? null, + ), + 'sms' => $this->messaging->createSMS( + $id, + $data['content'] ?? '', + $topics, + $users, + $targets, + true, + ), + 'push' => $this->messaging->createPush( + $id, + $data['title'] ?? null, + $data['body'] ?? null, + $topics, + $users, + $targets, + !empty($data['data']) ? $data['data'] : null, + $data['action'] ?? null, + $data['image'] ?? null, + $data['icon'] ?? null, + $data['sound'] ?? null, + $data['color'] ?? null, + $data['tag'] ?? null, + $data['badge'] ?? null, + true, + ), + default => throw new \Exception('Unknown message provider type: ' . $resource->getProviderType()), + }; + } + + /** + * 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. + */ + protected function resolveTargetId(Subscriber $resource): string + { + $response = $this->users->listTargets($resource->getUserId()); + + foreach ($response['targets'] as $target) { + if ($target['providerType'] === $resource->getProviderType()) { + return $target['$id']; + } + } + + return $resource->getTargetId(); + } } diff --git a/src/Migration/Destinations/Local.php b/src/Migration/Destinations/Local.php index 4c17ab20..c05354ec 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 9645cc6f..b9ab5001 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 00000000..92e4687d --- /dev/null +++ b/src/Migration/Resources/Messaging/Message.php @@ -0,0 +1,130 @@ + $topics + * @param array $users + * @param array $targets + * @param array $data + * @param string $messageStatus + * @param string $scheduledAt + */ + 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 = '', + 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['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, + '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; + } +} diff --git a/src/Migration/Resources/Messaging/Provider.php b/src/Migration/Resources/Messaging/Provider.php new file mode 100644 index 00000000..da1af487 --- /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 00000000..91106ebc --- /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 00000000..0bbfbe66 --- /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 fb4a146a..13049a30 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,14 @@ 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 + */ + protected function exportGroupMessaging(int $batchSize, array $resources): void + { + } } diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 3f4a004e..ce6fad09 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,316 @@ 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 + { + $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) { + $messages[] = new Message( + $message['$id'], + $message['providerType'] ?? '', + $message['topics'] ?? [], + $message['users'] ?? [], + $message['targets'] ?? [], + $message['data'] ?? [], + $message['status'] ?? '', + $message['scheduledAt'] ?? '', + $message['$createdAt'] ?? '', + $message['$updatedAt'] ?? '', + ); + + $lastDocument = $message['$id']; + } + + $this->callback($messages); + + if (\count($messages) < $batchSize) { + break; + } + } + } + /** * Build queries with optional filtering by resource IDs */ diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 16330922..4a77d38a 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 7c9806c6..b2d21699 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 41d352e3..c6439831 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); + } + } } diff --git a/tests/Migration/Unit/General/MessagingTest.php b/tests/Migration/Unit/General/MessagingTest.php new file mode 100644 index 00000000..6da41796 --- /dev/null +++ b/tests/Migration/Unit/General/MessagingTest.php @@ -0,0 +1,365 @@ +source = new MockSource(); + $this->destination = new MockDestination(); + + $this->transfer = new Transfer( + $this->source, + $this->destination + ); + } + + public function testProviderResource(): void + { + $provider = new Provider( + 'provider1', + 'My Mailgun', + 'mailgun', + 'email', + true, + ['apiKey' => 'key123', 'domain' => 'example.com'], + ['fromName' => 'Test', 'fromEmail' => 'test@example.com'], + '2024-01-01T00:00:00.000+00:00', + '2024-01-01T00:00:00.000+00:00', + ); + + $this->assertSame(Resource::TYPE_PROVIDER, $provider::getName()); + $this->assertSame(Transfer::GROUP_MESSAGING, $provider->getGroup()); + $this->assertSame('provider1', $provider->getId()); + $this->assertSame('My Mailgun', $provider->getProviderName()); + $this->assertSame('mailgun', $provider->getProvider()); + $this->assertSame('email', $provider->getType()); + $this->assertTrue($provider->getEnabled()); + $this->assertSame(['apiKey' => 'key123', 'domain' => 'example.com'], $provider->getCredentials()); + $this->assertSame(['fromName' => 'Test', 'fromEmail' => 'test@example.com'], $provider->getOptions()); + } + + public function testProviderFromArray(): void + { + $data = [ + 'id' => 'provider2', + 'name' => 'My Twilio', + 'provider' => 'twilio', + 'type' => 'sms', + 'enabled' => false, + 'credentials' => ['accountSid' => 'sid123', 'authToken' => 'token123'], + 'options' => ['from' => '+1234567890'], + 'createdAt' => '2024-01-01T00:00:00.000+00:00', + 'updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]; + + $provider = Provider::fromArray($data); + + $this->assertSame('provider2', $provider->getId()); + $this->assertSame('My Twilio', $provider->getProviderName()); + $this->assertSame('twilio', $provider->getProvider()); + $this->assertSame('sms', $provider->getType()); + $this->assertFalse($provider->getEnabled()); + $this->assertSame(['accountSid' => 'sid123', 'authToken' => 'token123'], $provider->getCredentials()); + $this->assertSame(['from' => '+1234567890'], $provider->getOptions()); + } + + public function testProviderJsonSerialize(): void + { + $provider = new Provider( + 'provider1', + 'My FCM', + 'fcm', + 'push', + true, + ['serviceAccountJSON' => ['key' => 'value']], + ); + + $json = $provider->jsonSerialize(); + + $this->assertSame('provider1', $json['id']); + $this->assertSame('My FCM', $json['name']); + $this->assertSame('fcm', $json['provider']); + $this->assertSame('push', $json['type']); + $this->assertTrue($json['enabled']); + $this->assertSame(['serviceAccountJSON' => ['key' => 'value']], $json['credentials']); + } + + public function testTopicResource(): void + { + $topic = new Topic( + 'topic1', + 'Newsletter', + ['role:all'], + '2024-01-01T00:00:00.000+00:00', + '2024-01-01T00:00:00.000+00:00', + ); + + $this->assertSame(Resource::TYPE_TOPIC, $topic::getName()); + $this->assertSame(Transfer::GROUP_MESSAGING, $topic->getGroup()); + $this->assertSame('topic1', $topic->getId()); + $this->assertSame('Newsletter', $topic->getTopicName()); + $this->assertSame(['role:all'], $topic->getSubscribe()); + } + + public function testTopicFromArray(): void + { + $data = [ + 'id' => 'topic2', + 'name' => 'Alerts', + 'subscribe' => ['role:member'], + 'createdAt' => '2024-01-01T00:00:00.000+00:00', + 'updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]; + + $topic = Topic::fromArray($data); + + $this->assertSame('topic2', $topic->getId()); + $this->assertSame('Alerts', $topic->getTopicName()); + $this->assertSame(['role:member'], $topic->getSubscribe()); + } + + public function testTopicJsonSerialize(): void + { + $topic = new Topic('topic1', 'Newsletter', ['role:all']); + + $json = $topic->jsonSerialize(); + + $this->assertSame('topic1', $json['id']); + $this->assertSame('Newsletter', $json['name']); + $this->assertSame(['role:all'], $json['subscribe']); + } + + public function testSubscriberResource(): void + { + $subscriber = new Subscriber( + 'sub1', + 'topic1', + 'target1', + 'user1', + 'John Doe', + 'email', + '2024-01-01T00:00:00.000+00:00', + '2024-01-01T00:00:00.000+00:00', + ); + + $this->assertSame(Resource::TYPE_SUBSCRIBER, $subscriber::getName()); + $this->assertSame(Transfer::GROUP_MESSAGING, $subscriber->getGroup()); + $this->assertSame('sub1', $subscriber->getId()); + $this->assertSame('topic1', $subscriber->getTopicId()); + $this->assertSame('target1', $subscriber->getTargetId()); + $this->assertSame('user1', $subscriber->getUserId()); + $this->assertSame('John Doe', $subscriber->getUserName()); + $this->assertSame('email', $subscriber->getProviderType()); + } + + public function testSubscriberFromArray(): void + { + $data = [ + 'id' => 'sub2', + 'topicId' => 'topic2', + 'targetId' => 'target2', + 'userId' => 'user2', + 'userName' => 'Jane Doe', + 'providerType' => 'sms', + 'createdAt' => '2024-01-01T00:00:00.000+00:00', + 'updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]; + + $subscriber = Subscriber::fromArray($data); + + $this->assertSame('sub2', $subscriber->getId()); + $this->assertSame('topic2', $subscriber->getTopicId()); + $this->assertSame('target2', $subscriber->getTargetId()); + $this->assertSame('user2', $subscriber->getUserId()); + $this->assertSame('Jane Doe', $subscriber->getUserName()); + $this->assertSame('sms', $subscriber->getProviderType()); + } + + public function testSubscriberJsonSerialize(): void + { + $subscriber = new Subscriber( + 'sub1', + 'topic1', + 'target1', + 'user1', + 'John Doe', + 'push', + ); + + $json = $subscriber->jsonSerialize(); + + $this->assertSame('sub1', $json['id']); + $this->assertSame('topic1', $json['topicId']); + $this->assertSame('target1', $json['targetId']); + $this->assertSame('user1', $json['userId']); + $this->assertSame('John Doe', $json['userName']); + $this->assertSame('push', $json['providerType']); + } + + public function testMessageResource(): void + { + $message = new Message( + 'msg1', + 'email', + ['topic1'], + ['user1'], + ['target1'], + ['subject' => 'Hello', 'content' => '

World

'], + 'draft', + '', + '2024-01-01T00:00:00.000+00:00', + '2024-01-01T00:00:00.000+00:00', + ); + + $this->assertSame(Resource::TYPE_MESSAGE, $message::getName()); + $this->assertSame(Transfer::GROUP_MESSAGING, $message->getGroup()); + $this->assertSame('msg1', $message->getId()); + $this->assertSame('email', $message->getProviderType()); + $this->assertSame(['topic1'], $message->getTopics()); + $this->assertSame(['user1'], $message->getUsers()); + $this->assertSame(['target1'], $message->getTargets()); + $this->assertSame(['subject' => 'Hello', 'content' => '

World

'], $message->getData()); + $this->assertSame('draft', $message->getMessageStatus()); + } + + public function testMessageFromArray(): void + { + $data = [ + 'id' => 'msg2', + 'providerType' => 'sms', + 'topics' => ['topic2'], + 'users' => [], + 'targets' => ['target2'], + 'data' => ['content' => 'Hello SMS'], + 'status' => 'sent', + 'scheduledAt' => '', + 'createdAt' => '2024-01-01T00:00:00.000+00:00', + 'updatedAt' => '2024-01-01T00:00:00.000+00:00', + ]; + + $message = Message::fromArray($data); + + $this->assertSame('msg2', $message->getId()); + $this->assertSame('sms', $message->getProviderType()); + $this->assertSame(['topic2'], $message->getTopics()); + $this->assertSame(['content' => 'Hello SMS'], $message->getData()); + $this->assertSame('sent', $message->getMessageStatus()); + } + + public function testMessageJsonSerialize(): void + { + $message = new Message( + 'msg1', + 'push', + ['topic1'], + [], + [], + ['title' => 'Alert', 'body' => 'New notification'], + 'draft', + ); + + $json = $message->jsonSerialize(); + + $this->assertSame('msg1', $json['id']); + $this->assertSame('push', $json['providerType']); + $this->assertSame(['topic1'], $json['topics']); + $this->assertSame(['title' => 'Alert', 'body' => 'New notification'], $json['data']); + $this->assertSame('draft', $json['messageStatus']); + } + + public function testMessagingTransfer(): void + { + $provider = new Provider( + 'provider1', + 'Test Provider', + 'mailgun', + 'email', + ); + + $topic = new Topic( + 'topic1', + 'Test Topic', + ['role:all'], + ); + + $message = new Message( + 'msg1', + 'email', + ['topic1'], + [], + [], + ['subject' => 'Test', 'content' => 'Hello'], + 'draft', + ); + + $this->source->pushMockResource($provider); + $this->source->pushMockResource($topic); + $this->source->pushMockResource($message); + + $this->transfer->run( + [Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_MESSAGE], + function () {}, + ); + + $this->assertCount(1, $this->destination->getResourceTypeData(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER)); + $this->assertCount(1, $this->destination->getResourceTypeData(Transfer::GROUP_MESSAGING, Resource::TYPE_TOPIC)); + $this->assertCount(1, $this->destination->getResourceTypeData(Transfer::GROUP_MESSAGING, Resource::TYPE_MESSAGE)); + + $transferredProvider = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER, 'provider1'); + /** @var Provider $transferredProvider */ + $this->assertNotNull($transferredProvider); + $this->assertSame('Test Provider', $transferredProvider->getProviderName()); + $this->assertSame('mailgun', $transferredProvider->getProvider()); + + $transferredTopic = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_TOPIC, 'topic1'); + /** @var Topic $transferredTopic */ + $this->assertNotNull($transferredTopic); + $this->assertSame('Test Topic', $transferredTopic->getTopicName()); + + $transferredMessage = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_MESSAGE, 'msg1'); + /** @var Message $transferredMessage */ + $this->assertNotNull($transferredMessage); + $this->assertSame('email', $transferredMessage->getProviderType()); + $this->assertSame(['subject' => 'Test', 'content' => 'Hello'], $transferredMessage->getData()); + } + + public function testMessagingRootResource(): void + { + $provider1 = new Provider('p1', 'Provider 1', 'mailgun', 'email'); + $provider2 = new Provider('p2', 'Provider 2', 'twilio', 'sms'); + + $this->source->pushMockResource($provider1); + $this->source->pushMockResource($provider2); + + $this->transfer->run( + [Resource::TYPE_PROVIDER], + function () {}, + 'p1', + Resource::TYPE_PROVIDER, + ); + + $this->assertCount(1, $this->destination->getResourceTypeData(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER)); + + $transferred = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER, 'p1'); + $this->assertNotNull($transferred); + + $notTransferred = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER, 'p2'); + $this->assertNull($notTransferred); + } +} From 123a830b60955a2827141b57fe535dd9f33131f8 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 6 Feb 2026 12:56:04 +0000 Subject: [PATCH 2/6] import messages as-is instead of drafts --- src/Migration/Destinations/Appwrite.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 5e67fa46..012b6ec1 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -1737,7 +1737,7 @@ protected function createMessage(Message $resource): void !empty($data['cc']) ? $data['cc'] : null, !empty($data['bcc']) ? $data['bcc'] : null, null, - true, + false, $data['html'] ?? null, ), 'sms' => $this->messaging->createSMS( @@ -1746,7 +1746,7 @@ protected function createMessage(Message $resource): void $topics, $users, $targets, - true, + false, ), 'push' => $this->messaging->createPush( $id, @@ -1763,7 +1763,7 @@ protected function createMessage(Message $resource): void $data['color'] ?? null, $data['tag'] ?? null, $data['badge'] ?? null, - true, + false, ), default => throw new \Exception('Unknown message provider type: ' . $resource->getProviderType()), }; From 2a8a3e29270cab214b4e53ea0d5a9013e514f852 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 9 Feb 2026 12:00:52 +0000 Subject: [PATCH 3/6] fix: handle empty string provider fields in messaging migration --- src/Migration/Destinations/Appwrite.php | 163 ++++++-- src/Migration/Resources/Messaging/Message.php | 42 ++ src/Migration/Source.php | 4 +- src/Migration/Sources/Appwrite.php | 35 ++ src/Migration/Sources/CSV.php | 8 + src/Migration/Sources/Firebase.php | 5 + src/Migration/Sources/JSON.php | 8 + src/Migration/Sources/NHost.php | 5 + .../Migration/Unit/General/MessagingTest.php | 365 ------------------ 9 files changed, 233 insertions(+), 402 deletions(-) delete mode 100644 tests/Migration/Unit/General/MessagingTest.php diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 012b6ec1..3880755f 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -1593,7 +1593,7 @@ public function importMessagingResource(Resource $resource): Resource * @throws AppwriteException * @throws \Exception */ - protected function createProvider(Provider $resource): void + protected function createProvider(Provider $resource): bool { $credentials = $resource->getCredentials(); $options = $resource->getOptions(); @@ -1608,30 +1608,30 @@ protected function createProvider(Provider $resource): void $credentials['apiKey'] ?? null, $credentials['domain'] ?? null, $credentials['isEuRegion'] ?? null, - $options['fromName'] ?? null, - $options['fromEmail'] ?? null, - $options['replyToName'] ?? null, - $options['replyToEmail'] ?? 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, + $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, + $options['fromName'] ?: null, + $options['fromEmail'] ?: null, + $options['replyToName'] ?: null, + $options['replyToEmail'] ?: null, $enabled, ), 'smtp' => $this->messaging->createSMTPProvider( @@ -1639,8 +1639,8 @@ protected function createProvider(Provider $resource): void $name, $credentials['host'] ?? '', $credentials['port'] ?? null, - $credentials['username'] ?? null, - $credentials['password'] ?? null, + $credentials['username'] ?: null, + $credentials['password'] ?: null, match ($options['encryption'] ?? '') { 'ssl' => SmtpEncryption::SSL(), 'tls' => SmtpEncryption::TLS(), @@ -1648,10 +1648,10 @@ protected function createProvider(Provider $resource): void }, $options['autoTLS'] ?? null, $options['mailer'] ?: null, - $options['fromName'] ?? null, - $options['fromEmail'] ?? null, - $options['replyToName'] ?? null, - $options['replyToEmail'] ?? null, + $options['fromName'] ?: null, + $options['fromEmail'] ?: null, + $options['replyToName'] ?: null, + $options['replyToEmail'] ?: null, $enabled, ), 'msg91' => $this->messaging->createMsg91Provider( @@ -1665,7 +1665,7 @@ protected function createProvider(Provider $resource): void 'telesign' => $this->messaging->createTelesignProvider( $id, $name, - $options['from'] ?? null, + $options['from'] ?: null, $credentials['customerId'] ?? null, $credentials['apiKey'] ?? null, $enabled, @@ -1673,7 +1673,7 @@ protected function createProvider(Provider $resource): void 'textmagic' => $this->messaging->createTextmagicProvider( $id, $name, - $options['from'] ?? null, + $options['from'] ?: null, $credentials['username'] ?? null, $credentials['apiKey'] ?? null, $enabled, @@ -1681,7 +1681,7 @@ protected function createProvider(Provider $resource): void 'twilio' => $this->messaging->createTwilioProvider( $id, $name, - $options['from'] ?? null, + $options['from'] ?: null, $credentials['accountSid'] ?? null, $credentials['authToken'] ?? null, $enabled, @@ -1689,7 +1689,7 @@ protected function createProvider(Provider $resource): void 'vonage' => $this->messaging->createVonageProvider( $id, $name, - $options['from'] ?? null, + $options['from'] ?: null, $credentials['apiKey'] ?? null, $credentials['apiSecret'] ?? null, $enabled, @@ -1712,50 +1712,105 @@ protected function createProvider(Provider $resource): void ), default => throw new \Exception('Unknown provider: ' . $resource->getProvider()), }; + + return true; } /** * @throws AppwriteException * @throws \Exception */ - protected function createMessage(Message $resource): void + 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 { - $id = $resource->getId(); $data = $resource->getData(); $topics = $resource->getTopics() ?: null; $users = $resource->getUsers() ?: null; - $targets = $resource->getTargets() ?: null; + $targets = $resolvedTargets ?: null; + $scheduledAt = $resource->getScheduledAt(); match ($resource->getProviderType()) { 'email' => $this->messaging->createEmail( - $id, + $resource->getId(), $data['subject'] ?? '', $data['content'] ?? '', $topics, $users, $targets, - !empty($data['cc']) ? $data['cc'] : null, - !empty($data['bcc']) ? $data['bcc'] : null, + $data['cc'] ?? null, + $data['bcc'] ?? null, null, false, $data['html'] ?? null, + $scheduledAt, ), 'sms' => $this->messaging->createSMS( - $id, + $resource->getId(), $data['content'] ?? '', $topics, $users, $targets, false, + $scheduledAt, ), 'push' => $this->messaging->createPush( - $id, + $resource->getId(), $data['title'] ?? null, $data['body'] ?? null, $topics, $users, $targets, - !empty($data['data']) ? $data['data'] : null, + $data['data'] ?? null, $data['action'] ?? null, $data['image'] ?? null, $data['icon'] ?? null, @@ -1764,9 +1819,49 @@ protected function createMessage(Message $resource): void $data['tag'] ?? null, $data['badge'] ?? null, false, + $scheduledAt, + $data['contentAvailable'] ?? null, + $data['critical'] ?? null, + null, ), - default => throw new \Exception('Unknown message provider type: ' . $resource->getProviderType()), + 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; + } + + if (!isset($targetCache[$userId])) { + $targetCache[$userId] = $this->users->listTargets($userId); + } + + foreach ($targetCache[$userId]['targets'] as $target) { + if ($target['providerType'] === $providerType) { + $resolvedTargets[] = $target['$id']; + break; + } + } + } + + return $resolvedTargets; } /** @@ -1775,7 +1870,7 @@ protected function createMessage(Message $resource): void * User targets are auto-generated on the destination with new IDs, * so we look up the matching target by userId and providerType. */ - protected function resolveTargetId(Subscriber $resource): string + private function resolveTargetId(Subscriber $resource): string { $response = $this->users->listTargets($resource->getUserId()); diff --git a/src/Migration/Resources/Messaging/Message.php b/src/Migration/Resources/Messaging/Message.php index 92e4687d..f17bed23 100644 --- a/src/Migration/Resources/Messaging/Message.php +++ b/src/Migration/Resources/Messaging/Message.php @@ -16,6 +16,10 @@ class Message extends Resource * @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, @@ -26,6 +30,10 @@ public function __construct( 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 = '', ) { @@ -47,6 +55,10 @@ public static function fromArray(array $array): self $array['data'] ?? [], $array['messageStatus'] ?? $array['status'] ?? '', $array['scheduledAt'] ?? '', + $array['deliveredAt'] ?? '', + $array['deliveryErrors'] ?? [], + $array['deliveredTotal'] ?? 0, + $array['targetUserMap'] ?? [], $array['createdAt'] ?? '', $array['updatedAt'] ?? '', ); @@ -66,6 +78,10 @@ public function jsonSerialize(): array '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, ]; @@ -127,4 +143,30 @@ 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/Source.php b/src/Migration/Source.php index 13049a30..a247c4ba 100644 --- a/src/Migration/Source.php +++ b/src/Migration/Source.php @@ -168,7 +168,5 @@ abstract protected function exportGroupFunctions(int $batchSize, array $resource * @param int $batchSize * @param array $resources Resources to export */ - protected function exportGroupMessaging(int $batchSize, array $resources): void - { - } + abstract protected function exportGroupMessaging(int $batchSize, array $resources): void; } diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index ce6fad09..a34d75c2 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -1892,6 +1892,7 @@ private function exportSubscribers(int $batchSize): void private function exportMessages(int $batchSize): void { + $targetUserMap = $this->buildTargetUserMap(); $lastDocument = null; while (true) { @@ -1915,6 +1916,13 @@ private function exportMessages(int $batchSize): void } 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'] ?? '', @@ -1924,6 +1932,10 @@ private function exportMessages(int $batchSize): void $message['data'] ?? [], $message['status'] ?? '', $message['scheduledAt'] ?? '', + $message['deliveredAt'] ?? '', + $message['deliveryErrors'] ?? [], + $message['deliveredTotal'] ?? 0, + $messageTargetMap, $message['$createdAt'] ?? '', $message['$updatedAt'] ?? '', ); @@ -1939,6 +1951,29 @@ private function exportMessages(int $batchSize): void } } + /** + * 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 */ + $response = $this->users->listTargets($user->getId()); + + foreach ($response['targets'] as $target) { + $map[$target['$id']] = $user->getId(); + } + } + + 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 7aaeaa35..c0a087e5 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 12117d6a..05dcccde 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 779e2671..51e24a74 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 f65e7005..2f2851d2 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/tests/Migration/Unit/General/MessagingTest.php b/tests/Migration/Unit/General/MessagingTest.php deleted file mode 100644 index 6da41796..00000000 --- a/tests/Migration/Unit/General/MessagingTest.php +++ /dev/null @@ -1,365 +0,0 @@ -source = new MockSource(); - $this->destination = new MockDestination(); - - $this->transfer = new Transfer( - $this->source, - $this->destination - ); - } - - public function testProviderResource(): void - { - $provider = new Provider( - 'provider1', - 'My Mailgun', - 'mailgun', - 'email', - true, - ['apiKey' => 'key123', 'domain' => 'example.com'], - ['fromName' => 'Test', 'fromEmail' => 'test@example.com'], - '2024-01-01T00:00:00.000+00:00', - '2024-01-01T00:00:00.000+00:00', - ); - - $this->assertSame(Resource::TYPE_PROVIDER, $provider::getName()); - $this->assertSame(Transfer::GROUP_MESSAGING, $provider->getGroup()); - $this->assertSame('provider1', $provider->getId()); - $this->assertSame('My Mailgun', $provider->getProviderName()); - $this->assertSame('mailgun', $provider->getProvider()); - $this->assertSame('email', $provider->getType()); - $this->assertTrue($provider->getEnabled()); - $this->assertSame(['apiKey' => 'key123', 'domain' => 'example.com'], $provider->getCredentials()); - $this->assertSame(['fromName' => 'Test', 'fromEmail' => 'test@example.com'], $provider->getOptions()); - } - - public function testProviderFromArray(): void - { - $data = [ - 'id' => 'provider2', - 'name' => 'My Twilio', - 'provider' => 'twilio', - 'type' => 'sms', - 'enabled' => false, - 'credentials' => ['accountSid' => 'sid123', 'authToken' => 'token123'], - 'options' => ['from' => '+1234567890'], - 'createdAt' => '2024-01-01T00:00:00.000+00:00', - 'updatedAt' => '2024-01-01T00:00:00.000+00:00', - ]; - - $provider = Provider::fromArray($data); - - $this->assertSame('provider2', $provider->getId()); - $this->assertSame('My Twilio', $provider->getProviderName()); - $this->assertSame('twilio', $provider->getProvider()); - $this->assertSame('sms', $provider->getType()); - $this->assertFalse($provider->getEnabled()); - $this->assertSame(['accountSid' => 'sid123', 'authToken' => 'token123'], $provider->getCredentials()); - $this->assertSame(['from' => '+1234567890'], $provider->getOptions()); - } - - public function testProviderJsonSerialize(): void - { - $provider = new Provider( - 'provider1', - 'My FCM', - 'fcm', - 'push', - true, - ['serviceAccountJSON' => ['key' => 'value']], - ); - - $json = $provider->jsonSerialize(); - - $this->assertSame('provider1', $json['id']); - $this->assertSame('My FCM', $json['name']); - $this->assertSame('fcm', $json['provider']); - $this->assertSame('push', $json['type']); - $this->assertTrue($json['enabled']); - $this->assertSame(['serviceAccountJSON' => ['key' => 'value']], $json['credentials']); - } - - public function testTopicResource(): void - { - $topic = new Topic( - 'topic1', - 'Newsletter', - ['role:all'], - '2024-01-01T00:00:00.000+00:00', - '2024-01-01T00:00:00.000+00:00', - ); - - $this->assertSame(Resource::TYPE_TOPIC, $topic::getName()); - $this->assertSame(Transfer::GROUP_MESSAGING, $topic->getGroup()); - $this->assertSame('topic1', $topic->getId()); - $this->assertSame('Newsletter', $topic->getTopicName()); - $this->assertSame(['role:all'], $topic->getSubscribe()); - } - - public function testTopicFromArray(): void - { - $data = [ - 'id' => 'topic2', - 'name' => 'Alerts', - 'subscribe' => ['role:member'], - 'createdAt' => '2024-01-01T00:00:00.000+00:00', - 'updatedAt' => '2024-01-01T00:00:00.000+00:00', - ]; - - $topic = Topic::fromArray($data); - - $this->assertSame('topic2', $topic->getId()); - $this->assertSame('Alerts', $topic->getTopicName()); - $this->assertSame(['role:member'], $topic->getSubscribe()); - } - - public function testTopicJsonSerialize(): void - { - $topic = new Topic('topic1', 'Newsletter', ['role:all']); - - $json = $topic->jsonSerialize(); - - $this->assertSame('topic1', $json['id']); - $this->assertSame('Newsletter', $json['name']); - $this->assertSame(['role:all'], $json['subscribe']); - } - - public function testSubscriberResource(): void - { - $subscriber = new Subscriber( - 'sub1', - 'topic1', - 'target1', - 'user1', - 'John Doe', - 'email', - '2024-01-01T00:00:00.000+00:00', - '2024-01-01T00:00:00.000+00:00', - ); - - $this->assertSame(Resource::TYPE_SUBSCRIBER, $subscriber::getName()); - $this->assertSame(Transfer::GROUP_MESSAGING, $subscriber->getGroup()); - $this->assertSame('sub1', $subscriber->getId()); - $this->assertSame('topic1', $subscriber->getTopicId()); - $this->assertSame('target1', $subscriber->getTargetId()); - $this->assertSame('user1', $subscriber->getUserId()); - $this->assertSame('John Doe', $subscriber->getUserName()); - $this->assertSame('email', $subscriber->getProviderType()); - } - - public function testSubscriberFromArray(): void - { - $data = [ - 'id' => 'sub2', - 'topicId' => 'topic2', - 'targetId' => 'target2', - 'userId' => 'user2', - 'userName' => 'Jane Doe', - 'providerType' => 'sms', - 'createdAt' => '2024-01-01T00:00:00.000+00:00', - 'updatedAt' => '2024-01-01T00:00:00.000+00:00', - ]; - - $subscriber = Subscriber::fromArray($data); - - $this->assertSame('sub2', $subscriber->getId()); - $this->assertSame('topic2', $subscriber->getTopicId()); - $this->assertSame('target2', $subscriber->getTargetId()); - $this->assertSame('user2', $subscriber->getUserId()); - $this->assertSame('Jane Doe', $subscriber->getUserName()); - $this->assertSame('sms', $subscriber->getProviderType()); - } - - public function testSubscriberJsonSerialize(): void - { - $subscriber = new Subscriber( - 'sub1', - 'topic1', - 'target1', - 'user1', - 'John Doe', - 'push', - ); - - $json = $subscriber->jsonSerialize(); - - $this->assertSame('sub1', $json['id']); - $this->assertSame('topic1', $json['topicId']); - $this->assertSame('target1', $json['targetId']); - $this->assertSame('user1', $json['userId']); - $this->assertSame('John Doe', $json['userName']); - $this->assertSame('push', $json['providerType']); - } - - public function testMessageResource(): void - { - $message = new Message( - 'msg1', - 'email', - ['topic1'], - ['user1'], - ['target1'], - ['subject' => 'Hello', 'content' => '

World

'], - 'draft', - '', - '2024-01-01T00:00:00.000+00:00', - '2024-01-01T00:00:00.000+00:00', - ); - - $this->assertSame(Resource::TYPE_MESSAGE, $message::getName()); - $this->assertSame(Transfer::GROUP_MESSAGING, $message->getGroup()); - $this->assertSame('msg1', $message->getId()); - $this->assertSame('email', $message->getProviderType()); - $this->assertSame(['topic1'], $message->getTopics()); - $this->assertSame(['user1'], $message->getUsers()); - $this->assertSame(['target1'], $message->getTargets()); - $this->assertSame(['subject' => 'Hello', 'content' => '

World

'], $message->getData()); - $this->assertSame('draft', $message->getMessageStatus()); - } - - public function testMessageFromArray(): void - { - $data = [ - 'id' => 'msg2', - 'providerType' => 'sms', - 'topics' => ['topic2'], - 'users' => [], - 'targets' => ['target2'], - 'data' => ['content' => 'Hello SMS'], - 'status' => 'sent', - 'scheduledAt' => '', - 'createdAt' => '2024-01-01T00:00:00.000+00:00', - 'updatedAt' => '2024-01-01T00:00:00.000+00:00', - ]; - - $message = Message::fromArray($data); - - $this->assertSame('msg2', $message->getId()); - $this->assertSame('sms', $message->getProviderType()); - $this->assertSame(['topic2'], $message->getTopics()); - $this->assertSame(['content' => 'Hello SMS'], $message->getData()); - $this->assertSame('sent', $message->getMessageStatus()); - } - - public function testMessageJsonSerialize(): void - { - $message = new Message( - 'msg1', - 'push', - ['topic1'], - [], - [], - ['title' => 'Alert', 'body' => 'New notification'], - 'draft', - ); - - $json = $message->jsonSerialize(); - - $this->assertSame('msg1', $json['id']); - $this->assertSame('push', $json['providerType']); - $this->assertSame(['topic1'], $json['topics']); - $this->assertSame(['title' => 'Alert', 'body' => 'New notification'], $json['data']); - $this->assertSame('draft', $json['messageStatus']); - } - - public function testMessagingTransfer(): void - { - $provider = new Provider( - 'provider1', - 'Test Provider', - 'mailgun', - 'email', - ); - - $topic = new Topic( - 'topic1', - 'Test Topic', - ['role:all'], - ); - - $message = new Message( - 'msg1', - 'email', - ['topic1'], - [], - [], - ['subject' => 'Test', 'content' => 'Hello'], - 'draft', - ); - - $this->source->pushMockResource($provider); - $this->source->pushMockResource($topic); - $this->source->pushMockResource($message); - - $this->transfer->run( - [Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_MESSAGE], - function () {}, - ); - - $this->assertCount(1, $this->destination->getResourceTypeData(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER)); - $this->assertCount(1, $this->destination->getResourceTypeData(Transfer::GROUP_MESSAGING, Resource::TYPE_TOPIC)); - $this->assertCount(1, $this->destination->getResourceTypeData(Transfer::GROUP_MESSAGING, Resource::TYPE_MESSAGE)); - - $transferredProvider = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER, 'provider1'); - /** @var Provider $transferredProvider */ - $this->assertNotNull($transferredProvider); - $this->assertSame('Test Provider', $transferredProvider->getProviderName()); - $this->assertSame('mailgun', $transferredProvider->getProvider()); - - $transferredTopic = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_TOPIC, 'topic1'); - /** @var Topic $transferredTopic */ - $this->assertNotNull($transferredTopic); - $this->assertSame('Test Topic', $transferredTopic->getTopicName()); - - $transferredMessage = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_MESSAGE, 'msg1'); - /** @var Message $transferredMessage */ - $this->assertNotNull($transferredMessage); - $this->assertSame('email', $transferredMessage->getProviderType()); - $this->assertSame(['subject' => 'Test', 'content' => 'Hello'], $transferredMessage->getData()); - } - - public function testMessagingRootResource(): void - { - $provider1 = new Provider('p1', 'Provider 1', 'mailgun', 'email'); - $provider2 = new Provider('p2', 'Provider 2', 'twilio', 'sms'); - - $this->source->pushMockResource($provider1); - $this->source->pushMockResource($provider2); - - $this->transfer->run( - [Resource::TYPE_PROVIDER], - function () {}, - 'p1', - Resource::TYPE_PROVIDER, - ); - - $this->assertCount(1, $this->destination->getResourceTypeData(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER)); - - $transferred = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER, 'p1'); - $this->assertNotNull($transferred); - - $notTransferred = $this->destination->getResourceById(Transfer::GROUP_MESSAGING, Resource::TYPE_PROVIDER, 'p2'); - $this->assertNull($notTransferred); - } -} From 8dc38bd35a42a4e571ba59a0af56f887872cff5c Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 9 Feb 2026 12:50:01 +0000 Subject: [PATCH 4/6] fix: improve error handling in target resolution --- src/Migration/Destinations/Appwrite.php | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 3880755f..f7ad3d7a 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -1849,15 +1849,20 @@ private function resolveMessageTargets(Message $resource): array continue; } - if (!isset($targetCache[$userId])) { - $targetCache[$userId] = $this->users->listTargets($userId); - } + 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; + 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; } } @@ -1880,6 +1885,6 @@ private function resolveTargetId(Subscriber $resource): string } } - return $resource->getTargetId(); + throw new \Exception('No matching target found for subscriber ' . $resource->getId() . ' with providerType ' . $resource->getProviderType()); } } From fd4c95c2a56a9f64a658aab9652546ae3011a8ce Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 9 Feb 2026 13:13:07 +0000 Subject: [PATCH 5/6] add default case and paginate target resolution --- src/Migration/Destinations/Appwrite.php | 2 ++ src/Migration/Sources/Appwrite.php | 21 ++++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index f7ad3d7a..39939ddc 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -1582,6 +1582,8 @@ public function importMessagingResource(Resource $resource): Resource /** @var Message $resource */ $this->createMessage($resource); break; + default: + throw new \Exception('Unknown messaging resource type: ' . $resource->getName()); } $resource->setStatus(Resource::STATUS_SUCCESS); diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index a34d75c2..57c70102 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -1964,10 +1964,25 @@ private function buildTargetUserMap(): array foreach ($users as $user) { /** @var User $user */ - $response = $this->users->listTargets($user->getId()); + $lastTarget = null; - foreach ($response['targets'] as $target) { - $map[$target['$id']] = $user->getId(); + 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; + } } } From dc0a3decadeee64cfe44ed923310aa8712001439 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 9 Feb 2026 14:55:34 +0000 Subject: [PATCH 6/6] fix: handle missing and empty provider option keys safely --- src/Migration/Destinations/Appwrite.php | 46 ++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 39939ddc..e2b4bbd2 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -1610,30 +1610,30 @@ protected function createProvider(Provider $resource): bool $credentials['apiKey'] ?? null, $credentials['domain'] ?? null, $credentials['isEuRegion'] ?? null, - $options['fromName'] ?: null, - $options['fromEmail'] ?: null, - $options['replyToName'] ?: null, - $options['replyToEmail'] ?: 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, + ($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, + ($options['fromName'] ?? '') ?: null, + ($options['fromEmail'] ?? '') ?: null, + ($options['replyToName'] ?? '') ?: null, + ($options['replyToEmail'] ?? '') ?: null, $enabled, ), 'smtp' => $this->messaging->createSMTPProvider( @@ -1641,19 +1641,19 @@ protected function createProvider(Provider $resource): bool $name, $credentials['host'] ?? '', $credentials['port'] ?? null, - $credentials['username'] ?: null, - $credentials['password'] ?: 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, + ($options['mailer'] ?? '') ?: null, + ($options['fromName'] ?? '') ?: null, + ($options['fromEmail'] ?? '') ?: null, + ($options['replyToName'] ?? '') ?: null, + ($options['replyToEmail'] ?? '') ?: null, $enabled, ), 'msg91' => $this->messaging->createMsg91Provider( @@ -1667,7 +1667,7 @@ protected function createProvider(Provider $resource): bool 'telesign' => $this->messaging->createTelesignProvider( $id, $name, - $options['from'] ?: null, + ($options['from'] ?? '') ?: null, $credentials['customerId'] ?? null, $credentials['apiKey'] ?? null, $enabled, @@ -1675,7 +1675,7 @@ protected function createProvider(Provider $resource): bool 'textmagic' => $this->messaging->createTextmagicProvider( $id, $name, - $options['from'] ?: null, + ($options['from'] ?? '') ?: null, $credentials['username'] ?? null, $credentials['apiKey'] ?? null, $enabled, @@ -1683,7 +1683,7 @@ protected function createProvider(Provider $resource): bool 'twilio' => $this->messaging->createTwilioProvider( $id, $name, - $options['from'] ?: null, + ($options['from'] ?? '') ?: null, $credentials['accountSid'] ?? null, $credentials['authToken'] ?? null, $enabled, @@ -1691,7 +1691,7 @@ protected function createProvider(Provider $resource): bool 'vonage' => $this->messaging->createVonageProvider( $id, $name, - $options['from'] ?: null, + ($options['from'] ?? '') ?: null, $credentials['apiKey'] ?? null, $credentials['apiSecret'] ?? null, $enabled,