-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/project onboarding stages #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 1.9.x
Are you sure you want to change the base?
Changes from all commits
e18e848
f1c24a0
92fe0b5
aa80d36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,6 +95,8 @@ | |
| 'tokens.write', | ||
| 'schedules.read', | ||
| 'schedules.write', | ||
| 'stages.read', | ||
| 'stages.write', | ||
| ]; | ||
|
|
||
| return [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -800,16 +800,76 @@ | |
| ->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'] ?? '') : ''; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After a client skips stage Before marking a route-derived stage complete, reload the latest project document from Prompt for AI assistanceCopy the prompt below and paste it into ChatGPT, Claude, or any LLM: |
||
| 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] = [ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After PATCH /v1/projects/{projectId}/stages/create_database with {"skip":true}, a later successful databases.create call still overwrites that skipped stage to completed because this hook updates the stale $project snapshot instead of reloading onboarding state first. Before computing $done and writing completion, reload the current project document from dbForPlatform (or perform a conditional/merge update) so an already-skipped stage is not reverted by a later request using an older injected $project instance. Prompt for AI assistanceCopy the prompt below and paste it into ChatGPT, Claude, or any LLM: |
||
| 'status' => ONBOARDING_STATUS_COMPLETED, | ||
| 'at' => DateTime::now(), | ||
| 'actorType' => $actorType, | ||
| ]; | ||
| $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(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (! empty($queueForEvents->getEvent())) { | ||
| if (empty($queueForEvents->getPayload())) { | ||
| $queueForEvents->setPayload($responsePayload); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| ->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] = [ | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After a client skips a stage via PATCH /v1/projects/{projectId}/stages/{stageId}, the later successful API call for that stage (for example creating a database) never flips it to completed because the shutdown hook in app/controllers/shared/api.php explicitly refuses to overwrite skipped status.
Suggested change
Prompt for AI assistanceCopy the prompt below and paste it into ChatGPT, Claude, or any LLM: |
||||||
| 'status' => ONBOARDING_STATUS_SKIPPED, | ||||||
| 'at' => DateTime::now(), | ||||||
| 'actorType' => $this->resolveActorType($apiKey, $user, $mode), | ||||||
| ]; | ||||||
| $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('onboarding', $byStageId)); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After a stage is skipped, a successful API call that matches that stage (for example PATCH /v1/projects/{id}/stages/create_database with skip=true followed by creating a database) will never mark it completed because the shutdown hook in app/controllers/shared/api.php now treats skipped as terminal. Change the shared API hook to only preserve COMPLETED as terminal, or add explicit logic here to let real route completion overwrite SKIPPED with COMPLETED when the mapped SDK action later succeeds. Prompt for AI assistanceCopy the prompt below and paste it into ChatGPT, Claude, or any LLM:
Comment on lines
+82
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This update reads and writes the project without an atomic compare or transaction, so a concurrent successful stage completion can be overwritten with Suggested fix if ($skip) {
$project = $dbForPlatform->getDocument('projects', $projectId);
$byStageId = $project->getAttribute('onboarding', []);
if (! \is_array($byStageId)) {
$byStageId = [];
}
$row = \is_array($byStageId[$stageId] ?? null) ? $byStageId[$stageId] : null;
$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(), new Document([
'onboarding' => $byStageId,
]));
}
}Prompt for AI assistanceCopy the prompt below and paste it into ChatGPT, Claude, or any LLM: |
||||||
| $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; | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onboardingattributeThe
onboardingattribute is added to theplatform.phpschema definition, but this PR does not include a corresponding migration class (e.g.,V25.php). Existing deployments that upgrade will not have theonboardingcolumn in theirprojectscollection. Any attempt by the shutdown hook or theUpdateendpoint to callupdateDocument('projects', …, new Document(['onboarding' => …]))on an upgraded installation will fail at the database layer. New fresh installs are unaffected becauseplatform.phpis consumed at collection-creation time, not on upgrade.