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
This commit is contained in:
eldadfux
2026-04-11 18:50:26 +02:00
parent 1ea108c2ce
commit e18e848a68
14 changed files with 426 additions and 1 deletions
+11
View File
@@ -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,
+30
View File
@@ -0,0 +1,30 @@
<?php
/**
* Project onboarding: each stage maps to an SDK method key (namespace + method name, same as Appwrite\SDK\Method).
* The sdk index is built once for O(1) lookup in the API shutdown hook.
*/
$stages = [
[
'id' => '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,
];
+2
View File
@@ -95,6 +95,8 @@ $admins = [
'tokens.write',
'schedules.read',
'schedules.write',
'stages.read',
'stages.write',
];
return [
+6
View File
@@ -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',
],
+46 -1
View File
@@ -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);
+1
View File
@@ -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);
+6
View File
@@ -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
*/
+3
View File
@@ -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());
@@ -0,0 +1,150 @@
<?php
namespace Appwrite\Platform\Modules\Projects\Http\Stages;
use Appwrite\Auth\Key;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Database\Documents\User;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName(): string
{
return 'updateStage';
}
public function __construct()
{
$this
->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<string, mixed>|null $row
* @return array<string, mixed>
*/
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;
}
}
@@ -0,0 +1,97 @@
<?php
namespace Appwrite\Platform\Modules\Projects\Http\Stages;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listStages';
}
public function __construct()
{
$this
->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<int, array<string, mixed>>
*/
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;
}
}
@@ -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());
}
}
+2
View File
@@ -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';
@@ -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', []);
@@ -0,0 +1,61 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
use Utopia\Database\Document;
class Stage extends Model
{
public function __construct()
{
$this
->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;
}
}