From e18e848a68671fdfd2dd2cb81ab769d8cbbc4b62 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 11 Apr 2026 18:50:26 +0200 Subject: [PATCH] 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 6195c11724..4775fd174f 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -342,6 +342,17 @@ $platformCollections = [ '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 0000000000..626ddd91ca --- /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 116e8ac932..0e9d391418 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -95,6 +95,8 @@ $admins = [ '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 6c7f75c08e..c195f1e9cb 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -151,6 +151,12 @@ return [ // List of publicly visible scopes '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 fa96d2ae80..bb8b118c46 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -800,16 +800,61 @@ Http::shutdown() ->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 35c8e3899d..1eba1d5dcd 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -26,6 +26,7 @@ Config::load('projectScopes', __DIR__ . '/../config/scopes/project.php', $config 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 3b907572ab..6e842a74a0 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -163,6 +163,12 @@ const ACTIVITY_TYPE_KEY_PROJECT = 'keyProject'; 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 dd97b03652..646ff45af9 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -126,6 +126,7 @@ use Appwrite\Utopia\Response\Model\Schedule; 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('Variables List', Response::MODEL_VARIABLE_LIST, 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 Headers()); 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 0000000000..a489dd2b3f --- /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 0000000000..e0f4f4074b --- /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 8275e664d5..56e8c26d73 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\Projects\XList as ListProjects; 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 @@ class Http extends Service $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 295348c665..c4a272f5bf 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 1ef73aa769..2c46d51243 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -308,6 +308,12 @@ class Project extends Model '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 0000000000..8adaaaae47 --- /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; + } +}