diff --git a/.env b/.env index feeb74d849..cb0ae9a424 100644 --- a/.env +++ b/.env @@ -98,4 +98,13 @@ _APP_VCS_GITHUB_CLIENT_SECRET= _APP_VCS_GITHUB_WEBHOOK_SECRET= _APP_MIGRATIONS_FIREBASE_CLIENT_ID= _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET= -_APP_ASSISTANT_OPENAI_API_KEY= \ No newline at end of file +_APP_ASSISTANT_OPENAI_API_KEY= +_APP_MESSAGE_SMS_PROVIDER_MSG91_SENDER_ID= +_APP_MESSAGE_SMS_PROVIDER_MSG91_AUTH_KEY= +_APP_MESSAGE_SMS_PROVIDER_MSG91_FROM= +_APP_MESSAGE_SMS_PROVIDER_MSG91_TO= +_APP_MESSAGE_SMS_PROVIDER_MAILGUN_API_KEY= +_APP_MESSAGE_SMS_PROVIDER_MAILGUN_DOMAIN= +_APP_MESSAGE_SMS_PROVIDER_MAILGUN_FROM= +_APP_MESSAGE_SMS_PROVIDER_MAILGUN_RECEIVER_EMAIL= +_APP_MESSAGE_SMS_PROVIDER_MAILGUN_IS_EU_REGION= diff --git a/.gitignore b/.gitignore index 3151de5adb..ac88830b49 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ debug/ app/sdks dev/yasd_init.php .phpunit.result.cache +Makefile diff --git a/README-CN.md b/README-CN.md index 1823f32b1e..c4a8cc5f3c 100644 --- a/README-CN.md +++ b/README-CN.md @@ -2,7 +2,7 @@

- Appwrite Logo + Appwrite Logo

