From d831b93934f8f5ffaa4985555d323721263403bc Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 01:43:05 +0000 Subject: [PATCH 01/51] Allow deleting user account with active memberships Instead of blocking account deletion when the user has confirmed team memberships, handle memberships gracefully during deletion: - Sole owner + sole member: delete the team and queue project cleanup - Sole owner + other members: transfer ownership to the next member - Non-owner / multiple owners: no special handling needed (worker cleans up) Also update the Deletes worker to transfer the team's primary user reference when removing a deleted user's memberships. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 63 +++++++++++++-- src/Appwrite/Platform/Workers/Deletes.php | 16 +++- .../Account/AccountConsoleClientTest.php | 77 +++++++++++++------ 3 files changed, 126 insertions(+), 30 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index d576bbce44..6ac6373c87 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -614,18 +614,71 @@ Http::delete('/v1/account') ->inject('dbForProject') ->inject('queueForEvents') ->inject('queueForDeletes') - ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes) { + ->inject('authorization') + ->action(function (Document $user, Document $project, Response $response, Database $dbForProject, Event $queueForEvents, Delete $queueForDeletes, Authorization $authorization) { if ($user->isEmpty()) { throw new Exception(Exception::USER_NOT_FOUND); } if ($project->getId() === 'console') { - // get all memberships $memberships = $user->getAttribute('memberships', []); foreach ($memberships as $membership) { - // prevent deletion if at least one active membership - if ($membership->getAttribute('confirm', false)) { - throw new Exception(Exception::USER_DELETION_PROHIBITED); + if (!$membership->getAttribute('confirm', false)) { + continue; + } + + $team = $dbForProject->getDocument('teams', $membership->getAttribute('teamId')); + if ($team->isEmpty()) { + continue; + } + + $isSoleOwner = false; + if (in_array('owner', $membership->getAttribute('roles', []))) { + $ownersCount = $dbForProject->count( + collection: 'memberships', + queries: [ + Query::contains('roles', ['owner']), + Query::equal('teamInternalId', [$team->getSequence()]) + ], + max: 2 + ); + $isSoleOwner = ($ownersCount === 1); + } + + $totalMembers = $team->getAttribute('total', 0); + + if ($isSoleOwner && $totalMembers <= 1) { + // User is the only owner and the only member — delete the team. + // The team deletion worker will clean up associated projects and resources. + $dbForProject->deleteDocument('teams', $team->getId()); + + $queueForDeletes + ->setType(DELETE_TYPE_TEAM_PROJECTS) + ->setDocument($team) + ->trigger(); + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($team) + ->trigger(); + } elseif ($isSoleOwner) { + // User is the sole owner but other members exist — transfer ownership + // to the next member before removing this user's membership. + $nextMember = $dbForProject->findOne('memberships', [ + Query::equal('teamInternalId', [$team->getSequence()]), + Query::notEqual('userInternalId', $user->getSequence()), + ]); + + if (!$nextMember->isEmpty()) { + $roles = $nextMember->getAttribute('roles', []); + if (!in_array('owner', $roles)) { + $roles[] = 'owner'; + $authorization->skip(fn () => $dbForProject->updateDocument('memberships', $nextMember->getId(), new Document([ + 'roles' => $roles, + ]))); + $dbForProject->purgeCachedDocument('users', $nextMember->getAttribute('userId')); + } + } } } } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index c420444112..9508f784bd 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -845,12 +845,26 @@ class Deletes extends Action $this->deleteByGroup('memberships', [ Query::equal('userInternalId', [$userInternalId]), Query::orderAsc() - ], $dbForProject, function (Document $document) use ($dbForProject) { + ], $dbForProject, function (Document $document) use ($dbForProject, $userInternalId) { if ($document->getAttribute('confirm')) { // Count only confirmed members $teamId = $document->getAttribute('teamId'); $team = $dbForProject->getDocument('teams', $teamId); if (!$team->isEmpty()) { $dbForProject->decreaseDocumentAttribute('teams', $teamId, 'total', 1, 0); + + // If this user was the team's primary user, transfer to the next member + if ($team->getAttribute('userInternalId') === $userInternalId) { + $nextMembership = $dbForProject->findOne('memberships', [ + Query::equal('teamInternalId', [$team->getSequence()]), + ]); + + if ($nextMembership !== false && !$nextMembership->isEmpty()) { + $dbForProject->updateDocument('teams', $team->getId(), new Document([ + 'userId' => $nextMembership->getAttribute('userId'), + 'userInternalId' => $nextMembership->getAttribute('userInternalId'), + ])); + } + } } } }); diff --git a/tests/e2e/Services/Account/AccountConsoleClientTest.php b/tests/e2e/Services/Account/AccountConsoleClientTest.php index 78f7798193..9f825c3c89 100644 --- a/tests/e2e/Services/Account/AccountConsoleClientTest.php +++ b/tests/e2e/Services/Account/AccountConsoleClientTest.php @@ -14,7 +14,12 @@ class AccountConsoleClientTest extends Scope use ProjectConsole; use SideClient; - public function testDeleteAccount(): void + /** + * Test that account deletion succeeds even with active team memberships. + * When the user is the sole owner and only member of a team, the team + * should be cleaned up automatically. + */ + public function testDeleteAccountWithMembership(): void { $email = uniqid() . 'user@localhost.test'; $password = 'password'; @@ -46,7 +51,7 @@ class AccountConsoleClientTest extends Scope $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; - // create team + // Create team — user becomes sole owner and only member $team = $this->client->call(Client::METHOD_POST, '/teams', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', @@ -58,7 +63,51 @@ class AccountConsoleClientTest extends Scope ]); $this->assertEquals($team['headers']['status-code'], 201); - $teamId = $team['body']['$id']; + // Account deletion should succeed even with active membership + $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + } + + /** + * Test that account deletion works when the user has no team memberships. + */ + public function testDeleteAccountWithoutMembership(): void + { + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals($response['headers']['status-code'], 201); + + $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ 'origin' => 'http://localhost', @@ -67,27 +116,7 @@ class AccountConsoleClientTest extends Scope 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); - $this->assertEquals($response['headers']['status-code'], 400); - - // DELETE TEAM - $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); - $this->assertEquals($response['headers']['status-code'], 204); - - $this->assertEventually(function () use ($session) { - $response = $this->client->call(Client::METHOD_DELETE, '/account', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, - ])); - - $this->assertEquals(204, $response['headers']['status-code']); - }, 10_000, 500); + $this->assertEquals(204, $response['headers']['status-code']); } public function testSessionAlert(): void From 16ed60a5c358a50cb3fd9ec606752efda8765156 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 02:02:09 +0000 Subject: [PATCH 02/51] Filter unconfirmed members when transferring team ownership Prevent unconfirmed (pending invite) members from being promoted to owner or set as the team's primary user during membership/account deletion by adding a Query::equal('confirm', [true]) filter to the relevant findOne queries. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 1 + src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php | 1 + src/Appwrite/Platform/Workers/Deletes.php | 1 + 3 files changed, 3 insertions(+) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6ac6373c87..83fc7465c9 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -667,6 +667,7 @@ Http::delete('/v1/account') $nextMember = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), Query::notEqual('userInternalId', $user->getSequence()), + Query::equal('confirm', [true]), ]); if (!$nextMember->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index 3b516c2d60..d055ecb23f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -126,6 +126,7 @@ class Delete extends Action if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) { $membership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ]); if (!$membership->isEmpty()) { diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 9508f784bd..a138f7c93b 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -856,6 +856,7 @@ class Deletes extends Action if ($team->getAttribute('userInternalId') === $userInternalId) { $nextMembership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ]); if ($nextMembership !== false && !$nextMembership->isEmpty()) { From 4297c70f58786f63c9ace630727baccd4b60b9e7 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 05:22:02 +0000 Subject: [PATCH 03/51] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20safer=20orphan=20approach,=20veteran=20ordering,=20?= =?UTF-8?q?deduplicate=20transfer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove team deletion for sole owner+sole member case; let orphan teams be cleaned up by Cloud's inactive project cleanup (safer, avoids accidental data loss) - Add explicit ordering by $createdAt so the most veteran member gets ownership transfer, with limit(1) for clarity - Remove confirm filter on primary user transfer in membership deletion so all members (including unconfirmed) are considered - Remove redundant ownership transfer from Deletes worker since the API controller already handles it before queueing Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 19 +++---------------- .../Modules/Teams/Http/Memberships/Delete.php | 1 - src/Appwrite/Platform/Workers/Deletes.php | 15 --------------- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 83fc7465c9..db2d5d2a26 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -647,27 +647,14 @@ Http::delete('/v1/account') $totalMembers = $team->getAttribute('total', 0); - if ($isSoleOwner && $totalMembers <= 1) { - // User is the only owner and the only member — delete the team. - // The team deletion worker will clean up associated projects and resources. - $dbForProject->deleteDocument('teams', $team->getId()); - - $queueForDeletes - ->setType(DELETE_TYPE_TEAM_PROJECTS) - ->setDocument($team) - ->trigger(); - - $queueForDeletes - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($team) - ->trigger(); - } elseif ($isSoleOwner) { + if ($isSoleOwner && $totalMembers > 1) { // User is the sole owner but other members exist — transfer ownership // to the next member before removing this user's membership. $nextMember = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), Query::notEqual('userInternalId', $user->getSequence()), - Query::equal('confirm', [true]), + Query::orderAsc('$createdAt'), + Query::limit(1), ]); if (!$nextMember->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index d055ecb23f..3b516c2d60 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -126,7 +126,6 @@ class Delete extends Action if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) { $membership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), - Query::equal('confirm', [true]), ]); if (!$membership->isEmpty()) { diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index a138f7c93b..5476aee529 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -851,21 +851,6 @@ class Deletes extends Action $team = $dbForProject->getDocument('teams', $teamId); if (!$team->isEmpty()) { $dbForProject->decreaseDocumentAttribute('teams', $teamId, 'total', 1, 0); - - // If this user was the team's primary user, transfer to the next member - if ($team->getAttribute('userInternalId') === $userInternalId) { - $nextMembership = $dbForProject->findOne('memberships', [ - Query::equal('teamInternalId', [$team->getSequence()]), - Query::equal('confirm', [true]), - ]); - - if ($nextMembership !== false && !$nextMembership->isEmpty()) { - $dbForProject->updateDocument('teams', $team->getId(), new Document([ - 'userId' => $nextMembership->getAttribute('userId'), - 'userInternalId' => $nextMembership->getAttribute('userInternalId'), - ])); - } - } } } }); From 8f6530d1e7a92e89092294c07f38c47560b6ef48 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 05:34:04 +0000 Subject: [PATCH 04/51] fix: remove unused $userInternalId from closure Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Appwrite/Platform/Workers/Deletes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 5476aee529..c420444112 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -845,7 +845,7 @@ class Deletes extends Action $this->deleteByGroup('memberships', [ Query::equal('userInternalId', [$userInternalId]), Query::orderAsc() - ], $dbForProject, function (Document $document) use ($dbForProject, $userInternalId) { + ], $dbForProject, function (Document $document) use ($dbForProject) { if ($document->getAttribute('confirm')) { // Count only confirmed members $teamId = $document->getAttribute('teamId'); $team = $dbForProject->getDocument('teams', $teamId); From ba32012744108b430431ded3cb42d73bddfee486 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 07:11:32 +0000 Subject: [PATCH 05/51] fix: filter unconfirmed members from owner count, ownership transfer, and primary user transfer Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 4 +++- .../Platform/Modules/Teams/Http/Memberships/Delete.php | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index db2d5d2a26..5ff21eee7c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -638,7 +638,8 @@ Http::delete('/v1/account') collection: 'memberships', queries: [ Query::contains('roles', ['owner']), - Query::equal('teamInternalId', [$team->getSequence()]) + Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ], max: 2 ); @@ -653,6 +654,7 @@ Http::delete('/v1/account') $nextMember = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), Query::notEqual('userInternalId', $user->getSequence()), + Query::equal('confirm', [true]), Query::orderAsc('$createdAt'), Query::limit(1), ]); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php index 3b516c2d60..d055ecb23f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Delete.php @@ -126,6 +126,7 @@ class Delete extends Action if ($team->getAttribute('userInternalId') === $membership->getAttribute('userInternalId')) { $membership = $dbForProject->findOne('memberships', [ Query::equal('teamInternalId', [$team->getSequence()]), + Query::equal('confirm', [true]), ]); if (!$membership->isEmpty()) { From cc82b1a5cf1d4e90282dd70b28e05a79be112569 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Sun, 5 Apr 2026 07:15:35 +0000 Subject: [PATCH 06/51] fix: don't promote non-owners on account deletion, leave team orphaned instead Co-Authored-By: Claude Opus 4.6 (1M context) --- app/controllers/api/account.php | 40 ++------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5ff21eee7c..f05a49c6bb 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -632,44 +632,8 @@ Http::delete('/v1/account') continue; } - $isSoleOwner = false; - if (in_array('owner', $membership->getAttribute('roles', []))) { - $ownersCount = $dbForProject->count( - collection: 'memberships', - queries: [ - Query::contains('roles', ['owner']), - Query::equal('teamInternalId', [$team->getSequence()]), - Query::equal('confirm', [true]), - ], - max: 2 - ); - $isSoleOwner = ($ownersCount === 1); - } - - $totalMembers = $team->getAttribute('total', 0); - - if ($isSoleOwner && $totalMembers > 1) { - // User is the sole owner but other members exist — transfer ownership - // to the next member before removing this user's membership. - $nextMember = $dbForProject->findOne('memberships', [ - Query::equal('teamInternalId', [$team->getSequence()]), - Query::notEqual('userInternalId', $user->getSequence()), - Query::equal('confirm', [true]), - Query::orderAsc('$createdAt'), - Query::limit(1), - ]); - - if (!$nextMember->isEmpty()) { - $roles = $nextMember->getAttribute('roles', []); - if (!in_array('owner', $roles)) { - $roles[] = 'owner'; - $authorization->skip(fn () => $dbForProject->updateDocument('memberships', $nextMember->getId(), new Document([ - 'roles' => $roles, - ]))); - $dbForProject->purgeCachedDocument('users', $nextMember->getAttribute('userId')); - } - } - } + // Team is left as-is — we don't promote non-owner members to owner. + // Orphan teams are cleaned up later by Cloud's inactive project cleanup. } } From 6da132db4697a6f3c38873b5f8e6abce68efe04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:05:27 +0200 Subject: [PATCH 07/51] Remove SMS templates and support null locale for mail templates --- app/controllers/api/projects.php | 226 ++----------------------------- 1 file changed, 11 insertions(+), 215 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5b82e6c1a3..3b83c37acd 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -833,74 +833,6 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Get custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'getSmsTemplate', - description: '/docs/references/projects/get-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.getSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'getSMSTemplate', - description: '/docs/references/projects/get-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['sms.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - $template = [ - 'message' => Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl')->render(), - ]; - } - - $template['type'] = $type; - $template['locale'] = $locale; - - $response->dynamic(new Document($template), Response::MODEL_SMS_TEMPLATE); - }); - - Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') ->groups(['api', 'projects']) @@ -920,11 +852,12 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - + ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1000,73 +933,6 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); -Http::patch('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Update custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSmsTemplate', - description: '/docs/references/projects/update-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'updateSMSTemplate', - description: '/docs/references/projects/update-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->param('message', '', new Text(0), 'Template message') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $message, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $templates['sms.' . $type . '-' . $locale] = [ - 'message' => $message - ]; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'message' => $message, - 'type' => $type, - 'locale' => $locale, - ]), Response::MODEL_SMS_TEMPLATE); - }); - Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Update custom email templates') ->groups(['api', 'projects']) @@ -1086,7 +952,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -1094,7 +960,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; $project = $dbForPlatform->getDocument('projects', $projectId); @@ -1124,78 +991,6 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ]), Response::MODEL_EMAIL_TEMPLATE); }); -Http::delete('/v1/projects/:projectId/templates/sms/:type/:locale') - ->desc('Reset custom SMS template') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteSmsTemplate', - description: '/docs/references/projects/delete-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - contentType: ContentType::JSON, - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.deleteSMSTemplate', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'templates', - name: 'deleteSMSTemplate', - description: '/docs/references/projects/delete-sms-template.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_SMS_TEMPLATE, - ) - ], - contentType: ContentType::JSON - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('type', '', new WhiteList(Config::getParam('locale-templates')['sms'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $templates = $project->getAttribute('templates', []); - $template = $templates['sms.' . $type . '-' . $locale] ?? null; - - if (is_null($template)) { - throw new Exception(Exception::PROJECT_TEMPLATE_DEFAULT_DELETION); - } - - unset($template['sms.' . $type . '-' . $locale]); - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('templates', $templates)); - - $response->dynamic(new Document([ - 'type' => $type, - 'locale' => $locale, - 'message' => $template['message'] - ]), Response::MODEL_SMS_TEMPLATE); - }); - Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) @@ -1216,11 +1011,12 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', false, ['localeCodes']) + ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { - + ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + $locale = $locale ?? 'worldwide'; + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { From dc39af50a12f2b74d75ffbdef0173beb3c667144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:05:46 +0200 Subject: [PATCH 08/51] Support for worldwide fallback custom template for all project emails --- app/controllers/api/account.php | 25 ++++++++++++++----- src/Appwrite/Bus/Listeners/Mails.php | 4 ++- .../Http/Account/MFA/Challenges/Create.php | 8 ++++-- .../Modules/Teams/Http/Memberships/Create.php | 8 ++++-- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 0035778523..357c071c85 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2265,7 +2265,10 @@ Http::post('/v1/account/tokens/magic-url') $subject = $locale->getText("emails.magicSession.subject"); $preview = $locale->getText("emails.magicSession.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? []; + + $customTemplate = + $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.magicSession-' . 'worldwide'] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -2575,7 +2578,9 @@ Http::post('/v1/account/tokens/email') $preview = $locale->getText("emails.otpSession.preview"); $heading = $locale->getText("emails.otpSession.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.otpSession-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -2968,7 +2973,9 @@ Http::post('/v1/account/tokens/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.login-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } @@ -3726,7 +3733,9 @@ Http::post('/v1/account/recovery') $body = $locale->getText("emails.recovery.body"); $subject = $locale->getText("emails.recovery.subject"); $preview = $locale->getText("emails.recovery.preview"); - $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.recovery-worldwide'] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); $message @@ -4034,7 +4043,9 @@ Http::post('/v1/account/verifications/email') $subject = $locale->getText("emails.verification.subject"); $heading = $locale->getText("emails.verification.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.verification-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -4333,7 +4344,9 @@ Http::post('/v1/account/verifications/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.verification-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 2ffcbc9aa4..7b33baced5 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -71,7 +71,9 @@ class Mails extends Listener throw new \Exception('Invalid template path'); } - $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? []; + $customTemplate = + $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? + $project->getAttribute('templates', [])['email.sessionAlert-worldwide'] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject'); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 20a6afed2e..6dc4f024a7 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -170,7 +170,9 @@ class Create extends Action $message = Template::fromFile($templatesPath . '/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.mfaChallenge-worldwide'] ?? []; if (!empty($customTemplate)) { $message = $customTemplate['message'] ?? $message; } @@ -223,7 +225,9 @@ class Create extends Action $preview = $locale->getText("emails.mfaChallenge.preview"); $heading = $locale->getText("emails.mfaChallenge.heading"); - $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.mfaChallenge-worldwide'] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 5edc69f445..3a8d4460cd 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -324,7 +324,9 @@ class Create extends Action $body = $locale->getText('emails.invitation.body'); $preview = $locale->getText('emails.invitation.preview'); $subject = $locale->getText('emails.invitation.subject'); - $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.invitation-worldwide'] ?? []; $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl'); $message @@ -407,7 +409,9 @@ class Create extends Action $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl'); - $customTemplate = $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? []; + $customTemplate = + $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? + $project->getAttribute('templates', [])['sms.invitation-worldwide'] ?? []; if (! empty($customTemplate)) { $message = $customTemplate['message']; } From 0da185e6894d82f6156ca3fe82e20e718206332c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:17:55 +0200 Subject: [PATCH 09/51] Refactor fixes --- app/controllers/api/account.php | 4 +-- app/controllers/api/projects.php | 3 +- app/init/models.php | 2 -- src/Appwrite/SDK/Specification/Format.php | 10 ------ src/Appwrite/Utopia/Response.php | 1 - .../Utopia/Response/Model/TemplateSMS.php | 32 ------------------- 6 files changed, 4 insertions(+), 48 deletions(-) delete mode 100644 src/Appwrite/Utopia/Response/Model/TemplateSMS.php diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 357c071c85..a913a3bd90 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2267,8 +2267,8 @@ Http::post('/v1/account/tokens/magic-url') $preview = $locale->getText("emails.magicSession.preview"); $customTemplate = - $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.magicSession-' . 'worldwide'] ?? []; + $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? + $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 3b83c37acd..0cd5a27c3a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -865,7 +865,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] ?? null; + $template = $templates['email.' . $type . '-' . $locale] + ?? ($locale !== 'worldwide' ? ($templates['email.' . $type . '-worldwide'] ?? null) : null); $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..9aa21e992b 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -135,7 +135,6 @@ use Appwrite\Utopia\Response\Model\TemplateFramework; use Appwrite\Utopia\Response\Model\TemplateFunction; use Appwrite\Utopia\Response\Model\TemplateRuntime; use Appwrite\Utopia\Response\Model\TemplateSite; -use Appwrite\Utopia\Response\Model\TemplateSMS; use Appwrite\Utopia\Response\Model\TemplateVariable; use Appwrite\Utopia\Response\Model\Token; use Appwrite\Utopia\Response\Model\Topic; @@ -373,7 +372,6 @@ Response::setModel(new Headers()); Response::setModel(new Specification()); Response::setModel(new Rule()); Response::setModel(new Schedule()); -Response::setModel(new TemplateSMS()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); Response::setModel(new MFAChallenge()); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 91b090a9f6..eb1b45142e 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -595,16 +595,6 @@ abstract class Format return 'EmailTemplateLocale'; } break; - case 'getSmsTemplate': - case 'updateSmsTemplate': - case 'deleteSmsTemplate': - switch ($param) { - case 'type': - return 'SmsTemplateType'; - case 'locale': - return 'SmsTemplateLocale'; - } - break; case 'createPlatform': switch ($param) { case 'type': diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 04d2813e30..d747373b59 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -265,7 +265,6 @@ class Response extends SwooleResponse public const MODEL_VARIABLE = 'variable'; public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; - public const MODEL_SMS_TEMPLATE = 'smsTemplate'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; // Health diff --git a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php b/src/Appwrite/Utopia/Response/Model/TemplateSMS.php deleted file mode 100644 index 2b19ef4878..0000000000 --- a/src/Appwrite/Utopia/Response/Model/TemplateSMS.php +++ /dev/null @@ -1,32 +0,0 @@ - Date: Wed, 15 Apr 2026 18:29:43 +0200 Subject: [PATCH 10/51] More cleanup of sms templates --- app/config/locale/templates.php | 6 ---- .../projects/delete-sms-template.md | 1 - docs/references/projects/get-sms-template.md | 1 - .../projects/update-sms-template.md | 1 - .../Utopia/Response/Model/Template.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 36 ------------------- 6 files changed, 1 insertion(+), 46 deletions(-) delete mode 100644 docs/references/projects/delete-sms-template.md delete mode 100644 docs/references/projects/get-sms-template.md delete mode 100644 docs/references/projects/update-sms-template.md diff --git a/app/config/locale/templates.php b/app/config/locale/templates.php index 6aa376678a..680034554b 100644 --- a/app/config/locale/templates.php +++ b/app/config/locale/templates.php @@ -9,11 +9,5 @@ return [ 'mfaChallenge', 'sessionAlert', 'otpSession' - ], - 'sms' => [ - 'verification', - 'login', - 'invitation', - 'mfaChallenge' ] ]; diff --git a/docs/references/projects/delete-sms-template.md b/docs/references/projects/delete-sms-template.md deleted file mode 100644 index c5a7e6cac9..0000000000 --- a/docs/references/projects/delete-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Reset a custom SMS template to its default value. This endpoint removes any custom message and restores the template to its original state. \ No newline at end of file diff --git a/docs/references/projects/get-sms-template.md b/docs/references/projects/get-sms-template.md deleted file mode 100644 index 6ef1d93029..0000000000 --- a/docs/references/projects/get-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Get a custom SMS template for the specified locale and type returning it's contents. \ No newline at end of file diff --git a/docs/references/projects/update-sms-template.md b/docs/references/projects/update-sms-template.md deleted file mode 100644 index 3e67f613b7..0000000000 --- a/docs/references/projects/update-sms-template.md +++ /dev/null @@ -1 +0,0 @@ -Update a custom SMS template for the specified locale and type. Use this endpoint to modify the content of your SMS templates. \ No newline at end of file diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php index 3ce9cacdb3..b0e127e07f 100644 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ b/src/Appwrite/Utopia/Response/Model/Template.php @@ -19,7 +19,7 @@ abstract class Template extends Model 'type' => self::TYPE_STRING, 'description' => 'Template locale', 'default' => '', - 'example' => 'en_us', + 'example' => 'worldwide', ]) ->addRule('message', [ 'type' => self::TYPE_STRING, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 7b9848e38f..597030413e 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1161,42 +1161,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); - - // Temporary disabled until implemented - // /** Get Default SMS Template */ - // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders())); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('{{token}}', $response['body']['message']); - - // /** Update SMS template */ - // $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders()), [ - // 'message' => 'Please verify your email {{token}}', - // ]); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']); - - // /** Get Updated SMS Template */ - // $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/sms/verification/en-us', array_merge([ - // 'content-type' => 'application/json', - // 'x-appwrite-project' => $this->getProject()['$id'], - // ], $this->getHeaders())); - - // $this->assertEquals(200, $response['headers']['status-code']); - // $this->assertEquals('verification', $response['body']['type']); - // $this->assertEquals('en-us', $response['body']['locale']); - // $this->assertEquals('Please verify your email {{token}}', $response['body']['message']); } public function testUpdateProjectAuthDuration(): void From 2b42487198f122dfae7917753efc6006a4822932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:30:06 +0200 Subject: [PATCH 11/51] Linter fix --- app/controllers/api/account.php | 2 +- app/controllers/api/projects.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a913a3bd90..4ddc9f8e92 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2265,7 +2265,7 @@ Http::post('/v1/account/tokens/magic-url') $subject = $locale->getText("emails.magicSession.subject"); $preview = $locale->getText("emails.magicSession.preview"); - + $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 0cd5a27c3a..6422ebb409 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -857,7 +857,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { $locale = $locale ?? 'worldwide'; - + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1017,7 +1017,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { $locale = $locale ?? 'worldwide'; - + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { From 90e14338787ad1e61f8325651abb1803a71890df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:38:08 +0200 Subject: [PATCH 12/51] Fix agent mistake --- app/controllers/api/projects.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 6422ebb409..411897d170 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -865,8 +865,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $templates = $project->getAttribute('templates', []); - $template = $templates['email.' . $type . '-' . $locale] - ?? ($locale !== 'worldwide' ? ($templates['email.' . $type . '-worldwide'] ?? null) : null); + $template = $templates['email.' . $type . '-' . $locale] ?? null; $localeObj = new Locale($locale); $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); From 590f0636946e9502910caa905a1cd482b6c95f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:40:29 +0200 Subject: [PATCH 13/51] Remove remaining sms leftover --- app/controllers/api/account.php | 14 -------------- .../Account/Http/Account/MFA/Challenges/Create.php | 7 ------- .../Modules/Teams/Http/Memberships/Create.php | 7 ------- 3 files changed, 28 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 4ddc9f8e92..03526bd49f 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2973,13 +2973,6 @@ Http::post('/v1/account/tokens/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.login-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.login-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $projectName = $project->getAttribute('name'); if ($project->getId() === 'console') { $projectName = $platform['platformName']; @@ -4344,13 +4337,6 @@ Http::post('/v1/account/verifications/phone') if ($sendSMS) { $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.verification-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.verification-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $messageContent = Template::fromString($locale->getText("sms.verification.body")); $messageContent ->setParam('{{project}}', $project->getAttribute('name')) diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 6dc4f024a7..319e080f25 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -170,13 +170,6 @@ class Create extends Action $message = Template::fromFile($templatesPath . '/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.mfaChallenge-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.mfaChallenge-worldwide'] ?? []; - if (!empty($customTemplate)) { - $message = $customTemplate['message'] ?? $message; - } - $messageContent = Template::fromString($locale->getText("sms.verification.body")); $messageContent ->setParam('{{project}}', $projectName) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 3a8d4460cd..161e817aed 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -409,13 +409,6 @@ class Create extends Action $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/sms-base.tpl'); - $customTemplate = - $project->getAttribute('templates', [])['sms.invitation-' . $locale->default] ?? - $project->getAttribute('templates', [])['sms.invitation-worldwide'] ?? []; - if (! empty($customTemplate)) { - $message = $customTemplate['message']; - } - $message = $message->setParam('{{token}}', $url); $message = $message->render(); From 8fd1c5d620c458539b9359ab30b6dab2a6d91c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:54:18 +0200 Subject: [PATCH 14/51] Remove worldwide to not be user-facing --- app/controllers/api/projects.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 411897d170..d4692a7774 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -928,7 +928,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $template['type'] = $type; - $template['locale'] = $locale; + $template['locale'] = $locale === 'worldwide' ? null : $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); @@ -982,7 +982,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale, + 'locale' => $locale === 'worldwide' ? null : $locale, 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, @@ -1036,7 +1036,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale, + 'locale' => $locale === 'worldwide' ? null : $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], From b510194f007c09d8e609c0f5c647a68a77b28958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 18:57:37 +0200 Subject: [PATCH 15/51] Expose "worldwide" locale --- app/controllers/api/projects.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d4692a7774..dbf608886f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -852,12 +852,13 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -928,7 +929,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') } $template['type'] = $type; - $template['locale'] = $locale === 'worldwide' ? null : $locale; + $template['locale'] = $locale; $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); @@ -961,8 +962,6 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->inject('response') ->inject('dbForPlatform') ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -982,7 +981,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale === 'worldwide' ? null : $locale, + 'locale' => $locale, 'senderName' => $senderName, 'senderEmail' => $senderEmail, 'subject' => $subject, @@ -1011,12 +1010,13 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { - $locale = $locale ?? 'worldwide'; - + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1036,7 +1036,7 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document([ 'type' => $type, - 'locale' => $locale === 'worldwide' ? null : $locale, + 'locale' => $locale, 'senderName' => $template['senderName'], 'senderEmail' => $template['senderEmail'], 'subject' => $template['subject'], From 6d2876ab268fd04d7d0001c85b2667d921c2781c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 19:01:35 +0200 Subject: [PATCH 16/51] New E2E tests --- .../Projects/ProjectsConsoleClientTest.php | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 597030413e..8fec74b6e3 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,6 +1163,97 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } + #[Group('smtpAndTemplates')] + public function testWorldwideTemplates(): void + { + $data = $this->setupProjectData(); + $id = $data['projectId']; + + /** Get default template without locale (should default to worldwide) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Get default template with explicit worldwide locale */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Set a worldwide email template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'Worldwide verify subject', + 'message' => 'Worldwide verify message {{url}}', + 'senderName' => 'Worldwide Sender', + 'senderEmail' => 'worldwide@appwrite.io', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide verify subject', $response['body']['subject']); + $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); + $this->assertEquals('Worldwide Sender', $response['body']['senderName']); + $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); + $this->assertEquals('verification', $response['body']['type']); + + /** Get the worldwide template back */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide verify subject', $response['body']['subject']); + $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); + $this->assertEquals('Worldwide Sender', $response['body']['senderName']); + $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + + /** Locale-specific template should still return default (not worldwide custom) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('en-us', $response['body']['locale']); + // en-us template was not customized, so it should return the default subject + $this->assertEquals('Account Verification for {{project}}', $response['body']['subject']); + + /** Delete the worldwide template */ + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + + /** After deletion, worldwide GET should return default template */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + // Should be back to default (no custom subject) + $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 55001a7daaa5170ca7c497ae0965fc39fc1a782f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 15 Apr 2026 19:27:26 +0200 Subject: [PATCH 17/51] New integration tests --- app/controllers/api/projects.php | 7 +- .../Projects/ProjectsConsoleClientTest.php | 132 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index dbf608886f..2163059963 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -953,7 +953,10 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', null, fn ($localeCodes) => new Nullable(new WhiteList($localeCodes)), 'Template locale', true, ['localeCodes']) + ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ + ...$localeCodes, + 'worldwide' + ])), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -961,7 +964,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 8fec74b6e3..a6f0c2815a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1254,6 +1254,138 @@ class ProjectsConsoleClientTest extends Scope $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); } + #[Group('smtpAndTemplates')] + public function testWorldwideFallbackOnMagicURL(): void + { + $smtpHost = System::getEnv('_APP_SMTP_HOST', 'maildev'); + $smtpPort = intval(System::getEnv('_APP_SMTP_PORT', '1025')); + $smtpUsername = System::getEnv('_APP_SMTP_USERNAME', 'user'); + $smtpPassword = System::getEnv('_APP_SMTP_PASSWORD', 'password'); + + /** Create a dedicated project for this test */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Worldwide Fallback Test Team', + ]); + $this->assertEquals(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Worldwide Fallback Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** Enable SMTP on the project pointing to maildev */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** Set worldwide magicSession template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'Worldwide Magic Login', + 'message' => 'Worldwide magic link: {{url}}', + 'senderName' => 'Worldwide Mailer', + 'senderEmail' => 'worldwide@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); + + /** Set German (de) magicSession template */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'subject' => 'German Magic Login', + 'message' => 'German magic link: {{url}}', + 'senderName' => 'German Mailer', + 'senderEmail' => 'german@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('German Magic Login', $response['body']['subject']); + + /** Trigger magic URL with English locale — should use worldwide fallback */ + $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'en', + ], [ + 'userId' => ID::unique(), + 'email' => $emailEn, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Trigger magic URL with German locale — should use German template */ + $emailDe = 'magic-de-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'de', + ], [ + 'userId' => ID::unique(), + 'email' => $emailDe, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Trigger magic URL with Polish locale — should use worldwide fallback */ + $emailPl = 'magic-pl-' . uniqid() . '@appwrite.io'; + $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'pl', + ], [ + 'userId' => ID::unique(), + 'email' => $emailPl, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Verify English email uses worldwide fallback template */ + $lastEmailEn = $this->getLastEmailByAddress($emailEn); + $this->assertEquals('Worldwide Magic Login', $lastEmailEn['subject']); + $this->assertEquals('worldwide@appwrite.io', $lastEmailEn['from'][0]['address']); + $this->assertEquals('Worldwide Mailer', $lastEmailEn['from'][0]['name']); + $this->assertStringContainsString('Worldwide magic link:', $lastEmailEn['html']); + + /** Verify German email uses the German-specific template */ + $lastEmailDe = $this->getLastEmailByAddress($emailDe); + $this->assertEquals('German Magic Login', $lastEmailDe['subject']); + $this->assertEquals('german@appwrite.io', $lastEmailDe['from'][0]['address']); + $this->assertEquals('German Mailer', $lastEmailDe['from'][0]['name']); + $this->assertStringContainsString('German magic link:', $lastEmailDe['html']); + + /** Verify Polish email uses worldwide fallback template */ + $lastEmailPl = $this->getLastEmailByAddress($emailPl); + $this->assertEquals('Worldwide Magic Login', $lastEmailPl['subject']); + $this->assertEquals('worldwide@appwrite.io', $lastEmailPl['from'][0]['address']); + $this->assertEquals('Worldwide Mailer', $lastEmailPl['from'][0]['name']); + $this->assertStringContainsString('Worldwide magic link:', $lastEmailPl['html']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 680cb04de792822fda78dc37f2c7d47258ae7a7f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:07:07 +0530 Subject: [PATCH 18/51] feat(specs): add discriminators for polymorphic responses --- app/init/models.php | 6 +- .../Repositories/Detections/Create.php | 1 + .../Http/Installations/Repositories/XList.php | 1 + src/Appwrite/SDK/Specification/Format.php | 111 ++++++++++++++++++ .../SDK/Specification/Format/OpenAPI3.php | 27 +++-- .../SDK/Specification/Format/Swagger2.php | 27 +++-- .../Utopia/Response/Model/Detection.php | 9 +- .../Response/Model/DetectionFramework.php | 6 +- .../Response/Model/DetectionRuntime.php | 6 +- .../Model/ProviderRepositoryFrameworkList.php | 30 +++++ .../Model/ProviderRepositoryRuntimeList.php | 30 +++++ 11 files changed, 231 insertions(+), 23 deletions(-) create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..c92295ae33 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -117,7 +117,9 @@ use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; +use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; +use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -190,8 +192,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_ Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION)); Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION)); Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION)); -Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK)); -Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME)); +Response::setModel(new ProviderRepositoryFrameworkList()); +Response::setModel(new ProviderRepositoryRuntimeList()); Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH)); Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK)); Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index 5dd5c6dcfa..6295fcd03b 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -307,6 +307,7 @@ class Create extends Action ]; } + $output->setAttribute('type', $type); $output->setAttribute('variables', $variables); $response->dynamic($output, $type === 'framework' ? Response::MODEL_DETECTION_FRAMEWORK : Response::MODEL_DETECTION_RUNTIME); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index d5b2b48175..b4172fabdf 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -313,6 +313,7 @@ class XList extends Action }, $repos); $response->dynamic(new Document([ + 'type' => $type, $type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos, 'total' => $total, ]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 91b090a9f6..f762d2bbaa 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,6 +263,117 @@ abstract class Format return $contents; } + protected function getRegisteredModel(string $type): Model + { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + } + + /** + * @param array $models + * @return array|null + */ + protected function getUnionDiscriminator(array $models, string $refPrefix): ?array + { + if (\count($models) < 2) { + return null; + } + + $candidateKeys = null; + + foreach ($models as $model) { + $keys = []; + + foreach ($model->conditions as $key => $condition) { + if ($this->isDiscriminatorConditionSupported($condition)) { + $keys[] = $key; + } + } + + $candidateKeys = $candidateKeys === null + ? $keys + : \array_values(\array_intersect($candidateKeys, $keys)); + } + + if (empty($candidateKeys)) { + return null; + } + + foreach ($candidateKeys as $key) { + $mapping = []; + $matchedModels = []; + + foreach ($models as $model) { + $rules = $model->getRules(); + + if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { + continue 2; + } + + $condition = $model->conditions[$key]; + $values = \is_array($condition) ? $condition : [$condition]; + + if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) { + $values = \array_values(\array_filter( + $values, + fn (mixed $value) => \in_array($value, $rules[$key]['enum'], true) + )); + } + + if ($values === []) { + continue 2; + } + + foreach ($values as $value) { + $mappingKey = \is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; + + if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $refPrefix . $model->getType()) { + continue 2; + } + + $mapping[$mappingKey] = $refPrefix . $model->getType(); + } + + $matchedModels[$model->getType()] = true; + } + + if (\count($matchedModels) !== \count($models)) { + continue; + } + + return [ + 'propertyName' => $key, + 'mapping' => $mapping, + ]; + } + + return null; + } + + protected function isDiscriminatorConditionSupported(mixed $condition): bool + { + if (\is_scalar($condition) || \is_bool($condition)) { + return true; + } + + if (!\is_array($condition) || $condition === []) { + return false; + } + + foreach ($condition as $value) { + if (!(\is_scalar($value) || \is_bool($value))) { + return false; + } + } + + return true; + } + protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index b611558826..c5af43f64d 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -316,9 +316,10 @@ class OpenAPI3 extends Format 'description' => $modelDescription, 'content' => [ $produces => [ - 'schema' => [ - 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model) - ], + 'schema' => \array_filter([ + 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model), + 'discriminator' => $this->getUnionDiscriminator($model, '#/components/schemas/'), + ]), ], ], ]; @@ -901,17 +902,25 @@ class OpenAPI3 extends Format if (\is_array($rule['type'])) { if ($rule['array']) { - $items = [ + $items = \array_filter([ 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; - }, $rule['type']) - ]; + }, $rule['type']), + 'discriminator' => $this->getUnionDiscriminator( + \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + '#/components/schemas/' + ), + ]); } else { - $items = [ + $items = \array_filter([ 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; - }, $rule['type']) - ]; + }, $rule['type']), + 'discriminator' => $this->getUnionDiscriminator( + \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + '#/components/schemas/' + ), + ]); } } else { $items = [ diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 413239f000..8e9a39a3c1 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -322,11 +322,12 @@ class Swagger2 extends Format } $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => $modelDescription, - 'schema' => [ + 'schema' => \array_filter([ 'x-oneOf' => \array_map(function ($m) { return ['$ref' => '#/definitions/' . $m->getType()]; - }, $model) - ], + }, $model), + 'x-discriminator' => $this->getUnionDiscriminator($model, '#/definitions/'), + ]), ]; } else { // Response definition using one type @@ -881,13 +882,21 @@ class Swagger2 extends Format if (\is_array($rule['type'])) { if ($rule['array']) { - $items = [ - 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']) - ]; + $items = \array_filter([ + 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), + 'x-discriminator' => $this->getUnionDiscriminator( + \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + '#/definitions/' + ), + ]); } else { - $items = [ - 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']) - ]; + $items = \array_filter([ + 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), + 'x-discriminator' => $this->getUnionDiscriminator( + \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + '#/definitions/' + ), + ]); } } else { $items = [ diff --git a/src/Appwrite/Utopia/Response/Model/Detection.php b/src/Appwrite/Utopia/Response/Model/Detection.php index 007182d1e9..d57e4a27c0 100644 --- a/src/Appwrite/Utopia/Response/Model/Detection.php +++ b/src/Appwrite/Utopia/Response/Model/Detection.php @@ -7,9 +7,16 @@ use Appwrite\Utopia\Response\Model; abstract class Detection extends Model { - public function __construct() + public function __construct(string $type) { $this + ->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Repository detection type.', + 'default' => $type, + 'example' => $type, + 'enum' => ['runtime', 'framework'], + ]) ->addRule('variables', [ 'type' => Response::MODEL_DETECTION_VARIABLE, 'description' => 'Environment variables found in .env files', diff --git a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php index 4cdf37bbcf..00f318ba4a 100644 --- a/src/Appwrite/Utopia/Response/Model/DetectionFramework.php +++ b/src/Appwrite/Utopia/Response/Model/DetectionFramework.php @@ -8,7 +8,11 @@ class DetectionFramework extends Detection { public function __construct() { - parent::__construct(); + $this->conditions = [ + 'type' => 'framework', + ]; + + parent::__construct('framework'); $this ->addRule('framework', [ diff --git a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php index 1e63929092..94368f890c 100644 --- a/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php +++ b/src/Appwrite/Utopia/Response/Model/DetectionRuntime.php @@ -8,7 +8,11 @@ class DetectionRuntime extends Detection { public function __construct() { - parent::__construct(); + $this->conditions = [ + 'type' => 'runtime', + ]; + + parent::__construct('runtime'); $this ->addRule('runtime', [ diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php new file mode 100644 index 0000000000..9816ce806e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php @@ -0,0 +1,30 @@ + 'framework', + ]; + + public function __construct() + { + parent::__construct( + 'Framework Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, + 'frameworkProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK + ); + + $this->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Repository detection type.', + 'default' => 'framework', + 'example' => 'framework', + 'enum' => ['runtime', 'framework'], + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php new file mode 100644 index 0000000000..a30fa4d3b5 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php @@ -0,0 +1,30 @@ + 'runtime', + ]; + + public function __construct() + { + parent::__construct( + 'Runtime Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + 'runtimeProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME + ); + + $this->addRule('type', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Repository detection type.', + 'default' => 'runtime', + 'example' => 'runtime', + 'enum' => ['runtime', 'framework'], + ]); + } +} From 6a7280e7dddb162b9cc994dec9aa462a57551fd8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:12:43 +0530 Subject: [PATCH 19/51] refactor(specs): inline discriminator condition checks --- src/Appwrite/SDK/Specification/Format.php | 47 ++++++++++------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index f762d2bbaa..76e8bc8678 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -287,13 +287,7 @@ abstract class Format $candidateKeys = null; foreach ($models as $model) { - $keys = []; - - foreach ($model->conditions as $key => $condition) { - if ($this->isDiscriminatorConditionSupported($condition)) { - $keys[] = $key; - } - } + $keys = \array_keys($model->conditions); $candidateKeys = $candidateKeys === null ? $keys @@ -316,7 +310,25 @@ abstract class Format } $condition = $model->conditions[$key]; - $values = \is_array($condition) ? $condition : [$condition]; + if (!\is_array($condition)) { + if (!\is_scalar($condition)) { + continue 2; + } + + $values = [$condition]; + } else { + if ($condition === []) { + continue 2; + } + + $values = $condition; + + foreach ($values as $value) { + if (!\is_scalar($value)) { + continue 3; + } + } + } if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) { $values = \array_values(\array_filter( @@ -355,25 +367,6 @@ abstract class Format return null; } - protected function isDiscriminatorConditionSupported(mixed $condition): bool - { - if (\is_scalar($condition) || \is_bool($condition)) { - return true; - } - - if (!\is_array($condition) || $condition === []) { - return false; - } - - foreach ($condition as $value) { - if (!(\is_scalar($value) || \is_bool($value))) { - return false; - } - } - - return true; - } - protected function getRequestEnumName(string $service, string $method, string $param): ?string { /* `$service` is `$namespace` */ From a0db02386088d162ebacdd2f17e99a9fe1151696 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:15:08 +0530 Subject: [PATCH 20/51] refactor(specs): simplify discriminator resolution --- src/Appwrite/SDK/Specification/Format.php | 52 ++++++++++++------- .../SDK/Specification/Format/OpenAPI3.php | 6 +-- .../SDK/Specification/Format/Swagger2.php | 6 +-- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 76e8bc8678..47c683b358 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -278,20 +278,16 @@ abstract class Format * @param array $models * @return array|null */ - protected function getUnionDiscriminator(array $models, string $refPrefix): ?array + protected function getDisciminator(array $models, string $refPrefix): ?array { if (\count($models) < 2) { return null; } - $candidateKeys = null; + $candidateKeys = \array_keys($models[0]->conditions); - foreach ($models as $model) { - $keys = \array_keys($model->conditions); - - $candidateKeys = $candidateKeys === null - ? $keys - : \array_values(\array_intersect($candidateKeys, $keys)); + foreach (\array_slice($models, 1) as $model) { + $candidateKeys = \array_values(\array_intersect($candidateKeys, \array_keys($model->conditions))); } if (empty($candidateKeys)) { @@ -300,34 +296,44 @@ abstract class Format foreach ($candidateKeys as $key) { $mapping = []; - $matchedModels = []; + $isValid = true; foreach ($models as $model) { $rules = $model->getRules(); + $condition = $model->conditions[$key] ?? null; if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { - continue 2; + $isValid = false; + break; } - $condition = $model->conditions[$key]; if (!\is_array($condition)) { if (!\is_scalar($condition)) { - continue 2; + $isValid = false; + break; } $values = [$condition]; } else { if ($condition === []) { - continue 2; + $isValid = false; + break; } $values = $condition; + $hasInvalidValue = false; foreach ($values as $value) { if (!\is_scalar($value)) { - continue 3; + $hasInvalidValue = true; + break; } } + + if ($hasInvalidValue) { + $isValid = false; + break; + } } if (isset($rules[$key]['enum']) && \is_array($rules[$key]['enum'])) { @@ -338,23 +344,29 @@ abstract class Format } if ($values === []) { - continue 2; + $isValid = false; + break; } + $ref = $refPrefix . $model->getType(); + foreach ($values as $value) { $mappingKey = \is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; - if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $refPrefix . $model->getType()) { - continue 2; + if (isset($mapping[$mappingKey]) && $mapping[$mappingKey] !== $ref) { + $isValid = false; + break; } - $mapping[$mappingKey] = $refPrefix . $model->getType(); + $mapping[$mappingKey] = $ref; } - $matchedModels[$model->getType()] = true; + if (!$isValid) { + break; + } } - if (\count($matchedModels) !== \count($models)) { + if (!$isValid || $mapping === []) { continue; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index c5af43f64d..bb9451ff4d 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -318,7 +318,7 @@ class OpenAPI3 extends Format $produces => [ 'schema' => \array_filter([ 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model), - 'discriminator' => $this->getUnionDiscriminator($model, '#/components/schemas/'), + 'discriminator' => $this->getDisciminator($model, '#/components/schemas/'), ]), ], ], @@ -906,7 +906,7 @@ class OpenAPI3 extends Format 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getUnionDiscriminator( + 'discriminator' => $this->getDisciminator( \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), '#/components/schemas/' ), @@ -916,7 +916,7 @@ class OpenAPI3 extends Format 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getUnionDiscriminator( + 'discriminator' => $this->getDisciminator( \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), '#/components/schemas/' ), diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 8e9a39a3c1..5258fc8b7c 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -326,7 +326,7 @@ class Swagger2 extends Format 'x-oneOf' => \array_map(function ($m) { return ['$ref' => '#/definitions/' . $m->getType()]; }, $model), - 'x-discriminator' => $this->getUnionDiscriminator($model, '#/definitions/'), + 'x-discriminator' => $this->getDisciminator($model, '#/definitions/'), ]), ]; } else { @@ -884,7 +884,7 @@ class Swagger2 extends Format if ($rule['array']) { $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getUnionDiscriminator( + 'x-discriminator' => $this->getDisciminator( \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), '#/definitions/' ), @@ -892,7 +892,7 @@ class Swagger2 extends Format } else { $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getUnionDiscriminator( + 'x-discriminator' => $this->getDisciminator( \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), '#/definitions/' ), From 945cdb3a99080fb8700e656ce4162c03b88c8fb4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:16:25 +0530 Subject: [PATCH 21/51] refactor(specs): inline model resolution --- src/Appwrite/SDK/Specification/Format.php | 11 ---------- .../SDK/Specification/Format/OpenAPI3.php | 20 +++++++++++++++++-- .../SDK/Specification/Format/Swagger2.php | 20 +++++++++++++++++-- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 47c683b358..b60d16274f 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,17 +263,6 @@ abstract class Format return $contents; } - protected function getRegisteredModel(string $type): Model - { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - } - /** * @param array $models * @return array|null diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index bb9451ff4d..72dac7064a 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -907,7 +907,15 @@ class OpenAPI3 extends Format return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), 'discriminator' => $this->getDisciminator( - \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/components/schemas/' ), ]); @@ -917,7 +925,15 @@ class OpenAPI3 extends Format return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), 'discriminator' => $this->getDisciminator( - \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/components/schemas/' ), ]); diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 5258fc8b7c..46280152e4 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -885,7 +885,15 @@ class Swagger2 extends Format $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), 'x-discriminator' => $this->getDisciminator( - \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/definitions/' ), ]); @@ -893,7 +901,15 @@ class Swagger2 extends Format $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), 'x-discriminator' => $this->getDisciminator( - \array_map(fn (string $type) => $this->getRegisteredModel($type), $rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/definitions/' ), ]); From b71d42d226610cbbb0dd6857538aee764ad87d11 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 11:29:16 +0530 Subject: [PATCH 22/51] fix(specs): rename getDisciminator typo and extract shared model resolution Fix misspelled method name (getDisciminator -> getDiscriminator) across Format, OpenAPI3, and Swagger2. Extract duplicated model-resolution lambda into Format::resolveModels(). Fix copy-pasted descriptions in ProviderRepository list models. --- src/Appwrite/SDK/Specification/Format.php | 19 +++++++++++++- .../SDK/Specification/Format/OpenAPI3.php | 26 ++++--------------- .../SDK/Specification/Format/Swagger2.php | 26 ++++--------------- .../Model/ProviderRepositoryFrameworkList.php | 2 +- .../Model/ProviderRepositoryRuntimeList.php | 2 +- 5 files changed, 30 insertions(+), 45 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index b60d16274f..2cf2759054 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,11 +263,28 @@ abstract class Format return $contents; } + /** + * @param array $types + * @return array + */ + protected function resolveModels(array $types): array + { + return \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $types); + } + /** * @param array $models * @return array|null */ - protected function getDisciminator(array $models, string $refPrefix): ?array + protected function getDiscriminator(array $models, string $refPrefix): ?array { if (\count($models) < 2) { return null; diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 72dac7064a..76fcdf9a4d 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -318,7 +318,7 @@ class OpenAPI3 extends Format $produces => [ 'schema' => \array_filter([ 'oneOf' => \array_map(fn ($m) => ['$ref' => '#/components/schemas/' . $m->getType()], $model), - 'discriminator' => $this->getDisciminator($model, '#/components/schemas/'), + 'discriminator' => $this->getDiscriminator($model, '#/components/schemas/'), ]), ], ], @@ -906,16 +906,8 @@ class OpenAPI3 extends Format 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getDisciminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), + 'discriminator' => $this->getDiscriminator( + $this->resolveModels($rule['type']), '#/components/schemas/' ), ]); @@ -924,16 +916,8 @@ class OpenAPI3 extends Format 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getDisciminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), + 'discriminator' => $this->getDiscriminator( + $this->resolveModels($rule['type']), '#/components/schemas/' ), ]); diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 46280152e4..2ca24dd921 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -326,7 +326,7 @@ class Swagger2 extends Format 'x-oneOf' => \array_map(function ($m) { return ['$ref' => '#/definitions/' . $m->getType()]; }, $model), - 'x-discriminator' => $this->getDisciminator($model, '#/definitions/'), + 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'), ]), ]; } else { @@ -884,32 +884,16 @@ class Swagger2 extends Format if ($rule['array']) { $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getDisciminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), + 'x-discriminator' => $this->getDiscriminator( + $this->resolveModels($rule['type']), '#/definitions/' ), ]); } else { $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getDisciminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), + 'x-discriminator' => $this->getDiscriminator( + $this->resolveModels($rule['type']), '#/definitions/' ), ]); diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php index 9816ce806e..4562b175a4 100644 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php @@ -21,7 +21,7 @@ class ProviderRepositoryFrameworkList extends BaseList $this->addRule('type', [ 'type' => self::TYPE_ENUM, - 'description' => 'Repository detection type.', + 'description' => 'Provider repository list type.', 'default' => 'framework', 'example' => 'framework', 'enum' => ['runtime', 'framework'], diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php index a30fa4d3b5..f2617d46f6 100644 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php @@ -21,7 +21,7 @@ class ProviderRepositoryRuntimeList extends BaseList $this->addRule('type', [ 'type' => self::TYPE_ENUM, - 'description' => 'Repository detection type.', + 'description' => 'Provider repository list type.', 'default' => 'runtime', 'example' => 'runtime', 'enum' => ['runtime', 'framework'], From 4545989c912f9debb000e1f3d43ad9021bba4648 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 12:22:37 +0530 Subject: [PATCH 23/51] fix(specs): remove type rule from list models, keep only on specific models --- app/init/models.php | 6 ++-- .../Http/Installations/Repositories/XList.php | 1 - .../Model/ProviderRepositoryFrameworkList.php | 30 ------------------- .../Model/ProviderRepositoryRuntimeList.php | 30 ------------------- 4 files changed, 2 insertions(+), 65 deletions(-) delete mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php delete mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php diff --git a/app/init/models.php b/app/init/models.php index c92295ae33..dd97b03652 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -117,9 +117,7 @@ use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; -use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; -use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -192,8 +190,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_ Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION)); Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION)); Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION)); -Response::setModel(new ProviderRepositoryFrameworkList()); -Response::setModel(new ProviderRepositoryRuntimeList()); +Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK)); +Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME)); Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH)); Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK)); Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index b4172fabdf..d5b2b48175 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -313,7 +313,6 @@ class XList extends Action }, $repos); $response->dynamic(new Document([ - 'type' => $type, $type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos, 'total' => $total, ]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST); diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php deleted file mode 100644 index 4562b175a4..0000000000 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php +++ /dev/null @@ -1,30 +0,0 @@ - 'framework', - ]; - - public function __construct() - { - parent::__construct( - 'Framework Provider Repositories List', - Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, - 'frameworkProviderRepositories', - Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK - ); - - $this->addRule('type', [ - 'type' => self::TYPE_ENUM, - 'description' => 'Provider repository list type.', - 'default' => 'framework', - 'example' => 'framework', - 'enum' => ['runtime', 'framework'], - ]); - } -} diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php deleted file mode 100644 index f2617d46f6..0000000000 --- a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php +++ /dev/null @@ -1,30 +0,0 @@ - 'runtime', - ]; - - public function __construct() - { - parent::__construct( - 'Runtime Provider Repositories List', - Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, - 'runtimeProviderRepositories', - Response::MODEL_PROVIDER_REPOSITORY_RUNTIME - ); - - $this->addRule('type', [ - 'type' => self::TYPE_ENUM, - 'description' => 'Provider repository list type.', - 'default' => 'runtime', - 'example' => 'runtime', - 'enum' => ['runtime', 'framework'], - ]); - } -} From 965836c8b4e7c5f5e9bea93ab3cb99944926b75c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 12:28:53 +0530 Subject: [PATCH 24/51] fix(specs): use swagger discriminator extension mapping --- src/Appwrite/SDK/Specification/Format.php | 17 ------- .../SDK/Specification/Format/OpenAPI3.php | 20 +++++++- .../SDK/Specification/Format/Swagger2.php | 51 ++++++++++++++----- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 2cf2759054..8cdb3ee5c3 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -263,23 +263,6 @@ abstract class Format return $contents; } - /** - * @param array $types - * @return array - */ - protected function resolveModels(array $types): array - { - return \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $types); - } - /** * @param array $models * @return array|null diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 76fcdf9a4d..84d99fc2f6 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -907,7 +907,15 @@ class OpenAPI3 extends Format return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), 'discriminator' => $this->getDiscriminator( - $this->resolveModels($rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/components/schemas/' ), ]); @@ -917,7 +925,15 @@ class OpenAPI3 extends Format return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), 'discriminator' => $this->getDiscriminator( - $this->resolveModels($rule['type']), + \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']), '#/components/schemas/' ), ]); diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 2ca24dd921..eede2183f0 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -322,12 +322,17 @@ class Swagger2 extends Format } $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => $modelDescription, - 'schema' => \array_filter([ - 'x-oneOf' => \array_map(function ($m) { - return ['$ref' => '#/definitions/' . $m->getType()]; - }, $model), - 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'), - ]), + 'schema' => (function () use ($model) { + $discriminator = $this->getDiscriminator($model, '#/definitions/'); + + return \array_filter([ + 'x-oneOf' => \array_map(function ($m) { + return ['$ref' => '#/definitions/' . $m->getType()]; + }, $model), + 'discriminator' => $discriminator['propertyName'] ?? null, + 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, + ]); + })(), ]; } else { // Response definition using one type @@ -882,20 +887,38 @@ class Swagger2 extends Format if (\is_array($rule['type'])) { if ($rule['array']) { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + $discriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getDiscriminator( - $this->resolveModels($rule['type']), - '#/definitions/' - ), + 'discriminator' => $discriminator['propertyName'] ?? null, + 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, ]); } else { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + $discriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'x-discriminator' => $this->getDiscriminator( - $this->resolveModels($rule['type']), - '#/definitions/' - ), + 'discriminator' => $discriminator['propertyName'] ?? null, + 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, ]); } } else { From 1493b7b8a6eb62e3852a183d75a628858c6262ea Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:02:57 +0530 Subject: [PATCH 25/51] feat(specs): unified discriminator with compound support and algo conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify getDiscriminator to produce a single discriminator object for both single-key and compound cases. Single-key returns standard {propertyName, mapping}. Compound falls back to extending the object with x-propertyNames and x-mapping for multi-property discrimination. Simplify call sites: OpenAPI3 uses 'discriminator', Swagger2 uses 'x-discriminator' — no more split keys. Add conditions to all 7 Algo models (AlgoArgon2, AlgoBcrypt, AlgoMd5, AlgoPhpass, AlgoScrypt, AlgoScryptModified, AlgoSha) to enable discriminator generation for hashOptions unions. --- src/Appwrite/SDK/Specification/Format.php | 73 ++++++++++++++++++- .../SDK/Specification/Format/OpenAPI3.php | 36 +++------ .../SDK/Specification/Format/Swagger2.php | 52 +++++-------- .../Utopia/Response/Model/AlgoArgon2.php | 4 + .../Utopia/Response/Model/AlgoBcrypt.php | 4 + .../Utopia/Response/Model/AlgoMd5.php | 4 + .../Utopia/Response/Model/AlgoPhpass.php | 4 + .../Utopia/Response/Model/AlgoScrypt.php | 4 + .../Response/Model/AlgoScryptModified.php | 4 + .../Utopia/Response/Model/AlgoSha.php | 4 + 10 files changed, 129 insertions(+), 60 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 8cdb3ee5c3..ce1eb97203 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -365,7 +365,78 @@ abstract class Format ]; } - return null; + // Single-key failed — try compound discriminator + return $this->getCompoundDiscriminator($models, $refPrefix); + } + + /** + * @param array $models + * @return array|null + */ + private function getCompoundDiscriminator(array $models, string $refPrefix): ?array + { + $allKeys = []; + foreach ($models as $model) { + foreach (\array_keys($model->conditions) as $key) { + if (!\in_array($key, $allKeys, true)) { + $allKeys[] = $key; + } + } + } + + if (\count($allKeys) < 2) { + return null; + } + + $primaryKey = $allKeys[0]; + $primaryMapping = []; + $compoundMapping = []; + + foreach ($models as $model) { + $rules = $model->getRules(); + $conditions = []; + + foreach ($model->conditions as $key => $condition) { + if (!isset($rules[$key]) || ($rules[$key]['required'] ?? false) !== true) { + return null; + } + + if (!\is_scalar($condition)) { + return null; + } + + $conditions[$key] = \is_bool($condition) ? ($condition ? 'true' : 'false') : (string) $condition; + } + + if (empty($conditions)) { + return null; + } + + $ref = $refPrefix . $model->getType(); + $compoundMapping[$ref] = $conditions; + + // Best-effort single-key mapping — last model with this value wins (fallback) + if (isset($conditions[$primaryKey])) { + $primaryMapping[$conditions[$primaryKey]] = $ref; + } + } + + // Verify compound uniqueness + $seen = []; + foreach ($compoundMapping as $conditions) { + $sig = \json_encode($conditions, JSON_THROW_ON_ERROR); + if (isset($seen[$sig])) { + return null; + } + $seen[$sig] = true; + } + + return \array_filter([ + 'propertyName' => $primaryKey, + 'mapping' => !empty($primaryMapping) ? $primaryMapping : null, + 'x-propertyNames' => $allKeys, + 'x-mapping' => $compoundMapping, + ]); } protected function getRequestEnumName(string $service, string $method, string $param): ?string diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 84d99fc2f6..fcff6ac2f4 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -901,41 +901,29 @@ class OpenAPI3 extends Format $rule['type'] = ($rule['type']) ? $rule['type'] : 'none'; if (\is_array($rule['type'])) { + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; + } + } + + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + if ($rule['array']) { $items = \array_filter([ 'anyOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getDiscriminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), - '#/components/schemas/' - ), + 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'), ]); } else { $items = \array_filter([ 'oneOf' => \array_map(function ($type) { return ['$ref' => '#/components/schemas/' . $type]; }, $rule['type']), - 'discriminator' => $this->getDiscriminator( - \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']), - '#/components/schemas/' - ), + 'discriminator' => $this->getDiscriminator($resolvedModels, '#/components/schemas/'), ]); } } else { diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index eede2183f0..8d47766117 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -322,17 +322,12 @@ class Swagger2 extends Format } $temp['responses'][(string)$response->getCode() ?? '500'] = [ 'description' => $modelDescription, - 'schema' => (function () use ($model) { - $discriminator = $this->getDiscriminator($model, '#/definitions/'); - - return \array_filter([ - 'x-oneOf' => \array_map(function ($m) { - return ['$ref' => '#/definitions/' . $m->getType()]; - }, $model), - 'discriminator' => $discriminator['propertyName'] ?? null, - 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, - ]); - })(), + 'schema' => \array_filter([ + 'x-oneOf' => \array_map(function ($m) { + return ['$ref' => '#/definitions/' . $m->getType()]; + }, $model), + 'x-discriminator' => $this->getDiscriminator($model, '#/definitions/'), + ]), ]; } else { // Response definition using one type @@ -886,39 +881,26 @@ class Swagger2 extends Format $rule['type'] = ($rule['type']) ?: 'none'; if (\is_array($rule['type'])) { - if ($rule['array']) { - $resolvedModels = \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } + $resolvedModels = \array_map(function (string $type) { + foreach ($this->models as $model) { + if ($model->getType() === $type) { + return $model; } + } - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']); - $discriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); + }, $rule['type']); + $xDiscriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); + if ($rule['array']) { $items = \array_filter([ 'x-anyOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'discriminator' => $discriminator['propertyName'] ?? null, - 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, + 'x-discriminator' => $xDiscriminator, ]); } else { - $resolvedModels = \array_map(function (string $type) { - foreach ($this->models as $model) { - if ($model->getType() === $type) { - return $model; - } - } - - throw new \RuntimeException("Unresolved model '{$type}'. Ensure the model is registered."); - }, $rule['type']); - $discriminator = $this->getDiscriminator($resolvedModels, '#/definitions/'); - $items = \array_filter([ 'x-oneOf' => \array_map(fn ($type) => ['$ref' => '#/definitions/' . $type], $rule['type']), - 'discriminator' => $discriminator['propertyName'] ?? null, - 'x-discriminator-mapping' => $discriminator['mapping'] ?? null, + 'x-discriminator' => $xDiscriminator, ]); } } else { diff --git a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php index 3e162bb905..a721235f94 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoArgon2.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoArgon2 extends Model { + public array $conditions = [ + 'type' => 'argon2', + ]; + public function __construct() { // No options if imported. If hashed by Appwrite, following configuration is available: diff --git a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php index 709dea1a41..ef15e5d50a 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoBcrypt.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoBcrypt extends Model { + public array $conditions = [ + 'type' => 'bcrypt', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php index 509ee70c31..26b2886330 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoMd5.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoMd5.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoMd5 extends Model { + public array $conditions = [ + 'type' => 'md5', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php index f16792086e..7d8400edec 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoPhpass.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoPhpass extends Model { + public array $conditions = [ + 'type' => 'phpass', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php index 4dda297d71..043a27166d 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoScrypt.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoScrypt extends Model { + public array $conditions = [ + 'type' => 'scrypt', + ]; + public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php index 40b9df1dad..24dd41bb77 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoScryptModified.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoScryptModified extends Model { + public array $conditions = [ + 'type' => 'scryptMod', + ]; + public function __construct() { $this diff --git a/src/Appwrite/Utopia/Response/Model/AlgoSha.php b/src/Appwrite/Utopia/Response/Model/AlgoSha.php index 2a0893adc4..52743ec26a 100644 --- a/src/Appwrite/Utopia/Response/Model/AlgoSha.php +++ b/src/Appwrite/Utopia/Response/Model/AlgoSha.php @@ -7,6 +7,10 @@ use Appwrite\Utopia\Response\Model; class AlgoSha extends Model { + public array $conditions = [ + 'type' => 'sha', + ]; + public function __construct() { // No options, because this can only be imported, and verifying doesnt require any configuration From 6dc17c91bce78310ab4f2bd58a8bb0fd537b220c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:08:14 +0530 Subject: [PATCH 26/51] trigger greptile From 98ec9e45c4e004ce5896e92003fd008a9fa8caf8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:16:13 +0530 Subject: [PATCH 27/51] fix(specs): narrow Detection type enum to each subclass's own value Each Detection subclass now declares only its own type value in the enum rather than sharing the full ['runtime', 'framework'] list. This prevents SDK validators from accepting invalid values on concrete models. --- src/Appwrite/Utopia/Response/Model/Detection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Detection.php b/src/Appwrite/Utopia/Response/Model/Detection.php index d57e4a27c0..9dfcc795d6 100644 --- a/src/Appwrite/Utopia/Response/Model/Detection.php +++ b/src/Appwrite/Utopia/Response/Model/Detection.php @@ -15,7 +15,7 @@ abstract class Detection extends Model 'description' => 'Repository detection type.', 'default' => $type, 'example' => $type, - 'enum' => ['runtime', 'framework'], + 'enum' => [$type], ]) ->addRule('variables', [ 'type' => Response::MODEL_DETECTION_VARIABLE, From 05d70f8826228502e4d919c256da610254a0d3ba Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:32:05 +0530 Subject: [PATCH 28/51] refactor(specs): rename x-propertyNames/x-mapping to x-discriminator-properties/x-union-typemap --- src/Appwrite/SDK/Specification/Format.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index ce1eb97203..d01ba20ae3 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -434,8 +434,8 @@ abstract class Format return \array_filter([ 'propertyName' => $primaryKey, 'mapping' => !empty($primaryMapping) ? $primaryMapping : null, - 'x-propertyNames' => $allKeys, - 'x-mapping' => $compoundMapping, + 'x-discriminator-properties' => $allKeys, + 'x-union-typemap' => $compoundMapping, ]); } From 19d0eb66c019a507b39dc23b5258d983746b320a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:09:38 +0200 Subject: [PATCH 29/51] Fix tests --- app/controllers/api/projects.php | 5 ++- .../Projects/ProjectsConsoleClientTest.php | 40 +++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 2163059963..60b6f5d770 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -868,8 +868,9 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $locale] ?? null; - $localeObj = new Locale($locale); - $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); + $fallbackLocale = System::getEnv('_APP_LOCALE', 'en'); + $localeObj = new Locale($locale === 'worldwide' ? $fallbackLocale : $locale); + $localeObj->setFallback($fallbackLocale); if (is_null($template)) { /** diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index a6f0c2815a..78b7661ab2 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1169,16 +1169,6 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectData(); $id = $data['projectId']; - /** Get default template without locale (should default to worldwide) */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - /** Get default template with explicit worldwide locale */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ 'content-type' => 'application/json', @@ -1221,7 +1211,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('worldwide', $response['body']['locale']); - /** Locale-specific template should still return default (not worldwide custom) */ + /** Locale-specific template should not return the worldwide custom template */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1230,8 +1220,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('verification', $response['body']['type']); $this->assertEquals('en-us', $response['body']['locale']); - // en-us template was not customized, so it should return the default subject - $this->assertEquals('Account Verification for {{project}}', $response['body']['subject']); + // en-us should NOT return the worldwide custom subject + $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); /** Delete the worldwide template */ $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ @@ -1325,6 +1315,30 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('German Magic Login', $response['body']['subject']); + /** Verify worldwide template is stored correctly */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); + + /** Verify German template is stored correctly */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('German Magic Login', $response['body']['subject']); + + /** Verify SMTP is enabled on the project */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['smtpEnabled']); + /** Trigger magic URL with English locale — should use worldwide fallback */ $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ From 4cf375de6d35c9625730fe26b385b0217fc25ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 Apr 2026 10:17:08 +0200 Subject: [PATCH 30/51] Re-add removed test --- app/controllers/api/projects.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 60b6f5d770..46806f79ed 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -858,7 +858,7 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') ])), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, ?string $locale, Response $response, Database $dbForPlatform) { + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 78b7661ab2..e4dfb8d85b 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1169,6 +1169,16 @@ class ProjectsConsoleClientTest extends Scope $data = $this->setupProjectData(); $id = $data['projectId']; + /** Get default template without locale (should default to worldwide) */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('verification', $response['body']['type']); + $this->assertEquals('worldwide', $response['body']['locale']); + /** Get default template with explicit worldwide locale */ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ 'content-type' => 'application/json', @@ -1247,10 +1257,10 @@ class ProjectsConsoleClientTest extends Scope #[Group('smtpAndTemplates')] public function testWorldwideFallbackOnMagicURL(): void { - $smtpHost = System::getEnv('_APP_SMTP_HOST', 'maildev'); - $smtpPort = intval(System::getEnv('_APP_SMTP_PORT', '1025')); - $smtpUsername = System::getEnv('_APP_SMTP_USERNAME', 'user'); - $smtpPassword = System::getEnv('_APP_SMTP_PASSWORD', 'password'); + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; /** Create a dedicated project for this test */ $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ From e472d98fe39a8661be61ca80e57a3e0ed55993fe Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 13:55:36 +0530 Subject: [PATCH 31/51] Revert "refactor(specs): rename x-propertyNames/x-mapping to x-discriminator-properties/x-union-typemap" This reverts commit 05d70f8826228502e4d919c256da610254a0d3ba. --- src/Appwrite/SDK/Specification/Format.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index d01ba20ae3..ce1eb97203 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -434,8 +434,8 @@ abstract class Format return \array_filter([ 'propertyName' => $primaryKey, 'mapping' => !empty($primaryMapping) ? $primaryMapping : null, - 'x-discriminator-properties' => $allKeys, - 'x-union-typemap' => $compoundMapping, + 'x-propertyNames' => $allKeys, + 'x-mapping' => $compoundMapping, ]); } From 807e8bec8be12af10e9e9af01d004d6cf68420df Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Apr 2026 16:29:42 +0530 Subject: [PATCH 32/51] feat(specs): add discriminator for provider repository list response union Add ProviderRepositoryFrameworkList and ProviderRepositoryRuntimeList model classes with conditions and type field so the listRepositories endpoint's oneOf response gets a discriminator on the type property. --- app/init/models.php | 6 ++-- .../Http/Installations/Repositories/XList.php | 1 + .../Model/ProviderRepositoryFrameworkList.php | 29 +++++++++++++++++++ .../Model/ProviderRepositoryRuntimeList.php | 29 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php create mode 100644 src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php diff --git a/app/init/models.php b/app/init/models.php index dd97b03652..c92295ae33 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -117,7 +117,9 @@ use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\ProviderRepository; use Appwrite\Utopia\Response\Model\ProviderRepositoryFramework; +use Appwrite\Utopia\Response\Model\ProviderRepositoryFrameworkList; use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntime; +use Appwrite\Utopia\Response\Model\ProviderRepositoryRuntimeList; use Appwrite\Utopia\Response\Model\ResourceToken; use Appwrite\Utopia\Response\Model\Row; use Appwrite\Utopia\Response\Model\Rule; @@ -190,8 +192,8 @@ Response::setModel(new BaseList('Site Templates List', Response::MODEL_TEMPLATE_ Response::setModel(new BaseList('Functions List', Response::MODEL_FUNCTION_LIST, 'functions', Response::MODEL_FUNCTION)); Response::setModel(new BaseList('Function Templates List', Response::MODEL_TEMPLATE_FUNCTION_LIST, 'templates', Response::MODEL_TEMPLATE_FUNCTION)); Response::setModel(new BaseList('Installations List', Response::MODEL_INSTALLATION_LIST, 'installations', Response::MODEL_INSTALLATION)); -Response::setModel(new BaseList('Framework Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, 'frameworkProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK)); -Response::setModel(new BaseList('Runtime Provider Repositories List', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, 'runtimeProviderRepositories', Response::MODEL_PROVIDER_REPOSITORY_RUNTIME)); +Response::setModel(new ProviderRepositoryFrameworkList()); +Response::setModel(new ProviderRepositoryRuntimeList()); Response::setModel(new BaseList('Branches List', Response::MODEL_BRANCH_LIST, 'branches', Response::MODEL_BRANCH)); Response::setModel(new BaseList('Frameworks List', Response::MODEL_FRAMEWORK_LIST, 'frameworks', Response::MODEL_FRAMEWORK)); Response::setModel(new BaseList('Runtimes List', Response::MODEL_RUNTIME_LIST, 'runtimes', Response::MODEL_RUNTIME)); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index d5b2b48175..b4172fabdf 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -313,6 +313,7 @@ class XList extends Action }, $repos); $response->dynamic(new Document([ + 'type' => $type, $type === 'framework' ? 'frameworkProviderRepositories' : 'runtimeProviderRepositories' => $repos, 'total' => $total, ]), ($type === 'framework') ? Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST : Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST); diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php new file mode 100644 index 0000000000..d1982e2f84 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryFrameworkList.php @@ -0,0 +1,29 @@ + 'framework', + ]; + + public function __construct() + { + parent::__construct( + 'Framework Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, + 'frameworkProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK + ); + + $this->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider repository list type.', + 'default' => 'framework', + 'example' => 'framework', + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php new file mode 100644 index 0000000000..f7ef1d7b5f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ProviderRepositoryRuntimeList.php @@ -0,0 +1,29 @@ + 'runtime', + ]; + + public function __construct() + { + parent::__construct( + 'Runtime Provider Repositories List', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + 'runtimeProviderRepositories', + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME + ); + + $this->addRule('type', [ + 'type' => self::TYPE_STRING, + 'description' => 'Provider repository list type.', + 'default' => 'runtime', + 'example' => 'runtime', + ]); + } +} From 463e5acf5069bf2331f2b95321f2fad6e4d84c42 Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Thu, 16 Apr 2026 16:57:19 +0530 Subject: [PATCH 33/51] compose fixes --- app/views/install/compose.phtml | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index ef4d4a1fe4..1bf36b7f6d 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -120,7 +120,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_SMTP_HOST - _APP_SMTP_PORT - _APP_SMTP_SECURE @@ -256,7 +255,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_USAGE_STATS - _APP_LOGGING_CONFIG @@ -287,7 +285,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-webhooks: @@ -315,7 +312,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -356,7 +352,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_STORAGE_DEVICE - _APP_STORAGE_S3_ACCESS_KEY - _APP_STORAGE_S3_SECRET @@ -416,7 +411,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-builds: @@ -453,7 +447,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_VCS_GITHUB_APP_NAME - _APP_VCS_GITHUB_PRIVATE_KEY @@ -529,7 +522,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG appwrite-worker-executions: @@ -592,7 +584,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_FUNCTIONS_TIMEOUT - _APP_SITES_TIMEOUT - _APP_COMPUTE_BUILD_TIMEOUT @@ -630,7 +621,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -673,7 +663,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_SMS_FROM - _APP_SMS_PROVIDER @@ -734,7 +723,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_LOGGING_CONFIG - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET @@ -773,7 +761,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_MAINTENANCE_INTERVAL - _APP_MAINTENANCE_RETENTION_EXECUTION - _APP_MAINTENANCE_RETENTION_CACHE @@ -806,7 +793,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -839,7 +825,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -871,7 +856,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -907,7 +891,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-executions: image: /: @@ -936,7 +919,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-task-scheduler-messages: image: /: @@ -965,7 +947,6 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DB_ADAPTER appwrite-assistant: @@ -1068,13 +1049,12 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); image: mongo:8.2.5 container_name: appwrite-mongodb <<: *x-logging + restart: unless-stopped networks: - appwrite volumes: - appwrite-mongodb:/data/db - appwrite-mongodb-keyfile:/data/keyfile - ports: - - "27017:27017" environment: - MONGO_INITDB_ROOT_USERNAME=root - MONGO_INITDB_ROOT_PASSWORD=${_APP_DB_ROOT_PASS} @@ -1205,7 +1185,6 @@ volumes: appwrite-mongodb: appwrite-mongodb-keyfile: - appwrite-mongodb-config: appwrite-redis: appwrite-cache: From 1e797b3f01b005d5a385ecf85e61646b0f7b2634 Mon Sep 17 00:00:00 2001 From: Aditya Oberai Date: Thu, 16 Apr 2026 17:00:28 +0000 Subject: [PATCH 34/51] Update React Admin template metadata --- app/config/templates/site.php | 12 ++++++------ ...dmin-dark.png => dashboard-react-admin-dark.png} | Bin ...in-light.png => dashboard-react-admin-light.png} | Bin 3 files changed, 6 insertions(+), 6 deletions(-) rename public/images/sites/templates/{crm-dashboard-react-admin-dark.png => dashboard-react-admin-dark.png} (100%) rename public/images/sites/templates/{crm-dashboard-react-admin-light.png => dashboard-react-admin-light.png} (100%) diff --git a/app/config/templates/site.php b/app/config/templates/site.php index 26f8e39817..b26d31f475 100644 --- a/app/config/templates/site.php +++ b/app/config/templates/site.php @@ -1487,13 +1487,13 @@ return [ ] ], [ - 'key' => 'crm-dashboard-react-admin', - 'name' => 'CRM dashboard with React Admin', - 'tagline' => 'A React-based admin dashboard template with CRM features.', + 'key' => 'dashboard-react-admin', + 'name' => 'E-commerce dashboard with React Admin', + 'tagline' => 'A React-based admin dashboard template with e-commerce features.', 'score' => 4, // 0 to 10 based on looks of screenshot (avoid 1,2,3,8,9,10 if possible) - 'useCases' => [SiteUseCases::DASHBOARD], - 'screenshotDark' => $url . '/images/sites/templates/crm-dashboard-react-admin-dark.png', - 'screenshotLight' => $url . '/images/sites/templates/crm-dashboard-react-admin-light.png', + 'useCases' => [SiteUseCases::DASHBOARD, SiteUseCases::ECOMMERCE], + 'screenshotDark' => $url . '/images/sites/templates/dashboard-react-admin-dark.png', + 'screenshotLight' => $url . '/images/sites/templates/dashboard-react-admin-light.png', 'frameworks' => [ getFramework('REACT', [ 'providerRootDirectory' => './react/react-admin', diff --git a/public/images/sites/templates/crm-dashboard-react-admin-dark.png b/public/images/sites/templates/dashboard-react-admin-dark.png similarity index 100% rename from public/images/sites/templates/crm-dashboard-react-admin-dark.png rename to public/images/sites/templates/dashboard-react-admin-dark.png diff --git a/public/images/sites/templates/crm-dashboard-react-admin-light.png b/public/images/sites/templates/dashboard-react-admin-light.png similarity index 100% rename from public/images/sites/templates/crm-dashboard-react-admin-light.png rename to public/images/sites/templates/dashboard-react-admin-light.png From 71b74e21a36773a71c3c8ea8f28525a6ca636574 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 13:36:48 +0530 Subject: [PATCH 35/51] added delay metric --- app/init/constants.php | 1 + app/realtime.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/init/constants.php b/app/init/constants.php index f2127cd666..12de293ff6 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -410,6 +410,7 @@ const METRIC_REALTIME_CONNECTIONS = 'realtime.connections'; const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent'; const METRIC_REALTIME_INBOUND = 'realtime.inbound'; const METRIC_REALTIME_OUTBOUND = 'realtime.outbound'; +const METRIC_REALTIME_DELIVERY_DELAY = 'realtime.delivery.delay'; // Resource types const RESOURCE_TYPE_PROJECTS = 'projects'; diff --git a/app/realtime.php b/app/realtime.php index 955832e93a..bbff9b98ab 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -600,6 +600,24 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, ]; + $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; + if (\is_string($updatedAt)) { + try { + $updatedAtDate = new \DateTimeImmutable($updatedAt); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $delayMs = (int) \max( + 0, + $nowTimestampMs - $updatedAtTimestampMs + ); + + $metrics[METRIC_REALTIME_DELIVERY_DELAY] = $delayMs; + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } + if ($outboundBytes > 0) { $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; } From b5ec92964c28b98bc554f87cf4b133fe82361f54 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 14:08:42 +0530 Subject: [PATCH 36/51] updated telemetry --- app/init/constants.php | 1 - app/realtime.php | 33 +++++++++++++++------------------ 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/app/init/constants.php b/app/init/constants.php index 12de293ff6..f2127cd666 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -410,7 +410,6 @@ const METRIC_REALTIME_CONNECTIONS = 'realtime.connections'; const METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT = 'realtime.messages.sent'; const METRIC_REALTIME_INBOUND = 'realtime.inbound'; const METRIC_REALTIME_OUTBOUND = 'realtime.outbound'; -const METRIC_REALTIME_DELIVERY_DELAY = 'realtime.delivery.delay'; // Resource types const RESOURCE_TYPE_PROJECTS = 'projects'; diff --git a/app/realtime.php b/app/realtime.php index bbff9b98ab..192672a2f3 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -398,6 +398,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); + $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram('realtime.server.delivery_delay', 'ms')); $attempts = 0; $start = time(); @@ -592,6 +593,20 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, if ($total > 0) { $register->get('telemetry.messageSentCounter')->add($total); $stats->incr($event['project'], 'messages', $total); + $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; + if (\is_string($updatedAt)) { + try { + $updatedAtDate = new \DateTimeImmutable($updatedAt); + $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); + $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; + $nowTimestampMs = (float) $now->format('U.u') * 1000; + $delayMs = (int) \max(0, $nowTimestampMs - $updatedAtTimestampMs); + + $register->get('telemetry.deliveryDelayHistogram')->record($delayMs); + } catch (\Throwable) { + // Ignore invalid timestamp payloads. + } + } $projectId = $event['project'] ?? null; @@ -600,24 +615,6 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, ]; - $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; - if (\is_string($updatedAt)) { - try { - $updatedAtDate = new \DateTimeImmutable($updatedAt); - $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); - $updatedAtTimestampMs = (float) $updatedAtDate->format('U.u') * 1000; - $nowTimestampMs = (float) $now->format('U.u') * 1000; - $delayMs = (int) \max( - 0, - $nowTimestampMs - $updatedAtTimestampMs - ); - - $metrics[METRIC_REALTIME_DELIVERY_DELAY] = $delayMs; - } catch (\Throwable) { - // Ignore invalid timestamp payloads. - } - } - if ($outboundBytes > 0) { $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; } From 11f23fdcfa3aa90cd862609e869ca430fb14f209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 10:52:21 +0200 Subject: [PATCH 37/51] Rework email templates PR after discussions --- app/controllers/api/account.php | 8 +- app/controllers/api/projects.php | 35 ++- src/Appwrite/Bus/Listeners/Mails.php | 2 +- .../Http/Account/MFA/Challenges/Create.php | 2 +- .../Modules/Teams/Http/Memberships/Create.php | 2 +- .../Utopia/Response/Model/Template.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 247 ------------------ 7 files changed, 25 insertions(+), 273 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 03526bd49f..7511f7d31f 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -2268,7 +2268,7 @@ Http::post('/v1/account/tokens/magic-url') $customTemplate = $project->getAttribute('templates', [])['email.magicSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.magicSession-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.magicSession-' . $locale->fallback] ?? []; $detector = new Detector($request->getUserAgent('UNKNOWN')); $agentOs = $detector->getOS(); @@ -2580,7 +2580,7 @@ Http::post('/v1/account/tokens/email') $customTemplate = $project->getAttribute('templates', [])['email.otpSession-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.otpSession-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.otpSession-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); @@ -3728,7 +3728,7 @@ Http::post('/v1/account/recovery') $preview = $locale->getText("emails.recovery.preview"); $customTemplate = $project->getAttribute('templates', [])['email.recovery-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.recovery-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.recovery-' . $locale->fallback] ?? []; $message = Template::fromFile(__DIR__ . '/../../config/locale/templates/email-inner-base.tpl'); $message @@ -4038,7 +4038,7 @@ Http::post('/v1/account/verifications/email') $customTemplate = $project->getAttribute('templates', [])['email.verification-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.verification-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.verification-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 46806f79ed..23d3af075a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -852,13 +852,13 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -868,9 +868,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $templates = $project->getAttribute('templates', []); $template = $templates['email.' . $type . '-' . $locale] ?? null; - $fallbackLocale = System::getEnv('_APP_LOCALE', 'en'); - $localeObj = new Locale($locale === 'worldwide' ? $fallbackLocale : $locale); - $localeObj->setFallback($fallbackLocale); + $localeObj = new Locale($locale); + $localeObj->setFallback(System::getEnv('_APP_LOCALE', 'en')); if (is_null($template)) { /** @@ -954,10 +953,7 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->param('subject', '', new Text(255), 'Email Subject') ->param('message', '', new Text(0), 'Template message') ->param('senderName', '', new Text(255, 0), 'Name of the email sender', true) @@ -965,7 +961,10 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ->param('replyTo', '', new Email(), 'Reply to email', true) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, string $subject, string $message, string $senderName, string $senderEmail, string $replyTo, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { @@ -1014,13 +1013,13 @@ Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('type', '', new WhiteList(Config::getParam('locale-templates')['email'] ?? [], true), 'Template type') - ->param('locale', 'worldwide', fn ($localeCodes) => new WhiteList(\array_merge([ - ...$localeCodes, - 'worldwide' - ])), 'Template locale', true, ['localeCodes']) + ->param('locale', '', fn ($localeCodes) => new WhiteList($localeCodes), 'Template locale', true, ['localeCodes']) ->inject('response') ->inject('dbForPlatform') - ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform) { + ->inject('locale') + ->action(function (string $projectId, string $type, string $locale, Response $response, Database $dbForPlatform, Locale $localeObject) { + $locale = $locale ?: $localeObject->default ?: $localeObject->fallback ?: System::getEnv('_APP_LOCALE', 'en'); + $project = $dbForPlatform->getDocument('projects', $projectId); if ($project->isEmpty()) { diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index 7b33baced5..e59bbe3536 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -73,7 +73,7 @@ class Mails extends Listener $customTemplate = $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? - $project->getAttribute('templates', [])['email.sessionAlert-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; $subject = $customTemplate['subject'] ?? $locale->getText('emails.sessionAlert.subject'); diff --git a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php index 319e080f25..14dc4e3237 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Create.php @@ -220,7 +220,7 @@ class Create extends Action $customTemplate = $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.mfaChallenge-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.mfaChallenge-' . $locale->fallback] ?? []; $smtpBaseTemplate = $project->getAttribute('smtpBaseTemplate', 'email-base'); $validator = new FileName(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 161e817aed..aa4ee2c66c 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -326,7 +326,7 @@ class Create extends Action $subject = $locale->getText('emails.invitation.subject'); $customTemplate = $project->getAttribute('templates', [])['email.invitation-' . $locale->default] ?? - $project->getAttribute('templates', [])['email.invitation-worldwide'] ?? []; + $project->getAttribute('templates', [])['email.invitation-' . $locale->fallback] ?? []; $message = Template::fromFile(APP_CE_CONFIG_DIR . '/locale/templates/email-inner-base.tpl'); $message diff --git a/src/Appwrite/Utopia/Response/Model/Template.php b/src/Appwrite/Utopia/Response/Model/Template.php index b0e127e07f..3ce9cacdb3 100644 --- a/src/Appwrite/Utopia/Response/Model/Template.php +++ b/src/Appwrite/Utopia/Response/Model/Template.php @@ -19,7 +19,7 @@ abstract class Template extends Model 'type' => self::TYPE_STRING, 'description' => 'Template locale', 'default' => '', - 'example' => 'worldwide', + 'example' => 'en_us', ]) ->addRule('message', [ 'type' => self::TYPE_STRING, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e4dfb8d85b..597030413e 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,253 +1163,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } - #[Group('smtpAndTemplates')] - public function testWorldwideTemplates(): void - { - $data = $this->setupProjectData(); - $id = $data['projectId']; - - /** Get default template without locale (should default to worldwide) */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Get default template with explicit worldwide locale */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Set a worldwide email template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'Worldwide verify subject', - 'message' => 'Worldwide verify message {{url}}', - 'senderName' => 'Worldwide Sender', - 'senderEmail' => 'worldwide@appwrite.io', - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide verify subject', $response['body']['subject']); - $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); - $this->assertEquals('Worldwide Sender', $response['body']['senderName']); - $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); - $this->assertEquals('verification', $response['body']['type']); - - /** Get the worldwide template back */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide verify subject', $response['body']['subject']); - $this->assertEquals('Worldwide verify message {{url}}', $response['body']['message']); - $this->assertEquals('Worldwide Sender', $response['body']['senderName']); - $this->assertEquals('worldwide@appwrite.io', $response['body']['senderEmail']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - - /** Locale-specific template should not return the worldwide custom template */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/en-us', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('en-us', $response['body']['locale']); - // en-us should NOT return the worldwide custom subject - $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); - - /** Delete the worldwide template */ - $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - - /** After deletion, worldwide GET should return default template */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id . '/templates/email/verification/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('verification', $response['body']['type']); - $this->assertEquals('worldwide', $response['body']['locale']); - // Should be back to default (no custom subject) - $this->assertNotEquals('Worldwide verify subject', $response['body']['subject']); - } - - #[Group('smtpAndTemplates')] - public function testWorldwideFallbackOnMagicURL(): void - { - $smtpHost = 'maildev'; - $smtpPort = 1025; - $smtpUsername = 'user'; - $smtpPassword = 'password'; - - /** Create a dedicated project for this test */ - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Worldwide Fallback Test Team', - ]); - $this->assertEquals(201, $team['headers']['status-code']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'projectId' => ID::unique(), - 'name' => 'Worldwide Fallback Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default'), - ]); - $this->assertEquals(201, $project['headers']['status-code']); - $projectId = $project['body']['$id']; - - /** Enable SMTP on the project pointing to maildev */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'enabled' => true, - 'senderEmail' => 'mailer@appwrite.io', - 'senderName' => 'Mailer', - 'host' => $smtpHost, - 'port' => $smtpPort, - 'username' => $smtpUsername, - 'password' => $smtpPassword, - ]); - $this->assertEquals(200, $response['headers']['status-code']); - - /** Set worldwide magicSession template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'Worldwide Magic Login', - 'message' => 'Worldwide magic link: {{url}}', - 'senderName' => 'Worldwide Mailer', - 'senderEmail' => 'worldwide@appwrite.io', - ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); - - /** Set German (de) magicSession template */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'subject' => 'German Magic Login', - 'message' => 'German magic link: {{url}}', - 'senderName' => 'German Mailer', - 'senderEmail' => 'german@appwrite.io', - ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('German Magic Login', $response['body']['subject']); - - /** Verify worldwide template is stored correctly */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/worldwide', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('Worldwide Magic Login', $response['body']['subject']); - - /** Verify German template is stored correctly */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/templates/email/magicSession/de', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('German Magic Login', $response['body']['subject']); - - /** Verify SMTP is enabled on the project */ - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertTrue($response['body']['smtpEnabled']); - - /** Trigger magic URL with English locale — should use worldwide fallback */ - $emailEn = 'magic-en-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'en', - ], [ - 'userId' => ID::unique(), - 'email' => $emailEn, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Trigger magic URL with German locale — should use German template */ - $emailDe = 'magic-de-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'de', - ], [ - 'userId' => ID::unique(), - 'email' => $emailDe, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Trigger magic URL with Polish locale — should use worldwide fallback */ - $emailPl = 'magic-pl-' . uniqid() . '@appwrite.io'; - $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-locale' => 'pl', - ], [ - 'userId' => ID::unique(), - 'email' => $emailPl, - ]); - $this->assertEquals(201, $response['headers']['status-code']); - - /** Verify English email uses worldwide fallback template */ - $lastEmailEn = $this->getLastEmailByAddress($emailEn); - $this->assertEquals('Worldwide Magic Login', $lastEmailEn['subject']); - $this->assertEquals('worldwide@appwrite.io', $lastEmailEn['from'][0]['address']); - $this->assertEquals('Worldwide Mailer', $lastEmailEn['from'][0]['name']); - $this->assertStringContainsString('Worldwide magic link:', $lastEmailEn['html']); - - /** Verify German email uses the German-specific template */ - $lastEmailDe = $this->getLastEmailByAddress($emailDe); - $this->assertEquals('German Magic Login', $lastEmailDe['subject']); - $this->assertEquals('german@appwrite.io', $lastEmailDe['from'][0]['address']); - $this->assertEquals('German Mailer', $lastEmailDe['from'][0]['name']); - $this->assertStringContainsString('German magic link:', $lastEmailDe['html']); - - /** Verify Polish email uses worldwide fallback template */ - $lastEmailPl = $this->getLastEmailByAddress($emailPl); - $this->assertEquals('Worldwide Magic Login', $lastEmailPl['subject']); - $this->assertEquals('worldwide@appwrite.io', $lastEmailPl['from'][0]['address']); - $this->assertEquals('Worldwide Mailer', $lastEmailPl['from'][0]['name']); - $this->assertStringContainsString('Worldwide magic link:', $lastEmailPl['html']); - } - public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From 1b826df8f97aae963f9857898f043f4df0be836e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:24:59 +0200 Subject: [PATCH 38/51] Non-URL locale to allow optional --- app/controllers/api/projects.php | 9 ++++++--- src/Appwrite/Bus/Listeners/Mails.php | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 23d3af075a..439692e1dd 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -833,7 +833,8 @@ Http::post('/v1/projects/:projectId/smtp/tests') $response->noContent(); }); -Http::get('/v1/projects/:projectId/templates/email/:type/:locale') +Http::get('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Get custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -934,7 +935,8 @@ Http::get('/v1/projects/:projectId/templates/email/:type/:locale') $response->dynamic(new Document($template), Response::MODEL_EMAIL_TEMPLATE); }); -Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') +Http::patch('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Update custom email templates') ->groups(['api', 'projects']) ->label('scope', 'projects.write') @@ -993,7 +995,8 @@ Http::patch('/v1/projects/:projectId/templates/email/:type/:locale') ]), Response::MODEL_EMAIL_TEMPLATE); }); -Http::delete('/v1/projects/:projectId/templates/email/:type/:locale') +Http::delete('/v1/projects/:projectId/templates/email') + ->alias('/v1/projects/:projectId/templates/email/:type/:locale') ->desc('Delete custom email template') ->groups(['api', 'projects']) ->label('scope', 'projects.write') diff --git a/src/Appwrite/Bus/Listeners/Mails.php b/src/Appwrite/Bus/Listeners/Mails.php index e59bbe3536..3d31101d2b 100644 --- a/src/Appwrite/Bus/Listeners/Mails.php +++ b/src/Appwrite/Bus/Listeners/Mails.php @@ -72,7 +72,7 @@ class Mails extends Listener } $customTemplate = - $project->getAttribute('templates', [])["email.sessionAlert-$event->locale"] ?? + $project->getAttribute('templates', [])["email.sessionAlert-" . $locale->default] ?? $project->getAttribute('templates', [])['email.sessionAlert-' . $locale->fallback] ?? []; $isBranded = $smtpBaseTemplate === APP_BRANDED_EMAIL_BASE_TEMPLATE; From bf9bb22ac5dd140d866e2a42d8690e574fc23320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:30:24 +0200 Subject: [PATCH 39/51] New tests --- .../Projects/ProjectsConsoleClientTest.php | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 597030413e..f937317b8f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1163,6 +1163,207 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('Please verify your email {{url}}', $response['body']['message']); } + #[Group('smtpAndTemplates')] + public function testSessionAlertLocaleFallback(): void + { + $smtpHost = 'maildev'; + $smtpPort = 1025; + $smtpUsername = 'user'; + $smtpPassword = 'password'; + + /** Create team */ + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'Session Alert Locale Fallback Test Team', + ]); + $this->assertEquals(201, $team['headers']['status-code']); + + /** Create project */ + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Session Alert Locale Fallback Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + /** Configure custom SMTP pointing to maildev */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/smtp', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true, + 'senderEmail' => 'mailer@appwrite.io', + 'senderName' => 'Mailer', + 'host' => $smtpHost, + 'port' => $smtpPort, + 'username' => $smtpUsername, + 'password' => $smtpPassword, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** + * Set custom sessionAlert template with no explicit locale. + * When locale is omitted, the server stores it under the request's + * default locale (en), which is the same slot used as the system-wide + * fallback when a session's locale has no dedicated template. + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'type' => 'sessionAlert', + // Intentionally no locale + 'subject' => 'Fallback sign-in alert', + 'message' => 'Fallback sign-in alert body', + 'senderName' => 'Fallback Mailer', + 'senderEmail' => 'fallback@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Fallback sign-in alert', $response['body']['subject']); + $this->assertEquals('Fallback sign-in alert body', $response['body']['message']); + $this->assertEquals('Fallback Mailer', $response['body']['senderName']); + $this->assertEquals('fallback@appwrite.io', $response['body']['senderEmail']); + + /** Set custom sessionAlert template for Slovak locale */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/templates/email', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'type' => 'sessionAlert', + 'locale' => 'sk', + 'subject' => 'Slovak sign-in alert', + 'message' => 'Slovak sign-in alert body', + 'senderName' => 'Slovak Mailer', + 'senderEmail' => 'sk@appwrite.io', + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Slovak sign-in alert', $response['body']['subject']); + $this->assertEquals('Slovak sign-in alert body', $response['body']['message']); + $this->assertEquals('Slovak Mailer', $response['body']['senderName']); + $this->assertEquals('sk@appwrite.io', $response['body']['senderEmail']); + + /** Enable session alerts */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/auth/session-alerts', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'alerts' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + /** Verify alerts are enabled */ + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertTrue($response['body']['authSessionAlerts']); + + /** Create user (email + password) in the project */ + $userEmail = 'session-alert-' . uniqid() . '@appwrite.io'; + $password = 'password'; + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $userEmail, + 'password' => $password, + 'name' => 'Session Alert User', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Prime first session — the listener suppresses the alert on the very + * first session of a user, so this session is setup only. + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** Create a new session with no locale — expect fallback (en) template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); + $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); + + /** Create a new session with German locale — expect fallback (en) template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'de', + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); + $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); + + /** Create a new session with Slovak locale — expect Slovak template */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-locale' => 'sk', + ], [ + 'email' => $userEmail, + 'password' => $password, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $lastEmail = $this->getLastEmailByAddress($userEmail); + $this->assertEquals('Slovak sign-in alert', $lastEmail['subject']); + $this->assertEquals('sk@appwrite.io', $lastEmail['from'][0]['address']); + $this->assertEquals('Slovak Mailer', $lastEmail['from'][0]['name']); + $this->assertStringContainsString('Slovak sign-in alert body', $lastEmail['html']); + + /** Cleanup — delete the project */ + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); + + /** Cleanup — delete the team */ + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(204, $response['headers']['status-code']); + } + public function testUpdateProjectAuthDuration(): void { $data = $this->setupProjectData(); From c97dd783353bf174edc9df7c5e7639a63bde1e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 11:40:05 +0200 Subject: [PATCH 40/51] Fix tests --- .../Projects/ProjectsConsoleClientTest.php | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f937317b8f..59ff5e353c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1180,6 +1180,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Session Alert Locale Fallback Test Team', ]); $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; /** Create project */ $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ @@ -1188,7 +1189,7 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders()), [ 'projectId' => ID::unique(), 'name' => 'Session Alert Locale Fallback Test', - 'teamId' => $team['body']['$id'], + 'teamId' => $teamId, 'region' => System::getEnv('_APP_REGION', 'default'), ]); $this->assertEquals(201, $project['headers']['status-code']); @@ -1307,9 +1308,17 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** + * Emails are delivered asynchronously via the mail queue, so maildev may + * still be catching up. The probe callback forces getLastEmailByAddress + * to keep polling until an email matching the expected `from` address + * appears — i.e. we await the new email rather than returning an older + * one already in the inbox from a previous session. + */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); - $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); @@ -1325,9 +1334,11 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** Probe on `from` address ensures we await a fallback-shaped email */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('fallback@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Fallback sign-in alert', $lastEmail['subject']); - $this->assertEquals('fallback@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Fallback Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Fallback sign-in alert body', $lastEmail['html']); @@ -1343,9 +1354,11 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); - $lastEmail = $this->getLastEmailByAddress($userEmail); + /** Probe on `from` address ensures we await the Slovak email specifically */ + $lastEmail = $this->getLastEmailByAddress($userEmail, function ($email) { + $this->assertEquals('sk@appwrite.io', $email['from'][0]['address']); + }); $this->assertEquals('Slovak sign-in alert', $lastEmail['subject']); - $this->assertEquals('sk@appwrite.io', $lastEmail['from'][0]['address']); $this->assertEquals('Slovak Mailer', $lastEmail['from'][0]['name']); $this->assertStringContainsString('Slovak sign-in alert body', $lastEmail['html']); From 47f3ab930b7ab744eb69779e5ab2485e7f6fe0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 13:14:34 +0200 Subject: [PATCH 41/51] Remove /status from project paths; Upgrade to platform 0.13 --- composer.json | 2 +- composer.lock | 14 +++++++------- .../Functions/Http/Deployments/Download/Get.php | 2 +- .../Http/Project/Protocols/{Status => }/Update.php | 13 +++++++------ .../Http/Project/Services/{Status => }/Update.php | 13 +++++++------ .../Platform/Modules/Project/Services/Http.php | 8 ++++---- src/Appwrite/Utopia/Request/Filters/V19.php | 7 +++++++ 7 files changed, 34 insertions(+), 25 deletions(-) rename src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/{Status => }/Update.php (89%) rename src/Appwrite/Platform/Modules/Project/Http/Project/Services/{Status => }/Update.php (89%) diff --git a/composer.json b/composer.json index 3aa6d157cf..6312243e32 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.9.*", - "utopia-php/platform": "0.12.*", + "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", "utopia-php/preloader": "0.2.*", diff --git a/composer.lock b/composer.lock index bc3d9d30bf..b1d559f87d 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": "f6a87c1012b316e614258f8f57a28e48", + "content-hash": "c5ae97637fd0ec0a950044d1c33677ea", "packages": [ { "name": "adhocore/jwt", @@ -4642,16 +4642,16 @@ }, { "name": "utopia-php/platform", - "version": "0.12.1", + "version": "0.13.0", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc" + "reference": "d23af5349a7ea9ee11f9920a13626226f985522e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", - "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/d23af5349a7ea9ee11f9920a13626226f985522e", + "reference": "d23af5349a7ea9ee11f9920a13626226f985522e", "shasum": "" }, "require": { @@ -4687,9 +4687,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/0.12.1" + "source": "https://github.com/utopia-php/platform/tree/0.13.0" }, - "time": "2026-04-08T04:11:31+00:00" + "time": "2026-04-17T09:57:18+00:00" }, { "name": "utopia-php/pools", diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php index 50c901e4c8..d3e7155dc6 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Download/Get.php @@ -31,7 +31,7 @@ class Get extends Action $this ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/v1/functions/:functionId/deployments/:deploymentId/download') - ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download', ['type' => 'output']) + ->httpAlias('/v1/functions/:functionId/deployments/:deploymentId/build/download') ->groups(['api', 'functions']) ->desc('Get deployment download') ->label('scope', 'functions.read') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php similarity index 89% rename from src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php rename to src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php index 71c20faca7..ad5691c1e0 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Update.php @@ -1,6 +1,6 @@ setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/protocols/:protocolId/status') + ->setHttpPath('/v1/project/protocols/:protocolId') + ->httpAlias('/v1/project/protocols/:protocolId/status') ->httpAlias('/v1/projects/:projectId/api') - ->desc('Update project protocol status') + ->desc('Update project protocol') ->groups(['api', 'project']) ->label('scope', 'project.write') ->label('event', 'protocols.[protocolId].update') @@ -40,9 +41,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: null, - name: 'updateProtocolStatus', + name: 'updateProtocol', description: <<setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/project/services/:serviceId/status') + ->setHttpPath('/v1/project/services/:serviceId') + ->httpAlias('/v1/project/services/:serviceId/status') ->httpAlias('/v1/projects/:projectId/service') - ->desc('Update project service status') + ->desc('Update project service') ->groups(['api', 'project']) ->label('scope', 'project.write') ->label('event', 'services.[serviceId].update') @@ -40,9 +41,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'project', group: null, - name: 'updateServiceStatus', + name: 'updateService', description: <<addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); - $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); - $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); + $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); + $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/src/Appwrite/Utopia/Request/Filters/V19.php b/src/Appwrite/Utopia/Request/Filters/V19.php index e7789ac0f7..4f2be12367 100644 --- a/src/Appwrite/Utopia/Request/Filters/V19.php +++ b/src/Appwrite/Utopia/Request/Filters/V19.php @@ -35,6 +35,13 @@ class V19 extends Filter case 'functions.updateVariable': $content['secret'] = false; break; + case 'functions.getDeploymentDownload': + // Pre-1.7.0 clients call the legacy alias + // `/v1/functions/:functionId/deployments/:deploymentId/build/download`, + // which always downloaded the build output. The merged 1.7.0 endpoint + // requires an explicit `type` param, so force it to `output` here. + $content['type'] = 'output'; + break; } return $content; } From c484c487a9a0a19bb0ed3c71cc1969c3d1aafb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 13:19:20 +0200 Subject: [PATCH 42/51] Update tests --- tests/e2e/Services/Project/ProtocolsBase.php | 29 +++++++++++++++++++- tests/e2e/Services/Project/ServicesBase.php | 29 +++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php index 0187fc8463..f828994ea3 100644 --- a/tests/e2e/Services/Project/ProtocolsBase.php +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -241,6 +241,33 @@ trait ProtocolsBase $this->assertSame(404, $response['headers']['status-code']); } + // Backwards compatibility + + public function testUpdateProtocolLegacyStatusPath(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + // Disable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['protocolStatusForRest']); + + // Re-enable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/protocols/rest/status', $headers, [ + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['protocolStatusForRest']); + } + // Helpers protected function updateProtocolStatus(string $protocolId, bool $enabled, bool $authenticated = true): mixed @@ -254,7 +281,7 @@ trait ProtocolsBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/protocols/' . $protocolId, $headers, [ 'enabled' => $enabled, ]); } diff --git a/tests/e2e/Services/Project/ServicesBase.php b/tests/e2e/Services/Project/ServicesBase.php index 1bc7ce5042..b5f94f8181 100644 --- a/tests/e2e/Services/Project/ServicesBase.php +++ b/tests/e2e/Services/Project/ServicesBase.php @@ -239,6 +239,33 @@ trait ServicesBase $this->assertSame(404, $response['headers']['status-code']); } + // Backwards compatibility + + public function testUpdateServiceLegacyStatusPath(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + // Disable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['serviceStatusForTeams']); + + // Re-enable via the legacy `/status` alias + $response = $this->client->call(Client::METHOD_PATCH, '/project/services/teams/status', $headers, [ + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['serviceStatusForTeams']); + } + // Helpers protected function updateServiceStatus(string $serviceId, bool $enabled, bool $authenticated = true): mixed @@ -252,7 +279,7 @@ trait ServicesBase $headers = array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId . '/status', $headers, [ + return $this->client->call(Client::METHOD_PATCH, '/project/services/' . $serviceId, $headers, [ 'enabled' => $enabled, ]); } From df0f7ba581ee5f2b7960914be5fdd7663a0a076b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 17 Apr 2026 18:02:04 +0530 Subject: [PATCH 43/51] added bucket boundary --- app/realtime.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/realtime.php b/app/realtime.php index 192672a2f3..5631a7f860 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -398,7 +398,11 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); - $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram('realtime.server.delivery_delay', 'ms')); + $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( + name: 'realtime.server.delivery_delay', + unit: 'ms', + advisory: ['ExplicitBucketBoundaries' => [100, 250, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000]], + )); $attempts = 0; $start = time(); From 27b0e48296d75c88ab4dbed69f69a3aab3da9017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 17 Apr 2026 14:53:59 +0200 Subject: [PATCH 44/51] Remove Status suffix from project event names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project.updateServiceStatus → project.updateService - project.updateProtocolStatus → project.updateProtocol --- src/Appwrite/Utopia/Request/Filters/V22.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php index 4f1e746775..7e4c5b8e41 100644 --- a/src/Appwrite/Utopia/Request/Filters/V22.php +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -73,10 +73,10 @@ class V22 extends Filter public function parse(array $content, string $model): array { switch ($model) { - case 'project.updateServiceStatus': + case 'project.updateService': $content = $this->parseUpdateServiceStatus($content); break; - case 'project.updateProtocolStatus': + case 'project.updateProtocol': $content = $this->parseUpdateProtocolStatus($content); break; case 'project.createKey': From 9765c7f0e313d00fafb5c5fabf751b912c4e2e3f Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:30:22 +0100 Subject: [PATCH 45/51] feat: use buildTimeout from message payload in build worker Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 0071b03d2d..323abfd564 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -592,9 +592,9 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); + $timeout = (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)); - $jwtExpiry = (int) System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900); + $jwtExpiry = $timeout; $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $apiKey = $jwtObj->encode([ From 4043153df313870ba5fe93c185bb272c36601fc4 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:47:41 +0100 Subject: [PATCH 46/51] fix: pass buildTimeout as parameter to buildDeployment to fix PHPStan error Co-Authored-By: Claude Sonnet 4.6 --- .../Platform/Modules/Functions/Workers/Builds.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 323abfd564..6b28de6601 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -144,7 +144,8 @@ class Builds extends Action $log, $executor, $plan, - $platform + $platform, + (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) ); break; @@ -179,7 +180,8 @@ class Builds extends Action Log $log, Executor $executor, array $plan, - array $platform + array $platform, + int $buildTimeout = 900 ): void { Console::info('Deployment action started'); @@ -592,7 +594,7 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)); + $timeout = $buildTimeout; $jwtExpiry = $timeout; $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); From 8f39783d7a30033c22b7b92c837928a4a1160c8c Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:47:58 +0100 Subject: [PATCH 47/51] refactor: remove jwtExpiry alias, use timeout directly Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index 6b28de6601..d6184107bd 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -596,8 +596,7 @@ class Builds extends Action $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); $timeout = $buildTimeout; - $jwtExpiry = $timeout; - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), From 7df181420322fa617d022ed0d2d67cfeb393130e Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:22:53 +0100 Subject: [PATCH 48/51] refactor: rename buildTimeout to timeout in payload and buildDeployment param Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Platform/Modules/Functions/Workers/Builds.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index d6184107bd..87e936a965 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -145,7 +145,7 @@ class Builds extends Action $executor, $plan, $platform, - (int) ($payload['buildTimeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) + (int) ($payload['timeout'] ?? System::getEnv('_APP_COMPUTE_BUILD_TIMEOUT', 900)) ); break; @@ -181,7 +181,7 @@ class Builds extends Action Executor $executor, array $plan, array $platform, - int $buildTimeout = 900 + int $timeout ): void { Console::info('Deployment action started'); @@ -594,8 +594,6 @@ class Builds extends Action $cpus = $spec['cpus'] ?? APP_COMPUTE_CPUS_DEFAULT; $memory = max($spec['memory'] ?? APP_COMPUTE_MEMORY_DEFAULT, $minMemory); - $timeout = $buildTimeout; - $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $timeout, 0); $apiKey = $jwtObj->encode([ From 956285d522b820dc869140c26383ad8667d3ca45 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:35:26 +0100 Subject: [PATCH 49/51] fix: do not cache error responses for storage preview, bump utopia-php/image to 0.8.5 Cache write hook now checks HTTP status code before writing to prevent failed AVIF (or any other) conversions from poisoning the cache. Bumps utopia-php/image to 0.8.5 which fixes AVIF/HEIC output by using native Imagick instead of the deprecated magick convert shell command. Co-Authored-By: Claude Sonnet 4.6 --- app/controllers/shared/api.php | 3 +- composer.lock | 22 ++++---- tests/e2e/Services/Storage/StorageBase.php | 65 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 5567281e67..bba00bede1 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -971,7 +971,8 @@ Http::shutdown() if ($useCache) { $resource = $resourceType = null; $data = $response->getPayload(); - if (! empty($data['payload'])) { + $statusCode = $response->getStatusCode(); + if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) { $pattern = $route->getLabel('cache.resource', null); if (! empty($pattern)) { $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); diff --git a/composer.lock b/composer.lock index b1d559f87d..56b838a0fe 100644 --- a/composer.lock +++ b/composer.lock @@ -4325,16 +4325,16 @@ }, { "name": "utopia-php/image", - "version": "0.8.4", + "version": "0.8.5", "source": { "type": "git", "url": "https://github.com/utopia-php/image.git", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65" + "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/image/zipball/ce788ff0121a79286fdbe3ef3eba566de646df65", - "reference": "ce788ff0121a79286fdbe3ef3eba566de646df65", + "url": "https://api.github.com/repos/utopia-php/image/zipball/9af2fcff028a42550465e2ccad88e3b31c3584f3", + "reference": "9af2fcff028a42550465e2ccad88e3b31c3584f3", "shasum": "" }, "require": { @@ -4343,10 +4343,12 @@ "php": ">=8.1" }, "require-dev": { - "laravel/pint": "1.2.*", - "phpstan/phpstan": "^1.10.0", - "phpunit/phpunit": "^9.3", - "vimeo/psalm": "4.13.1" + "laravel/pint": "1.24.*", + "phpstan/phpstan": "2.1.*", + "phpunit/phpunit": "10.5.*" + }, + "suggest": { + "ext-imagick": "Imagick extension is required for Imagick adapter" }, "type": "library", "autoload": { @@ -4368,9 +4370,9 @@ ], "support": { "issues": "https://github.com/utopia-php/image/issues", - "source": "https://github.com/utopia-php/image/tree/0.8.4" + "source": "https://github.com/utopia-php/image/tree/0.8.5" }, - "time": "2025-06-03T08:32:20+00:00" + "time": "2026-04-17T15:02:49+00:00" }, { "name": "utopia-php/locale", diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index d1cb548016..60a4aefc85 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1050,6 +1050,28 @@ trait StorageBase $this->assertEquals(404, $file['headers']['status-code']); } + public function testFilePreviewAvifPublic(): void + { + $data = $this->setupBucketFile(); + $bucketId = $data['bucketId']; + $fileId = $data['fileId']; + $projectId = $this->getProject()['$id']; + + // Matches the customer's URL pattern: no headers, project + output in query string only + $preview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', [ + 'content-type' => 'application/json', + ], [ + 'project' => $projectId, + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $preview['headers']['status-code']); + $this->assertEquals('image/avif', $preview['headers']['content-type']); + $this->assertNotEmpty($preview['body']); + } + public function testFilePreview(): void { $data = $this->setupBucketFile(); @@ -1069,6 +1091,49 @@ trait StorageBase $this->assertEquals(200, $preview['headers']['status-code']); $this->assertEquals('image/webp', $preview['headers']['content-type']); $this->assertNotEmpty($preview['body']); + + // Preview PNG as avif + $avifPreview = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $avifPreview['headers']['status-code']); + $this->assertEquals('image/avif', $avifPreview['headers']['content-type']); + $this->assertNotEmpty($avifPreview['body']); + + // Preview JPEG as avif + $jpegFile = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/disk-a/kitten-1.jpg'), 'image/jpeg', 'kitten-1.jpg'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $jpegFile['headers']['status-code']); + + $avifFromJpeg = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $jpegFile['body']['$id'] . '/preview', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'width' => 1080, + 'quality' => 40, + 'output' => 'avif', + ]); + + $this->assertEquals(200, $avifFromJpeg['headers']['status-code']); + $this->assertEquals('image/avif', $avifFromJpeg['headers']['content-type']); + $this->assertNotEmpty($avifFromJpeg['body']); } public function testDeletePartiallyUploadedFile(): void From ad3bdee6c1b7bfac0eee1bfd982f9fff72098da7 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:34:13 +0100 Subject: [PATCH 50/51] fix: include project ID in storage preview cache key Cache key never included the project ID, so two projects with the same bucketId, fileId, and transform params would share a cache key. On a cache hit, Appwrite re-validates the bucket from the cached resourceType (another project's bucket), which doesn't exist in the requesting project's DB, throwing storage_bucket_not_found. Fix: add 'project' to cache.params on the preview route (covers query param case) and fall back to the X-Appwrite-Project header in cacheIdentifier() for authenticated requests. Co-Authored-By: Claude Sonnet 4.6 --- .../Modules/Storage/Http/Buckets/Files/Preview/Get.php | 2 +- src/Appwrite/Utopia/Request.php | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index f0ee045214..f6b6eb25da 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -54,7 +54,7 @@ class Get extends Action ->label('cache', true) ->label('cache.resourceType', 'bucket/{request.bucketId}') ->label('cache.resource', 'file/{request.fileId}') - ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output']) + ->label('cache.params', ['width', 'height', 'gravity', 'quality', 'borderWidth', 'borderColor', 'borderRadius', 'opacity', 'rotation', 'background', 'output', 'project']) ->label('sdk', new Method( namespace: 'storage', group: 'files', diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 3f1ea794ab..bd0a870f7a 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -239,6 +239,9 @@ class Request extends UtopiaRequest $params = array_intersect_key($params, array_flip($allowedParams)); } ksort($params); + if (!isset($params['project'])) { + $params['project'] = $this->getHeader('x-appwrite-project', ''); + } return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER); } From 08b43dce504a3a8b8c6e85ccf6667f6f6f2275b9 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:45:00 +0100 Subject: [PATCH 51/51] fix: ksort after project injection to keep cache key order stable Co-Authored-By: Claude Sonnet 4.6 --- src/Appwrite/Utopia/Request.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index bd0a870f7a..32f0fa89a9 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -238,10 +238,10 @@ class Request extends UtopiaRequest if ($allowedParams !== null) { $params = array_intersect_key($params, array_flip($allowedParams)); } - ksort($params); if (!isset($params['project'])) { $params['project'] = $this->getHeader('x-appwrite-project', ''); } + ksort($params); return md5($this->getURI() . '*' . serialize($params) . '*' . APP_CACHE_BUSTER); }