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; + } +}