适用于[Flutter/Vue/Angular/React/iOS/Android/* 等等平台 *]的完整后端服务 diff --git a/app/config/collections.php b/app/config/collections.php index 7cc9db49cc..bf2dd07975 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -1449,6 +1449,17 @@ $commonCollections = [ 'array' => false, 'filters' => ['json', 'encrypt'], ], + [ + '$id' => ID::custom('options'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => false, + 'filters' => ['json'], + ], [ '$id' => ID::custom('search'), 'type' => Database::VAR_STRING, @@ -1534,6 +1545,28 @@ $commonCollections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('description'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('status'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => 'processing', + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('data'), 'type' => Database::VAR_STRING, @@ -1567,6 +1600,17 @@ $commonCollections = [ 'array' => false, 'filters' => ['datetime'], ], + [ + '$id' => ID::custom('deliveredAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], [ '$id' => ID::custom('deliveryErrors'), 'type' => Database::VAR_STRING, @@ -1589,17 +1633,6 @@ $commonCollections = [ 'array' => false, 'filters' => [], ], - [ - '$id' => ID::custom('delivered'), - 'type' => Database::VAR_BOOLEAN, - 'format' => '', - 'size' => 0, - 'signed' => true, - 'required' => false, - 'default' => false, - 'array' => false, - 'filters' => [], - ], [ '$id' => ID::custom('search'), 'type' => Database::VAR_STRING, @@ -1912,6 +1945,20 @@ $commonCollections = [ 'attributes' => ['providerInternalId'], 'lengths' => [], 'orders' => [], + ], + [ + '$id' => ID::custom('_key_identifier'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['identifier'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_identifier_providerId'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['providerId', 'identifier'], + 'lengths' => [], + 'orders' => [], ] ], ], diff --git a/app/config/errors.php b/app/config/errors.php index 575a4588ba..8001517d78 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -240,6 +240,16 @@ return [ 'description' => 'OAuth2 provider returned some error.', 'code' => 424, ], + Exception::USER_TARGET_NOT_FOUND => [ + 'name' => Exception::USER_TARGET_NOT_FOUND, + 'description' => 'The target could not be found.', + 'code' => 404, + ], + Exception::USER_TARGET_ALREADY_EXISTS => [ + 'name' => Exception::USER_TARGET_ALREADY_EXISTS, + 'description' => 'A target with the same ID already exists.', + 'code' => 409, + ], /** Teams */ Exception::TEAM_NOT_FOUND => [ @@ -728,4 +738,28 @@ return [ 'description' => 'Migration is already in progress. You can check the status of the migration in your Appwrite Console\'s "Settings" > "Migrations".', 'code' => 409, ], + + /** Provider Errors */ + Exception::PROVIDER_NOT_FOUND => [ + 'name' => Exception::PROVIDER_NOT_FOUND, + 'description' => 'Provider with the requested ID could not be found.', + 'code' => 404, + ], + Exception::PROVIDER_ALREADY_EXISTS => [ + 'name' => Exception::PROVIDER_ALREADY_EXISTS, + 'description' => 'Provider with the requested ID already exists.', + 'code' => 409, + ], + Exception::PROVIDER_INCORRECT_TYPE => [ + 'name' => Exception::PROVIDER_INCORRECT_TYPE, + 'description' => 'Provider with the requested ID is of incorrect type: ', + 'code' => 400, + ], + + /** Message Errors */ + Exception::MESSAGE_NOT_FOUND => [ + 'name' => Exception::MESSAGE_NOT_FOUND, + 'description' => 'Message with the requested ID could not be found.', + 'code' => 404, + ] ]; diff --git a/app/config/events.php b/app/config/events.php index 4aaa324e9c..b07d356470 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -258,6 +258,9 @@ return [ 'create' => [ '$description' => 'This event triggers when a message is created.', ], + 'update' => [ + '$description' => 'This event triggers when a message is updated.', + ], 'topics' => [ '$model' => Response::MODEL_TOPIC, '$resource' => true, diff --git a/app/config/services.php b/app/config/services.php index 3700af659a..cfefd60c8b 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -251,4 +251,17 @@ return [ 'optional' => true, 'icon' => '/images/services/migrations.png', ], + 'messaging' => [ + 'key' => 'messaging', + 'name' => 'Messaging', + 'subtitle' => 'The Messaging service allows you to send messages to any provider type (SMTP, push notification, SMS, etc.).', + 'description' => '/docs/services/messaging.md', + 'controller' => 'api/messaging.php', + 'sdk' => true, + 'docs' => true, + 'docsUrl' => 'https://appwrite.io/docs/server/messaging', + 'tests' => true, + 'optional' => true, + 'icon' => '/images/services/messaging.png', + ] ]; diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 40c4c2aca3..ffa3ba570b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -8,7 +8,6 @@ use Appwrite\Auth\Validator\Phone; use Appwrite\Detector\Detector; use Appwrite\Event\Event; use Appwrite\Event\Mail; -use Appwrite\Event\Phone as EventPhone; use Appwrite\Extend\Exception; use Appwrite\Network\Validator\Email; use Utopia\Validator\Host; @@ -45,6 +44,7 @@ use Utopia\Validator\WhiteList; use Appwrite\Auth\Validator\PasswordHistory; use Appwrite\Auth\Validator\PasswordDictionary; use Appwrite\Auth\Validator\PersonalData; +use Appwrite\Event\Messaging; $oauthDefaultSuccess = '/auth/oauth2/success'; $oauthDefaultFailure = '/auth/oauth2/failure'; @@ -1238,9 +1238,12 @@ App::post('/v1/account/sessions/phone') ->inject('events') ->inject('messaging') ->inject('locale') - ->action(function (string $userId, string $phone, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $events, EventPhone $messaging, Locale $locale) { - - if (empty(App::getEnv('_APP_SMS_PROVIDER'))) { + ->action(function (string $userId, string $phone, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $events, Messaging $messaging, Locale $locale) { + $provider = Authorization::skip(fn () => $dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['sms']) + ])); + if ($provider === false || $provider->isEmpty()) { throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } @@ -1326,9 +1329,34 @@ App::post('/v1/account/sessions/phone') $message = $message->setParam('{{token}}', $secret); $message = $message->render(); + $target = $dbForProject->findOne('targets', [ + Query::equal('identifier', [$phone]), + Query::equal('providerInternalId', [$provider->getInternalId()]) + ]); + + if (!$target) { + $target = $dbForProject->createDocument('targets', new Document([ + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), + 'providerId' => $provider->getId(), + 'providerInternalId' => $provider->getInternalId(), + 'identifier' => $phone, + ])); + } + + $messageDoc = $dbForProject->createDocument('messages', new Document([ + '$id' => $token->getId(), + 'to' => [$target->getId()], + 'data' => [ + 'content' => $message, + ], + 'providerId' => $provider->getId(), + 'providerInternalId' => $provider->getInternalId(), + ])); + $messaging - ->setRecipient($phone) - ->setMessage($message) + ->setMessageId($messageDoc->getId()) + ->setProject($project) ->trigger(); $events->setPayload( @@ -2885,10 +2913,13 @@ App::post('/v1/account/verification/phone') ->inject('messaging') ->inject('project') ->inject('locale') - ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $events, EventPhone $messaging, Document $project, Locale $locale) { - - if (empty(App::getEnv('_APP_SMS_PROVIDER'))) { - throw new Exception(Exception::GENERAL_PHONE_DISABLED); + ->action(function (Request $request, Response $response, Document $user, Database $dbForProject, Event $events, Messaging $messaging, Document $project, Locale $locale) { + $provider = Authorization::skip(fn () => $dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['sms']) + ])); + if ($provider === false || $provider->isEmpty()) { + throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); } if (empty($user->getAttribute('phone'))) { @@ -2933,11 +2964,35 @@ App::post('/v1/account/verification/phone') $message = $message->setParam('{{token}}', $secret); $message = $message->render(); + $target = $dbForProject->findOne('targets', [ + Query::equal('identifier', [$user->getAttribute('phone')]), + Query::equal('providerInternalId', [$provider->getInternalId()]) + ]); + + if (!$target) { + $target = $dbForProject->createDocument('targets', new Document([ + 'userId' => $user->getId(), + 'userInternalId' => $user->getInternalId(), + 'providerId' => $provider->getId(), + 'providerInternalId' => $provider->getInternalId(), + 'identifier' => $user->getAttribute('phone'), + ])); + } + + $messageDoc = $dbForProject->createDocument('messages', new Document([ + '$id' => $verification->getId(), + 'to' => [$target->getId()], + 'data' => [ + 'content' => $message, + ], + 'providerId' => $provider->getId(), + 'providerInternalId' => $provider->getInternalId(), + ])); + $messaging - ->setRecipient($user->getAttribute('phone')) - ->setMessage($message) - ->trigger() - ; + ->setMessageId($messageDoc->getId()) + ->setProject($project) + ->trigger(); $events ->setParam('userId', $user->getId()) diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php new file mode 100644 index 0000000000..533086a058 --- /dev/null +++ b/app/controllers/api/messaging.php @@ -0,0 +1,1397 @@ +desc('Create Mailgun Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createMailgunProvider') + ->label('sdk.description', '/docs/references/messaging/create-mailgun-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('isEuRegion', false, new Boolean(), 'Set as EU region.', true) + ->param('from', '', new Text(256), 'Sender Email Address.') + ->param('apiKey', '', new Text(0), 'Mailgun API Key.') + ->param('domain', '', new Text(0), 'Mailgun Domain.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, bool $isEuRegion, string $from, string $apiKey, string $domain, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'mailgun', + 'type' => 'email', + 'default' => $default, + 'enabled' => $enabled, + 'search' => $providerId . ' ' . $name . ' ' . 'mailgun' . ' ' . 'email', + 'credentials' => [ + 'apiKey' => $apiKey, + 'domain' => $domain, + 'isEuRegion' => $isEuRegion, + ], + 'options' => [ + 'from' => $from, + ] + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['email']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::post('/v1/messaging/providers/sendgrid') + ->desc('Create Sendgrid Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createSendgridProvider') + ->label('sdk.description', '/docs/references/messaging/create-sengrid-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('apiKey', '', new Text(0), 'Sendgrid API key.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, string $apiKey, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'sendgrid', + 'type' => 'email', + 'default' => $default, + 'enabled' => $enabled, + 'options' => [], + 'search' => $providerId . ' ' . $name . ' ' . 'sendgrid' . ' ' . 'email', + 'credentials' => [ + 'apiKey' => $apiKey, + ], + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['sms']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::post('/v1/messaging/providers/msg91') + ->desc('Create Msg91 Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createMsg91Provider') + ->label('sdk.description', '/docs/references/messaging/create-msg91-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('from', '', new Text(256), 'Sender Number.') + ->param('senderId', '', new Text(0), 'Msg91 Sender ID.') + ->param('authKey', '', new Text(0), 'Msg91 Auth Key.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, string $from, string $senderId, string $authKey, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'msg91', + 'type' => 'sms', + 'search' => $providerId . ' ' . $name . ' ' . 'msg91' . ' ' . 'sms', + 'default' => $default, + 'enabled' => $enabled, + 'credentials' => [ + 'senderId' => $senderId, + 'authKey' => $authKey, + ], + 'options' => [ + 'from' => $from, + ] + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['sms']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::post('/v1/messaging/providers/telesign') + ->desc('Create Telesign Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createTelesignProvider') + ->label('sdk.description', '/docs/references/messaging/create-telesign-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('username', '', new Text(0), 'Telesign username.') + ->param('password', '', new Text(0), 'Telesign password.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, string $username, string $password, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'telesign', + 'type' => 'sms', + 'search' => $providerId . ' ' . $name . ' ' . 'telesign' . ' ' . 'sms', + 'default' => $default, + 'enabled' => $enabled, + 'credentials' => [ + 'username' => $username, + 'password' => $password, + ], + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['sms']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::post('/v1/messaging/providers/textmagic') + ->desc('Create Textmagic Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createTextmagicProvider') + ->label('sdk.description', '/docs/references/messaging/create-textmagic-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('username', '', new Text(0), 'Textmagic username.') + ->param('apiKey', '', new Text(0), 'Textmagic apiKey.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, string $username, string $apiKey, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'text-magic', + 'type' => 'sms', + 'search' => $providerId . ' ' . $name . ' ' . 'text-magic' . ' ' . 'sms', + 'default' => $default, + 'enabled' => $enabled, + 'credentials' => [ + 'username' => $username, + 'apiKey' => $apiKey, + ], + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['sms']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::post('/v1/messaging/providers/twilio') + ->desc('Create Twilio Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createTwilioProvider') + ->label('sdk.description', '/docs/references/messaging/create-twilio-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('accountSid', '', new Text(0), 'Twilio account secret ID.') + ->param('authToken', '', new Text(0), 'Twilio authentication token.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, string $accountSid, string $authToken, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'twilio', + 'type' => 'sms', + 'search' => $providerId . ' ' . $name . ' ' . 'twilio' . ' ' . 'sms', + 'default' => $default, + 'enabled' => $enabled, + 'credentials' => [ + 'accountSid' => $accountSid, + 'authToken' => $authToken, + ], + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['sms']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::post('/v1/messaging/providers/vonage') + ->desc('Create Vonage Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createVonageProvider') + ->label('sdk.description', '/docs/references/messaging/create-vonage-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('apiKey', '', new Text(0), 'Vonage API key.') + ->param('apiSecret', '', new Text(0), 'Vonage API secret.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, string $apiKey, string $apiSecret, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'vonage', + 'type' => 'sms', + 'search' => $providerId . ' ' . $name . ' ' . 'vonage' . ' ' . 'sms', + 'default' => $default, + 'enabled' => $enabled, + 'credentials' => [ + 'apiKey' => $apiKey, + 'apiSecret' => $apiSecret, + ], + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['sms']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::post('/v1/messaging/providers/fcm') + ->desc('Create FCM Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createFcmProvider') + ->label('sdk.description', '/docs/references/messaging/create-fcm-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('serverKey', '', new Text(0), 'FCM Server Key.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, string $serverKey, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'fcm', + 'type' => 'push', + 'search' => $providerId . ' ' . $name . ' ' . 'fcm' . ' ' . 'push', + 'default' => $default, + 'enabled' => $enabled, + 'credentials' => [ + 'serverKey' => $serverKey, + ], + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['push']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::post('/v1/messaging/providers/apns') + ->desc('Create APNS Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.create') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'createApnsProvider') + ->label('sdk.description', '/docs/references/messaging/create-apns-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new CustomId(), 'Provider ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Provider name.') + ->param('default', false, new Boolean(), 'Set as default provider.', true) + ->param('enabled', true, new Boolean(), 'Set as enabled.', true) + ->param('authKey', '', new Text(0), 'APNS authentication key.') + ->param('authKeyId', '', new Text(0), 'APNS authentication key ID.') + ->param('teamId', '', new Text(0), 'APNS team ID.') + ->param('bundleId', '', new Text(0), 'APNS bundle ID.') + ->param('endpoint', '', new Text(0), 'APNS endpoint.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, bool $default, bool $enabled, string $authKey, string $authKeyId, string $teamId, string $bundleId, string $endpoint, Database $dbForProject, Response $response) { + $providerId = $providerId == 'unique()' ? ID::unique() : $providerId; + $provider = new Document([ + '$id' => $providerId, + 'name' => $name, + 'provider' => 'apns', + 'type' => 'push', + 'search' => $providerId . ' ' . $name . ' ' . 'apns' . ' ' . 'push', + 'default' => $default, + 'enabled' => $enabled, + 'credentials' => [ + 'authKey' => $authKey, + 'authKeyId' => $authKeyId, + 'teamId' => $teamId, + 'bundleId' => $bundleId, + 'endpoint' => $endpoint, + ], + ]); + + // Check if a default provider exists, if not, set this one as default + if ( + empty($dbForProject->findOne('providers', [ + Query::equal('default', [true]), + Query::equal('type', ['push']) + ])) + ) { + $provider->setAttribute('default', true); + } + + try { + $provider = $dbForProject->createDocument('providers', $provider); + } catch (DuplicateException) { + throw new Exception(Exception::PROVIDER_ALREADY_EXISTS); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::get('/v1/messaging/providers') + ->desc('List Providers') + ->groups(['api', 'messaging']) + ->label('scope', 'providers.read') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'listProviders') + ->label('sdk.description', '/docs/references/messaging/list-providers.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER_LIST) + ->param('queries', [], new Providers(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Providers::ALLOWED_ATTRIBUTES), true) + ->inject('dbForProject') + ->inject('response') + ->action(function (array $queries, Database $dbForProject, Response $response) { + $queries = Query::parseQueries($queries); + + // Get cursor document if there was a cursor query + $cursor = Query::getByType($queries, [Query::TYPE_CURSORAFTER, Query::TYPE_CURSORBEFORE]); + $cursor = reset($cursor); + + if ($cursor) { + $providerId = $cursor->getValue(); + $cursorDocument = Authorization::skip(fn () => $dbForProject->findOne('providers', [ + Query::equal('$id', [$providerId]), + ])); + + if ($cursorDocument === false || $cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Provider '{$providerId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + $response->dynamic(new Document([ + 'total' => $dbForProject->count('providers', $filterQueries, APP_LIMIT_COUNT), + 'providers' => $dbForProject->find('providers', $queries), + ]), Response::MODEL_PROVIDER_LIST); + }); + +App::get('/v1/messaging/providers/:providerId') + ->desc('Get Provider') + ->groups(['api', 'messaging']) + ->label('scope', 'providers.read') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'getProvider') + ->label('sdk.description', '/docs/references/messaging/get-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + + $response->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::patch('/v1/messaging/providers/mailgun/:providerId') + ->desc('Update Mailgun Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateMailgunProvider') + ->label('sdk.description', '/docs/references/messaging/update-mailgun-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('isEuRegion', null, new Boolean(), 'Set as eu region.', true) + ->param('from', '', new Text(256), 'Sender Email Address.', true) + ->param('apiKey', '', new Text(0), 'Mailgun API Key.', true) + ->param('domain', '', new Text(0), 'Mailgun Domain.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, ?bool $isEuRegion, string $from, string $apiKey, string $domain, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'mailgun') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'mailgun' . ' ' . 'email'); + } + + if (!empty($from)) { + $provider->setAttribute('options', [ + 'from' => $from, + ]); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + $credentials = $provider->getAttribute('credentials'); + + if ($isEuRegion === true || $isEuRegion === false) { + $credentials['isEuRegion'] = $isEuRegion; + } + + if (!empty($apiKey)) { + $credentials['apiKey'] = $apiKey; + } + + if (!empty($domain)) { + $credentials['domain'] = $domain; + } + + $provider->setAttribute('credentials', $credentials); + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::patch('/v1/messaging/providers/sendgrid/:providerId') + ->desc('Update Sendgrid Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateSendgridProvider') + ->label('sdk.description', '/docs/references/messaging/update-sendgrid-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('apiKey', '', new Text(0), 'Sendgrid API key.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, string $apiKey, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'sendgrid') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'sendgrid' . ' ' . 'email'); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + if (!empty($apiKey)) { + $provider->setAttribute('credentials', [ + 'apiKey' => $apiKey, + ]); + } + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::patch('/v1/messaging/providers/msg91/:providerId') + ->desc('Update Msg91 Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateMsg91Provider') + ->label('sdk.description', '/docs/references/messaging/update-msg91-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('senderId', '', new Text(0), 'Msg91 Sender ID.', true) + ->param('authKey', '', new Text(0), 'Msg91 Auth Key.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, string $senderId, string $authKey, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'msg91') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'msg91' . ' ' . 'sms'); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + $credentials = $provider->getAttribute('credentials'); + + if (!empty($senderId)) { + $credentials['senderId'] = $senderId; + } + + if (!empty($authKey)) { + $credentials['authKey'] = $authKey; + } + + $provider->setAttribute('credentials', $credentials); + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::patch('/v1/messaging/providers/telesign/:providerId') + ->desc('Update Telesign Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateTelesignProvider') + ->label('sdk.description', '/docs/references/messaging/update-telesign-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('username', '', new Text(0), 'Telesign username.', true) + ->param('password', '', new Text(0), 'Telesign password.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, string $username, string $password, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'telesign') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'telesign' . ' ' . 'sms'); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + $credentials = $provider->getAttribute('credentials'); + + if (!empty($username)) { + $credentials['username'] = $username; + } + + if (!empty($password)) { + $credentials['password'] = $password; + } + + $provider->setAttribute('credentials', $credentials); + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::patch('/v1/messaging/providers/textmagic/:providerId') + ->desc('Update Textmagic Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateTextmagicProvider') + ->label('sdk.description', '/docs/references/messaging/update-textmagic-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('username', '', new Text(0), 'Textmagic username.', true) + ->param('apiKey', '', new Text(0), 'Textmagic apiKey.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, string $username, string $apiKey, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'text-magic') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'textmagic' . ' ' . 'sms'); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + $credentials = $provider->getAttribute('credentials'); + + if (!empty($username)) { + $credentials['username'] = $username; + } + + if (!empty($apiKey)) { + $credentials['apiKey'] = $apiKey; + } + + $provider->setAttribute('credentials', $credentials); + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::patch('/v1/messaging/providers/twilio/:providerId') + ->desc('Update Twilio Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateTwilioProvider') + ->label('sdk.description', '/docs/references/messaging/update-twilio-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('accountSid', null, new Text(0), 'Twilio account secret ID.', true) + ->param('authToken', null, new Text(0), 'Twilio authentication token.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, string $accountSid, string $authToken, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'twilio') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'twilio' . ' ' . 'sms'); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + $credentials = $provider->getAttribute('credentials'); + + if (!empty($accountSid)) { + $credentials['accountSid'] = $accountSid; + } + + if (!empty($authToken)) { + $credentials['authToken'] = $authToken; + } + + $provider->setAttribute('credentials', $credentials); + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::patch('/v1/messaging/providers/vonage/:providerId') + ->desc('Update Vonage Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateVonageProvider') + ->label('sdk.description', '/docs/references/messaging/update-vonage-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('apiKey', '', new Text(0), 'Vonage API key.', true) + ->param('apiSecret', '', new Text(0), 'Vonage API secret.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, string $apiKey, string $apiSecret, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'vonage') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'vonage' . ' ' . 'sms'); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + $credentials = $provider->getAttribute('credentials'); + + if (!empty($apiKey)) { + $credentials['apiKey'] = $apiKey; + } + + if (!empty($apiSecret)) { + $credentials['apiSecret'] = $apiSecret; + } + + $provider->setAttribute('credentials', $credentials); + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::patch('/v1/messaging/providers/fcm/:providerId') + ->desc('Update FCM Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateFcmProvider') + ->label('sdk.description', '/docs/references/messaging/update-fcm-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('serverKey', '', new Text(0), 'FCM Server Key.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, string $serverKey, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'fcm') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'fcm' . ' ' . 'push'); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + if (!empty($serverKey)) { + $provider->setAttribute('credentials', ['serverKey' => $serverKey]); + } + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + + +App::patch('/v1/messaging/providers/apns/:providerId') + ->desc('Update APNS Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.update') + ->label('audits.resource', 'providers/{response.$id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateApnsProvider') + ->label('sdk.description', '/docs/references/messaging/update-apns-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_PROVIDER) + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('name', '', new Text(128), 'Provider name.', true) + ->param('enabled', null, new Boolean(), 'Set as enabled.', true) + ->param('authKey', '', new Text(0), 'APNS authentication key.', true) + ->param('authKeyId', '', new Text(0), 'APNS authentication key ID.', true) + ->param('teamId', '', new Text(0), 'APNS team ID.', true) + ->param('bundleId', '', new Text(0), 'APNS bundle ID.', true) + ->param('endpoint', '', new Text(0), 'APNS endpoint.', true) + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, string $name, ?bool $enabled, string $authKey, string $authKeyId, string $teamId, string $bundleId, string $endpoint, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + $providerAttr = $provider->getAttribute('provider'); + + if ($providerAttr !== 'apns') { + throw new Exception(Exception::PROVIDER_INCORRECT_TYPE); + } + + if (!empty($name)) { + $provider->setAttribute('name', $name); + $provider->setAttribute('search', $provider->getId() . ' ' . $name . ' ' . 'apns' . ' ' . 'push'); + } + + if ($enabled === true || $enabled === false) { + $provider->setAttribute('enabled', $enabled); + } + + $credentials = $provider->getAttribute('credentials'); + + if (!empty($authKey)) { + $credentials['authKey'] = $authKey; + } + + if (!empty($authKeyId)) { + $credentials['authKeyId'] = $authKeyId; + } + + if (!empty($teamId)) { + $credentials['teamId'] = $teamId; + } + + if (!empty($bundleId)) { + $credentials['bundle'] = $bundleId; + } + + if (!empty($endpoint)) { + $credentials['endpoint'] = $endpoint; + } + + $provider->setAttribute('credentials', $credentials); + + $provider = $dbForProject->updateDocument('providers', $provider->getId(), $provider); + + $response + ->dynamic($provider, Response::MODEL_PROVIDER); + }); + +App::delete('/v1/messaging/providers/:providerId') + ->desc('Delete Provider') + ->groups(['api', 'messaging']) + ->label('audits.event', 'providers.delete') + ->label('audits.resource', 'providers/{request.id}') + ->label('scope', 'providers.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'deleteProvider') + ->label('sdk.description', '/docs/references/messaging/delete-provider.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('providerId', '', new UID(), 'Provider ID.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $providerId, Database $dbForProject, Response $response) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + + $dbForProject->deleteDocument('providers', $provider->getId()); + + $response->noContent(); + }); + +App::post('/v1/messaging/messages/email') + ->desc('Send an email.') + ->groups(['api', 'messaging']) + ->label('audits.event', 'messages.create') + ->label('audits.resource', 'messages/{response.$id}') + ->label('scope', 'messages.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'sendEmail') + ->label('sdk.description', '/docs/references/messaging/send-email.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MESSAGE) + ->param('messageId', '', new CustomId(), 'Message ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('providerId', '', new UID(), 'Email Provider ID.') + ->param('to', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of Topic IDs or List of User IDs or List of Target IDs.') + ->param('subject', '', new Text(998), 'Email Subject.') + ->param('description', '', new Text(256), 'Description for Message.', true) + ->param('content', '', new Text(64230), 'Email Content.') + ->param('status', 'processing', new WhiteList(['draft', 'processing']), 'Message Status.', true) + ->param('html', false, new Boolean(), 'Is content of type HTML', true) + ->inject('dbForProject') + ->inject('project') + ->inject('messaging') + ->inject('response') + ->action(function (string $messageId, string $providerId, array $to, string $subject, string $description, string $content, string $status, bool $html, Database $dbForProject, Document $project, Messaging $messaging, Response $response) { + $messageId = $messageId == 'unique()' ? ID::unique() : $messageId; + + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + + $message = $dbForProject->createDocument('messages', new Document([ + '$id' => $messageId, + 'providerId' => $provider->getId(), + 'providerInternalId' => $provider->getInternalId(), + 'to' => $to, + 'data' => [ + 'subject' => $subject, + 'content' => $content, + 'html' => $html, + 'description' => $description, + ], + 'status' => $status, + 'search' => $messageId . ' ' . $description . ' ' . $subject, + ])); + + if ($status === 'processing') { + $messaging + ->setMessageId($message->getId()) + ->setProject($project) + ->trigger(); + } + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($message, Response::MODEL_MESSAGE); + }); + +App::get('/v1/messaging/messages') + ->desc('List Messages') + ->groups(['api', 'messaging']) + ->label('scope', 'messages.read') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'listMessages') + ->label('sdk.description', '/docs/references/messaging/list-messages.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MESSAGE_LIST) + ->param('queries', [], new Providers(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Providers::ALLOWED_ATTRIBUTES), true) + ->inject('dbForProject') + ->inject('response') + ->action(function (array $queries, Database $dbForProject, Response $response) { + $queries = Query::parseQueries($queries); + + // Get cursor document if there was a cursor query + $cursor = Query::getByType($queries, [Query::TYPE_CURSORAFTER, Query::TYPE_CURSORBEFORE]); + $cursor = reset($cursor); + + if ($cursor) { + $messageId = $cursor->getValue(); + $cursorDocument = Authorization::skip(fn () => $dbForProject->findOne('messages', [ + Query::equal('$id', [$messageId]), + ])); + + if ($cursorDocument === false || $cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Message '{$messageId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + $response->dynamic(new Document([ + 'total' => $dbForProject->count('messages', $filterQueries, APP_LIMIT_COUNT), + 'messages' => $dbForProject->find('messages', $queries), + ]), Response::MODEL_MESSAGE_LIST); + }); + +App::get('/v1/messaging/messages/:messageId') + ->desc('Get Message') + ->groups(['api', 'messaging']) + ->label('scope', 'messages.read') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'getMessage') + ->label('sdk.description', '/docs/references/messaging/get-message.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MESSAGE) + ->param('messageId', '', new UID(), 'Message ID.') + ->inject('dbForProject') + ->inject('response') + ->action(function (string $messageId, Database $dbForProject, Response $response) { + $message = $dbForProject->getDocument('messages', $messageId); + + if ($message->isEmpty()) { + throw new Exception(Exception::MESSAGE_NOT_FOUND); + } + + $response->dynamic($message, Response::MODEL_MESSAGE); + }); + +App::patch('/v1/messaging/messages/email/:messageId') + ->desc('Update an email.') + ->groups(['api', 'messaging']) + ->label('audits.event', 'messages.update') + ->label('audits.resource', 'messages/{response.$id}') + ->label('scope', 'messages.write') + ->label('sdk.auth', [APP_AUTH_TYPE_ADMIN, APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'messaging') + ->label('sdk.method', 'updateEmail') + ->label('sdk.description', '/docs/references/messaging/update-email.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_MESSAGE) + ->param('messageId', '', new UID(), 'Message ID.') + ->param('to', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of Topic IDs or List of User IDs or List of Target IDs.', true) + ->param('subject', '', new Text(998), 'Email Subject.', true) + ->param('description', '', new Text(256), 'Description for Message.', true) + ->param('content', '', new Text(64230), 'Email Content.', true) + ->param('status', '', new WhiteList(['draft', 'processing']), 'Message Status.', true) + ->param('html', false, new Boolean(), 'Is content of type HTML', true) + ->param('deliveryTime', DateTime::now(), new DatetimeValidator(), 'Delivery time for message.', true) + ->inject('dbForProject') + ->inject('project') + ->inject('messaging') + ->inject('response') + ->action(function (string $messageId, array $to, string $subject, string $description, string $content, string $status, bool $html, string $deliveryTime, Database $dbForProject, Document $project, Messaging $messaging, Response $response) { + $message = $dbForProject->getDocument('messages', $messageId); + + if ($message->isEmpty()) { + throw new Exception(Exception::MESSAGE_NOT_FOUND); + } + + if (\count($to) > 0) { + $message->setAttribute('to', $to); + } + + $data = $message->getAttribute('data'); + + if (!empty($subject)) { + $data['subject'] = $subject; + } + + if (!empty($content)) { + $data['content'] = $content; + } + + if (!empty($description)) { + $data['description'] = $description; + } + + if (!empty($html)) { + $data['html'] = $html; + } + + $message->setAttribute('data', $data); + $message->setAttribute('search', $message->getId() . ' ' . $data['description'] . ' ' . $data['subject']); + + if (!empty($status)) { + $message->setAttribute('status', $status); + } + + $message = $dbForProject->updateDocument('messages', $message->getId(), $message); + + if ($status === 'processing') { + $messaging + ->setMessageId($message->getId()) + ->setDeliveryTime($deliveryTime) + ->setProject($project) + ->trigger(); + } + + $response + ->dynamic($message, Response::MODEL_MESSAGE); + }); diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index db163d5c0a..ccdf82e8ee 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -6,7 +6,7 @@ use Appwrite\Detector\Detector; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Event\Mail; -use Appwrite\Event\Phone as EventPhone; +use Appwrite\Event\Messaging; use Appwrite\Extend\Exception; use Appwrite\Network\Validator\Email; use Utopia\Validator\Host; @@ -380,6 +380,7 @@ App::post('/v1/teams/:teamId/memberships') ->param('roles', [], new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.') ->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the invitation email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients']) // TODO add our own built-in confirm page ->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true) + ->param('from', '', new Text(128), 'Sender of the message. It can be alphanumeric (Ex: MyCompany20). Restrictions may apply depending of the destination.', true) ->inject('response') ->inject('project') ->inject('user') @@ -388,7 +389,7 @@ App::post('/v1/teams/:teamId/memberships') ->inject('mails') ->inject('messaging') ->inject('events') - ->action(function (string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Mail $mails, EventPhone $messaging, Event $events) { + ->action(function (string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, string $from, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Mail $mails, Messaging $messaging, Event $events) { $isAPIKey = Auth::isAppUser(Authorization::getRoles()); $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); @@ -632,6 +633,15 @@ App::post('/v1/teams/:teamId/memberships') ->trigger() ; } elseif (!empty($phone)) { + $provider = Authorization::skip(fn () => $dbForProject->findOne('providers', [ + Query::equal('default', [true, false]), + Query::equal('type', ['sms']) + ])); + + if ($provider === false || $provider->isEmpty()) { + throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured'); + } + $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? []; @@ -642,9 +652,29 @@ App::post('/v1/teams/:teamId/memberships') $message = $message->setParam('{{token}}', $url); $message = $message->render(); + $target = $dbForProject->createDocument('targets', new Document([ + 'userId' => $invitee->getId(), + 'userInternalId' => $invitee->getInternalId(), + 'providerId' => $provider->getId(), + 'providerInternalId' => $provider->getInternalId(), + 'identifier' => $phone, + ])); + + $messageDoc = $dbForProject->createDocument('messages', new Document([ + // Here membership ID is used as message ID so that it can be used in test cases to verify the message + '$id' => $membership->getId(), + 'to' => [$target->getId()], + 'data' => [ + 'content' => $message, + ], + 'providerId' => $provider->getId(), + 'providerInternalId' => $provider->getInternalId(), + 'deliveryTime' => Datetime::now(), + ])); + $messaging - ->setRecipient($phone) - ->setMessage($message) + ->setMessageId($messageDoc->getId()) + ->setProject($project) ->trigger(); } } diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 8c97fc08b3..9551a05ed9 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -380,6 +380,64 @@ App::post('/v1/users/scrypt-modified') ->dynamic($user, Response::MODEL_USER); }); +App::post('/v1/users/:userId/targets') + ->desc('Create User Target') + ->groups(['api', 'users']) + ->label('audits.event', 'users.targets.create') + ->label('audits.resource', 'target/response.$id') + ->label('scope', 'targets.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'createTarget') + ->label('sdk.description', '/docs/references/users/create-target.md') + ->label('sdk.response.code', Response::STATUS_CODE_CREATED) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TARGET) + ->param('userId', '', new UID(), 'User ID.') + ->param('targetId', '', new UID(), 'Target ID.') + ->param('providerId', '', new UID(), 'Provider ID.') + ->param('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)') + ->inject('response') + ->inject('dbForProject') + ->inject('events') + ->action(function (string $userId, string $targetId, string $providerId, string $identifier, Response $response, Database $dbForProject, Event $events) { + $provider = $dbForProject->getDocument('providers', $providerId); + + if ($provider->isEmpty()) { + throw new Exception(Exception::PROVIDER_NOT_FOUND); + } + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $target = $dbForProject->getDocument('targets', $targetId); + + if (!$target->isEmpty()) { + throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); + } + + try { + $target = $dbForProject->createDocument('targets', new Document([ + '$id' => $targetId, + 'providerId' => $providerId, + 'providerInternalId' => $provider->getInternalId(), + 'userId' => $userId, + 'userInternalId' => $user->getInternalId(), + 'identifier' => $identifier, + ])); + } catch (Duplicate) { + throw new Exception(Exception::USER_TARGET_ALREADY_EXISTS); + } + $dbForProject->deleteCachedDocument('users', $user->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($target, Response::MODEL_TARGET); + }); + App::get('/v1/users') ->desc('List users') ->groups(['api', 'users']) @@ -483,6 +541,38 @@ App::get('/v1/users/:userId/prefs') $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); }); +App::get('/v1/users/:userId/targets/:targetId') + ->desc('Get User Target') + ->groups(['api', 'users']) + ->label('scope', 'targets.read') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'getTarget') + ->label('sdk.description', '/docs/references/users/get-user-target.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TARGET) + ->param('userId', '', new UID(), 'User ID.') + ->param('targetId', '', new UID(), 'Target ID.') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $userId, string $targetId, Response $response, Database $dbForProject) { + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $target = $user->find('$id', $targetId, 'targets'); + + if (empty($target)) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + $response->dynamic($target, Response::MODEL_TARGET); + }); + App::get('/v1/users/:userId/sessions') ->desc('List user sessions') ->groups(['api', 'users']) @@ -647,6 +737,35 @@ App::get('/v1/users/:userId/logs') ]), Response::MODEL_LOG_LIST); }); +App::get('/v1/users/:userId/targets') + ->desc('List User Targets') + ->groups(['api', 'users']) + ->label('scope', 'targets.read') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'listTargets') + ->label('sdk.description', '/docs/references/users/list-user-targets.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TARGET_LIST) + ->param('userId', '', new UID(), 'User ID.') + ->inject('response') + ->inject('dbForProject') + ->action(function (string $userId, Response $response, Database $dbForProject) { + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $targets = $user->getAttribute('targets', []); + $response->dynamic(new Document([ + 'targets' => $targets, + 'total' => \count($targets), + ]), Response::MODEL_TARGET_LIST); + }); + App::get('/v1/users/identities') ->desc('List Identities') ->groups(['api', 'users']) @@ -1081,6 +1200,56 @@ App::patch('/v1/users/:userId/prefs') $response->dynamic(new Document($prefs), Response::MODEL_PREFERENCES); }); +App::patch('/v1/users/:userId/targets/:targetId/identifier') + ->desc('Update user target\'s identifier') + ->groups(['api', 'users']) + ->label('audits.event', 'users.targets.update') + ->label('audits.resource', 'target/{response.$id}') + ->label('scope', 'targets.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'updateTargetIdentifier') + ->label('sdk.description', '/docs/references/users/update-target-identifier.md') + ->label('sdk.response.code', Response::STATUS_CODE_OK) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_TARGET) + ->param('userId', '', new UID(), 'User ID.') + ->param('targetId', '', new UID(), 'Target ID.') + ->param('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)') + ->inject('response') + ->inject('dbForProject') + ->inject('events') + ->action(function (string $userId, string $targetId, string $identifier, Response $response, Database $dbForProject, Event $events) { + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $target = $dbForProject->getDocument('targets', $targetId); + + if ($target->isEmpty()) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + if ($user->getId() !== $target->getAttribute('userId')) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + $target->setAttribute('identifier', $identifier); + + $target = $dbForProject->updateDocument('targets', $target->getId(), $target); + $dbForProject->deleteCachedDocument('users', $user->getId()); + + $events + ->setParam('userId', $userId) + ->setParam('targetId', $targetId); + + $response + ->dynamic($target, Response::MODEL_TARGET); + }); + App::delete('/v1/users/:userId/sessions/:sessionId') ->desc('Delete user session') ->groups(['api', 'users']) @@ -1211,6 +1380,48 @@ App::delete('/v1/users/:userId') $response->noContent(); }); +App::delete('/v1/users/:userId/targets/:targetId') + ->desc('Delete user target') + ->groups(['api', 'users']) + ->label('audits.event', 'users.targets.delete') + ->label('audits.resource', 'target/{request.$targetId}') + ->label('scope', 'targets.write') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY, APP_AUTH_TYPE_ADMIN]) + ->label('sdk.namespace', 'users') + ->label('sdk.method', 'deleteTarget') + ->label('sdk.description', '/docs/references/users/delete-target.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('userId', '', new UID(), 'User ID.') + ->param('targetId', '', new UID(), 'Target ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('events') + ->action(function (string $userId, string $targetId, Response $response, Database $dbForProject, Event $events) { + + $user = $dbForProject->getDocument('users', $userId); + + if ($user->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $target = $dbForProject->getDocument('targets', $targetId); + + if ($target->isEmpty()) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + if ($user->getId() !== $target->getAttribute('userId')) { + throw new Exception(Exception::USER_TARGET_NOT_FOUND); + } + + $target = $dbForProject->deleteDocument('targets', $target->getId()); + $dbForProject->deleteCachedDocument('users', $user->getId()); + + $response->noContent(); + }); + App::delete('/v1/users/identities/:identityId') ->desc('Delete Identity') ->groups(['api', 'users']) diff --git a/app/init.php b/app/init.php index 99794a4aaf..5b40ad386c 100644 --- a/app/init.php +++ b/app/init.php @@ -25,7 +25,7 @@ use Appwrite\Event\Audit; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Event; use Appwrite\Event\Mail; -use Appwrite\Event\Phone; +use Appwrite\Event\Messaging; use Appwrite\Event\Delete; use Appwrite\GraphQL\Schema; use Appwrite\Network\Validator\Email; @@ -539,7 +539,6 @@ Database::addFilter( return Authorization::skip(fn() => $database ->find('targets', [ Query::equal('userInternalId', [$document->getInternalId()]), - Query::limit(APP_LIMIT_SUBQUERY), ])); } ); @@ -555,7 +554,6 @@ Database::addFilter( $database ->find('subscribers', [ Query::equal('topicInternalId', [$document->getInternalId()]), - Query::limit(APP_LIMIT_SUBQUERY), ]) )); if (\count($targetIds) > 0) { @@ -926,7 +924,7 @@ App::setResource('audits', fn() => new Audit()); App::setResource('mails', fn() => new Mail()); App::setResource('deletes', fn() => new Delete()); App::setResource('database', fn() => new EventDatabase()); -App::setResource('messaging', fn() => new Phone()); +App::setResource('messaging', fn() => new Messaging()); App::setResource('queue', function (Group $pools) { return $pools->get('queue')->pop()->getResource(); }, ['pools']); diff --git a/app/workers/messaging.php b/app/workers/messaging.php index 5732c8c00b..8432e43d80 100644 --- a/app/workers/messaging.php +++ b/app/workers/messaging.php @@ -1,17 +1,30 @@ getUser(); - $secret = $dsn->getPassword(); - - $this->sms = match ($dsn->getHost()) { - 'mock' => new Mock($user, $secret), // used for tests - 'twilio' => new Twilio($user, $secret), - 'text-magic' => new TextMagic($user, $secret), - 'telesign' => new Telesign($user, $secret), - 'msg91' => new Msg91($user, $secret), - 'vonage' => new Vonage($user, $secret), - default => null - }; - - $this->from = App::getEnv('_APP_SMS_FROM'); } public function run(): void { - if (empty(App::getEnv('_APP_SMS_PROVIDER'))) { - Console::info('Skipped sms processing. No Phone provider has been set.'); - return; + $project = new Document($this->args['project']); + $this->dbForProject = $this->getProjectDB($project); + + $message = $this->dbForProject->getDocument('messages', $this->args['messageId']); + + $provider = $this->dbForProject->getDocument('providers', $message->getAttribute('providerId')); + + $this->processMessage($message, $provider); + } + + private function processMessage(Document $message, Document $provider): void + { + $adapter = match ($provider->getAttribute('type')) { + 'sms' => $this->sms($provider), + 'push' => $this->push($provider), + 'email' => $this->email($provider), + default => throw new Exception(Exception::PROVIDER_INCORRECT_TYPE) + }; + + $recipientsId = $message->getAttribute('to'); + + /** + * @var Document[] $recipients + */ + $recipients = []; + + $topics = $this->dbForProject->find('topics', [Query::equal('$id', $recipientsId)]); + foreach ($topics as $topic) { + $recipients = \array_merge($recipients, $topic->getAttribute('targets')); } - if (empty($this->from)) { - Console::info('Skipped sms processing. No phone number has been set.'); - return; + $users = $this->dbForProject->find('users', [Query::equal('$id', $recipientsId)]); + foreach ($users as $user) { + $recipients = \array_merge($recipients, $user->getAttribute('targets')); } - $message = new SMS( - to: [$this->args['recipient']], - content: $this->args['message'], - from: $this->from, - ); + $targets = $this->dbForProject->find('targets', [Query::equal('$id', $recipientsId)]); + $recipients = \array_merge($recipients, $targets); + $recipients = \array_filter($recipients, function (Document $recipient) use ($provider) { + return $recipient->getAttribute('providerId') === $provider->getId(); + }); - try { - $this->sms->send($message); - } catch (\Exception $error) { - throw new Exception('Error sending message: ' . $error->getMessage(), 500); + $identifiers = \array_map(function (Document $recipient) { + return $recipient->getAttribute('identifier'); + }, $recipients); + + $maxBatchSize = $adapter->getMaxMessagesPerRequest(); + $batches = \array_chunk($identifiers, $maxBatchSize); + $batchIndex = 0; + + $results = batch(\array_map(function ($batch) use ($message, $provider, $adapter, $batchIndex) { + return function () use ($batch, $message, $provider, $adapter, $batchIndex) { + $deliveredTo = 0; + $deliveryErrors = []; + $messageData = clone $message; + $messageData->setAttribute('to', $batch); + $data = match ($provider->getAttribute('type')) { + 'sms' => $this->buildSMSMessage($messageData, $provider), + 'push' => $this->buildPushMessage($messageData), + 'email' => $this->buildEmailMessage($messageData, $provider), + default => throw new Exception(Exception::PROVIDER_INCORRECT_TYPE) + }; + try { + $adapter->send($data); + $deliveredTo += \count($batch); + } catch (\Exception $e) { + $deliveryErrors[] = 'Failed sending to targets ' . $batchIndex + 1 . '-' . \count($batch) . ' with error: ' . $e->getMessage(); + } finally { + $batchIndex++; + return [ + 'deliveredTo' => $deliveredTo, + 'deliveryErrors' => $deliveryErrors, + ]; + } + }; + }, $batches)); + + $deliveredTo = 0; + $deliveryErrors = []; + foreach ($results as $result) { + $deliveredTo += $result['deliveredTo']; + $deliveryErrors = \array_merge($deliveryErrors, $result['deliveryErrors']); } + $message->setAttribute('deliveryErrors', $deliveryErrors); + + if (\count($message->getAttribute('deliveryErrors')) > 0) { + $message->setAttribute('status', 'failed'); + } else { + $message->setAttribute('status', 'sent'); + } + $message->setAttribute('to', $recipientsId); + $message->setAttribute('deliveredTo', $deliveredTo); + $message->setAttribute('deliveredAt', DateTime::now()); + + $this->dbForProject->updateDocument('messages', $message->getId(), $message); } public function shutdown(): void { } + + private function sms(Document $provider): ?SMSAdapter + { + $credentials = $provider->getAttribute('credentials'); + return match ($provider->getAttribute('provider')) { + 'mock' => new Mock('username', 'password'), + 'twilio' => new Twilio($credentials['accountSid'], $credentials['authToken']), + 'text-magic' => new TextMagic($credentials['username'], $credentials['apiKey']), + 'telesign' => new Telesign($credentials['username'], $credentials['password']), + 'msg91' => new Msg91($credentials['senderId'], $credentials['authKey']), + 'vonage' => new Vonage($credentials['apiKey'], $credentials['apiSecret']), + default => null + }; + } + + private function push(Document $provider): ?PushAdapter + { + $credentials = $provider->getAttribute('credentials'); + return match ($provider->getAttribute('provider')) { + 'apns' => new APNS( + $credentials['authKey'], + $credentials['authKeyId'], + $credentials['teamId'], + $credentials['bundleId'], + $credentials['endpoint'] + ), + 'fcm' => new FCM($credentials['serverKey']), + default => null + }; + } + + private function email(Document $provider): ?EmailAdapter + { + $credentials = $provider->getAttribute('credentials'); + return match ($provider->getAttribute('provider')) { + 'mailgun' => new Mailgun($credentials['apiKey'], $credentials['domain'], $credentials['isEuRegion']), + 'sendgrid' => new SendGrid($credentials['apiKey']), + default => null + }; + } + + private function buildEmailMessage(Document $message, Document $provider): Email + { + $from = $provider['options']['from']; + $to = $message['to']; + $subject = $message['data']['subject']; + $content = $message['data']['content']; + $html = $message['data']['html']; + + return new Email($to, $subject, $content, $from, null, $html); + } + + private function buildSMSMessage(Document $message, Document $provider): SMS + { + $to = $message['to']; + $content = $message['data']['content']; + $from = $provider['options']['from']; + + return new SMS($to, $content, $from); + } + + private function buildPushMessage(Document $message): Push + { + $to = $message['to']; + $title = $message['data']['title']; + $body = $message['data']['body']; + $data = $message['data']['data']; + $action = $message['data']['action']; + $sound = $message['data']['sound']; + $icon = $message['data']['icon']; + $color = $message['data']['color']; + $tag = $message['data']['tag']; + $badge = $message['data']['badge']; + + return new Push($to, $title, $body, $data, $action, $sound, $icon, $color, $tag, $badge); + } } diff --git a/composer.json b/composer.json index 0d415677ce..ed5cfbd825 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,7 @@ "utopia-php/image": "0.5.*", "utopia-php/locale": "0.4.*", "utopia-php/logger": "0.3.*", - "utopia-php/messaging": "0.1.*", + "utopia-php/messaging": "0.2.*", "utopia-php/migration": "0.3.*", "utopia-php/orchestration": "0.9.*", "utopia-php/platform": "0.4.*", diff --git a/composer.lock b/composer.lock index 8c9db35f78..8abede5fa4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "13a3bdc7c1dec5756bf58ec73a49753d", + "content-hash": "34cb0b1c81424d1858df197aed030793", "packages": [ { "name": "adhocore/jwt", @@ -1050,16 +1050,16 @@ }, { "name": "matomo/device-detector", - "version": "6.1.5", + "version": "6.1.6", "source": { "type": "git", "url": "https://github.com/matomo-org/device-detector.git", - "reference": "40ca2990dba2c1719e5c62168e822e0b86c167d4" + "reference": "5cbea85106e561c7138d03603eb6e05128480409" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matomo-org/device-detector/zipball/40ca2990dba2c1719e5c62168e822e0b86c167d4", - "reference": "40ca2990dba2c1719e5c62168e822e0b86c167d4", + "url": "https://api.github.com/repos/matomo-org/device-detector/zipball/5cbea85106e561c7138d03603eb6e05128480409", + "reference": "5cbea85106e561c7138d03603eb6e05128480409", "shasum": "" }, "require": { @@ -1115,7 +1115,7 @@ "source": "https://github.com/matomo-org/matomo", "wiki": "https://dev.matomo.org/" }, - "time": "2023-08-17T16:17:41+00:00" + "time": "2023-10-02T10:01:54+00:00" }, { "name": "mongodb/mongodb", @@ -2152,16 +2152,16 @@ }, { "name": "utopia-php/database", - "version": "0.43.4", + "version": "0.43.5", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "cabdd02e8dc1732eb0b22007c511e7bb3caa5c8c" + "reference": "5f7b05189cfbcc0506090498c580c5765375a00a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/cabdd02e8dc1732eb0b22007c511e7bb3caa5c8c", - "reference": "cabdd02e8dc1732eb0b22007c511e7bb3caa5c8c", + "url": "https://api.github.com/repos/utopia-php/database/zipball/5f7b05189cfbcc0506090498c580c5765375a00a", + "reference": "5f7b05189cfbcc0506090498c580c5765375a00a", "shasum": "" }, "require": { @@ -2202,9 +2202,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/0.43.4" + "source": "https://github.com/utopia-php/database/tree/0.43.5" }, - "time": "2023-09-28T09:00:05+00:00" + "time": "2023-10-06T06:49:47+00:00" }, { "name": "utopia-php/domains", @@ -2516,16 +2516,16 @@ }, { "name": "utopia-php/messaging", - "version": "0.1.1", + "version": "0.2.0", "source": { "type": "git", "url": "https://github.com/utopia-php/messaging.git", - "reference": "a75d66ddd59b834ab500a4878a2c084e6572604a" + "reference": "2d0f474a106bb1da285f85e105c29b46085d3a43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/messaging/zipball/a75d66ddd59b834ab500a4878a2c084e6572604a", - "reference": "a75d66ddd59b834ab500a4878a2c084e6572604a", + "url": "https://api.github.com/repos/utopia-php/messaging/zipball/2d0f474a106bb1da285f85e105c29b46085d3a43", + "reference": "2d0f474a106bb1da285f85e105c29b46085d3a43", "shasum": "" }, "require": { @@ -2534,8 +2534,8 @@ }, "require-dev": { "laravel/pint": "^1.2", - "phpmailer/phpmailer": "6.6.*", - "phpunit/phpunit": "9.5.*" + "phpmailer/phpmailer": "6.8.*", + "phpunit/phpunit": "9.6.*" }, "type": "library", "autoload": { @@ -2558,9 +2558,9 @@ ], "support": { "issues": "https://github.com/utopia-php/messaging/issues", - "source": "https://github.com/utopia-php/messaging/tree/0.1.1" + "source": "https://github.com/utopia-php/messaging/tree/0.2.0" }, - "time": "2023-02-07T05:42:46+00:00" + "time": "2023-09-14T20:48:42+00:00" }, { "name": "utopia-php/migration", @@ -6019,5 +6019,5 @@ "platform-overrides": { "php": "8.0" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/docker-compose.yml b/docker-compose.yml index b229609d5b..f0ddbfe41b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -188,6 +188,15 @@ services: - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET - _APP_ASSISTANT_OPENAI_API_KEY + - _APP_MESSAGE_SMS_PROVIDER_MSG91_SENDER_ID + - _APP_MESSAGE_SMS_PROVIDER_MSG91_AUTH_KEY + - _APP_MESSAGE_SMS_PROVIDER_MSG91_FROM + - _APP_MESSAGE_SMS_PROVIDER_MSG91_TO + - _APP_MESSAGE_SMS_PROVIDER_MAILGUN_API_KEY + - _APP_MESSAGE_SMS_PROVIDER_MAILGUN_DOMAIN + - _APP_MESSAGE_SMS_PROVIDER_MAILGUN_FROM + - _APP_MESSAGE_SMS_PROVIDER_MAILGUN_RECEIVER_EMAIL + - _APP_MESSAGE_SMS_PROVIDER_MAILGUN_IS_EU_REGION appwrite-realtime: entrypoint: realtime @@ -571,8 +580,11 @@ services: - _APP_REDIS_PORT - _APP_REDIS_USER - _APP_REDIS_PASS - - _APP_SMS_PROVIDER - - _APP_SMS_FROM + - _APP_DB_HOST + - _APP_DB_PORT + - _APP_DB_SCHEMA + - _APP_DB_USER + - _APP_DB_PASS - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG diff --git a/phpunit.xml b/phpunit.xml index f83f9f0fae..cffe166336 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -32,6 +32,7 @@ ./tests/e2e/Services/Teams ./tests/e2e/Services/Users ./tests/e2e/Services/Webhooks + ./tests/e2e/Services/Messaging ./tests/e2e/Services/Functions/FunctionsBase.php ./tests/e2e/Services/Functions/FunctionsCustomServerTest.php ./tests/e2e/Services/Functions/FunctionsCustomClientTest.php diff --git a/src/Appwrite/Event/Messaging.php b/src/Appwrite/Event/Messaging.php new file mode 100644 index 0000000000..18887ffcd3 --- /dev/null +++ b/src/Appwrite/Event/Messaging.php @@ -0,0 +1,76 @@ +messageId = $messageId; + + return $this; + } + + /** + * Returns set message ID for the messaging event. + * + * @return string + */ + public function getMessageId(): string + { + return $this->messageId; + } + + /** + * Sets Delivery time for the messaging event. + * + * @param string $deliveryTime + * @return self + */ + public function setDeliveryTime(string $deliveryTime): self + { + $this->deliveryTime = $deliveryTime; + + return $this; + } + + /** + * Returns set Delivery Time for the messaging event. + * + * @return string + */ + public function getDeliveryTime(): string + { + return $this->deliveryTime; + } + + /** + * Executes the event and sends it to the messaging worker. + */ + public function trigger(): string | bool + { + ResqueScheduler::enqueueAt(!empty($this->deliveryTime) ? $this->deliveryTime : DateTime::now(), $this->queue, $this->class, [ + 'project' => $this->project, + 'user' => $this->user, + 'messageId' => $this->messageId, + ]); + return true; + } +} diff --git a/src/Appwrite/Event/Phone.php b/src/Appwrite/Event/Phone.php deleted file mode 100644 index 8baa5120c9..0000000000 --- a/src/Appwrite/Event/Phone.php +++ /dev/null @@ -1,80 +0,0 @@ -recipient = $recipient; - - return $this; - } - - /** - * Returns set recipient for this messaging event. - * - * @return string - */ - public function getRecipient(): string - { - return $this->recipient; - } - - /** - * Sets url for the messaging event. - * - * @param string $message - * @return self - */ - public function setMessage(string $message): self - { - $this->message = $message; - - return $this; - } - - /** - * Returns set url for the messaging event. - * - * @return string - */ - public function getMessage(): string - { - return $this->message; - } - - /** - * Executes the event and sends it to the messaging worker. - * - * @return string|bool - * @throws \InvalidArgumentException - */ - public function trigger(): string|bool - { - return Resque::enqueue($this->queue, $this->class, [ - 'project' => $this->project, - 'user' => $this->user, - 'payload' => $this->payload, - 'recipient' => $this->recipient, - 'message' => $this->message, - 'events' => Event::generateEvents($this->getEvent(), $this->getParams()) - ]); - } -} diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 77dc03e310..cdd2538abb 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -84,7 +84,8 @@ class Exception extends \Exception public const USER_OAUTH2_BAD_REQUEST = 'user_oauth2_bad_request'; public const USER_OAUTH2_UNAUTHORIZED = 'user_oauth2_unauthorized'; public const USER_OAUTH2_PROVIDER_ERROR = 'user_oauth2_provider_error'; - + public const USER_TARGET_NOT_FOUND = 'user_target_not_found'; + public const USER_TARGET_ALREADY_EXISTS = 'user_target_already_exists'; /** Teams */ public const TEAM_NOT_FOUND = 'team_not_found'; public const TEAM_INVITE_ALREADY_EXISTS = 'team_invite_already_exists'; @@ -224,6 +225,14 @@ class Exception extends \Exception public const MIGRATION_ALREADY_EXISTS = 'migration_already_exists'; public const MIGRATION_IN_PROGRESS = 'migration_in_progress'; + /** Provider */ + public const PROVIDER_NOT_FOUND = 'provider_not_found'; + public const PROVIDER_ALREADY_EXISTS = 'provider_already_exists'; + public const PROVIDER_INCORRECT_TYPE = 'provider_incorrect_type'; + + /** Message */ + public const MESSAGE_NOT_FOUND = 'message_not_found'; + protected $type = ''; protected $errors = []; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Providers.php b/src/Appwrite/Utopia/Database/Validator/Queries/Providers.php new file mode 100644 index 0000000000..e72153734c --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Providers.php @@ -0,0 +1,23 @@ +addRule('deliveryTime', [ 'type' => self::TYPE_DATETIME, - 'description' => 'Time the message is delivered at.', + 'description' => 'The scheduled time for message.', 'required' => false, 'default' => DateTime::now(), 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('deliveredAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'The time when the message was delivered.', + 'required' => false, + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) ->addRule('deliveryErrors', [ 'type' => self::TYPE_STRING, 'description' => 'Delivery errors if any.', @@ -51,11 +58,18 @@ class Message extends Any 'default' => 0, 'example' => 1, ]) - ->addRule('delivered', [ - 'type' => self::TYPE_BOOLEAN, + ->addRule('status', [ + 'type' => self::TYPE_STRING, 'description' => 'Status of delivery.', - 'default' => false, - 'example' => true, + 'default' => 'processing', + 'example' => 'Message status can be one of the following: processing, sent, failed.', + ]) + ->addRule('description', [ + 'type' => self::TYPE_STRING, + 'description' => 'Message description.', + 'required' => false, + 'default' => '', + 'example' => 'Welcome Email.', ]); } diff --git a/src/Appwrite/Utopia/Response/Model/Provider.php b/src/Appwrite/Utopia/Response/Model/Provider.php index e33e5a253d..552e9783e8 100644 --- a/src/Appwrite/Utopia/Response/Model/Provider.php +++ b/src/Appwrite/Utopia/Response/Model/Provider.php @@ -45,6 +45,23 @@ class Provider extends Model 'description' => 'Type of provider.', 'default' => '', 'example' => 'sms', + ]) + ->addRule('credentials', [ + 'type' => self::TYPE_JSON, + 'description' => 'Provider credentials.', + 'default' => [], + 'example' => [ + 'key' => '123456789' + ], + ]) + ->addRule('options', [ + 'type' => self::TYPE_JSON, + 'description' => 'Provider options.', + 'default' => [], + 'required' => false, + 'example' => [ + 'from' => 'sender-email@mydomain' + ], ]); } diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 408b8c8fe7..5ab199f2bb 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -81,6 +81,12 @@ trait ProjectCustom 'locale.read', 'avatars.read', 'health.read', + 'targets.read', + 'targets.write', + 'providers.read', + 'providers.write', + 'messages.read', + 'messages.write', ], ]); diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 1441ab7f98..7f4cb84b05 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2,13 +2,12 @@ namespace Tests\E2E\Services\Account; -use Appwrite\Extend\Exception; -use Appwrite\SMS\Adapter\Mock; use Appwrite\Tests\Retry; use Tests\E2E\Client; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\SideClient; +use Utopia\App; use Utopia\Database\DateTime; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\Datetime as DatetimeValidator; @@ -744,8 +743,29 @@ class AccountCustomClientTest extends Scope public function testCreatePhone(): array { - $number = '+123456789'; + $to = App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_TO'); + $from = App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_FROM'); + $authKey = App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_AUTH_KEY'); + $senderId = App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_SENDER_ID'); + if (empty($to) || empty($from) || empty($authKey) || empty($senderId)) { + $this->markTestSkipped('SMS provider not configured'); + } + + $number = $to; + $response = $this->client->call(Client::METHOD_POST, '/messaging/providers/msg91', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'providerId' => ID::unique(), + 'name' => 'Sms provider', + 'senderId' => $senderId, + 'authKey' => $authKey, + 'default' => true, + 'from' => $from, + ]); + $this->assertEquals(201, $response['headers']['status-code']); /** * Test for SUCCESS */ @@ -756,6 +776,7 @@ class AccountCustomClientTest extends Scope ]), [ 'userId' => ID::unique(), 'phone' => $number, + 'from' => $from, ]); $this->assertEquals(201, $response['headers']['status-code']); @@ -764,6 +785,7 @@ class AccountCustomClientTest extends Scope $this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['expire'])); $userId = $response['body']['userId']; + $messageId = $response['body']['$id']; /** * Test for FAILURE @@ -780,17 +802,19 @@ class AccountCustomClientTest extends Scope \sleep(5); - $smsRequest = $this->getLastRequest(); + $message = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); - $this->assertEquals('http://request-catcher:5000/mock-sms', $smsRequest['url']); - $this->assertEquals('Appwrite Mock Message Sender', $smsRequest['headers']['User-Agent']); - $this->assertEquals('username', $smsRequest['headers']['X-Username']); - $this->assertEquals('password', $smsRequest['headers']['X-Key']); - $this->assertEquals('POST', $smsRequest['method']); - $this->assertEquals('+123456789', $smsRequest['data']['from']); - $this->assertEquals($number, $smsRequest['data']['to']); + $this->assertEquals(200, $message['headers']['status-code']); + $this->assertEquals(1, $message['body']['deliveredTo']); + $this->assertEquals(0, \count($message['body']['deliveryErrors'])); - $data['token'] = $smsRequest['data']['message']; + + $data['token'] = $message['body']['data']['content']; $data['id'] = $userId; $data['number'] = $number; @@ -999,19 +1023,28 @@ class AccountCustomClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); + ]), ['from' => App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_FROM')]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEmpty($response['body']['secret']); $this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['expire'])); - \sleep(2); + \sleep(3); - $smsRequest = $this->getLastRequest(); + $message = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $response['body']['$id'], [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $message['headers']['status-code']); + $this->assertEquals(1, $message['body']['deliveredTo']); + $this->assertEquals(0, \count($message['body']['deliveryErrors'])); return \array_merge($data, [ - 'token' => $smsRequest['data']['message'] + 'token' => $message['body']['data']['content'] ]); } diff --git a/tests/e2e/Services/GraphQL/AccountTest.php b/tests/e2e/Services/GraphQL/AccountTest.php index 7fd70b5015..801be808c9 100644 --- a/tests/e2e/Services/GraphQL/AccountTest.php +++ b/tests/e2e/Services/GraphQL/AccountTest.php @@ -6,6 +6,7 @@ use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; +use Utopia\App; use Utopia\Database\Helpers\ID; class AccountTest extends Scope @@ -122,7 +123,35 @@ class AccountTest extends Scope */ public function testCreatePhoneVerification(): array { + $to = App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_TO'); + $from = App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_FROM'); + $authKey = App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_AUTH_KEY'); + $senderId = App::getEnv('_APP_MESSAGE_SMS_PROVIDER_MSG91_SENDER_ID'); + + if (empty($to) || empty($from) || empty($authKey) || empty($senderId)) { + $this->markTestSkipped('SMS provider not configured'); + } + $projectId = $this->getProject()['$id']; + $query = $this->getQuery(self::$CREATE_MSG91_PROVIDER); + $graphQLPayload = [ + 'query' => $query, + 'variables' => [ + 'providerId' => ID::unique(), + 'name' => 'Sms Provider', + 'from' => $from, + 'senderId' => $senderId, + 'authKey' => $authKey, + 'default' => true, + ], + ]; + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $graphQLPayload); + $query = $this->getQuery(self::$CREATE_PHONE_VERIFICATION); $graphQLPayload = [ 'query' => $query, diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index 3a4b88e2c2..0a93ac34c4 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -197,6 +197,29 @@ trait Base public static string $GET_QRCODE = 'get_qrcode'; public static string $GET_USER_INITIALS = 'get_user_initials'; + // Providers + public static string $CREATE_MAILGUN_PROVIDER = 'create_mailgun_provider'; + public static string $CREATE_SENDGRID_PROVIDER = 'create_sendgrid_provider'; + public static string $CREATE_TWILIO_PROVIDER = 'create_twilio_provider'; + public static string $CREATE_TELESIGN_PROVIDER = 'create_telesign_provider'; + public static string $CREATE_TEXTMAGIC_PROVIDER = 'create_textmagic_provider'; + public static string $CREATE_MSG91_PROVIDER = 'create_msg91_provider'; + public static string $CREATE_VONAGE_PROVIDER = 'create_vonage_provider'; + public static string $CREATE_FCM_PROVIDER = 'create_fcm_provider'; + public static string $CREATE_APNS_PROVIDER = 'create_apns_provider'; + public static string $LIST_PROVIDERS = 'list_providers'; + public static string $GET_PROVIDER = 'get_provider'; + public static string $UPDATE_MAILGUN_PROVIDER = 'update_mailgun_provider'; + public static string $UPDATE_SENDGRID_PROVIDER = 'update_sendgrid_provider'; + public static string $UPDATE_TWILIO_PROVIDER = 'update_twilio_provider'; + public static string $UPDATE_TELESIGN_PROVIDER = 'update_telesign_provider'; + public static string $UPDATE_TEXTMAGIC_PROVIDER = 'update_textmagic_provider'; + public static string $UPDATE_MSG91_PROVIDER = 'update_msg91_provider'; + public static string $UPDATE_VONAGE_PROVIDER = 'update_vonage_provider'; + public static string $UPDATE_FCM_PROVIDER = 'update_fcm_provider'; + public static string $UPDATE_APNS_PROVIDER = 'update_apns_provider'; + public static string $DELETE_PROVIDER = 'delete_provider'; + // Complex queries public static string $COMPLEX_QUERY = 'complex_query'; @@ -1686,6 +1709,235 @@ trait Base status } }'; + case self::$CREATE_MAILGUN_PROVIDER: + return 'mutation createMailgunProvider($providerId: String!, $name: String!, $domain: String!, $apiKey: String!, $from: String!) { + messagingCreateMailgunProvider(providerId: $providerId, name: $name, domain: $domain, apiKey: $apiKey, from: $from) { + _id + name + provider + type + default + enabled + } + }'; + case self::$CREATE_SENDGRID_PROVIDER: + return 'mutation createSendgridProvider($providerId: String!, $name: String!, $apiKey: String!) { + messagingCreateSendgridProvider(providerId: $providerId, name: $name, apiKey: $apiKey) { + _id + name + provider + type + default + enabled + } + }'; + case self::$CREATE_TWILIO_PROVIDER: + return 'mutation createTwilioProvider($providerId: String!, $name: String!, $accountSid: String!, $authToken: String!) { + messagingCreateTwilioProvider(providerId: $providerId, name: $name, accountSid: $accountSid, authToken: $authToken) { + _id + name + provider + type + default + enabled + } + }'; + case self::$CREATE_TELESIGN_PROVIDER: + return 'mutation createTelesignProvider($providerId: String!, $name: String!, $username: String!, $password: String!) { + messagingCreateTelesignProvider(providerId: $providerId, name: $name, username: $username, password: $password) { + _id + name + provider + type + default + enabled + } + }'; + case self::$CREATE_TEXTMAGIC_PROVIDER: + return 'mutation createTextmagicProvider($providerId: String!, $name: String!, $username: String!, $apiKey: String!) { + messagingCreateTextmagicProvider(providerId: $providerId, name: $name, username: $username, apiKey: $apiKey) { + _id + name + provider + type + default + enabled + } + }'; + case self::$CREATE_MSG91_PROVIDER: + return 'mutation createMsg91Provider($providerId: String!, $name: String!, $from: String!, $senderId: String!, $authKey: String!, $default: Boolean, $enabled: Boolean) { + messagingCreateMsg91Provider(providerId: $providerId, name: $name, from: $from, senderId: $senderId, authKey: $authKey, default: $default, enabled: $enabled) { + _id + name + provider + type + default + enabled + } + }'; + case self::$CREATE_VONAGE_PROVIDER: + return 'mutation createVonageProvider($providerId: String!, $name: String!, $apiKey: String!, $apiSecret: String!) { + messagingCreateVonageProvider(providerId: $providerId, name: $name, apiKey: $apiKey, apiSecret: $apiSecret) { + _id + name + provider + type + default + enabled + } + }'; + case self::$CREATE_FCM_PROVIDER: + return 'mutation createFcmProvider($providerId: String!, $name: String!, $serverKey: String!) { + messagingCreateFcmProvider(providerId: $providerId, name: $name, serverKey: $serverKey) { + _id + name + provider + type + default + enabled + } + }'; + case self::$CREATE_APNS_PROVIDER: + return 'mutation createApnsProvider($providerId: String!, $name: String!, $authKey: String!, $authKeyId: String!, $teamId: String!, $bundleId: String!, $endpoint: String!) { + messagingCreateApnsProvider(providerId: $providerId, name: $name, authKey: $authKey, authKeyId: $authKeyId, teamId: $teamId, bundleId: $bundleId, endpoint: $endpoint) { + _id + name + provider + type + default + enabled + } + }'; + case self::$LIST_PROVIDERS: + return 'query listProviders { + messagingListProviders { + total + providers { + _id + name + provider + type + default + enabled + } + } + }'; + case self::$GET_PROVIDER: + return 'query getProvider($providerId: String!) { + messagingGetProvider(providerId: $providerId) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_MAILGUN_PROVIDER: + return 'mutation updateMailgunProvider($providerId: String!, $name: String!, $domain: String!, $apiKey: String!, $isEuRegion: Boolean, $enabled: Boolean) { + messagingUpdateMailgunProvider(providerId: $providerId, name: $name, domain: $domain, apiKey: $apiKey, isEuRegion: $isEuRegion, enabled: $enabled) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_SENDGRID_PROVIDER: + return 'mutation messagingUpdateSendgridProvider($providerId: String!, $name: String!, $apiKey: String!) { + messagingUpdateSendgridProvider(providerId: $providerId, name: $name, apiKey: $apiKey) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_TWILIO_PROVIDER: + return 'mutation updateTwilioProvider($providerId: String!, $name: String!, $accountSid: String!, $authToken: String!) { + messagingUpdateTwilioProvider(providerId: $providerId, name: $name, accountSid: $accountSid, authToken: $authToken) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_TELESIGN_PROVIDER: + return 'mutation updateTelesignProvider($providerId: String!, $name: String!, $username: String!, $password: String!) { + messagingUpdateTelesignProvider(providerId: $providerId, name: $name, username: $username, password: $password) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_TEXTMAGIC_PROVIDER: + return 'mutation updateTextmagicProvider($providerId: String!, $name: String!, $username: String!, $apiKey: String!) { + messagingUpdateTextmagicProvider(providerId: $providerId, name: $name, username: $username, apiKey: $apiKey) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_MSG91_PROVIDER: + return 'mutation updateMsg91Provider($providerId: String!, $name: String!, $senderId: String!, $authKey: String!) { + messagingUpdateMsg91Provider(providerId: $providerId, name: $name, senderId: $senderId, authKey: $authKey) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_VONAGE_PROVIDER: + return 'mutation updateVonageProvider($providerId: String!, $name: String!, $apiKey: String!, $apiSecret: String!) { + messagingUpdateVonageProvider(providerId: $providerId, name: $name, apiKey: $apiKey, apiSecret: $apiSecret) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_FCM_PROVIDER: + return 'mutation updateFcmProvider($providerId: String!, $name: String!, $serverKey: String!) { + messagingUpdateFcmProvider(providerId: $providerId, name: $name, serverKey: $serverKey) { + _id + name + provider + type + default + enabled + } + }'; + case self::$UPDATE_APNS_PROVIDER: + return 'mutation updateApnsProvider($providerId: String!, $name: String!, $authKey: String!, $authKeyId: String!, $teamId: String!, $bundleId: String!, $endpoint: String!) { + messagingUpdateApnsProvider(providerId: $providerId, name: $name, authKey: $authKey, authKeyId: $authKeyId, teamId: $teamId, bundleId: $bundleId, endpoint: $endpoint) { + _id + name + provider + type + default + enabled + } + }'; + case self::$DELETE_PROVIDER: + return 'mutation deleteProvider($providerId: String!) { + messagingDeleteProvider(providerId: $providerId) { + status + } + }'; case self::$COMPLEX_QUERY: return 'mutation complex($databaseId: String!, $databaseName: String!, $collectionId: String!, $collectionName: String!, $documentSecurity: Boolean!, $collectionPermissions: [String!]!) { databasesCreate(databaseId: $databaseId, name: $databaseName) { diff --git a/tests/e2e/Services/GraphQL/MessagingTest.php b/tests/e2e/Services/GraphQL/MessagingTest.php new file mode 100644 index 0000000000..99bad52887 --- /dev/null +++ b/tests/e2e/Services/GraphQL/MessagingTest.php @@ -0,0 +1,260 @@ + [ + 'providerId' => ID::unique(), + 'name' => 'Sengrid1', + 'apiKey' => 'my-apikey', + ], + 'Mailgun' => [ + 'providerId' => ID::unique(), + 'name' => 'Mailgun1', + 'apiKey' => 'my-apikey', + 'domain' => 'my-domain', + 'from' => 'sender-email@my-domain', + ], + 'Twilio' => [ + 'providerId' => ID::unique(), + 'name' => 'Twilio1', + 'accountSid' => 'my-accountSid', + 'authToken' => 'my-authToken', + ], + 'Telesign' => [ + 'providerId' => ID::unique(), + 'name' => 'Telesign1', + 'username' => 'my-username', + 'password' => 'my-password', + ], + 'Textmagic' => [ + 'providerId' => ID::unique(), + 'name' => 'Textmagic1', + 'username' => 'my-username', + 'apiKey' => 'my-apikey', + ], + 'Msg91' => [ + 'providerId' => ID::unique(), + 'name' => 'Ms91-1', + 'senderId' => 'my-senderid', + 'authKey' => 'my-authkey', + 'from' => '+123456789' + ], + 'Vonage' => [ + 'providerId' => ID::unique(), + 'name' => 'Vonage1', + 'apiKey' => 'my-apikey', + 'apiSecret' => 'my-apisecret', + ], + 'Fcm' => [ + 'providerId' => ID::unique(), + 'name' => 'FCM1', + 'serverKey' => 'my-serverkey', + ], + 'Apns' => [ + 'providerId' => ID::unique(), + 'name' => 'APNS1', + 'authKey' => 'my-authkey', + 'authKeyId' => 'my-authkeyid', + 'teamId' => 'my-teamid', + 'bundleId' => 'my-bundleid', + 'endpoint' => 'my-endpoint', + ], + ]; + + $providers = []; + + foreach (\array_keys($providersParams) as $key) { + $query = $this->getQuery('create_' . \strtolower($key) . '_provider'); + $graphQLPayload = [ + 'query' => $query, + 'variables' => $providersParams[$key], + ]; + $response = $this->client->call(Client::METHOD_POST, '/graphql', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), $graphQLPayload); + \array_push($providers, $response['body']['data']['messagingCreate' . $key . 'Provider']); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($providersParams[$key]['name'], $response['body']['data']['messagingCreate' . $key . 'Provider']['name']); + } + + return $providers; + } + + /** + * @depends testCreateProviders + */ + public function testUpdateProviders(array $providers): array + { + $providersParams = [ + 'Sendgrid' => [ + 'providerId' => $providers[0]['_id'], + 'name' => 'Sengrid2', + 'apiKey' => 'my-apikey', + ], + 'Mailgun' => [ + 'providerId' => $providers[1]['_id'], + 'name' => 'Mailgun2', + 'apiKey' => 'my-apikey', + 'domain' => 'my-domain', + ], + 'Twilio' => [ + 'providerId' => $providers[2]['_id'], + 'name' => 'Twilio2', + 'accountSid' => 'my-accountSid', + 'authToken' => 'my-authToken', + ], + 'Telesign' => [ + 'providerId' => $providers[3]['_id'], + 'name' => 'Telesign2', + 'username' => 'my-username', + 'password' => 'my-password', + ], + 'Textmagic' => [ + 'providerId' => $providers[4]['_id'], + 'name' => 'Textmagic2', + 'username' => 'my-username', + 'apiKey' => 'my-apikey', + ], + 'Msg91' => [ + 'providerId' => $providers[5]['_id'], + 'name' => 'Ms91-2', + 'senderId' => 'my-senderid', + 'authKey' => 'my-authkey', + ], + 'Vonage' => [ + 'providerId' => $providers[6]['_id'], + 'name' => 'Vonage2', + 'apiKey' => 'my-apikey', + 'apiSecret' => 'my-apisecret', + ], + 'Fcm' => [ + 'providerId' => $providers[7]['_id'], + 'name' => 'FCM2', + 'serverKey' => 'my-serverkey', + ], + 'Apns' => [ + 'providerId' => $providers[8]['_id'], + 'name' => 'APNS2', + 'authKey' => 'my-authkey', + 'authKeyId' => 'my-authkeyid', + 'teamId' => 'my-teamid', + 'bundleId' => 'my-bundleid', + 'endpoint' => 'my-endpoint', + ], + ]; + foreach (\array_keys($providersParams) as $index => $key) { + $query = $this->getQuery('update_' . \strtolower($key) . '_provider'); + $graphQLPayload = [ + 'query' => $query, + 'variables' => $providersParams[$key], + ]; + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $graphQLPayload); + $providers[$index] = $response['body']['data']['messagingUpdate' . $key . 'Provider']; + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($providersParams[$key]['name'], $response['body']['data']['messagingUpdate' . $key . 'Provider']['name']); + } + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'query' => $this->getQuery('update_mailgun_provider'), + 'variables' => [ + 'providerId' => $providers[1]['_id'], + 'name' => 'Mailgun2', + 'apiKey' => 'my-apikey', + 'domain' => 'my-domain', + 'isEuRegion' => true, + 'enabled' => false, + ] + ]); + $providers[1] = $response['body']['data']['messagingUpdateMailgunProvider']; + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Mailgun2', $response['body']['data']['messagingUpdateMailgunProvider']['name']); + $this->assertEquals(false, $response['body']['data']['messagingUpdateMailgunProvider']['enabled']); + return $providers; + } + + /** + * @depends testUpdateProviders + */ + public function testListProviders(array $providers) + { + $query = $this->getQuery(self::$LIST_PROVIDERS); + $graphQLPayload = [ + 'query' => $query, + ]; + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $graphQLPayload); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(\count($providers), \count($response['body']['data']['messagingListProviders']['providers'])); + } + + /** + * @depends testUpdateProviders + */ + public function testGetProvider(array $providers) + { + $query = $this->getQuery(self::$GET_PROVIDER); + $graphQLPayload = [ + 'query' => $query, + 'variables' => [ + 'providerId' => $providers[0]['_id'], + ] + ]; + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $graphQLPayload); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($providers[0]['name'], $response['body']['data']['messagingGetProvider']['name']); + } + + /** + * @depends testUpdateProviders + */ + public function testDeleteProvider(array $providers) + { + foreach ($providers as $provider) { + $query = $this->getQuery(self::$DELETE_PROVIDER); + $graphQLPayload = [ + 'query' => $query, + 'variables' => [ + 'providerId' => $provider['_id'], + ] + ]; + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $graphQLPayload); + $this->assertEquals(204, $response['headers']['status-code']); + } + } +} diff --git a/tests/e2e/Services/Messaging/MessagingBase.php b/tests/e2e/Services/Messaging/MessagingBase.php new file mode 100644 index 0000000000..3282330d86 --- /dev/null +++ b/tests/e2e/Services/Messaging/MessagingBase.php @@ -0,0 +1,211 @@ + [ + 'providerId' => ID::unique(), + 'name' => 'Sengrid1', + 'apiKey' => 'my-apikey', + ], + 'mailgun' => [ + 'providerId' => ID::unique(), + 'name' => 'Mailgun1', + 'apiKey' => 'my-apikey', + 'domain' => 'my-domain', + 'from' => 'sender-email@my-domain', + ], + 'twilio' => [ + 'providerId' => ID::unique(), + 'name' => 'Twilio1', + 'accountSid' => 'my-accountSid', + 'authToken' => 'my-authToken', + ], + 'telesign' => [ + 'providerId' => ID::unique(), + 'name' => 'Telesign1', + 'username' => 'my-username', + 'password' => 'my-password', + ], + 'textmagic' => [ + 'providerId' => ID::unique(), + 'name' => 'Textmagic1', + 'username' => 'my-username', + 'apiKey' => 'my-apikey', + ], + 'msg91' => [ + 'providerId' => ID::unique(), + 'name' => 'Ms91-1', + 'senderId' => 'my-senderid', + 'authKey' => 'my-authkey', + 'from' => '+123456789' + ], + 'vonage' => [ + 'providerId' => ID::unique(), + 'name' => 'Vonage1', + 'apiKey' => 'my-apikey', + 'apiSecret' => 'my-apisecret', + ], + 'fcm' => [ + 'providerId' => ID::unique(), + 'name' => 'FCM1', + 'serverKey' => 'my-serverkey', + ], + 'apns' => [ + 'providerId' => ID::unique(), + 'name' => 'APNS1', + 'authKey' => 'my-authkey', + 'authKeyId' => 'my-authkeyid', + 'teamId' => 'my-teamid', + 'bundleId' => 'my-bundleid', + 'endpoint' => 'my-endpoint', + ], + ]; + $providers = []; + + foreach (\array_keys($providersParams) as $key) { + $response = $this->client->call(Client::METHOD_POST, '/messaging/providers/' . $key, \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), $providersParams[$key]); + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($providersParams[$key]['name'], $response['body']['name']); + \array_push($providers, $response['body']); + } + + return $providers; + } + + /** + * @depends testCreateProviders + */ + public function testUpdateProviders(array $providers): array + { + $providersParams = [ + 'sendgrid' => [ + 'name' => 'Sengrid2', + 'apiKey' => 'my-apikey', + ], + 'mailgun' => [ + 'name' => 'Mailgun2', + 'apiKey' => 'my-apikey', + 'domain' => 'my-domain', + ], + 'twilio' => [ + 'name' => 'Twilio2', + 'accountSid' => 'my-accountSid', + 'authToken' => 'my-authToken', + ], + 'telesign' => [ + 'name' => 'Telesign2', + 'username' => 'my-username', + 'password' => 'my-password', + ], + 'textmagic' => [ + 'name' => 'Textmagic2', + 'username' => 'my-username', + 'apiKey' => 'my-apikey', + ], + 'msg91' => [ + 'name' => 'Ms91-2', + 'senderId' => 'my-senderid', + 'authKey' => 'my-authkey', + ], + 'vonage' => [ + 'name' => 'Vonage2', + 'apiKey' => 'my-apikey', + 'apiSecret' => 'my-apisecret', + ], + 'fcm' => [ + 'name' => 'FCM2', + 'serverKey' => 'my-serverkey', + ], + 'apns' => [ + 'name' => 'APNS2', + 'authKey' => 'my-authkey', + 'authKeyId' => 'my-authkeyid', + 'teamId' => 'my-teamid', + 'bundleId' => 'my-bundleid', + 'endpoint' => 'my-endpoint', + ], + ]; + foreach (\array_keys($providersParams) as $index => $key) { + $response = $this->client->call(Client::METHOD_PATCH, '/messaging/providers/' . $key . '/' . $providers[$index]['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $providersParams[$key]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($providersParams[$key]['name'], $response['body']['name']); + $providers[$index] = $response['body']; + } + + $response = $this->client->call(Client::METHOD_PATCH, '/messaging/providers/mailgun/' . $providers[1]['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'name' => 'Mailgun2', + 'apiKey' => 'my-apikey', + 'domain' => 'my-domain', + 'isEuRegion' => true, + 'enabled' => false, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Mailgun2', $response['body']['name']); + $this->assertEquals(false, $response['body']['enabled']); + $providers[1] = $response['body']; + return $providers; + } + + /** + * @depends testUpdateProviders + */ + public function testListProviders(array $providers) + { + $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(\count($providers), \count($response['body']['providers'])); + } + + /** + * @depends testUpdateProviders + */ + public function testGetProvider(array $providers) + { + $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providers[0]['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($providers[0]['name'], $response['body']['name']); + } + + /** + * @depends testUpdateProviders + */ + public function testDeleteProvider(array $providers) + { + foreach ($providers as $provider) { + $response = $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $provider['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals(204, $response['headers']['status-code']); + } + } +} diff --git a/tests/e2e/Services/Messaging/MessagingCustomServerTest.php b/tests/e2e/Services/Messaging/MessagingCustomServerTest.php new file mode 100644 index 0000000000..19e0113364 --- /dev/null +++ b/tests/e2e/Services/Messaging/MessagingCustomServerTest.php @@ -0,0 +1,14 @@ +assertEquals($response['headers']['status-code'], 400); } + /** + * @depends testGetUser + */ + public function testCreateUserTarget(array $data): array + { + $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'providerId' => 'unique()', + 'name' => 'Sengrid1', + 'apiKey' => 'my-apikey' + ]); + $this->assertEquals(201, $provider['headers']['status-code']); + $response = $this->client->call(Client::METHOD_POST, '/users/' . $data['userId'] . '/targets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'targetId' => ID::unique(), + 'providerId' => $provider['body']['$id'], + 'identifier' => 'my-token', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($provider['body']['$id'], $response['body']['providerId']); + $this->assertEquals('my-token', $response['body']['identifier']); + return $response['body']; + } + + /** + * @depends testCreateUserTarget + */ + public function testUpdateUserTarget(array $data): array + { + $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $data['userId'] . '/targets/' . $data['$id'] . '/identifier', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'identifier' => 'my-updated-token', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('my-updated-token', $response['body']['identifier']); + return $response['body']; + } + + /** + * @depends testUpdateUserTarget + */ + public function testListUserTarget(array $data) + { + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/targets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, \count($response['body']['targets'])); + } + + /** + * @depends testUpdateUserTarget + */ + public function testGetUserTarget(array $data) + { + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/targets/' . $data['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($data['$id'], $response['body']['$id']); + } + + /** + * @depends testUpdateUserTarget + */ + public function testDeleteUserTarget(array $data) + { + $response = $this->client->call(Client::METHOD_DELETE, '/users/' . $data['userId'] . '/targets/' . $data['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); + $response = $this->client->call(Client::METHOD_GET, '/users/' . $data['userId'] . '/targets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(0, $response['body']['total']); + } + /** * @depends testGetUser */