From e18e848a68671fdfd2dd2cb81ab769d8cbbc4b62 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 11 Apr 2026 18:50:26 +0200 Subject: [PATCH 1/3] feat(projects): add onboarding stages tracking and stages API Add configurable onboarding stages keyed by SDK method, persist completion in project onboarding JSON on successful API responses, and expose listStages and updateStage routes with stages.read/write scopes and stage/stageList models. Made-with: Cursor --- app/config/collections/platform.php | 11 ++ app/config/onboarding.php | 30 ++++ app/config/roles.php | 2 + app/config/scopes/project.php | 6 + app/controllers/shared/api.php | 47 +++++- app/init/configs.php | 1 + app/init/constants.php | 6 + app/init/models.php | 3 + .../Modules/Projects/Http/Stages/Update.php | 150 ++++++++++++++++++ .../Modules/Projects/Http/Stages/XList.php | 97 +++++++++++ .../Modules/Projects/Services/Http.php | 5 + src/Appwrite/Utopia/Response.php | 2 + .../Utopia/Response/Model/Project.php | 6 + src/Appwrite/Utopia/Response/Model/Stage.php | 61 +++++++ 14 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 app/config/onboarding.php create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Stages/Update.php create mode 100644 src/Appwrite/Platform/Modules/Projects/Http/Stages/XList.php create mode 100644 src/Appwrite/Utopia/Response/Model/Stage.php diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 6195c11724f..4775fd174fa 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -342,6 +342,17 @@ 'array' => true, 'filters' => [], ], + [ + '$id' => ID::custom('onboarding'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 65536, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => false, + 'filters' => ['json'], + ], [ '$id' => 'status', 'type' => Database::VAR_STRING, diff --git a/app/config/onboarding.php b/app/config/onboarding.php new file mode 100644 index 00000000000..626ddd91cad --- /dev/null +++ b/app/config/onboarding.php @@ -0,0 +1,30 @@ + 'create_database', + 'sdk' => 'databases.create', + ], + [ + 'id' => 'create_bucket', + 'sdk' => 'storage.createBucket', + ], + [ + 'id' => 'create_function', + 'sdk' => 'functions.create', + ], +]; + +$sdkIndex = []; +foreach ($stages as $stage) { + $sdkIndex[$stage['sdk']] = $stage['id']; +} + +return [ + 'stages' => $stages, + 'sdkIndex' => $sdkIndex, +]; diff --git a/app/config/roles.php b/app/config/roles.php index 116e8ac9325..0e9d3914183 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -95,6 +95,8 @@ 'tokens.write', 'schedules.read', 'schedules.write', + 'stages.read', + 'stages.write', ]; return [ diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 6c7f75c08eb..c195f1e9cb5 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -151,6 +151,12 @@ 'schedules.write' => [ 'description' => 'Access to create, update, and delete your project\'s schedules', ], + 'stages.read' => [ + 'description' => 'Access to read your project\'s stages', + ], + 'stages.write' => [ + 'description' => 'Access to update your project\'s stages', + ], 'migrations.read' => [ 'description' => 'Access to read your project\'s migrations', ], diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fa96d2ae80e..bb8b118c464 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -800,16 +800,61 @@ ->inject('queueForWebhooks') ->inject('queueForRealtime') ->inject('dbForProject') + ->inject('dbForPlatform') ->inject('authorization') ->inject('timelimit') ->inject('eventProcessor') ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Audit $queueForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Database $dbForPlatform, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); + /** + * Persist completed stage when the route matches a configured SDK method (stored on project as `onboarding`: stageId → row). + */ + if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300 && $project->getId() !== 'console') { + $sdkLabel = $utopia->getRoute()?->getLabel('sdk', false); + $stageId = null; + if ($sdkLabel !== false && $sdkLabel !== null) { + $sdkIndex = Config::getParam('onboarding', [])['sdkIndex'] ?? []; + foreach ($sdkLabel instanceof Method ? [$sdkLabel] : (\is_array($sdkLabel) ? $sdkLabel : []) as $sdkMethod) { + if ($sdkMethod instanceof Method && isset($sdkIndex[$k = $sdkMethod->getNamespace() . '.' . $sdkMethod->getMethodName()])) { + $stageId = $sdkIndex[$k]; + break; + } + } + } + if ($stageId !== null) { + $byStageId = $project->getAttribute('onboarding', []); + if (! \is_array($byStageId)) { + $byStageId = []; + } + $done = \is_array($byStageId[$stageId] ?? null) ? ($byStageId[$stageId]['status'] ?? '') : ''; + if ($done !== ONBOARDING_STATUS_COMPLETED && $done !== ONBOARDING_STATUS_SKIPPED) { + $actorType = ($apiKey !== null && $apiKey->getRole() === User::ROLE_APPS) + ? match ($apiKey->getType()) { + API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, + API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, + API_KEY_STANDARD, API_KEY_DYNAMIC => ACTIVITY_TYPE_KEY_PROJECT, + default => ACTIVITY_TYPE_KEY_PROJECT, + } + : (! $user->isEmpty() + ? ($mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER) + : ACTIVITY_TYPE_GUEST); + $byStageId[$stageId] = [ + 'status' => ONBOARDING_STATUS_COMPLETED, + 'at' => DateTime::now(), + 'actorType' => $actorType, + ]; + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'onboarding' => $byStageId, + ]))); + } + } + } + if (! empty($queueForEvents->getEvent())) { if (empty($queueForEvents->getPayload())) { $queueForEvents->setPayload($responsePayload); diff --git a/app/init/configs.php b/app/init/configs.php index 35c8e3899d3..1eba1d5dcd6 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -26,6 +26,7 @@ Config::load('organizationScopes', __DIR__ . '/../config/scopes/organization.php', $configAdapter); Config::load('accountScopes', __DIR__ . '/../config/scopes/account.php', $configAdapter); Config::load('services', __DIR__ . '/../config/services.php', $configAdapter); // List of services +Config::load('onboarding', __DIR__ . '/../config/onboarding.php', $configAdapter); // Project onboarding stages → routes Config::load('variables', __DIR__ . '/../config/variables.php', $configAdapter); // List of env variables Config::load('regions', __DIR__ . '/../config/regions.php', $configAdapter); // List of available regions Config::load('avatar-browsers', __DIR__ . '/../config/avatars/browsers.php', $configAdapter); diff --git a/app/init/constants.php b/app/init/constants.php index 3b907572ab8..6e842a74a0d 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -163,6 +163,12 @@ const ACTIVITY_TYPE_KEY_ACCOUNT = 'keyAccount'; const ACTIVITY_TYPE_KEY_ORGANIZATION = 'keyOrganization'; +/** + * Project onboarding stage status (stored per stage id under project.onboarding JSON). + */ +const ONBOARDING_STATUS_COMPLETED = 'completed'; +const ONBOARDING_STATUS_SKIPPED = 'skipped'; + /** * MFA */ diff --git a/app/init/models.php b/app/init/models.php index dd97b036520..646ff45af92 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -126,6 +126,7 @@ use Appwrite\Utopia\Response\Model\Session; use Appwrite\Utopia\Response\Model\Site; use Appwrite\Utopia\Response\Model\Specification; +use Appwrite\Utopia\Response\Model\Stage; use Appwrite\Utopia\Response\Model\Subscriber; use Appwrite\Utopia\Response\Model\Table; use Appwrite\Utopia\Response\Model\Target; @@ -212,6 +213,7 @@ Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); +Response::setModel(new BaseList('Stages List', Response::MODEL_STAGE_LIST, 'stages', Response::MODEL_STAGE, false, false)); Response::setModel(new BaseList('Locale codes list', Response::MODEL_LOCALE_CODE_LIST, 'localeCodes', Response::MODEL_LOCALE_CODE)); Response::setModel(new BaseList('Provider list', Response::MODEL_PROVIDER_LIST, 'providers', Response::MODEL_PROVIDER)); Response::setModel(new BaseList('Message list', Response::MODEL_MESSAGE_LIST, 'messages', Response::MODEL_MESSAGE)); @@ -373,6 +375,7 @@ Response::setModel(new Specification()); Response::setModel(new Rule()); Response::setModel(new Schedule()); +Response::setModel(new Stage()); Response::setModel(new TemplateSMS()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Stages/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Stages/Update.php new file mode 100644 index 00000000000..a489dd2b3f7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Stages/Update.php @@ -0,0 +1,150 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/projects/:projectId/stages/:stageId') + ->desc('Update stage') + ->groups(['api', 'projects']) + ->label('scope', 'stages.write') + ->label('audits.event', 'stages.update') + ->label('audits.resource', 'project/{request.projectId}') + ->label('sdk', new Method( + namespace: 'projects', + group: 'stages', + name: 'updateStage', + description: '/docs/references/projects/update-stage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_STAGE, + ) + ], + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('stageId', '', new Text(64), 'Stage ID.') + ->param('skip', true, new Boolean(), 'Mark the stage as skipped.', true) + ->inject('response') + ->inject('dbForPlatform') + ->inject('apiKey') + ->inject('user') + ->inject('mode') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $stageId, bool $skip, Response $response, Database $dbForPlatform, ?Key $apiKey, User $user, string $mode): void + { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $definition = $this->getStageDefinition($stageId); + + $byStageId = $project->getAttribute('onboarding', []); + if (! \is_array($byStageId)) { + $byStageId = []; + } + + $row = \is_array($byStageId[$stageId] ?? null) ? $byStageId[$stageId] : null; + + if ($skip) { + $prev = \is_array($row) ? ($row['status'] ?? '') : ''; + if ($prev !== ONBOARDING_STATUS_COMPLETED) { + $byStageId[$stageId] = [ + 'status' => ONBOARDING_STATUS_SKIPPED, + 'at' => DateTime::now(), + 'actorType' => $this->resolveActorType($apiKey, $user, $mode), + ]; + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('onboarding', $byStageId)); + $byStageId = $project->getAttribute('onboarding', []); + $row = \is_array($byStageId[$stageId] ?? null) ? $byStageId[$stageId] : null; + } + } + + $response->dynamic(new Document($this->formatStageRow($definition, $row)), Response::MODEL_STAGE); + } + + /** + * @return array{id: string, sdk: string} + */ + private function getStageDefinition(string $stageId): array + { + foreach (Config::getParam('onboarding', [])['stages'] ?? [] as $definition) { + if (($definition['id'] ?? '') === $stageId) { + return $definition; + } + } + + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Unknown stage ID: ' . $stageId); + } + + /** + * @param array|null $row + * @return array + */ + private function formatStageRow(array $definition, ?array $row): array + { + $stageId = $definition['id']; + $status = \is_array($row) ? ($row['status'] ?? null) : null; + $at = \is_array($row) ? ($row['at'] ?? '') : ''; + $actorType = \is_array($row) ? ($row['actorType'] ?? '') : ''; + + return [ + 'id' => $stageId, + 'sdk' => $definition['sdk'] ?? '', + 'status' => $status ?? 'pending', + 'at' => $at, + 'actorType' => $actorType, + ]; + } + + private function resolveActorType(?Key $apiKey, User $user, string $mode): string + { + if ($apiKey !== null && $apiKey->getRole() === User::ROLE_APPS) { + return match ($apiKey->getType()) { + API_KEY_ACCOUNT => ACTIVITY_TYPE_KEY_ACCOUNT, + API_KEY_ORGANIZATION => ACTIVITY_TYPE_KEY_ORGANIZATION, + API_KEY_STANDARD, API_KEY_DYNAMIC => ACTIVITY_TYPE_KEY_PROJECT, + default => ACTIVITY_TYPE_KEY_PROJECT, + }; + } + + if (! $user->isEmpty()) { + return $mode === APP_MODE_ADMIN ? ACTIVITY_TYPE_ADMIN : ACTIVITY_TYPE_USER; + } + + return ACTIVITY_TYPE_GUEST; + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Stages/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Stages/XList.php new file mode 100644 index 00000000000..e0f4f4074ba --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Stages/XList.php @@ -0,0 +1,97 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/projects/:projectId/stages') + ->desc('List stages') + ->groups(['api', 'projects']) + ->label('scope', 'stages.read') + ->label('sdk', new Method( + namespace: 'projects', + group: 'stages', + name: 'listStages', + description: '/docs/references/projects/list-stages.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_STAGE_LIST, + ) + ], + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $projectId, Response $response, Database $dbForPlatform): void + { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $response->dynamic(new Document([ + 'stages' => $this->buildStagesList($project), + ]), Response::MODEL_STAGE_LIST); + } + + /** + * @return array> + */ + private function buildStagesList(Document $project): array + { + $definitions = Config::getParam('onboarding', [])['stages'] ?? []; + $byStageId = $project->getAttribute('onboarding', []); + if (! \is_array($byStageId)) { + $byStageId = []; + } + + $out = []; + foreach ($definitions as $definition) { + $stageId = $definition['id']; + $row = $byStageId[$stageId] ?? null; + $status = \is_array($row) ? ($row['status'] ?? null) : null; + + $at = \is_array($row) ? ($row['at'] ?? '') : ''; + $actorType = \is_array($row) ? ($row['actorType'] ?? '') : ''; + + $out[] = [ + 'id' => $stageId, + 'sdk' => $definition['sdk'] ?? '', + 'status' => $status ?? 'pending', + 'at' => $at, + 'actorType' => $actorType, + ]; + } + + return $out; + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index 8275e664d51..56e8c26d730 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -14,6 +14,8 @@ use Appwrite\Platform\Modules\Projects\Http\Schedules\Create as CreateSchedule; use Appwrite\Platform\Modules\Projects\Http\Schedules\Get as GetSchedule; use Appwrite\Platform\Modules\Projects\Http\Schedules\XList as ListSchedules; +use Appwrite\Platform\Modules\Projects\Http\Stages\Update as UpdateStages; +use Appwrite\Platform\Modules\Projects\Http\Stages\XList as ListStages; use Utopia\Platform\Service; class Http extends Service @@ -35,5 +37,8 @@ public function __construct() $this->addAction(CreateSchedule::getName(), new CreateSchedule()); $this->addAction(GetSchedule::getName(), new GetSchedule()); $this->addAction(ListSchedules::getName(), new ListSchedules()); + + $this->addAction(ListStages::getName(), new ListStages()); + $this->addAction(UpdateStages::getName(), new UpdateStages()); } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 295348c6657..c4a272f5bf3 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -247,6 +247,8 @@ class Response extends SwooleResponse // Project public const MODEL_PROJECT = 'project'; public const MODEL_PROJECT_LIST = 'projectList'; + public const MODEL_STAGE = 'stage'; + public const MODEL_STAGE_LIST = 'stageList'; public const MODEL_WEBHOOK = 'webhook'; public const MODEL_WEBHOOK_LIST = 'webhookList'; public const MODEL_KEY = 'key'; diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 1ef73aa7699..2c46d512438 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -308,6 +308,12 @@ public function __construct() 'default' => 'active', 'example' => 'active', ]) + ->addRule('onboarding', [ + 'type' => self::TYPE_JSON, + 'description' => 'Stage progress (completed or skipped) with timestamps and actor types, keyed by stage id.', + 'default' => [], + 'example' => [], + ]) ; $services = Config::getParam('services', []); diff --git a/src/Appwrite/Utopia/Response/Model/Stage.php b/src/Appwrite/Utopia/Response/Model/Stage.php new file mode 100644 index 00000000000..8adaaaae47e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Stage.php @@ -0,0 +1,61 @@ +addRule('id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Stage ID.', + 'default' => '', + 'example' => 'create_database', + ]) + ->addRule('sdk', [ + 'type' => self::TYPE_STRING, + 'description' => 'SDK method key (namespace.name) for this stage.', + 'default' => '', + 'example' => 'databases.create', + ]) + ->addRule('status', [ + 'type' => self::TYPE_STRING, + 'description' => 'Stage status.', + 'default' => 'pending', + 'example' => 'completed', + ]) + ->addRule('at', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'When the stage was completed or skipped, in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('actorType', [ + 'type' => self::TYPE_STRING, + 'description' => 'Actor type when the stage was recorded.', + 'default' => '', + 'example' => 'user', + ]) + ; + } + + public function getName(): string + { + return 'Stage'; + } + + public function getType(): string + { + return Response::MODEL_STAGE; + } + + public function filter(Document $document): Document + { + return $document; + } +} From f1c24a0b24ba37811cd85980df99e2d9eb821c38 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 11 Apr 2026 19:14:57 +0200 Subject: [PATCH 2/3] feat(projects): realtime on stage completion; add stages E2E tests Emit console realtime when onboarding stages complete in API shutdown. Add Project/StagesBase + StagesConsoleClientTest following Platforms pattern. Made-with: Cursor --- app/controllers/shared/api.php | 15 ++ tests/e2e/Services/Project/StagesBase.php | 179 ++++++++++++++++++ .../Project/StagesConsoleClientTest.php | 14 ++ 3 files changed, 208 insertions(+) create mode 100644 tests/e2e/Services/Project/StagesBase.php create mode 100644 tests/e2e/Services/Project/StagesConsoleClientTest.php diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index bb8b118c464..6f132504058 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -851,6 +851,21 @@ $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ 'onboarding' => $byStageId, ]))); + + $queueForRealtime->reset(); + $queueForRealtime + ->setProject($project) + ->setSubscribers(['console']) + ->setEvent('projects.[projectId].stages.[stageId].complete') + ->setParam('projectId', $project->getId()) + ->setParam('stageId', $stageId) + ->setPayload([ + 'stageId' => $stageId, + 'status' => ONBOARDING_STATUS_COMPLETED, + 'at' => $byStageId[$stageId]['at'], + 'actorType' => $actorType, + ]) + ->trigger(); } } } diff --git a/tests/e2e/Services/Project/StagesBase.php b/tests/e2e/Services/Project/StagesBase.php new file mode 100644 index 00000000000..9bf3f21f4b7 --- /dev/null +++ b/tests/e2e/Services/Project/StagesBase.php @@ -0,0 +1,179 @@ +createTeamAndProject(); + + $response = $this->listStages($projectId); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['stages']); + $this->assertCount(3, $response['body']['stages']); + + $ids = array_column($response['body']['stages'], 'id'); + $this->assertSame(['create_database', 'create_bucket', 'create_function'], $ids); + + foreach ($response['body']['stages'] as $stage) { + $this->assertArrayHasKey('sdk', $stage); + $this->assertArrayHasKey('status', $stage); + $this->assertArrayHasKey('at', $stage); + $this->assertArrayHasKey('actorType', $stage); + $this->assertSame('pending', $stage['status']); + } + + // Verify via GET for unknown project + $response = $this->listStages(ID::unique()); + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame(Exception::PROJECT_NOT_FOUND, $response['body']['type']); + } + + public function testListProjectStagesWithoutAuthentication(): void + { + $projectId = $this->createTeamAndProject(); + + $response = $this->listStages($projectId, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Update stage (skip) tests + // ========================================================================= + + public function testUpdateProjectStageSkip(): void + { + $projectId = $this->createTeamAndProject(); + + $response = $this->updateStage($projectId, 'create_database', true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('create_database', $response['body']['id']); + $this->assertSame('databases.create', $response['body']['sdk']); + $this->assertSame(ONBOARDING_STATUS_SKIPPED, $response['body']['status']); + $this->assertNotEmpty($response['body']['at']); + $this->assertSame(ACTIVITY_TYPE_USER, $response['body']['actorType']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($response['body']['at'])); + + $list = $this->listStages($projectId); + $this->assertSame(200, $list['headers']['status-code']); + $first = $list['body']['stages'][0]; + $this->assertSame('create_database', $first['id']); + $this->assertSame(ONBOARDING_STATUS_SKIPPED, $first['status']); + $this->assertSame(ACTIVITY_TYPE_USER, $first['actorType']); + + $response = $this->updateStage($projectId, 'unknown_stage_xyz', true); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame(Exception::GENERAL_ARGUMENT_INVALID, $response['body']['type']); + + $response = $this->updateStage(ID::unique(), 'create_bucket', true); + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame(Exception::PROJECT_NOT_FOUND, $response['body']['type']); + } + + public function testUpdateProjectStageSkipFalseLeavesPending(): void + { + $projectId = $this->createTeamAndProject(); + + $response = $this->updateStage($projectId, 'create_bucket', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('create_bucket', $response['body']['id']); + $this->assertSame('pending', $response['body']['status']); + } + + public function testUpdateProjectStageWithoutAuthentication(): void + { + $projectId = $this->createTeamAndProject(); + + $response = $this->updateStage($projectId, 'create_database', true, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + protected function listStages(string $projectId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/stages', $headers, []); + } + + protected function updateStage(string $projectId, string $stageId, bool $skip, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId . '/stages/' . $stageId, $headers, [ + 'skip' => $skip, + ]); + } + + /** + * Creates a new team and child project under the console session (isolated from cached getProject()). + */ + protected function createTeamAndProject(): string + { + $teamId = ID::unique(); + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], $this->getHeaders()), [ + 'teamId' => $teamId, + 'name' => 'Stages E2E Team', + ]); + + $this->assertContains($team['headers']['status-code'], [201, 409]); + if ($team['headers']['status-code'] === 201) { + $teamId = $team['body']['$id']; + } + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => 'console', + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Stages E2E Project', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertSame(201, $project['headers']['status-code']); + + return $project['body']['$id']; + } +} diff --git a/tests/e2e/Services/Project/StagesConsoleClientTest.php b/tests/e2e/Services/Project/StagesConsoleClientTest.php new file mode 100644 index 00000000000..cde4f4ab795 --- /dev/null +++ b/tests/e2e/Services/Project/StagesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Sat, 11 Apr 2026 21:44:14 +0100 Subject: [PATCH 3/3] docs: add projects stages API reference markdown for spec generation Made-with: Cursor --- docs/references/projects/list-stages.md | 1 + docs/references/projects/update-stage.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/references/projects/list-stages.md create mode 100644 docs/references/projects/update-stage.md diff --git a/docs/references/projects/list-stages.md b/docs/references/projects/list-stages.md new file mode 100644 index 00000000000..3a2f6cc4014 --- /dev/null +++ b/docs/references/projects/list-stages.md @@ -0,0 +1 @@ +Get the onboarding stages for the current project, including each stage’s SDK method key and status (for example pending, completed, or skipped). diff --git a/docs/references/projects/update-stage.md b/docs/references/projects/update-stage.md new file mode 100644 index 00000000000..9e6f43919eb --- /dev/null +++ b/docs/references/projects/update-stage.md @@ -0,0 +1 @@ +Update an onboarding stage for the current project. Use this endpoint to skip a stage or leave it unchanged without performing the related API action.