diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index b0bc78c..51aea8d 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -70,6 +70,8 @@ ## Active Technologies - PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity) - PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist) - PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist) +- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp) +- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp) - PHP 8.4.15 (feat/005-bulk-operations) @@ -89,8 +91,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging - 139-verify-access-permissions-assist: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes - 137-platform-provider-identity: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12 -- 136-admin-canonical-tenant: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail diff --git a/app/Exceptions/Onboarding/OnboardingDraftConflictException.php b/app/Exceptions/Onboarding/OnboardingDraftConflictException.php new file mode 100644 index 0000000..4281941 --- /dev/null +++ b/app/Exceptions/Onboarding/OnboardingDraftConflictException.php @@ -0,0 +1,19 @@ +statePath('data') ->schema([ + SchemaView::make('filament.schemas.components.managed-tenant-onboarding-checkpoint-poll') + ->visible(fn (): bool => $this->shouldPollCheckpointLifecycle()), ...$this->resumeContextSchema(), Wizard::make([ Step::make('Identify managed tenant') @@ -455,7 +466,7 @@ public function content(Schema $schema): Schema ->visible(fn (): bool => $this->connectionRecentlyUpdated()), Text::make('The selected provider connection has changed since this verification run. Start verification again to validate the current connection.') ->visible(fn (): bool => $this->verificationRunIsStaleForSelectedConnection()), - Text::make('Verification is in progress. Use “Refresh results” to see the latest stored status.') + Text::make('Verification is in progress. Status updates automatically about every 5 seconds. Use “Refresh” to re-check immediately.') ->visible(fn (): bool => $this->verificationStatus() === 'in_progress'), SchemaActions::make([ Action::make('wizardStartVerification') @@ -632,7 +643,7 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str $this->showDraftPicker = false; $this->showStartState = false; - $this->onboardingSession = $draft; + $this->setOnboardingSession($draft); $tenant = $draft->tenant; @@ -807,7 +818,7 @@ private function startNewOnboardingDraft(): void $this->showDraftPicker = false; $this->showStartState = true; $this->managedTenant = null; - $this->onboardingSession = null; + $this->setOnboardingSession(null); $this->selectedProviderConnectionId = null; $this->selectedBootstrapOperationTypes = []; $this->data = []; @@ -869,11 +880,26 @@ private function cancelOnboardingDraft(): void return; } - $this->onboardingSession->forceFill([ - 'current_step' => 'cancelled', - 'cancelled_at' => now(), - 'updated_by_user_id' => (int) $user->getKey(), - ])->save(); + try { + $this->setOnboardingSession($this->mutationService()->mutate( + draft: $this->onboardingSession, + actor: $user, + expectedVersion: $this->expectedDraftVersion(), + mutator: function (TenantOnboardingSession $draft): void { + $draft->current_step = 'cancelled'; + $draft->completed_at = null; + $draft->cancelled_at = now(); + }, + )); + } catch (OnboardingDraftConflictException) { + $this->handleDraftConflict(); + + return; + } catch (OnboardingDraftImmutableException) { + $this->handleImmutableDraft(); + + return; + } app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, @@ -891,8 +917,6 @@ private function cancelOnboardingDraft(): void resourceId: (string) $this->onboardingSession->getKey(), ); - $this->onboardingSession->refresh(); - Notification::make() ->title('Onboarding draft cancelled') ->success() @@ -966,6 +990,142 @@ private function currentUser(): ?User return $user instanceof User ? $user : null; } + private function lifecycleService(): OnboardingLifecycleService + { + return app(OnboardingLifecycleService::class); + } + + private function mutationService(): OnboardingDraftMutationService + { + return app(OnboardingDraftMutationService::class); + } + + private function expectedDraftVersion(): ?int + { + return is_int($this->onboardingSessionVersion) && $this->onboardingSessionVersion > 0 + ? $this->onboardingSessionVersion + : null; + } + + private function setOnboardingSession(?TenantOnboardingSession $draft): void + { + $this->onboardingSession = $draft; + $this->onboardingSessionVersion = $draft instanceof TenantOnboardingSession + ? $draft->expectedVersion() + : null; + } + + private function refreshOnboardingDraftFromBackend(): void + { + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + return; + } + + $user = $this->currentUser(); + + if (! $user instanceof User) { + return; + } + + $this->setOnboardingSession(app(OnboardingDraftResolver::class)->resolve( + $this->onboardingSession, + $user, + $this->workspace, + )); + + if ($this->onboardingSession->tenant instanceof Tenant) { + $this->managedTenant = $this->onboardingSession->tenant; + } + + $providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null; + $providerConnectionId = is_int($providerConnectionId) + ? $providerConnectionId + : (is_numeric($providerConnectionId) ? (int) $providerConnectionId : null); + + $this->selectedProviderConnectionId = $providerConnectionId; + $this->initializeWizardData(); + } + + private function handleDraftConflict(string $title = 'This onboarding draft changed in another tab or session.'): void + { + $this->refreshOnboardingDraftFromBackend(); + + Notification::make() + ->title($title) + ->body('TenantAtlas refreshed the latest saved state. Review it and try again.') + ->warning() + ->send(); + } + + private function handleImmutableDraft(string $title = 'This onboarding draft is no longer editable.'): void + { + $this->refreshOnboardingDraftFromBackend(); + + Notification::make() + ->title($title) + ->body('The latest draft state is now shown below.') + ->warning() + ->send(); + } + + private function lifecycleState(): OnboardingLifecycleState + { + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + return OnboardingLifecycleState::Draft; + } + + return $this->lifecycleService()->snapshot($this->onboardingSession)['lifecycle_state']; + } + + private function lifecycleStateLabel(): string + { + return $this->lifecycleState()->label(); + } + + private function lifecycleStateColor(): string + { + return match ($this->lifecycleState()) { + OnboardingLifecycleState::Draft => 'gray', + OnboardingLifecycleState::Verifying => 'info', + OnboardingLifecycleState::ActionRequired => 'warning', + OnboardingLifecycleState::Bootstrapping => 'info', + OnboardingLifecycleState::ReadyForActivation => 'success', + OnboardingLifecycleState::Completed => 'success', + OnboardingLifecycleState::Cancelled => 'danger', + }; + } + + private function currentCheckpointLabel(): string + { + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + return OnboardingCheckpoint::Identify->label(); + } + + return ($this->lifecycleService()->snapshot($this->onboardingSession)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label() + ?? OnboardingCheckpoint::Identify->label(); + } + + public function shouldPollCheckpointLifecycle(): bool + { + return $this->onboardingSession instanceof TenantOnboardingSession + && $this->lifecycleService()->hasActiveCheckpoint($this->onboardingSession); + } + + public function refreshCheckpointLifecycle(): void + { + if (! $this->onboardingSession instanceof TenantOnboardingSession) { + return; + } + + $this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($this->onboardingSession)); + + if ($this->managedTenant instanceof Tenant) { + $this->managedTenant->refresh(); + } + + $this->initializeWizardData(); + } + private function initializeWizardData(): void { // Ensure all entangled schema state paths exist at render time. @@ -1081,27 +1241,11 @@ private function verificationStatusLabel(): string private function verificationStatus(): string { - $run = $this->verificationRun(); - - if (! $run instanceof OperationRun) { + if (! $this->onboardingSession instanceof TenantOnboardingSession) { return 'not_started'; } - if (! $this->verificationRunMatchesSelectedConnection($run)) { - return 'needs_attention'; - } - - if ($run->status !== OperationRunStatus::Completed->value) { - return 'in_progress'; - } - - return match ($this->verificationReportOverall()) { - VerificationReportOverall::Blocked->value => 'blocked', - VerificationReportOverall::NeedsAttention->value => 'needs_attention', - VerificationReportOverall::Ready->value => 'ready', - VerificationReportOverall::Running->value => 'in_progress', - default => $this->verificationStatusFromRunOutcome($run), - }; + return $this->lifecycleService()->verificationStatus($this->onboardingSession, $this->selectedProviderConnectionId); } private function verificationStatusFromRunOutcome(OperationRun $run): string @@ -1509,14 +1653,24 @@ private function bootstrapRunsLabel(): string return ''; } - $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; - $runs = is_array($runs) ? $runs : []; + $runs = $this->lifecycleService()->bootstrapRunSummaries($this->onboardingSession, $this->selectedProviderConnectionId); if ($runs === []) { return ''; } - return sprintf('Started %d bootstrap run(s).', count($runs)); + $activeRuns = array_filter($runs, static fn (array $run): bool => (bool) $run['is_active']); + $failedRuns = array_filter($runs, static fn (array $run): bool => (bool) $run['is_failure'] || (bool) $run['is_partial_failure']); + + if ($activeRuns !== []) { + return sprintf('Bootstrap is running across %d operation run(s).', count($activeRuns)); + } + + if ($failedRuns !== []) { + return sprintf('Bootstrap needs attention for %d operation run(s).', count($failedRuns)); + } + + return sprintf('Bootstrap completed across %d operation run(s).', count($runs)); } private function touchOnboardingSessionStep(string $step): void @@ -1531,10 +1685,29 @@ private function touchOnboardingSessionStep(string $step): void return; } - $this->onboardingSession->forceFill([ - 'current_step' => $step, - 'updated_by_user_id' => (int) $user->getKey(), - ])->save(); + if ($this->onboardingSession->current_step === $step) { + return; + } + + try { + $this->setOnboardingSession($this->mutationService()->mutate( + draft: $this->onboardingSession, + actor: $user, + expectedVersion: $this->expectedDraftVersion(), + incrementVersion: false, + mutator: function (TenantOnboardingSession $draft) use ($step): void { + $draft->current_step = $step; + }, + )); + } catch (OnboardingDraftConflictException) { + $this->handleDraftConflict(); + + return; + } catch (OnboardingDraftImmutableException) { + $this->handleImmutableDraft(); + + return; + } app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, @@ -1627,168 +1800,168 @@ public function identifyManagedTenant(array $data): void $notificationTitle = 'Onboarding draft ready'; $notificationBody = null; - DB::transaction(function () use ($user, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes, &$notificationTitle, &$notificationBody): void { - $auditLogger = app(WorkspaceAuditLogger::class); - $membershipManager = app(TenantMembershipManager::class); - $currentDraftId = $this->onboardingSession?->getKey(); + try { + DB::transaction(function () use ($user, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes, &$notificationTitle, &$notificationBody): void { + $auditLogger = app(WorkspaceAuditLogger::class); + $membershipManager = app(TenantMembershipManager::class); + $currentDraftId = $this->onboardingSession?->getKey(); + $sessionWasCreated = false; - $existingTenant = Tenant::query() - ->withTrashed() - ->where('tenant_id', $entraTenantId) - ->first(); + $existingTenant = Tenant::query() + ->withTrashed() + ->where('tenant_id', $entraTenantId) + ->first(); - if ($existingTenant instanceof Tenant) { - if ($existingTenant->trashed() || $existingTenant->status === Tenant::STATUS_ARCHIVED) { - abort(404); - } - - if ($existingTenant->workspace_id === null) { - $resolvedWorkspaceId = $this->resolveWorkspaceIdForUnboundTenant($existingTenant); - - if ($resolvedWorkspaceId === (int) $this->workspace->getKey()) { - $existingTenant->forceFill(['workspace_id' => $resolvedWorkspaceId])->save(); - } - } - - if ((int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { - abort(404); - } - - $existingTenant->forceFill([ - 'name' => $tenantName, - 'environment' => $environment, - 'domain' => $primaryDomain, - 'status' => $existingTenant->status === Tenant::STATUS_DRAFT ? Tenant::STATUS_ONBOARDING : $existingTenant->status, - 'metadata' => array_merge(is_array($existingTenant->metadata) ? $existingTenant->metadata : [], array_filter([ - 'notes' => $notes, - ], static fn ($value): bool => $value !== null)), - ])->save(); - - $tenant = $existingTenant; - } else { - try { - $tenant = Tenant::query()->create([ - 'workspace_id' => (int) $this->workspace->getKey(), - 'name' => $tenantName, - 'tenant_id' => $entraTenantId, - 'domain' => $primaryDomain, - 'environment' => $environment, - 'status' => Tenant::STATUS_ONBOARDING, - 'metadata' => array_filter([ - 'notes' => $notes, - ], static fn ($value): bool => $value !== null), - ]); - } catch (QueryException $exception) { - // Race-safe global uniqueness: if another workspace created the tenant_id first, - // treat it as deny-as-not-found. - $existingTenant = Tenant::query() - ->withTrashed() - ->where('tenant_id', $entraTenantId) - ->first(); - - if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { + if ($existingTenant instanceof Tenant) { + if ($existingTenant->trashed() || $existingTenant->status === Tenant::STATUS_ARCHIVED) { abort(404); } - if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id === (int) $this->workspace->getKey()) { - $tenant = $existingTenant; - } else { - throw $exception; + if ($existingTenant->workspace_id === null) { + $resolvedWorkspaceId = $this->resolveWorkspaceIdForUnboundTenant($existingTenant); + + if ($resolvedWorkspaceId === (int) $this->workspace->getKey()) { + $existingTenant->forceFill(['workspace_id' => $resolvedWorkspaceId])->save(); + } + } + + if ((int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { + abort(404); + } + + $existingTenant->forceFill([ + 'name' => $tenantName, + 'environment' => $environment, + 'domain' => $primaryDomain, + 'status' => $existingTenant->status === Tenant::STATUS_DRAFT ? Tenant::STATUS_ONBOARDING : $existingTenant->status, + 'metadata' => array_merge(is_array($existingTenant->metadata) ? $existingTenant->metadata : [], array_filter([ + 'notes' => $notes, + ], static fn ($value): bool => $value !== null)), + ])->save(); + + $tenant = $existingTenant; + } else { + try { + $tenant = Tenant::query()->create([ + 'workspace_id' => (int) $this->workspace->getKey(), + 'name' => $tenantName, + 'tenant_id' => $entraTenantId, + 'domain' => $primaryDomain, + 'environment' => $environment, + 'status' => Tenant::STATUS_ONBOARDING, + 'metadata' => array_filter([ + 'notes' => $notes, + ], static fn ($value): bool => $value !== null), + ]); + } catch (QueryException $exception) { + // Race-safe global uniqueness: if another workspace created the tenant_id first, + // treat it as deny-as-not-found. + $existingTenant = Tenant::query() + ->withTrashed() + ->where('tenant_id', $entraTenantId) + ->first(); + + if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { + abort(404); + } + + if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id === (int) $this->workspace->getKey()) { + $tenant = $existingTenant; + } else { + throw $exception; + } } } - } - $membershipManager->addMember( - tenant: $tenant, - actor: $user, - member: $user, - role: 'owner', - source: 'manual', - ); + $membershipManager->addMember( + tenant: $tenant, + actor: $user, + member: $user, + role: 'owner', + source: 'manual', + ); - $ownerCount = TenantMembership::query() - ->where('tenant_id', $tenant->getKey()) - ->where('role', 'owner') - ->count(); + $ownerCount = TenantMembership::query() + ->where('tenant_id', $tenant->getKey()) + ->where('role', 'owner') + ->count(); - if ($ownerCount === 0) { - throw new RuntimeException('Tenant must have at least one owner.'); - } + if ($ownerCount === 0) { + throw new RuntimeException('Tenant must have at least one owner.'); + } - $session = TenantOnboardingSession::query() - ->where('workspace_id', (int) $this->workspace->getKey()) - ->where('entra_tenant_id', $entraTenantId) - ->resumable() - ->first(); + $this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant); - $sessionWasCreated = false; + $session = $this->mutationService()->createOrResume( + workspace: $this->workspace, + actor: $user, + entraTenantId: $entraTenantId, + preferredDraft: $this->onboardingSession, + expectedVersion: $this->expectedDraftVersion(), + mutator: function (TenantOnboardingSession $draft) use ($tenant, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes): void { + $draft->tenant_id = (int) $tenant->getKey(); + $draft->current_step = 'identify'; + $draft->state = array_merge($draft->state ?? [], [ + 'entra_tenant_id' => $entraTenantId, + 'tenant_name' => $tenantName, + 'environment' => $environment, + 'primary_domain' => $primaryDomain, + 'notes' => $notes, + ]); - if (! $session instanceof TenantOnboardingSession) { - $session = new TenantOnboardingSession; - $session->workspace_id = (int) $this->workspace->getKey(); - $session->entra_tenant_id = $entraTenantId; - $session->tenant_id = (int) $tenant->getKey(); - $session->started_by_user_id = (int) $user->getKey(); - $sessionWasCreated = true; - } + if ($this->selectedProviderConnectionId !== null) { + $draft->state = array_merge($draft->state ?? [], [ + 'provider_connection_id' => (int) $this->selectedProviderConnectionId, + ]); + } + }, + wasCreated: $sessionWasCreated, + ); - $session->entra_tenant_id = $entraTenantId; - $session->tenant_id = (int) $tenant->getKey(); - $session->current_step = 'identify'; - $session->state = array_merge($session->state ?? [], [ - 'entra_tenant_id' => $entraTenantId, - 'tenant_name' => $tenantName, - 'environment' => $environment, - 'primary_domain' => $primaryDomain, - 'notes' => $notes, - ]); - $session->updated_by_user_id = (int) $user->getKey(); - $session->save(); + $didResumeExistingDraft = ! $sessionWasCreated + && ($currentDraftId === null || (int) $currentDraftId !== (int) $session->getKey()); - $this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant); + $notificationTitle = $didResumeExistingDraft + ? 'Existing onboarding draft resumed' + : 'Onboarding draft ready'; + $notificationBody = $didResumeExistingDraft + ? 'A resumable draft already exists for this tenant. TenantAtlas reopened it instead of creating a duplicate.' + : null; - if ($this->selectedProviderConnectionId !== null) { - $session->state = array_merge($session->state ?? [], [ - 'provider_connection_id' => (int) $this->selectedProviderConnectionId, - ]); - $session->save(); - } - - $didResumeExistingDraft = ! $sessionWasCreated - && ($currentDraftId === null || (int) $currentDraftId !== (int) $session->getKey()); - - $notificationTitle = $didResumeExistingDraft - ? 'Existing onboarding draft resumed' - : 'Onboarding draft ready'; - $notificationBody = $didResumeExistingDraft - ? 'A resumable draft already exists for this tenant. TenantAtlas reopened it instead of creating a duplicate.' - : null; - - $auditLogger->log( - workspace: $this->workspace, - action: ($sessionWasCreated - ? AuditActionId::ManagedTenantOnboardingStart - : AuditActionId::ManagedTenantOnboardingResume - )->value, - context: [ - 'metadata' => [ - 'workspace_id' => (int) $this->workspace->getKey(), - 'tenant_db_id' => (int) $tenant->getKey(), - 'entra_tenant_id' => $entraTenantId, - 'tenant_name' => $tenantName, - 'onboarding_session_id' => (int) $session->getKey(), - 'current_step' => (string) $session->current_step, + $auditLogger->log( + workspace: $this->workspace, + action: ($sessionWasCreated + ? AuditActionId::ManagedTenantOnboardingStart + : AuditActionId::ManagedTenantOnboardingResume + )->value, + context: [ + 'metadata' => [ + 'workspace_id' => (int) $this->workspace->getKey(), + 'tenant_db_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => $entraTenantId, + 'tenant_name' => $tenantName, + 'onboarding_session_id' => (int) $session->getKey(), + 'current_step' => (string) $session->current_step, + ], ], - ], - actor: $user, - status: 'success', - resourceType: 'tenant', - resourceId: (string) $tenant->getKey(), - ); + actor: $user, + status: 'success', + resourceType: 'tenant', + resourceId: (string) $tenant->getKey(), + ); - $this->managedTenant = $tenant; - $this->onboardingSession = $session; - }); + $this->managedTenant = $tenant; + $this->setOnboardingSession($session); + }); + } catch (OnboardingDraftConflictException) { + $this->handleDraftConflict('The onboarding draft changed before the tenant details were saved.'); + + return; + } catch (OnboardingDraftImmutableException) { + $this->handleImmutableDraft('The onboarding draft is no longer editable.'); + + return; + } $notification = Notification::make() ->title($notificationTitle) @@ -1835,16 +2008,31 @@ public function selectProviderConnection(int $providerConnectionId): void $this->selectedProviderConnectionId = (int) $connection->getKey(); if ($this->onboardingSession instanceof TenantOnboardingSession) { - $this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange( - state: array_merge($this->onboardingSession->state ?? [], [ - 'provider_connection_id' => (int) $connection->getKey(), - ]), - previousProviderConnectionId: $previousProviderConnectionId, - newProviderConnectionId: (int) $connection->getKey(), - ); - $this->onboardingSession->current_step = 'connection'; - $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); - $this->onboardingSession->save(); + try { + $this->setOnboardingSession($this->mutationService()->mutate( + draft: $this->onboardingSession, + actor: $user, + expectedVersion: $this->expectedDraftVersion(), + mutator: function (TenantOnboardingSession $draft) use ($connection, $previousProviderConnectionId): void { + $draft->state = $this->resetDependentOnboardingStateOnConnectionChange( + state: array_merge($draft->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + ]), + previousProviderConnectionId: $previousProviderConnectionId, + newProviderConnectionId: (int) $connection->getKey(), + ); + $draft->current_step = 'connection'; + }, + )); + } catch (OnboardingDraftConflictException) { + $this->handleDraftConflict(); + + return; + } catch (OnboardingDraftImmutableException) { + $this->handleImmutableDraft(); + + return; + } } app(WorkspaceAuditLogger::class)->log( @@ -1868,6 +2056,8 @@ public function selectProviderConnection(int $providerConnectionId): void ->title('Provider connection selected') ->success() ->send(); + + $this->initializeWizardData(); } /** @@ -2044,16 +2234,31 @@ public function createProviderConnection(array $data): void ? $previousProviderConnectionId : (is_numeric($previousProviderConnectionId) ? (int) $previousProviderConnectionId : null); - $this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange( - state: array_merge($this->onboardingSession->state ?? [], [ - 'provider_connection_id' => (int) $connection->getKey(), - ]), - previousProviderConnectionId: $previousProviderConnectionId, - newProviderConnectionId: (int) $connection->getKey(), - ); - $this->onboardingSession->current_step = 'connection'; - $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); - $this->onboardingSession->save(); + try { + $this->setOnboardingSession($this->mutationService()->mutate( + draft: $this->onboardingSession, + actor: $user, + expectedVersion: $this->expectedDraftVersion(), + mutator: function (TenantOnboardingSession $draft) use ($connection, $previousProviderConnectionId): void { + $draft->state = $this->resetDependentOnboardingStateOnConnectionChange( + state: array_merge($draft->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + ]), + previousProviderConnectionId: $previousProviderConnectionId, + newProviderConnectionId: (int) $connection->getKey(), + ); + $draft->current_step = 'connection'; + }, + )); + } catch (OnboardingDraftConflictException) { + $this->handleDraftConflict(); + + return; + } catch (OnboardingDraftImmutableException) { + $this->handleImmutableDraft(); + + return; + } } app(WorkspaceAuditLogger::class)->log( @@ -2077,6 +2282,8 @@ public function createProviderConnection(array $data): void ->title('Provider connection created') ->success() ->send(); + + $this->initializeWizardData(); } private function platformAppClientId(): string @@ -2129,36 +2336,59 @@ public function startVerification(): void return; } - $result = app(ProviderOperationStartGate::class)->start( - tenant: $tenant, - connection: $connection, - operationType: 'provider.connection.check', - dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void { - ProviderConnectionHealthCheckJob::dispatch( - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - providerConnectionId: (int) $connection->getKey(), - operationRun: $run, - ); - }, - initiator: $user, - extraContext: [ - 'wizard' => [ - 'flow' => 'managed_tenant_onboarding', - 'step' => 'verification', - ], - ], - ); + $result = null; - if ($this->onboardingSession instanceof TenantOnboardingSession) { - $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ - 'provider_connection_id' => (int) $connection->getKey(), - 'verification_operation_run_id' => (int) $result->run->getKey(), - 'connection_recently_updated' => false, - ]); - $this->onboardingSession->current_step = 'verify'; - $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); - $this->onboardingSession->save(); + try { + $this->setOnboardingSession($this->mutationService()->mutate( + draft: $this->onboardingSession, + actor: $user, + expectedVersion: $this->expectedDraftVersion(), + mutator: function (TenantOnboardingSession $draft) use ($tenant, $user, $connection, &$result): void { + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: $connection, + operationType: 'provider.connection.check', + dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void { + ProviderConnectionHealthCheckJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + operationRun: $run, + ); + }, + initiator: $user, + extraContext: [ + 'wizard' => [ + 'flow' => 'managed_tenant_onboarding', + 'step' => 'verification', + ], + ], + ); + + if (! $result instanceof \App\Services\Providers\ProviderOperationStartResult) { + throw new RuntimeException('Verification start did not return a run result.'); + } + + $draft->state = array_merge($draft->state ?? [], [ + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $result->run->getKey(), + 'connection_recently_updated' => false, + ]); + $draft->current_step = 'verify'; + }, + )); + } catch (OnboardingDraftConflictException) { + $this->handleDraftConflict(); + + return; + } catch (OnboardingDraftImmutableException) { + $this->handleImmutableDraft(); + + return; + } + + if (! $result instanceof \App\Services\Providers\ProviderOperationStartResult) { + throw new RuntimeException('Verification start did not return a run result.'); } $auditStatus = match ($result->status) { @@ -2290,13 +2520,7 @@ public function startVerification(): void public function refreshVerificationStatus(): void { - if ($this->managedTenant instanceof Tenant) { - $this->managedTenant->refresh(); - } - - if ($this->onboardingSession instanceof TenantOnboardingSession) { - $this->onboardingSession->refresh(); - } + $this->refreshCheckpointLifecycle(); Notification::make() ->title('Verification refreshed') @@ -2378,78 +2602,109 @@ public function startBootstrap(array $operationTypes): void return; } - /** @var array{status: 'started', runs: array, created: array}|array{status: 'scope_busy', run: OperationRun} $result */ - $result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array { - $lockedConnection = ProviderConnection::query() - ->whereKey($connection->getKey()) - ->lockForUpdate() - ->firstOrFail(); + $result = null; - $activeRun = OperationRun::query() - ->where('tenant_id', $tenant->getKey()) - ->active() - ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) - ->orderByDesc('id') - ->first(); + try { + $this->setOnboardingSession($this->mutationService()->mutate( + draft: $this->onboardingSession, + actor: $user, + expectedVersion: $this->expectedDraftVersion(), + mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void { + $lockedConnection = ProviderConnection::query() + ->whereKey($connection->getKey()) + ->lockForUpdate() + ->firstOrFail(); - if ($activeRun instanceof OperationRun) { - return [ - 'status' => 'scope_busy', - 'run' => $activeRun, - ]; - } + $activeRun = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->active() + ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) + ->orderByDesc('id') + ->first(); - $runsService = app(OperationRunService::class); + if ($activeRun instanceof OperationRun) { + $result = [ + 'status' => 'scope_busy', + 'run' => $activeRun, + ]; - $bootstrapRuns = []; - $bootstrapCreated = []; + return; + } - foreach ($types as $operationType) { - $definition = $registry->get($operationType); + $runsService = app(OperationRunService::class); + $bootstrapRuns = []; + $bootstrapCreated = []; - $context = [ - 'wizard' => [ - 'flow' => 'managed_tenant_onboarding', - 'step' => 'bootstrap', - ], - 'provider' => $lockedConnection->provider, - 'module' => $definition['module'], - 'provider_connection_id' => (int) $lockedConnection->getKey(), - 'target_scope' => [ - 'entra_tenant_id' => $lockedConnection->entra_tenant_id, - ], - ]; + foreach ($types as $operationType) { + $definition = $registry->get($operationType); - $run = $runsService->ensureRunWithIdentity( - tenant: $tenant, - type: $operationType, - identityInputs: [ - 'provider_connection_id' => (int) $lockedConnection->getKey(), - ], - context: $context, - initiator: $user, - ); + $context = [ + 'wizard' => [ + 'flow' => 'managed_tenant_onboarding', + 'step' => 'bootstrap', + ], + 'provider' => $lockedConnection->provider, + 'module' => $definition['module'], + 'provider_connection_id' => (int) $lockedConnection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => $lockedConnection->entra_tenant_id, + ], + ]; - if ($run->wasRecentlyCreated) { - $this->dispatchBootstrapJob( - operationType: $operationType, - tenantId: (int) $tenant->getKey(), - userId: (int) $user->getKey(), - providerConnectionId: (int) $lockedConnection->getKey(), - run: $run, - ); - } + $run = $runsService->ensureRunWithIdentity( + tenant: $tenant, + type: $operationType, + identityInputs: [ + 'provider_connection_id' => (int) $lockedConnection->getKey(), + ], + context: $context, + initiator: $user, + ); - $bootstrapRuns[$operationType] = (int) $run->getKey(); - $bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated; - } + if ($run->wasRecentlyCreated) { + $this->dispatchBootstrapJob( + operationType: $operationType, + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $lockedConnection->getKey(), + run: $run, + ); + } - return [ - 'status' => 'started', - 'runs' => $bootstrapRuns, - 'created' => $bootstrapCreated, - ]; - }); + $bootstrapRuns[$operationType] = (int) $run->getKey(); + $bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated; + } + + $state = $draft->state ?? []; + $existing = $state['bootstrap_operation_runs'] ?? []; + $existing = is_array($existing) ? $existing : []; + + $state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns); + $state['bootstrap_operation_types'] = $types; + + $draft->state = $state; + $draft->current_step = 'bootstrap'; + + $result = [ + 'status' => 'started', + 'runs' => $bootstrapRuns, + 'created' => $bootstrapCreated, + ]; + }, + )); + } catch (OnboardingDraftConflictException) { + $this->handleDraftConflict(); + + return; + } catch (OnboardingDraftImmutableException) { + $this->handleImmutableDraft(); + + return; + } + + if (! is_array($result)) { + throw new RuntimeException('Bootstrap start did not return a run result.'); + } if ($result['status'] === 'scope_busy') { OpsUxBrowserEvents::dispatchRunEnqueued($this); @@ -2471,19 +2726,6 @@ public function startBootstrap(array $operationTypes): void $bootstrapRuns = $result['runs']; if ($this->onboardingSession instanceof TenantOnboardingSession) { - $state = $this->onboardingSession->state ?? []; - - $existing = $state['bootstrap_operation_runs'] ?? []; - $existing = is_array($existing) ? $existing : []; - - $state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns); - $state['bootstrap_operation_types'] = $types; - - $this->onboardingSession->state = $state; - $this->onboardingSession->current_step = 'bootstrap'; - $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); - $this->onboardingSession->save(); - app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value, @@ -2589,40 +2831,14 @@ public function verificationSucceeded(): bool private function verificationCanProceed(): bool { - $run = $this->verificationRun(); - - if (! $run instanceof OperationRun) { - return false; - } - - if ($run->status !== OperationRunStatus::Completed->value) { - return false; - } - - if (! $this->verificationRunMatchesSelectedConnection($run)) { - return false; - } - - return in_array($this->verificationStatus(), ['ready', 'needs_attention'], true); + return $this->onboardingSession instanceof TenantOnboardingSession + && $this->lifecycleService()->verificationCanProceed($this->onboardingSession, $this->selectedProviderConnectionId); } private function verificationIsBlocked(): bool { - $run = $this->verificationRun(); - - if (! $run instanceof OperationRun) { - return false; - } - - if ($run->status !== OperationRunStatus::Completed->value) { - return false; - } - - if (! $this->verificationRunMatchesSelectedConnection($run)) { - return false; - } - - return $this->verificationStatus() === 'blocked'; + return $this->onboardingSession instanceof TenantOnboardingSession + && $this->lifecycleService()->verificationIsBlocked($this->onboardingSession, $this->selectedProviderConnectionId); } private function canCompleteOnboarding(): bool @@ -2635,7 +2851,7 @@ private function canCompleteOnboarding(): bool return false; } - if ($this->verificationCanProceed()) { + if ($this->lifecycleState() === OnboardingLifecycleState::ReadyForActivation) { return true; } @@ -2858,16 +3074,42 @@ public function completeOnboarding(): void $overrideBlocked = (bool) ($this->data['override_blocked'] ?? false); $overrideReason = trim((string) ($this->data['override_reason'] ?? '')); - DB::transaction(function () use ($tenant, $user): void { - $tenant->update(['status' => Tenant::STATUS_ACTIVE]); + try { + DB::transaction(function () use ($tenant, $user): void { + $tenant->update(['status' => Tenant::STATUS_ACTIVE]); - $this->onboardingSession->forceFill([ - 'completed_at' => now(), - 'cancelled_at' => null, - 'current_step' => 'complete', - 'updated_by_user_id' => (int) $user->getKey(), - ])->save(); - }); + $this->setOnboardingSession($this->mutationService()->mutate( + draft: $this->onboardingSession, + actor: $user, + expectedVersion: $this->expectedDraftVersion(), + mutator: function (TenantOnboardingSession $draft): void { + $draft->completed_at = now(); + $draft->cancelled_at = null; + $draft->current_step = 'complete'; + }, + )); + }); + } catch (OnboardingDraftConflictException) { + $tenant->refresh(); + + if ($tenant->status === Tenant::STATUS_ACTIVE) { + $tenant->update(['status' => Tenant::STATUS_ONBOARDING]); + } + + $this->handleDraftConflict('Activation was blocked because the onboarding draft changed.'); + + return; + } catch (OnboardingDraftImmutableException) { + $tenant->refresh(); + + if ($tenant->status === Tenant::STATUS_ACTIVE) { + $tenant->update(['status' => Tenant::STATUS_ONBOARDING]); + } + + $this->handleImmutableDraft('Activation was blocked because the onboarding draft is no longer editable.'); + + return; + } if ($overrideBlocked) { app(WorkspaceAuditLogger::class)->log( @@ -2914,24 +3156,11 @@ public function completeOnboarding(): void private function verificationRun(): ?OperationRun { - if (! $this->managedTenant instanceof Tenant) { - return null; - } - if (! $this->onboardingSession instanceof TenantOnboardingSession) { return null; } - $runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null; - - if (! is_int($runId)) { - return null; - } - - return OperationRun::query() - ->where('tenant_id', (int) $this->managedTenant->getKey()) - ->whereKey($runId) - ->first(); + return $this->lifecycleService()->verificationRun($this->onboardingSession); } private function verificationHasSucceeded(): bool @@ -3307,21 +3536,36 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId } if ($this->onboardingSession instanceof TenantOnboardingSession) { - $state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : []; + try { + $this->setOnboardingSession($this->mutationService()->mutate( + draft: $this->onboardingSession, + actor: $user, + expectedVersion: $this->expectedDraftVersion(), + mutator: function (TenantOnboardingSession $draft) use ($connection): void { + $state = is_array($draft->state) ? $draft->state : []; - unset( - $state['verification_operation_run_id'], - $state['bootstrap_operation_runs'], - $state['bootstrap_operation_types'], - ); + unset( + $state['verification_operation_run_id'], + $state['bootstrap_operation_runs'], + $state['bootstrap_operation_types'], + ); - $state['connection_recently_updated'] = true; + $state['connection_recently_updated'] = true; - $this->onboardingSession->state = array_merge($state, [ - 'provider_connection_id' => (int) $connection->getKey(), - ]); - $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); - $this->onboardingSession->save(); + $draft->state = array_merge($state, [ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + }, + )); + } catch (OnboardingDraftConflictException) { + $this->handleDraftConflict(); + + return; + } catch (OnboardingDraftImmutableException) { + $this->handleImmutableDraft(); + + return; + } } Notification::make() diff --git a/app/Models/TenantOnboardingSession.php b/app/Models/TenantOnboardingSession.php index 8bc8ba2..a5f8050 100644 --- a/app/Models/TenantOnboardingSession.php +++ b/app/Models/TenantOnboardingSession.php @@ -4,7 +4,9 @@ namespace App\Models; +use App\Support\Onboarding\OnboardingCheckpoint; use App\Support\Onboarding\OnboardingDraftStatus; +use App\Support\Onboarding\OnboardingLifecycleState; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -41,6 +43,10 @@ class TenantOnboardingSession extends Model protected $casts = [ 'state' => 'array', + 'version' => 'integer', + 'lifecycle_state' => OnboardingLifecycleState::class, + 'current_checkpoint' => OnboardingCheckpoint::class, + 'last_completed_checkpoint' => OnboardingCheckpoint::class, 'completed_at' => 'datetime', 'cancelled_at' => 'datetime', ]; @@ -108,29 +114,80 @@ public function scopeResumable(Builder $query): Builder public function isCompleted(): bool { - return $this->completed_at !== null; + return $this->completed_at !== null + || $this->lifecycleState() === OnboardingLifecycleState::Completed; } public function isCancelled(): bool { - return $this->cancelled_at !== null; + return $this->cancelled_at !== null + || $this->lifecycleState() === OnboardingLifecycleState::Cancelled; } public function status(): OnboardingDraftStatus { - if ($this->isCancelled()) { - return OnboardingDraftStatus::Cancelled; - } - - if ($this->isCompleted()) { - return OnboardingDraftStatus::Completed; - } - - return OnboardingDraftStatus::Draft; + return OnboardingDraftStatus::fromLifecycleState($this->lifecycleState()); } public function isResumable(): bool { return $this->status()->isResumable(); } + + public function isTerminal(): bool + { + return $this->lifecycleState()->isTerminal(); + } + + public function lifecycleState(): OnboardingLifecycleState + { + if ($this->lifecycle_state instanceof OnboardingLifecycleState) { + return $this->lifecycle_state; + } + + if (is_string($this->lifecycle_state) && OnboardingLifecycleState::tryFrom($this->lifecycle_state) instanceof OnboardingLifecycleState) { + return OnboardingLifecycleState::from($this->lifecycle_state); + } + + if ($this->completed_at !== null) { + return OnboardingLifecycleState::Completed; + } + + if ($this->cancelled_at !== null) { + return OnboardingLifecycleState::Cancelled; + } + + return OnboardingLifecycleState::Draft; + } + + public function currentCheckpoint(): ?OnboardingCheckpoint + { + if ($this->current_checkpoint instanceof OnboardingCheckpoint) { + return $this->current_checkpoint; + } + + if (is_string($this->current_checkpoint) && OnboardingCheckpoint::tryFrom($this->current_checkpoint) instanceof OnboardingCheckpoint) { + return OnboardingCheckpoint::from($this->current_checkpoint); + } + + return OnboardingCheckpoint::fromCurrentStep($this->current_step); + } + + public function lastCompletedCheckpoint(): ?OnboardingCheckpoint + { + if ($this->last_completed_checkpoint instanceof OnboardingCheckpoint) { + return $this->last_completed_checkpoint; + } + + if (is_string($this->last_completed_checkpoint) && OnboardingCheckpoint::tryFrom($this->last_completed_checkpoint) instanceof OnboardingCheckpoint) { + return OnboardingCheckpoint::from($this->last_completed_checkpoint); + } + + return null; + } + + public function expectedVersion(): int + { + return max(1, (int) ($this->version ?? 1)); + } } diff --git a/app/Services/Onboarding/OnboardingDraftMutationService.php b/app/Services/Onboarding/OnboardingDraftMutationService.php new file mode 100644 index 0000000..e59a2ff --- /dev/null +++ b/app/Services/Onboarding/OnboardingDraftMutationService.php @@ -0,0 +1,163 @@ +resolveDraftForIdentity($workspace, $entraTenantId, $preferredDraft); + $isNew = ! $draft instanceof TenantOnboardingSession; + $wasCreated = $isNew; + + if ($isNew) { + $draft = new TenantOnboardingSession; + $draft->workspace_id = (int) $workspace->getKey(); + $draft->entra_tenant_id = $entraTenantId; + $draft->started_by_user_id = (int) $actor->getKey(); + $draft->version = 0; + } elseif ($expectedVersion !== null) { + $this->assertExpectedVersion($draft, $expectedVersion); + } + + $draft->entra_tenant_id = $entraTenantId; + $draft->updated_by_user_id = (int) $actor->getKey(); + + $mutator($draft); + + $this->persistDraft( + draft: $draft, + incrementVersion: $incrementVersion || $isNew, + ); + + return $draft->refresh(); + }); + } + + /** + * @param callable(TenantOnboardingSession):void $mutator + */ + public function mutate( + TenantOnboardingSession $draft, + User $actor, + callable $mutator, + ?int $expectedVersion = null, + bool $incrementVersion = true, + bool $allowTerminal = false, + ): TenantOnboardingSession { + return DB::transaction(function () use ($draft, $actor, $mutator, $expectedVersion, $incrementVersion, $allowTerminal): TenantOnboardingSession { + $lockedDraft = TenantOnboardingSession::query() + ->whereKey($draft->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + if (! $allowTerminal && $lockedDraft->lifecycleState()->isTerminal()) { + throw new OnboardingDraftImmutableException( + draftId: (int) $lockedDraft->getKey(), + lifecycleState: $lockedDraft->lifecycleState(), + ); + } + + if ($expectedVersion !== null) { + $this->assertExpectedVersion($lockedDraft, $expectedVersion); + } + + $lockedDraft->updated_by_user_id = (int) $actor->getKey(); + + $mutator($lockedDraft); + + $this->persistDraft( + draft: $lockedDraft, + incrementVersion: $incrementVersion, + ); + + return $lockedDraft->refresh(); + }); + } + + private function resolveDraftForIdentity( + Workspace $workspace, + string $entraTenantId, + ?TenantOnboardingSession $preferredDraft = null, + ): ?TenantOnboardingSession { + if ($preferredDraft instanceof TenantOnboardingSession) { + $lockedPreferredDraft = TenantOnboardingSession::query() + ->whereKey($preferredDraft->getKey()) + ->where('workspace_id', (int) $workspace->getKey()) + ->lockForUpdate() + ->first(); + + if ($lockedPreferredDraft instanceof TenantOnboardingSession && $lockedPreferredDraft->entra_tenant_id === $entraTenantId) { + if ($lockedPreferredDraft->lifecycleState()->isTerminal()) { + throw new OnboardingDraftImmutableException( + draftId: (int) $lockedPreferredDraft->getKey(), + lifecycleState: $lockedPreferredDraft->lifecycleState(), + ); + } + + return $lockedPreferredDraft; + } + } + + return TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('entra_tenant_id', $entraTenantId) + ->resumable() + ->orderByDesc('updated_at') + ->lockForUpdate() + ->first(); + } + + private function assertExpectedVersion(TenantOnboardingSession $draft, int $expectedVersion): void + { + $actualVersion = max(1, (int) ($draft->version ?? 1)); + + if ($expectedVersion === $actualVersion) { + return; + } + + throw new OnboardingDraftConflictException( + draftId: (int) $draft->getKey(), + expectedVersion: $expectedVersion, + actualVersion: $actualVersion, + ); + } + + private function persistDraft(TenantOnboardingSession $draft, bool $incrementVersion): void + { + $currentVersion = max(0, (int) ($draft->version ?? 0)); + + $draft->version = $incrementVersion + ? $currentVersion + 1 + : max(1, $currentVersion); + + $this->lifecycleService->applySnapshot($draft, false); + + $draft->save(); + } +} diff --git a/app/Services/Onboarding/OnboardingDraftResolver.php b/app/Services/Onboarding/OnboardingDraftResolver.php index 6de1a06..a15e456 100644 --- a/app/Services/Onboarding/OnboardingDraftResolver.php +++ b/app/Services/Onboarding/OnboardingDraftResolver.php @@ -14,6 +14,10 @@ class OnboardingDraftResolver { + public function __construct( + private readonly OnboardingLifecycleService $lifecycleService, + ) {} + /** * @throws AuthorizationException * @throws NotFoundHttpException @@ -39,7 +43,9 @@ public function resolve(TenantOnboardingSession|int|string $draft, User $user, W Gate::forUser($user)->authorize('view', $resolvedDraft); - return $resolvedDraft; + return $this->lifecycleService + ->syncPersistedLifecycle($resolvedDraft) + ->loadMissing(['tenant', 'startedByUser', 'updatedByUser']); } /** @@ -62,6 +68,9 @@ public function resumableDraftsFor(User $user, Workspace $workspace): Collection } return true; - })->values(); + })->map(fn (TenantOnboardingSession $draft): TenantOnboardingSession => $this->lifecycleService + ->syncPersistedLifecycle($draft) + ->loadMissing(['tenant', 'startedByUser', 'updatedByUser'])) + ->values(); } } diff --git a/app/Services/Onboarding/OnboardingDraftStageResolver.php b/app/Services/Onboarding/OnboardingDraftStageResolver.php index 8b795f6..ba6b394 100644 --- a/app/Services/Onboarding/OnboardingDraftStageResolver.php +++ b/app/Services/Onboarding/OnboardingDraftStageResolver.php @@ -4,138 +4,52 @@ namespace App\Services\Onboarding; -use App\Models\OperationRun; use App\Models\TenantOnboardingSession; +use App\Support\Onboarding\OnboardingCheckpoint; use App\Support\Onboarding\OnboardingDraftStage; -use App\Support\OperationRunStatus; +use App\Support\Onboarding\OnboardingLifecycleState; class OnboardingDraftStageResolver { + public function __construct( + private readonly OnboardingLifecycleService $lifecycleService, + ) {} + public function resolve(?TenantOnboardingSession $draft): OnboardingDraftStage { if (! $draft instanceof TenantOnboardingSession) { return OnboardingDraftStage::Identify; } - if ($draft->isCancelled()) { + $snapshot = $this->lifecycleService->snapshot($draft); + $lifecycleState = $snapshot['lifecycle_state']; + + if ($lifecycleState === OnboardingLifecycleState::Cancelled) { return OnboardingDraftStage::Cancelled; } - if ($draft->isCompleted()) { + if ($lifecycleState === OnboardingLifecycleState::Completed) { return OnboardingDraftStage::Completed; } - $state = is_array($draft->state) ? $draft->state : []; + $checkpoint = $snapshot['current_checkpoint']; - if (! $this->hasTenantIdentity($draft, $state)) { - return OnboardingDraftStage::Identify; + if ($checkpoint instanceof OnboardingCheckpoint) { + return match ($checkpoint) { + OnboardingCheckpoint::Identify => OnboardingDraftStage::Identify, + OnboardingCheckpoint::ConnectProvider => OnboardingDraftStage::ConnectProvider, + OnboardingCheckpoint::VerifyAccess => OnboardingDraftStage::VerifyAccess, + OnboardingCheckpoint::Bootstrap => OnboardingDraftStage::Bootstrap, + OnboardingCheckpoint::CompleteActivate => OnboardingDraftStage::Review, + }; } - if (! $this->hasProviderConnection($state)) { - return OnboardingDraftStage::ConnectProvider; - } - - if (! $this->verificationCanProceed($draft, $state)) { - return OnboardingDraftStage::VerifyAccess; - } - - if ($this->reviewStateWasConfirmed($draft, $state)) { - return OnboardingDraftStage::Review; - } - - return OnboardingDraftStage::Bootstrap; - } - - /** - * @param array $state - */ - private function hasTenantIdentity(TenantOnboardingSession $draft, array $state): bool - { - if ($draft->tenant_id !== null) { - return true; - } - - $entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id; - - return is_string($entraTenantId) && trim($entraTenantId) !== ''; - } - - /** - * @param array $state - */ - private function hasProviderConnection(array $state): bool - { - $providerConnectionId = $state['provider_connection_id'] ?? null; - - return is_int($providerConnectionId) - || (is_numeric($providerConnectionId) && (int) $providerConnectionId > 0); - } - - /** - * @param array $state - */ - private function verificationCanProceed(TenantOnboardingSession $draft, array $state): bool - { - $run = $this->verificationRun($draft, $state); - - if (! $run instanceof OperationRun) { - return false; - } - - if ($run->status !== OperationRunStatus::Completed->value) { - return false; - } - - $selectedProviderConnectionId = $state['provider_connection_id'] ?? null; - $selectedProviderConnectionId = is_int($selectedProviderConnectionId) - ? $selectedProviderConnectionId - : (is_numeric($selectedProviderConnectionId) ? (int) $selectedProviderConnectionId : null); - - $runContext = is_array($run->context) ? $run->context : []; - $runProviderConnectionId = $runContext['provider_connection_id'] ?? null; - $runProviderConnectionId = is_int($runProviderConnectionId) - ? $runProviderConnectionId - : (is_numeric($runProviderConnectionId) ? (int) $runProviderConnectionId : null); - - return $selectedProviderConnectionId !== null - && $runProviderConnectionId !== null - && $selectedProviderConnectionId === $runProviderConnectionId; - } - - /** - * @param array $state - */ - private function reviewStateWasConfirmed(TenantOnboardingSession $draft, array $state): bool - { - if (in_array($draft->current_step, ['bootstrap', 'complete'], true)) { - return true; - } - - $bootstrapRuns = $state['bootstrap_operation_runs'] ?? null; - if (is_array($bootstrapRuns) && $bootstrapRuns !== []) { - return true; - } - - $bootstrapTypes = $state['bootstrap_operation_types'] ?? null; - - return is_array($bootstrapTypes) && $bootstrapTypes !== []; - } - - /** - * @param array $state - */ - private function verificationRun(TenantOnboardingSession $draft, array $state): ?OperationRun - { - $runId = $state['verification_operation_run_id'] ?? null; - $runId = is_int($runId) ? $runId : (is_numeric($runId) ? (int) $runId : null); - - if (! is_int($runId) || $runId <= 0) { - return null; - } - - return OperationRun::query() - ->whereKey($runId) - ->where('workspace_id', (int) $draft->workspace_id) - ->first(); + return match (OnboardingCheckpoint::fromCurrentStep($draft->current_step)) { + OnboardingCheckpoint::ConnectProvider => OnboardingDraftStage::ConnectProvider, + OnboardingCheckpoint::VerifyAccess => OnboardingDraftStage::VerifyAccess, + OnboardingCheckpoint::Bootstrap => OnboardingDraftStage::Bootstrap, + OnboardingCheckpoint::CompleteActivate => OnboardingDraftStage::Review, + default => OnboardingDraftStage::Identify, + }; } } diff --git a/app/Services/Onboarding/OnboardingLifecycleService.php b/app/Services/Onboarding/OnboardingLifecycleService.php new file mode 100644 index 0000000..6ec5bf1 --- /dev/null +++ b/app/Services/Onboarding/OnboardingLifecycleService.php @@ -0,0 +1,697 @@ +whereKey($draft->getKey())->first(); + + if (! $freshDraft instanceof TenantOnboardingSession) { + return $draft; + } + + $changed = $this->applySnapshot($freshDraft, $incrementVersion); + + if ($changed) { + $freshDraft->save(); + } + + return $freshDraft->refresh(); + } + + public function applySnapshot(TenantOnboardingSession $draft, bool $incrementVersion = false): bool + { + $snapshot = $this->snapshot($draft); + $lifecycleState = $draft->lifecycle_state instanceof OnboardingLifecycleState + ? $draft->lifecycle_state + : OnboardingLifecycleState::tryFrom((string) $draft->lifecycle_state); + $currentCheckpoint = $draft->current_checkpoint instanceof OnboardingCheckpoint + ? $draft->current_checkpoint + : OnboardingCheckpoint::tryFrom((string) $draft->current_checkpoint); + $lastCompletedCheckpoint = $draft->last_completed_checkpoint instanceof OnboardingCheckpoint + ? $draft->last_completed_checkpoint + : OnboardingCheckpoint::tryFrom((string) $draft->last_completed_checkpoint); + + $changed = false; + + if ($lifecycleState !== $snapshot['lifecycle_state']) { + $draft->lifecycle_state = $snapshot['lifecycle_state']; + $changed = true; + } + + if ($currentCheckpoint !== $snapshot['current_checkpoint']) { + $draft->current_checkpoint = $snapshot['current_checkpoint']; + $changed = true; + } + + if ($lastCompletedCheckpoint !== $snapshot['last_completed_checkpoint']) { + $draft->last_completed_checkpoint = $snapshot['last_completed_checkpoint']; + $changed = true; + } + + if (($draft->reason_code ?? null) !== $snapshot['reason_code']) { + $draft->reason_code = $snapshot['reason_code']; + $changed = true; + } + + if (($draft->blocking_reason_code ?? null) !== $snapshot['blocking_reason_code']) { + $draft->blocking_reason_code = $snapshot['blocking_reason_code']; + $changed = true; + } + + $version = max(1, (int) ($draft->version ?? 1)); + + if ((int) ($draft->version ?? 0) !== $version) { + $draft->version = $version; + $changed = true; + } + + if ($changed && $incrementVersion) { + $draft->version = $version + 1; + } + + return $changed; + } + + /** + * @return array{ + * lifecycle_state: OnboardingLifecycleState, + * current_checkpoint: OnboardingCheckpoint|null, + * last_completed_checkpoint: OnboardingCheckpoint|null, + * reason_code: string|null, + * blocking_reason_code: string|null + * } + */ + public function snapshot(TenantOnboardingSession $draft): array + { + $selectedProviderConnectionId = $this->selectedProviderConnectionId($draft); + $verificationRun = $this->verificationRun($draft); + $verificationStatus = $this->verificationStatus($draft, $selectedProviderConnectionId, $verificationRun); + $bootstrapState = $this->bootstrapState($draft, $selectedProviderConnectionId); + $hasIdentity = $this->hasTenantIdentity($draft); + $hasProviderConnection = $selectedProviderConnectionId !== null; + $connectionRecentlyUpdated = $this->connectionRecentlyUpdated($draft); + + $currentCheckpoint = OnboardingCheckpoint::Identify; + $lastCompletedCheckpoint = null; + $lifecycleState = OnboardingLifecycleState::Draft; + $reasonCode = null; + $blockingReasonCode = null; + + if ($hasIdentity) { + $currentCheckpoint = OnboardingCheckpoint::ConnectProvider; + $lastCompletedCheckpoint = OnboardingCheckpoint::Identify; + } + + if ($hasProviderConnection) { + $currentCheckpoint = OnboardingCheckpoint::VerifyAccess; + $lastCompletedCheckpoint = OnboardingCheckpoint::ConnectProvider; + } + + if ($draft->completed_at !== null) { + return [ + 'lifecycle_state' => OnboardingLifecycleState::Completed, + 'current_checkpoint' => OnboardingCheckpoint::CompleteActivate, + 'last_completed_checkpoint' => OnboardingCheckpoint::CompleteActivate, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + if ($draft->cancelled_at !== null) { + return [ + 'lifecycle_state' => OnboardingLifecycleState::Cancelled, + 'current_checkpoint' => $currentCheckpoint, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + if (! $hasIdentity) { + return [ + 'lifecycle_state' => $lifecycleState, + 'current_checkpoint' => $currentCheckpoint, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + if (! $hasProviderConnection) { + return [ + 'lifecycle_state' => $lifecycleState, + 'current_checkpoint' => $currentCheckpoint, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + if ($verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value) { + return [ + 'lifecycle_state' => OnboardingLifecycleState::Verifying, + 'current_checkpoint' => OnboardingCheckpoint::VerifyAccess, + 'last_completed_checkpoint' => OnboardingCheckpoint::ConnectProvider, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + if ($connectionRecentlyUpdated && ! ($verificationRun instanceof OperationRun)) { + return [ + 'lifecycle_state' => OnboardingLifecycleState::ActionRequired, + 'current_checkpoint' => OnboardingCheckpoint::VerifyAccess, + 'last_completed_checkpoint' => OnboardingCheckpoint::ConnectProvider, + 'reason_code' => 'provider_connection_changed', + 'blocking_reason_code' => 'provider_connection_changed', + ]; + } + + if ($verificationRun instanceof OperationRun && ! $this->verificationRunMatchesSelectedConnection($verificationRun, $selectedProviderConnectionId)) { + $staleReason = $connectionRecentlyUpdated + ? 'provider_connection_changed' + : 'verification_result_stale'; + + return [ + 'lifecycle_state' => OnboardingLifecycleState::ActionRequired, + 'current_checkpoint' => OnboardingCheckpoint::VerifyAccess, + 'last_completed_checkpoint' => OnboardingCheckpoint::ConnectProvider, + 'reason_code' => $staleReason, + 'blocking_reason_code' => $staleReason, + ]; + } + + if ($verificationRun instanceof OperationRun && $verificationStatus === 'blocked') { + $blockingReasonCode = $this->verificationBlockingReasonCode($verificationRun); + + return [ + 'lifecycle_state' => OnboardingLifecycleState::ActionRequired, + 'current_checkpoint' => OnboardingCheckpoint::VerifyAccess, + 'last_completed_checkpoint' => OnboardingCheckpoint::ConnectProvider, + 'reason_code' => $blockingReasonCode, + 'blocking_reason_code' => $blockingReasonCode, + ]; + } + + if (! $this->verificationCanProceed($draft, $selectedProviderConnectionId, $verificationRun)) { + return [ + 'lifecycle_state' => $lifecycleState, + 'current_checkpoint' => OnboardingCheckpoint::VerifyAccess, + 'last_completed_checkpoint' => OnboardingCheckpoint::ConnectProvider, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + $lastCompletedCheckpoint = OnboardingCheckpoint::VerifyAccess; + + if (! $bootstrapState['has_selected_types']) { + return [ + 'lifecycle_state' => OnboardingLifecycleState::ReadyForActivation, + 'current_checkpoint' => OnboardingCheckpoint::CompleteActivate, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + if ($bootstrapState['has_active_runs']) { + return [ + 'lifecycle_state' => OnboardingLifecycleState::Bootstrapping, + 'current_checkpoint' => OnboardingCheckpoint::Bootstrap, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + if ($bootstrapState['has_partial_failure']) { + $reasonCode = 'bootstrap_partial_failure'; + + return [ + 'lifecycle_state' => OnboardingLifecycleState::ActionRequired, + 'current_checkpoint' => OnboardingCheckpoint::Bootstrap, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => $reasonCode, + 'blocking_reason_code' => $reasonCode, + ]; + } + + if ($bootstrapState['has_failure']) { + $reasonCode = 'bootstrap_failed'; + + return [ + 'lifecycle_state' => OnboardingLifecycleState::ActionRequired, + 'current_checkpoint' => OnboardingCheckpoint::Bootstrap, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => $reasonCode, + 'blocking_reason_code' => $reasonCode, + ]; + } + + if (! $bootstrapState['all_selected_types_completed']) { + return [ + 'lifecycle_state' => OnboardingLifecycleState::Draft, + 'current_checkpoint' => OnboardingCheckpoint::Bootstrap, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + return [ + 'lifecycle_state' => OnboardingLifecycleState::ReadyForActivation, + 'current_checkpoint' => OnboardingCheckpoint::CompleteActivate, + 'last_completed_checkpoint' => OnboardingCheckpoint::Bootstrap, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]; + } + + public function verificationRun(TenantOnboardingSession $draft): ?OperationRun + { + $state = is_array($draft->state) ? $draft->state : []; + $runId = $this->normalizeInteger($state['verification_operation_run_id'] ?? $state['verification_run_id'] ?? null); + + if ($runId === null) { + return null; + } + + $query = OperationRun::query() + ->whereKey($runId) + ->where('workspace_id', (int) $draft->workspace_id); + + if ($draft->tenant_id !== null) { + $query->where('tenant_id', (int) $draft->tenant_id); + } + + return $query->first(); + } + + public function verificationStatus( + TenantOnboardingSession $draft, + ?int $selectedProviderConnectionId = null, + ?OperationRun $run = null, + ): string { + $selectedProviderConnectionId ??= $this->selectedProviderConnectionId($draft); + $run ??= $this->verificationRun($draft); + + if (! $run instanceof OperationRun) { + return 'not_started'; + } + + if (! $this->verificationRunMatchesSelectedConnection($run, $selectedProviderConnectionId)) { + return 'needs_attention'; + } + + if ($run->status !== OperationRunStatus::Completed->value) { + return 'in_progress'; + } + + $overall = $this->verificationReportOverall($run); + + return match ($overall) { + VerificationReportOverall::Blocked->value => 'blocked', + VerificationReportOverall::NeedsAttention->value => 'needs_attention', + VerificationReportOverall::Ready->value => 'ready', + VerificationReportOverall::Running->value => 'in_progress', + default => $this->verificationStatusFromRunOutcome($run), + }; + } + + public function verificationCanProceed( + TenantOnboardingSession $draft, + ?int $selectedProviderConnectionId = null, + ?OperationRun $run = null, + ): bool { + $selectedProviderConnectionId ??= $this->selectedProviderConnectionId($draft); + $run ??= $this->verificationRun($draft); + + if (! $run instanceof OperationRun) { + return false; + } + + if ($run->status !== OperationRunStatus::Completed->value) { + return false; + } + + if (! $this->verificationRunMatchesSelectedConnection($run, $selectedProviderConnectionId)) { + return false; + } + + return in_array($this->verificationStatus($draft, $selectedProviderConnectionId, $run), ['ready', 'needs_attention'], true); + } + + public function verificationIsBlocked(TenantOnboardingSession $draft, ?int $selectedProviderConnectionId = null): bool + { + return $this->verificationStatus($draft, $selectedProviderConnectionId) === 'blocked'; + } + + /** + * @return array + */ + public function bootstrapRunSummaries(TenantOnboardingSession $draft, ?int $selectedProviderConnectionId = null): array + { + $selectedProviderConnectionId ??= $this->selectedProviderConnectionId($draft); + + return $this->bootstrapState($draft, $selectedProviderConnectionId)['summaries']; + } + + public function isReadyForActivation(TenantOnboardingSession $draft): bool + { + return $this->snapshot($draft)['lifecycle_state'] === OnboardingLifecycleState::ReadyForActivation; + } + + public function hasActiveCheckpoint(TenantOnboardingSession $draft): bool + { + $snapshot = $this->snapshot($draft); + + return in_array($snapshot['lifecycle_state'], [OnboardingLifecycleState::Verifying, OnboardingLifecycleState::Bootstrapping], true); + } + + private function hasTenantIdentity(TenantOnboardingSession $draft): bool + { + if ($draft->tenant_id !== null) { + return true; + } + + $state = is_array($draft->state) ? $draft->state : []; + $entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id; + + return is_string($entraTenantId) && trim($entraTenantId) !== ''; + } + + private function selectedProviderConnectionId(TenantOnboardingSession $draft): ?int + { + $state = is_array($draft->state) ? $draft->state : []; + + return $this->normalizeInteger($state['provider_connection_id'] ?? $state['selected_provider_connection_id'] ?? null); + } + + private function connectionRecentlyUpdated(TenantOnboardingSession $draft): bool + { + $state = is_array($draft->state) ? $draft->state : []; + + return (bool) ($state['connection_recently_updated'] ?? false); + } + + private function verificationRunMatchesSelectedConnection(OperationRun $run, ?int $selectedProviderConnectionId): bool + { + if ($selectedProviderConnectionId === null) { + return false; + } + + $context = is_array($run->context ?? null) ? $run->context : []; + $runProviderConnectionId = $this->normalizeInteger($context['provider_connection_id'] ?? null); + + if ($runProviderConnectionId === null) { + return false; + } + + return $runProviderConnectionId === $selectedProviderConnectionId; + } + + private function verificationStatusFromRunOutcome(OperationRun $run): string + { + return match ($run->outcome) { + OperationRunOutcome::Blocked->value => 'blocked', + OperationRunOutcome::Succeeded->value => 'ready', + OperationRunOutcome::PartiallySucceeded->value => 'needs_attention', + OperationRunOutcome::Failed->value => $this->failedVerificationStatus($run), + default => 'needs_attention', + }; + } + + private function failedVerificationStatus(OperationRun $run): string + { + foreach ($this->runReasonCodes($run) as $reasonCode) { + if (str_contains($reasonCode, 'permission') || str_contains($reasonCode, 'consent') || str_contains($reasonCode, 'auth')) { + return 'blocked'; + } + } + + return 'needs_attention'; + } + + private function verificationReportOverall(OperationRun $run): ?string + { + $report = VerificationReportViewer::report($run); + $summary = is_array($report['summary'] ?? null) ? $report['summary'] : null; + $overall = $summary['overall'] ?? null; + + if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) { + return null; + } + + return $overall; + } + + private function verificationBlockingReasonCode(OperationRun $run): string + { + foreach ($this->runReasonCodes($run) as $reasonCode) { + if (str_contains($reasonCode, 'permission') || str_contains($reasonCode, 'consent') || str_contains($reasonCode, 'auth')) { + return 'verification_blocked_permissions'; + } + } + + return 'verification_failed'; + } + + /** + * @return array + */ + private function runReasonCodes(OperationRun $run): array + { + $context = is_array($run->context ?? null) ? $run->context : []; + $codes = []; + + if (is_string($context['reason_code'] ?? null) && trim((string) $context['reason_code']) !== '') { + $codes[] = strtolower(trim((string) $context['reason_code'])); + } + + $failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : []; + + foreach ($failures as $failure) { + if (! is_array($failure)) { + continue; + } + + foreach (['reason_code', 'code'] as $key) { + $candidate = $failure[$key] ?? null; + + if (! is_string($candidate) || trim($candidate) === '') { + continue; + } + + $codes[] = strtolower(trim($candidate)); + } + } + + return array_values(array_unique($codes)); + } + + /** + * @return array{ + * has_selected_types: bool, + * all_selected_types_completed: bool, + * has_active_runs: bool, + * has_failure: bool, + * has_partial_failure: bool, + * summaries: array + * } + */ + private function bootstrapState(TenantOnboardingSession $draft, ?int $selectedProviderConnectionId): array + { + $selectedTypes = $this->bootstrapOperationTypes($draft); + $runMap = $this->bootstrapRunMap($draft, $selectedTypes); + + if ($selectedTypes === []) { + return [ + 'has_selected_types' => false, + 'all_selected_types_completed' => false, + 'has_active_runs' => false, + 'has_failure' => false, + 'has_partial_failure' => false, + 'summaries' => [], + ]; + } + + $runs = OperationRun::query() + ->where('workspace_id', (int) $draft->workspace_id) + ->whereIn('id', array_values($runMap)) + ->get() + ->keyBy(static fn (OperationRun $run): int => (int) $run->getKey()); + + $summaries = []; + $hasActiveRuns = false; + $hasFailure = false; + $hasPartialFailure = false; + $allSelectedTypesCompleted = true; + + foreach ($selectedTypes as $type) { + $runId = $runMap[$type] ?? null; + $run = $runId !== null ? $runs->get($runId) : null; + + if ($run instanceof OperationRun && $selectedProviderConnectionId !== null) { + $context = is_array($run->context ?? null) ? $run->context : []; + $runProviderConnectionId = $this->normalizeInteger($context['provider_connection_id'] ?? null); + + if ($runProviderConnectionId !== null && $runProviderConnectionId !== $selectedProviderConnectionId) { + $run = null; + } + } + + $status = $run instanceof OperationRun ? (string) $run->status : null; + $outcome = $run instanceof OperationRun ? (string) $run->outcome : null; + $isActive = $run instanceof OperationRun && $status !== OperationRunStatus::Completed->value; + $isCompleted = $run instanceof OperationRun && $status === OperationRunStatus::Completed->value; + $isPartialFailure = $outcome === OperationRunOutcome::PartiallySucceeded->value; + $isFailure = in_array($outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true); + + $summaries[] = [ + 'type' => $type, + 'run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null, + 'status' => $status, + 'outcome' => $outcome, + 'is_active' => $isActive, + 'is_failure' => $isFailure, + 'is_partial_failure' => $isPartialFailure, + 'is_completed' => $isCompleted, + ]; + + $hasActiveRuns = $hasActiveRuns || $isActive; + $hasFailure = $hasFailure || $isFailure; + $hasPartialFailure = $hasPartialFailure || $isPartialFailure; + $allSelectedTypesCompleted = $allSelectedTypesCompleted && $isCompleted && ! $isFailure && ! $isPartialFailure; + } + + return [ + 'has_selected_types' => true, + 'all_selected_types_completed' => $allSelectedTypesCompleted, + 'has_active_runs' => $hasActiveRuns, + 'has_failure' => $hasFailure, + 'has_partial_failure' => $hasPartialFailure, + 'summaries' => $summaries, + ]; + } + + /** + * @return array + */ + private function bootstrapOperationTypes(TenantOnboardingSession $draft): array + { + $state = is_array($draft->state) ? $draft->state : []; + $types = $state['bootstrap_operation_types'] ?? []; + + if (! is_array($types)) { + return []; + } + + return array_values(array_filter( + array_map(static fn (mixed $value): string => is_string($value) ? trim($value) : '', $types), + static fn (string $value): bool => $value !== '', + )); + } + + /** + * @param array $selectedTypes + * @return array + */ + private function bootstrapRunMap(TenantOnboardingSession $draft, array $selectedTypes): array + { + $state = is_array($draft->state) ? $draft->state : []; + $runs = $state['bootstrap_operation_runs'] ?? null; + $runMap = []; + + if (is_array($runs)) { + foreach ($runs as $type => $runId) { + if (! is_string($type) || trim($type) === '') { + continue; + } + + $normalizedRunId = $this->normalizeInteger($runId); + + if ($normalizedRunId === null) { + continue; + } + + $runMap[trim($type)] = $normalizedRunId; + } + } + + if ($runMap !== []) { + return $runMap; + } + + $legacyRunIds = $state['bootstrap_run_ids'] ?? null; + + if (! is_array($legacyRunIds)) { + return []; + } + + foreach (array_values($selectedTypes) as $index => $type) { + $runId = $this->normalizeInteger($legacyRunIds[$index] ?? null); + + if ($runId === null) { + continue; + } + + $runMap[$type] = $runId; + } + + return $runMap; + } + + private function normalizeInteger(mixed $value): ?int + { + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_string($value) && ctype_digit(trim($value))) { + $normalized = (int) trim($value); + + return $normalized > 0 ? $normalized : null; + } + + if (is_numeric($value)) { + $normalized = (int) $value; + + return $normalized > 0 ? $normalized : null; + } + + return null; + } +} diff --git a/app/Support/Onboarding/OnboardingCheckpoint.php b/app/Support/Onboarding/OnboardingCheckpoint.php new file mode 100644 index 0000000..66b160d --- /dev/null +++ b/app/Support/Onboarding/OnboardingCheckpoint.php @@ -0,0 +1,59 @@ + 'Identify managed tenant', + self::ConnectProvider => 'Connect provider', + self::VerifyAccess => 'Verify access', + self::Bootstrap => 'Bootstrap', + self::CompleteActivate => 'Complete / Activate', + }; + } + + public function wizardStep(): int + { + return match ($this) { + self::Identify => 1, + self::ConnectProvider => 2, + self::VerifyAccess => 3, + self::Bootstrap => 4, + self::CompleteActivate => 5, + }; + } + + public static function fromCurrentStep(?string $step): ?self + { + return match ($step) { + 'identify' => self::Identify, + 'connection' => self::ConnectProvider, + 'verify' => self::VerifyAccess, + 'bootstrap' => self::Bootstrap, + 'complete' => self::CompleteActivate, + default => null, + }; + } + + /** + * @return array + */ + public static function values(): array + { + return array_map( + static fn (self $case): string => $case->value, + self::cases(), + ); + } +} diff --git a/app/Support/Onboarding/OnboardingDraftStatus.php b/app/Support/Onboarding/OnboardingDraftStatus.php index d0538fc..e882a68 100644 --- a/app/Support/Onboarding/OnboardingDraftStatus.php +++ b/app/Support/Onboarding/OnboardingDraftStatus.php @@ -10,6 +10,15 @@ enum OnboardingDraftStatus: string case Completed = 'completed'; case Cancelled = 'cancelled'; + public static function fromLifecycleState(OnboardingLifecycleState $state): self + { + return match ($state) { + OnboardingLifecycleState::Completed => self::Completed, + OnboardingLifecycleState::Cancelled => self::Cancelled, + default => self::Draft, + }; + } + public function label(): string { return match ($this) { @@ -23,4 +32,9 @@ public function isResumable(): bool { return $this === self::Draft; } + + public function isTerminal(): bool + { + return ! $this->isResumable(); + } } diff --git a/app/Support/Onboarding/OnboardingLifecycleState.php b/app/Support/Onboarding/OnboardingLifecycleState.php new file mode 100644 index 0000000..84cbda8 --- /dev/null +++ b/app/Support/Onboarding/OnboardingLifecycleState.php @@ -0,0 +1,45 @@ + 'Draft', + self::Verifying => 'Verifying', + self::ActionRequired => 'Action required', + self::Bootstrapping => 'Bootstrapping', + self::ReadyForActivation => 'Ready for activation', + self::Completed => 'Completed', + self::Cancelled => 'Cancelled', + }; + } + + public function isTerminal(): bool + { + return in_array($this, [self::Completed, self::Cancelled], true); + } + + /** + * @return array + */ + public static function values(): array + { + return array_map( + static fn (self $case): string => $case->value, + self::cases(), + ); + } +} diff --git a/database/factories/TenantOnboardingSessionFactory.php b/database/factories/TenantOnboardingSessionFactory.php index 67ddd76..bb2441e 100644 --- a/database/factories/TenantOnboardingSessionFactory.php +++ b/database/factories/TenantOnboardingSessionFactory.php @@ -8,6 +8,8 @@ use App\Models\TenantOnboardingSession; use App\Models\User; use App\Models\Workspace; +use App\Support\Onboarding\OnboardingCheckpoint; +use App\Support\Onboarding\OnboardingLifecycleState; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -34,6 +36,12 @@ public function definition(): array ], 'started_by_user_id' => User::factory(), 'updated_by_user_id' => User::factory(), + 'version' => 1, + 'lifecycle_state' => OnboardingLifecycleState::Draft->value, + 'current_checkpoint' => OnboardingCheckpoint::Identify->value, + 'last_completed_checkpoint' => null, + 'reason_code' => null, + 'blocking_reason_code' => null, 'completed_at' => null, 'cancelled_at' => null, ]; @@ -79,6 +87,8 @@ public function withProviderConnection(int $providerConnectionId): static { return $this->state(fn (array $attributes): array => [ 'current_step' => 'connection', + 'current_checkpoint' => OnboardingCheckpoint::ConnectProvider->value, + 'last_completed_checkpoint' => OnboardingCheckpoint::Identify->value, 'state' => array_merge(is_array($attributes['state'] ?? null) ? $attributes['state'] : [], [ 'provider_connection_id' => $providerConnectionId, ]), @@ -89,6 +99,8 @@ public function withVerificationRun(int $operationRunId): static { return $this->state(fn (array $attributes): array => [ 'current_step' => 'verify', + 'current_checkpoint' => OnboardingCheckpoint::VerifyAccess->value, + 'last_completed_checkpoint' => OnboardingCheckpoint::ConnectProvider->value, 'state' => array_merge(is_array($attributes['state'] ?? null) ? $attributes['state'] : [], [ 'verification_operation_run_id' => $operationRunId, ]), @@ -99,6 +111,9 @@ public function reviewReady(): static { return $this->state(fn (array $attributes): array => [ 'current_step' => 'bootstrap', + 'lifecycle_state' => OnboardingLifecycleState::ReadyForActivation->value, + 'current_checkpoint' => OnboardingCheckpoint::CompleteActivate->value, + 'last_completed_checkpoint' => OnboardingCheckpoint::VerifyAccess->value, 'state' => array_merge(is_array($attributes['state'] ?? null) ? $attributes['state'] : [], [ 'bootstrap_operation_types' => [], ]), @@ -109,6 +124,11 @@ public function completed(): static { return $this->state(fn (): array => [ 'current_step' => 'complete', + 'lifecycle_state' => OnboardingLifecycleState::Completed->value, + 'current_checkpoint' => OnboardingCheckpoint::CompleteActivate->value, + 'last_completed_checkpoint' => OnboardingCheckpoint::CompleteActivate->value, + 'reason_code' => null, + 'blocking_reason_code' => null, 'completed_at' => now(), 'cancelled_at' => null, ]); @@ -118,6 +138,11 @@ public function cancelled(): static { return $this->state(fn (): array => [ 'current_step' => 'cancelled', + 'lifecycle_state' => OnboardingLifecycleState::Cancelled->value, + 'current_checkpoint' => OnboardingCheckpoint::Identify->value, + 'last_completed_checkpoint' => null, + 'reason_code' => null, + 'blocking_reason_code' => null, 'completed_at' => null, 'cancelled_at' => now(), ]); diff --git a/database/migrations/2026_03_14_000001_add_lifecycle_and_version_to_managed_tenant_onboarding_sessions.php b/database/migrations/2026_03_14_000001_add_lifecycle_and_version_to_managed_tenant_onboarding_sessions.php new file mode 100644 index 0000000..67e9201 --- /dev/null +++ b/database/migrations/2026_03_14_000001_add_lifecycle_and_version_to_managed_tenant_onboarding_sessions.php @@ -0,0 +1,228 @@ +unsignedBigInteger('version')->default(1); + } + + if ($needsLifecycleState) { + $table->string('lifecycle_state')->default(OnboardingLifecycleState::Draft->value); + } + + if ($needsCurrentCheckpoint) { + $table->string('current_checkpoint')->nullable(); + } + + if ($needsLastCompletedCheckpoint) { + $table->string('last_completed_checkpoint')->nullable(); + } + + if ($needsReasonCode) { + $table->string('reason_code')->nullable(); + } + + if ($needsBlockingReasonCode) { + $table->string('blocking_reason_code')->nullable(); + } + }); + } + + DB::table('managed_tenant_onboarding_sessions') + ->orderBy('id') + ->chunkById(500, function ($rows): void { + foreach ($rows as $row) { + $state = is_string($row->state) ? json_decode($row->state, true) : null; + $state = is_array($state) ? $state : []; + + $currentCheckpoint = $this->currentCheckpoint($row->current_step, $state, $row->completed_at, $row->cancelled_at); + $lastCompletedCheckpoint = $this->lastCompletedCheckpoint($currentCheckpoint, $state, $row->completed_at); + $lifecycleState = $this->lifecycleState($row->completed_at, $row->cancelled_at, $currentCheckpoint, $state); + + DB::table('managed_tenant_onboarding_sessions') + ->where('id', $row->id) + ->update([ + 'version' => max(1, (int) ($row->version ?? 1)), + 'lifecycle_state' => $lifecycleState, + 'current_checkpoint' => $currentCheckpoint, + 'last_completed_checkpoint' => $lastCompletedCheckpoint, + 'reason_code' => null, + 'blocking_reason_code' => null, + ]); + } + }, 'id'); + + if (! $this->hasIndex('managed_tenant_onboarding_sessions_lifecycle_state_index')) { + Schema::table('managed_tenant_onboarding_sessions', function (Blueprint $table): void { + $table->index('lifecycle_state', 'managed_tenant_onboarding_sessions_lifecycle_state_index'); + }); + } + } + + public function down(): void + { + if (! Schema::hasTable('managed_tenant_onboarding_sessions')) { + return; + } + + if ($this->hasIndex('managed_tenant_onboarding_sessions_lifecycle_state_index')) { + Schema::table('managed_tenant_onboarding_sessions', function (Blueprint $table): void { + $table->dropIndex('managed_tenant_onboarding_sessions_lifecycle_state_index'); + }); + } + + Schema::table('managed_tenant_onboarding_sessions', function (Blueprint $table): void { + foreach (['version', 'lifecycle_state', 'current_checkpoint', 'last_completed_checkpoint', 'reason_code', 'blocking_reason_code'] as $column) { + if (Schema::hasColumn('managed_tenant_onboarding_sessions', $column)) { + $table->dropColumn($column); + } + } + }); + } + + /** + * @param array $state + */ + private function currentCheckpoint( + ?string $currentStep, + array $state, + mixed $completedAt, + mixed $cancelledAt, + ): ?string { + if ($completedAt !== null) { + return OnboardingCheckpoint::CompleteActivate->value; + } + + if (is_string($currentStep) && OnboardingCheckpoint::fromCurrentStep($currentStep) instanceof OnboardingCheckpoint) { + return OnboardingCheckpoint::fromCurrentStep($currentStep)?->value; + } + + if ($cancelledAt !== null) { + return OnboardingCheckpoint::Identify->value; + } + + if ($this->normalizeInteger($state['provider_connection_id'] ?? null) !== null) { + return OnboardingCheckpoint::VerifyAccess->value; + } + + $entraTenantId = $state['entra_tenant_id'] ?? null; + + if (is_string($entraTenantId) && trim($entraTenantId) !== '') { + return OnboardingCheckpoint::ConnectProvider->value; + } + + return OnboardingCheckpoint::Identify->value; + } + + /** + * @param array $state + */ + private function lastCompletedCheckpoint(?string $currentCheckpoint, array $state, mixed $completedAt): ?string + { + if ($completedAt !== null) { + return OnboardingCheckpoint::CompleteActivate->value; + } + + return match ($currentCheckpoint) { + OnboardingCheckpoint::ConnectProvider->value => OnboardingCheckpoint::Identify->value, + OnboardingCheckpoint::VerifyAccess->value => OnboardingCheckpoint::ConnectProvider->value, + OnboardingCheckpoint::Bootstrap->value => OnboardingCheckpoint::VerifyAccess->value, + OnboardingCheckpoint::CompleteActivate->value => $this->hasBootstrapSelection($state) + ? OnboardingCheckpoint::Bootstrap->value + : OnboardingCheckpoint::VerifyAccess->value, + default => null, + }; + } + + /** + * @param array $state + */ + private function lifecycleState( + mixed $completedAt, + mixed $cancelledAt, + ?string $currentCheckpoint, + array $state, + ): string { + if ($completedAt !== null) { + return OnboardingLifecycleState::Completed->value; + } + + if ($cancelledAt !== null) { + return OnboardingLifecycleState::Cancelled->value; + } + + if ($currentCheckpoint === OnboardingCheckpoint::CompleteActivate->value) { + return OnboardingLifecycleState::ReadyForActivation->value; + } + + if ($currentCheckpoint === OnboardingCheckpoint::Bootstrap->value && $this->hasBootstrapSelection($state)) { + return OnboardingLifecycleState::Draft->value; + } + + return OnboardingLifecycleState::Draft->value; + } + + /** + * @param array $state + */ + private function hasBootstrapSelection(array $state): bool + { + $types = $state['bootstrap_operation_types'] ?? null; + + return is_array($types) && $types !== []; + } + + private function normalizeInteger(mixed $value): ?int + { + if (is_int($value) && $value > 0) { + return $value; + } + + if (is_string($value) && ctype_digit(trim($value))) { + $normalized = (int) trim($value); + + return $normalized > 0 ? $normalized : null; + } + + return null; + } + + private function hasIndex(string $indexName): bool + { + $driver = Schema::getConnection()->getDriverName(); + + return match ($driver) { + 'pgsql' => DB::table('pg_indexes') + ->where('schemaname', 'public') + ->where('indexname', $indexName) + ->exists(), + 'sqlite' => collect(DB::select("PRAGMA index_list('managed_tenant_onboarding_sessions')")) + ->contains(fn (object $index): bool => ($index->name ?? null) === $indexName), + default => false, + }; + } +}; diff --git a/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php b/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php index a34646a..fe43459 100644 --- a/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php +++ b/resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php @@ -163,7 +163,7 @@ @elseif ($status !== 'completed')
- Report unavailable while the run is in progress. Use “Refresh” to re-check stored status. + Report unavailable while the run is in progress. Stored status updates automatically about every 5 seconds. Use “Refresh” to re-check immediately.
@else
diff --git a/resources/views/filament/modals/onboarding-verification-technical-details.blade.php b/resources/views/filament/modals/onboarding-verification-technical-details.blade.php index ab58cd6..ef66fbd 100644 --- a/resources/views/filament/modals/onboarding-verification-technical-details.blade.php +++ b/resources/views/filament/modals/onboarding-verification-technical-details.blade.php @@ -103,9 +103,9 @@ @endif @if (! $hasReport) -
No report yet. Refresh results in a moment.
+
No report yet. Stored status updates automatically about every 5 seconds.
@else -
Partial results available. Use “Refresh results” to update the stored status in the wizard.
+
Partial results are available. The wizard updates automatically about every 5 seconds, or you can use “Refresh” for an immediate re-check.
@endif
@endif diff --git a/resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php b/resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php index cc61477..a741733 100644 --- a/resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php +++ b/resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php @@ -1,3 +1,3 @@ - + {{ $this->content }} diff --git a/resources/views/filament/schemas/components/managed-tenant-onboarding-checkpoint-poll.blade.php b/resources/views/filament/schemas/components/managed-tenant-onboarding-checkpoint-poll.blade.php new file mode 100644 index 0000000..e91e6df --- /dev/null +++ b/resources/views/filament/schemas/components/managed-tenant-onboarding-checkpoint-poll.blade.php @@ -0,0 +1 @@ + diff --git a/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/checklists/requirements.md b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/checklists/requirements.md new file mode 100644 index 0000000..2f8a3c0 --- /dev/null +++ b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Onboarding Lifecycle, Operation Checkpoints & Concurrency MVP + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-14 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Architectural constraints are intentionally described at the workflow level because this repository's constitution requires explicit lifecycle, operation, authorization, and Filament-surface alignment in feature specs. \ No newline at end of file diff --git a/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/contracts/onboarding-lifecycle-logical-contract.openapi.yaml b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/contracts/onboarding-lifecycle-logical-contract.openapi.yaml new file mode 100644 index 0000000..58cd0d2 --- /dev/null +++ b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/contracts/onboarding-lifecycle-logical-contract.openapi.yaml @@ -0,0 +1,309 @@ +openapi: 3.1.0 +info: + title: Managed Tenant Onboarding Lifecycle Logical Contract + version: 0.1.0 + summary: Logical mutation contract for feature 140 + description: | + This contract documents the logical onboarding draft read and mutation surface + required by feature 140. It does not authorize creating new public routes. + Implementation must continue to use the existing Filament and Livewire wizard + mounted on /admin/onboarding and /admin/onboarding/{onboardingDraft}. +servers: + - url: /admin +paths: + /onboarding/{onboardingDraft}: + get: + summary: Read onboarding draft lifecycle state + operationId: readOnboardingDraftLifecycle + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + responses: + '200': + description: Current draft workflow state + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingDraftView' + '403': + description: Member lacks required capability + '404': + description: Draft not found or caller not entitled to workspace scope + /onboarding/{onboardingDraft}/commands/provider-connection: + post: + summary: Commit provider connection selection or change + operationId: commitOnboardingProviderConnection + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionMutation' + responses: + '200': + description: Draft updated + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingDraftMutationResult' + '403': + description: Member lacks required capability + '404': + description: Draft not found or caller not entitled + '409': + description: Optimistic locking conflict + '422': + description: Invalid selection or transition + /onboarding/{onboardingDraft}/commands/verify: + post: + summary: Start or rerun Verify Access for the selected provider connection + operationId: startOnboardingVerification + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VersionedMutationRequest' + responses: + '202': + description: Verification accepted and relevant run queued or reused + content: + application/json: + schema: + $ref: '#/components/schemas/CheckpointRunAccepted' + '403': + description: Member lacks required capability + '404': + description: Draft not found or caller not entitled + '409': + description: Optimistic locking conflict + '422': + description: Draft is not in a valid state to start verification + /onboarding/{onboardingDraft}/commands/bootstrap: + post: + summary: Start selected bootstrap operations + operationId: startOnboardingBootstrap + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BootstrapMutation' + responses: + '202': + description: Bootstrap accepted and relevant runs queued or reused + content: + application/json: + schema: + $ref: '#/components/schemas/CheckpointRunAccepted' + '403': + description: Member lacks required capability + '404': + description: Draft not found or caller not entitled + '409': + description: Optimistic locking conflict + '422': + description: Verification or selection preconditions are not met + /onboarding/{onboardingDraft}/commands/activate: + post: + summary: Activate a ready onboarding draft + operationId: activateOnboardingDraft + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActivationMutation' + responses: + '200': + description: Tenant activated and draft completed + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingDraftMutationResult' + '403': + description: Member lacks activation capability + '404': + description: Draft not found or caller not entitled + '409': + description: Optimistic locking conflict + '422': + description: Backend truth no longer permits activation + /onboarding/{onboardingDraft}/commands/cancel: + post: + summary: Cancel an editable onboarding draft + operationId: cancelOnboardingDraft + parameters: + - $ref: '#/components/parameters/OnboardingDraftId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VersionedMutationRequest' + responses: + '200': + description: Draft cancelled + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingDraftMutationResult' + '403': + description: Member lacks required capability + '404': + description: Draft not found or caller not entitled + '409': + description: Optimistic locking conflict + '422': + description: Draft is already terminal or not cancellable +components: + parameters: + OnboardingDraftId: + name: onboardingDraft + in: path + required: true + schema: + type: integer + schemas: + OnboardingDraftView: + type: object + required: + - id + - version + - lifecycle_state + - current_checkpoint + properties: + id: + type: integer + version: + type: integer + minimum: 1 + lifecycle_state: + $ref: '#/components/schemas/LifecycleState' + current_checkpoint: + oneOf: + - $ref: '#/components/schemas/Checkpoint' + - type: 'null' + last_completed_checkpoint: + oneOf: + - $ref: '#/components/schemas/Checkpoint' + - type: 'null' + reason_code: + type: + - string + - 'null' + blocking_reason_code: + type: + - string + - 'null' + verification_operation_run_id: + type: + - integer + - 'null' + bootstrap_operation_run_ids: + type: array + items: + type: integer + VersionedMutationRequest: + type: object + required: + - expected_version + properties: + expected_version: + type: integer + minimum: 1 + ProviderConnectionMutation: + allOf: + - $ref: '#/components/schemas/VersionedMutationRequest' + - type: object + required: + - provider_connection_id + properties: + provider_connection_id: + type: integer + BootstrapMutation: + allOf: + - $ref: '#/components/schemas/VersionedMutationRequest' + - type: object + required: + - bootstrap_operation_types + properties: + bootstrap_operation_types: + type: array + items: + type: string + ActivationMutation: + allOf: + - $ref: '#/components/schemas/VersionedMutationRequest' + - type: object + properties: + override_blocked: + type: boolean + override_reason: + type: + - string + - 'null' + CheckpointRunAccepted: + type: object + required: + - draft + - operation_run_id + properties: + draft: + $ref: '#/components/schemas/OnboardingDraftMutationResult' + operation_run_id: + type: integer + OnboardingDraftMutationResult: + type: object + required: + - id + - version + - lifecycle_state + properties: + id: + type: integer + version: + type: integer + lifecycle_state: + $ref: '#/components/schemas/LifecycleState' + current_checkpoint: + oneOf: + - $ref: '#/components/schemas/Checkpoint' + - type: 'null' + last_completed_checkpoint: + oneOf: + - $ref: '#/components/schemas/Checkpoint' + - type: 'null' + reason_code: + type: + - string + - 'null' + blocking_reason_code: + type: + - string + - 'null' + LifecycleState: + type: string + enum: + - draft + - verifying + - action_required + - bootstrapping + - ready_for_activation + - completed + - cancelled + Checkpoint: + type: string + enum: + - identify + - connect_provider + - verify_access + - bootstrap + - complete_activate \ No newline at end of file diff --git a/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/data-model.md b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/data-model.md new file mode 100644 index 0000000..52e6fa5 --- /dev/null +++ b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/data-model.md @@ -0,0 +1,128 @@ +# Data Model: Onboarding Lifecycle, Operation Checkpoints & Concurrency MVP + +## Entity: TenantOnboardingSession + +Existing persisted workflow record for managed tenant onboarding drafts. + +### Fields + +| Field | Type | Required | Notes | +|---|---|---|---| +| `id` | bigint | yes | Primary key | +| `workspace_id` | foreign key | yes | Workspace ownership and authorization boundary | +| `tenant_id` | foreign key nullable | conditional | May remain null before tenant identification, per constitution exception | +| `entra_tenant_id` | string | yes | Current draft identity key for resumable uniqueness | +| `current_step` | string nullable | no | Existing UI-oriented step breadcrumb; no longer canonical workflow truth | +| `state` | json nullable | no | Detailed persisted step data and run references | +| `started_by_user_id` | foreign key nullable | no | Draft creator | +| `updated_by_user_id` | foreign key nullable | no | Last mutating actor | +| `completed_at` | timestamp nullable | no | Terminal historical marker | +| `cancelled_at` | timestamp nullable | no | Terminal historical marker | +| `version` | bigint or integer | yes | Starts at `1`, increments on every relevant successful mutation | +| `lifecycle_state` | controlled string or enum | yes | Canonical workflow state | +| `current_checkpoint` | controlled string or enum nullable | no | Governing checkpoint for forward progress | +| `last_completed_checkpoint` | controlled string or enum nullable | no | Last satisfied checkpoint | +| `reason_code` | string nullable | no | Machine-readable lifecycle precision | +| `blocking_reason_code` | string nullable | no | Machine-readable explicit blocker | +| `created_at` | timestamp | yes | Existing audit chronology | +| `updated_at` | timestamp | yes | Existing audit chronology | + +### Relationships + +- Belongs to `Workspace` +- Belongs to `Tenant` when identified +- Belongs to `startedByUser` +- Belongs to `updatedByUser` +- References relevant verification and bootstrap `OperationRun` records through IDs stored in `state` + +### Validation Rules + +- `version >= 1` +- `lifecycle_state` must be one of the controlled lifecycle values +- `current_checkpoint` and `last_completed_checkpoint` must be null or one of the controlled checkpoint values +- `blocking_reason_code` must be null unless the workflow is actually blocked from forward progress +- `completed_at` and `cancelled_at` remain mutually exclusive terminal markers +- Terminal records (`completed_at` or `cancelled_at` set) are non-editable + +## Entity: OnboardingLifecycleState + +Controlled value set used for canonical workflow truth. + +### Values + +| Value | Meaning | +|---|---| +| `draft` | No active governing checkpoint run and not yet ready for activation | +| `verifying` | Relevant verification run is queued or running | +| `action_required` | Operator intervention is required before safe progression | +| `bootstrapping` | One or more selected bootstrap runs are queued or running | +| `ready_for_activation` | All gating conditions are satisfied and the workflow awaits final activation | +| `completed` | Activation succeeded and the draft is historical | +| `cancelled` | Draft was intentionally cancelled and is historical | + +## Entity: OnboardingCheckpoint + +Controlled checkpoint precision used for progression and UI state. + +### Values + +| Value | Meaning | +|---|---| +| `identify` | Tenant identity or workspace-scoped start state | +| `connect_provider` | Provider selection or connection management checkpoint | +| `verify_access` | Verify Access checkpoint | +| `bootstrap` | Optional bootstrap checkpoint | +| `complete_activate` | Final activation checkpoint | + +## Entity: OnboardingReasonCode + +Machine-readable precision for non-terminal workflow state. + +### Expected starter values + +- `verification_blocked_permissions` +- `verification_failed` +- `provider_connection_changed` +- `verification_result_stale` +- `bootstrap_failed` +- `bootstrap_partial_failure` +- `owner_activation_required` + +This list is intentionally controlled and should be extended centrally rather than spread through page code. + +## Entity: RelevantCheckpointRun + +Derived relationship between a draft and the `OperationRun` records that currently govern progression. + +### Verification invariants + +- The verification run must match the currently selected provider connection. +- A queued or running verification run implies `lifecycle_state = verifying`. +- A terminal verification run only allows progression when it is current for the selected connection and satisfies readiness conditions. + +### Bootstrap invariants + +- Bootstrap run references remain stored in `state` as selected run metadata. +- One or more queued or running relevant bootstrap runs imply `lifecycle_state = bootstrapping`. +- Any blocking bootstrap failure moves the workflow to `action_required`. + +## State Transition Model + +| From | Trigger | To | Notes | +|---|---|---|---| +| `draft` | verification started | `verifying` | Relevant verification run created or reused | +| `verifying` | verification succeeds and no bootstrap selected | `ready_for_activation` | Verification must be current for selected connection | +| `verifying` | verification succeeds and bootstrap started | `bootstrapping` | Selected bootstrap runs become active | +| `verifying` | verification blocked, failed, stale, or mismatched | `action_required` | `reason_code` and optional `blocking_reason_code` set | +| `action_required` | blocker resolved by rerun or reset | `verifying`, `bootstrapping`, `ready_for_activation`, or `draft` | Chosen by centralized lifecycle recalculation | +| `bootstrapping` | all selected bootstrap runs complete successfully | `ready_for_activation` | No remaining blocker allowed | +| `bootstrapping` | selected bootstrap run fails in blocking way | `action_required` | Failure reason preserved | +| any editable state | cancel action | `cancelled` | Terminal and immutable | +| `ready_for_activation` | activation succeeds | `completed` | Terminal and immutable | + +## Concurrency Contract + +- Every relevant mutation must carry an expected `version`. +- The write path must compare expected version to persisted version inside the same transaction or atomic update. +- On mismatch, no lifecycle field, checkpoint field, JSON state, or related run reference may be partially written. +- Conflict rejection keeps the user on the wizard and returns a visible refresh-required error. diff --git a/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/plan.md b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/plan.md new file mode 100644 index 0000000..c8255be --- /dev/null +++ b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/plan.md @@ -0,0 +1,151 @@ +# Implementation Plan: Onboarding Lifecycle, Operation Checkpoints & Concurrency MVP + +**Branch**: `140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp` | **Date**: 2026-03-14 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/spec.md` + +## Summary + +Keep the existing Filament onboarding wizard as the operator shell, but move workflow truth out of ad hoc page-state inference and into canonical draft lifecycle fields plus existing `OperationRun` execution truth. The implementation will extend `managed_tenant_onboarding_sessions` with lifecycle, checkpoint, blocker, and version columns; centralize lifecycle recalculation and readiness evaluation in one onboarding service; move wizard mutations behind version-checked draft writes; and add conditional polling to Verify Access and Bootstrap so active checkpoint state reflects backend truth without manual refresh. Existing verification, bootstrap, audit, authorization, and Spec 139 assist behavior remain in place. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging +**Storage**: PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records +**Testing**: Pest feature, browser, and unit tests run through `vendor/bin/sail artisan test --compact` +**Target Platform**: Laravel web application in the `/admin` Filament panel, developed locally through Sail and deployed containerized +**Project Type**: Single web application +**Performance Goals**: Active Verify Access and Bootstrap states update within an active `5s` poll interval while the page is open; no manual refresh is required to observe terminal checkpoint results; lifecycle recalculation remains request-bounded DB work +**Constraints**: No new onboarding routes; no new Graph call surfaces; no new operation backend; no lease or takeover model; Spec 139 assist must remain additive; `OperationRun` transitions remain service-owned; monitoring pages remain DB-only at render time; all relevant draft mutations must enforce optimistic locking +**Scale/Scope**: Workspace-scoped admin onboarding flow for managed tenants, small concurrent editor set but multi-tab and multi-operator safe, limited to the existing onboarding wizard, supporting services, database schema, and focused onboarding or Ops-UX tests + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS. This feature changes workflow coordination only; it does not alter inventory or snapshot truth. +- Read/write separation: PASS. The feature introduces draft writes and gating logic only. Verification, bootstrap, activation, cancel, and override audit paths remain explicit and test-covered. +- Graph contract path: PASS. No new Graph calls or contract bypasses are introduced; verification and bootstrap continue to use existing providers and operation flows. +- Deterministic capabilities: PASS. Capability checks remain registry-based and reusable through existing onboarding capability constants. +- Workspace isolation: PASS. The feature remains in the workspace-admin plane and keeps deny-as-not-found semantics for non-members. +- Tenant isolation: PASS. Tenant-bound verification, bootstrap, and activation truth still come from tenant-scoped records and access checks. +- RBAC-UX 404/403 semantics: PASS. Existing onboarding authorization remains server-side; the plan preserves 404 for non-members and 403 for in-scope capability denial. +- Destructive confirmations: PASS. No new destructive actions are added; existing `Cancel onboarding` confirmation remains in place. +- Global search safety: PASS. No global search behavior is introduced or modified. +- Run observability: PASS. Verify Access and Bootstrap continue reusing `OperationRun`, enqueue-only start surfaces, and Monitoring DB truth. +- Ops-UX 3-surface feedback: PASS. The feature keeps queued toast intent feedback, in-wizard progress state, and existing terminal notification behavior, with no new ad hoc completion notifications. +- Ops-UX lifecycle ownership: PASS. `OperationRun.status` and `OperationRun.outcome` transitions remain owned by `OperationRunService`. +- Ops-UX summary counts and guards: PASS. No new summary key contract is introduced; existing guards remain applicable. +- Ops-UX system-run rules: PASS. No initiator-null notification changes are introduced. +- Automation, idempotency, and dedupe: PASS. Existing verification and bootstrap dispatch continue through current gate and run services. +- Data minimization: PASS. The feature adds workflow metadata only and does not broaden secrets or payload storage. +- Badge semantics: PASS. Any new lifecycle or checkpoint badge presentation must route through the existing centralized status semantics rather than page-local mappings. +- UI naming: PASS. Operator language remains aligned with current onboarding and Spec 139 terminology. +- Filament Action Surface Contract: PASS WITH EXEMPTION. The wizard remains a composite page, and Step 3 or Step 4 continue using documented in-step checkpoint exemptions rather than list-table affordances. +- Filament UX-001: PASS WITH EXEMPTION. The feature extends an existing wizard surface rather than creating new create, edit, or view pages. + +No constitution violations require justification at plan time. + +## Project Structure + +### Documentation (this feature) + +```text +specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── onboarding-lifecycle-logical-contract.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/Pages/Workspaces/ +│ └── ManagedTenantOnboardingWizard.php +├── Models/ +│ └── TenantOnboardingSession.php +├── Services/ +│ ├── Onboarding/ +│ │ ├── OnboardingDraftResolver.php +│ │ ├── OnboardingDraftStageResolver.php +│ │ ├── OnboardingLifecycleService.php +│ │ └── OnboardingDraftMutationService.php +│ ├── OperationRunService.php +│ └── Providers/ +│ └── ProviderOperationStartGate.php +├── Support/Onboarding/ +│ └── OnboardingDraftStatus.php +└── Notifications/ + └── OperationRunCompleted.php + +database/ +├── migrations/ +└── factories/ + +resources/ +└── views/ + ├── filament/pages/workspaces/ + │ └── managed-tenant-onboarding-wizard.blade.php + └── filament/forms/components/ + └── managed-tenant-onboarding-verification-report.blade.php + +tests/ +├── Browser/ +│ ├── OnboardingDraftRefreshTest.php +│ └── OnboardingDraftVerificationResumeTest.php +├── Feature/ +│ ├── Onboarding/ +│ │ ├── OnboardingActivationTest.php +│ │ ├── OnboardingDraftAccessTest.php +│ │ ├── OnboardingDraftAuthorizationTest.php +│ │ ├── OnboardingDraftLifecycleTest.php +│ │ ├── OnboardingDraftMultiTabTest.php +│ │ ├── OnboardingVerificationAssistTest.php +│ │ └── OnboardingVerificationTest.php +│ ├── OpsUx/ +│ └── Rbac/ +└── Unit/ + └── Onboarding/ + ├── OnboardingLifecycleServiceTest.php + └── OnboardingDraftStageResolverTest.php +``` + +**Structure Decision**: Use the existing single Laravel application structure. Most implementation work should land in the onboarding wizard page, onboarding services, onboarding support enums or value objects, one migration, and focused Feature, Browser, and Unit tests. No new top-level module or route layer is needed. + +## Phase 0 Research Summary + +- Current onboarding draft truth is split between `TenantOnboardingSession.state`, `current_step`, `completed_at`, `cancelled_at`, and ad hoc reads of `OperationRun` state. Research confirms the smallest additive change is to promote lifecycle and checkpoint metadata into first-class session columns. +- Verification currently requires manual refresh in the wizard. Existing repo patterns use conditional `wire:poll` in Livewire views and page shells, and this feature standardizes on an active `5s` cadence to match existing operation-detail behavior without a new real-time stack. +- Current wizard mutations save the onboarding session directly in the page class. That is the main seam for introducing centralized version checks and lifecycle recalculation. +- Verification start already flows through `ProviderOperationStartGate` and bootstrap start already records selected operation runs. Those existing services should remain the dispatch boundary. + +## Phase 1 Design Summary + +- Add top-level draft fields for `version`, `lifecycle_state`, `current_checkpoint`, `last_completed_checkpoint`, `reason_code`, and `blocking_reason_code`. +- Introduce controlled lifecycle and checkpoint value types in the onboarding support layer. +- Introduce a centralized onboarding lifecycle service responsible for recalculation, readiness evaluation, and mapping current run or connection conditions into canonical lifecycle fields. +- Introduce a versioned draft mutation path so all onboarding writes compare expected version and fail atomically on mismatch. +- Add conditional `5s` polling and checkpoint refresh hooks to Verify Access and Bootstrap using existing Livewire polling patterns. +- Keep Spec 139 assist visibility and new-tab deep-dive continuity as a derived concern of Step 3 state rather than as a competing lifecycle owner. + +## Post-Design Constitution Check + +- Re-check result: PASS. +- The design preserves Livewire v4 and Filament v5 compatibility, keeps provider registration unchanged in `bootstrap/providers.php`, introduces no new global-searchable resources, keeps `Cancel onboarding` as the only destructive action with confirmation, reuses existing asset strategy, and adds a focused testing plan for lifecycle, checkpoint, authorization, conflict, and browser polling behavior. + +## Complexity Tracking + +No constitution exceptions or complexity waivers are required for this plan. + +## Planned Outputs + +- [research.md](./research.md) +- [data-model.md](./data-model.md) +- [quickstart.md](./quickstart.md) +- [contracts/onboarding-lifecycle-logical-contract.openapi.yaml](./contracts/onboarding-lifecycle-logical-contract.openapi.yaml) + diff --git a/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/quickstart.md b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/quickstart.md new file mode 100644 index 0000000..3e9ee91 --- /dev/null +++ b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/quickstart.md @@ -0,0 +1,87 @@ +# Quickstart: Onboarding Lifecycle, Operation Checkpoints & Concurrency MVP + +## Goal + +Validate the lifecycle, checkpoint-monitoring, and optimistic-locking design on the existing managed tenant onboarding wizard without adding routes or replacing the current flow. + +## Prerequisites + +1. Start the local stack with `vendor/bin/sail up -d`. +2. Ensure the database is migrated after implementation with `vendor/bin/sail artisan migrate --no-interaction`. +3. Use a workspace member with onboarding permissions and, for activation scenarios, an owner-capable actor. + +## Manual Validation Flow + +### Scenario 1: New draft lifecycle foundation + +1. Open `/admin/onboarding`. +2. Create or resume a draft. +3. Confirm the persisted draft starts in `draft` and `current_checkpoint` reflects the current wizard step. + +### Scenario 2: Verify Access as a live checkpoint + +1. Select a provider connection. +2. Start Verify Access from Step 3. +3. Confirm the draft moves to `verifying` and the step shows live checkpoint messaging. +4. Keep the page open until the run becomes terminal. +5. Confirm the step updates within the active `5s` polling cadence without manual refresh and the lifecycle transitions to either `ready_for_activation` or `action_required`. +6. Confirm the Spec 139 required-permissions assist still works when verification is blocked or needs attention. + +### Scenario 3: Bootstrap as an optional live checkpoint + +1. Select one or more bootstrap actions in Step 4. +2. Start bootstrap. +3. Confirm the draft moves to `bootstrapping`. +4. Keep the page open until selected runs become terminal. +5. Confirm the step renders per-run status within the active `5s` polling cadence and stops polling when no relevant runs remain active. + +### Scenario 4: Activation gating + +1. Reach a state that appears ready for activation. +2. Invalidate readiness by changing the provider connection or by forcing a relevant blocking condition in another session. +3. Attempt activation. +4. Confirm activation rechecks backend truth and refuses to complete until readiness is restored. + +### Scenario 5: Optimistic locking conflict + +1. Open the same onboarding draft in two tabs. +2. Save a draft mutation in Tab A. +3. Submit a stale mutation in Tab B. +4. Confirm Tab B sees a clear conflict notification, remains on the wizard, and does not show a false success state. + +## Focused Test Targets + +- `tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php` +- `tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php` +- `tests/Feature/Onboarding/OnboardingActivationTest.php` +- `tests/Feature/Onboarding/OnboardingVerificationAssistTest.php` +- `tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php` +- `tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php` +- `tests/Feature/OpsUx/QueuedToastCopyTest.php` +- `tests/Feature/OperationRunServiceTest.php` +- `tests/Browser/OnboardingDraftRefreshTest.php` +- `tests/Browser/OnboardingDraftVerificationResumeTest.php` +- `tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php` +- `tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php` + +## Suggested Verification Commands + +1. `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php` +2. `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php` +3. `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingActivationTest.php` +4. `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingVerificationAssistTest.php` +5. `vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php` +6. `vendor/bin/sail artisan test --compact tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php` +7. `vendor/bin/sail artisan test --compact tests/Feature/OpsUx/QueuedToastCopyTest.php` +8. `vendor/bin/sail artisan test --compact tests/Feature/OperationRunServiceTest.php` +9. `vendor/bin/sail artisan test --compact tests/Browser/OnboardingDraftRefreshTest.php` +10. `vendor/bin/sail artisan test --compact tests/Browser/OnboardingDraftVerificationResumeTest.php` +11. `vendor/bin/sail artisan test --compact tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php` + +## Expected Outcome + +- No new onboarding routes are needed. +- Verify Access and Bootstrap behave as operation-backed checkpoints. +- Lifecycle truth is queryable from top-level draft fields. +- Terminal drafts remain immutable. +- Stale mutations fail safely with visible conflict guidance. diff --git a/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/research.md b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/research.md new file mode 100644 index 0000000..0944eb4 --- /dev/null +++ b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/research.md @@ -0,0 +1,57 @@ +# Research: Onboarding Lifecycle, Operation Checkpoints & Concurrency MVP + +## Decision 1: Extend the existing onboarding session table instead of introducing a new workflow table + +- Decision: Add `version`, `lifecycle_state`, `current_checkpoint`, `last_completed_checkpoint`, `reason_code`, and `blocking_reason_code` directly to `managed_tenant_onboarding_sessions`. +- Rationale: The current onboarding draft is already persisted in `TenantOnboardingSession`, and the constitution explicitly allows this workflow record to remain workspace-scoped and coordination-oriented. First-class lifecycle fields make the workflow queryable without duplicating draft identity or adding a second coordination backend. +- Alternatives considered: + - Create a separate onboarding lifecycle table: rejected because it would split draft truth across two workflow records and add sync complexity without improving authorization boundaries. + - Keep lifecycle fully derived from JSON and runs: rejected because the current ambiguity is the problem this feature is meant to solve. + +## Decision 2: Replace ad hoc stage inference with a centralized onboarding lifecycle recalculation service + +- Decision: Introduce a dedicated onboarding lifecycle service or resolver that owns canonical lifecycle transitions, checkpoint precision, and readiness evaluation, while `OnboardingDraftStageResolver` becomes a presentation-oriented wrapper or consumer. +- Rationale: Current wizard progression is inferred from `current_step`, JSON state, and run inspection in page logic plus `OnboardingDraftStageResolver`. A shared lifecycle service keeps one deterministic source of workflow truth and prevents drift between rendering, activation gating, and background refresh. +- Alternatives considered: + - Keep logic in the page class: rejected because lifecycle sprawl is already visible and would worsen with checkpoint polling and concurrency rules. + - Use model accessors only: rejected because the feature needs explicit write-time recalculation and conflict-aware mutation orchestration, not only read-time convenience. + +## Decision 3: Reuse existing OperationRun infrastructure for checkpoint execution truth + +- Decision: Keep Verify Access on `ProviderOperationStartGate` plus `provider.connection.check` and keep Bootstrap on the existing operation dispatch paths and `OperationRunService`. +- Rationale: The repo already enforces Ops-UX lifecycle ownership, dedupe, notifications, and monitoring through `OperationRunService`. Reusing those paths satisfies the constitution and avoids accidental creation of an onboarding-only operations stack. +- Alternatives considered: + - Introduce onboarding-specific async job records: rejected because it would violate the spec's additive architecture constraint. + - Perform verification or bootstrap inline in the wizard: rejected because the constitution requires long-running and remote work to remain observable through `OperationRun`. + +## Decision 4: Add conditional Livewire polling to the existing wizard instead of introducing real-time push infrastructure + +- Decision: Use conditional `wire:poll` for Step 3 and Step 4 while relevant runs are active, following existing patterns used by operation viewers and progress widgets. +- Rationale: The repo already uses conditional polling in `BulkOperationProgress` and the tenantless operation run viewer. Polling matches the feature scope, avoids WebSocket or SSE complexity, and satisfies the active-session trust requirement. +- Alternatives considered: + - WebSockets or SSE: rejected because the spec explicitly excludes real-time push infrastructure. + - Continue manual refresh: rejected because the feature's trust goal requires the checkpoint to refresh while the page stays open. + +## Decision 5: Implement optimistic locking through version-checked draft mutations rather than leases or takeovers + +- Decision: Add a numeric `version` column and require all relevant onboarding mutations to submit and compare an expected version before commit. +- Rationale: The spec explicitly chooses optimistic locking as the MVP concurrency mechanism. It prevents silent overwrites with minimal information architecture change and keeps the user on the existing wizard page when conflicts occur. +- Alternatives considered: + - Claimed-by or lease model: rejected because it is explicitly out of scope. + - Last-write-wins with warnings: rejected because it does not prevent data loss. + +## Decision 6: Preserve Spec 139 as a Step 3 assist layered on top of canonical checkpoint state + +- Decision: Treat the required-permissions assist as an additive Step 3 recovery surface that is driven by current verification lifecycle and report truth rather than as a separate owner of checkpoint semantics. +- Rationale: Spec 139 already established in-step recovery and new-tab deep-dive continuity. Feature 140 needs broader lifecycle semantics, but must not replace that recovery surface or force same-tab navigation. +- Alternatives considered: + - Fold the assist into a new full verification dashboard: rejected because it would redesign the flow and conflict with Spec 139's additive scope. + - Ignore the assist during lifecycle planning: rejected because Step 3 rendering must remain compatible with it under polling. + +## Decision 7: Keep top-level lifecycle values controlled and machine-readable + +- Decision: Represent lifecycle state, checkpoints, and blocker precision through enums or equivalent controlled values in the onboarding support layer. +- Rationale: The current `OnboardingDraftStatus` enum only models `draft`, `completed`, and `cancelled`. Feature 140 needs additional workflow truth that can be queried, rendered consistently, and tested without ad hoc strings spread across page code. +- Alternatives considered: + - Raw string literals in the page and model: rejected because it undermines determinism and testability. + - Nested JSON-only codes: rejected because the feature explicitly needs top-level queryable lifecycle state. diff --git a/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/spec.md b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/spec.md new file mode 100644 index 0000000..1f30eea --- /dev/null +++ b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/spec.md @@ -0,0 +1,635 @@ +# Feature Specification: Onboarding Lifecycle, Operation Checkpoints & Concurrency MVP + +**Feature Branch**: `140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp` +**Created**: 2026-03-14 +**Status**: Draft +**Input**: User description: "Keep the existing Filament wizard as the primary onboarding shell, convert Verify Access and Bootstrap into operation-backed checkpoints, introduce a pragmatic handlungsorientiertes lifecycle model, and add optimistic locking as the concurrency MVP. Do not redesign the whole flow or introduce lease/takeover in this spec." + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - `/admin/onboarding` + - `/admin/onboarding/{onboardingDraft}` +- **Data Ownership**: + - Managed tenant onboarding drafts remain workspace-scoped workflow records. + - Managed tenants remain tenant/workspace-owned domain records. + - Provider connections remain tenant-bound operational records. + - Verification and bootstrap runs remain `OperationRun` records and continue to own execution truth. + - This spec introduces no parallel onboarding-only operation backend. +- **RBAC**: + - Existing onboarding capabilities continue to govern access to view, edit, resume, cancel, verify, bootstrap, and activate onboarding drafts. + - Existing owner-level activation restrictions remain in effect. + - Existing provider-connection and tenant authorization semantics remain the source of truth. + - Non-members or actors outside workspace scope remain deny-as-not-found. + - This spec must not weaken current authorization boundaries. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Track a trustworthy lifecycle in one wizard (Priority: P1) + +As an onboarding operator, I want the existing wizard to show a clear, canonical onboarding lifecycle so that I can tell whether a draft is still being prepared, actively verifying, blocked, bootstrapping, ready for activation, or already closed. + +**Why this priority**: Lifecycle ambiguity is the root trust problem. Operators cannot safely resume or triage onboarding work if the system does not expose a single queryable workflow truth. + +**Independent Test**: Create and resume onboarding drafts across normal, blocked, active, ready, completed, and cancelled states, then confirm each draft persists the expected lifecycle and checkpoint metadata without changing the current routing model. + +**Acceptance Scenarios**: + +1. **Given** a new onboarding draft is created, **When** the draft is first persisted, **Then** it starts in `draft` with checkpoint metadata that reflects the current stage of progress. +2. **Given** a draft has a relevant verification run in progress, **When** the operator views the wizard, **Then** the draft is represented as `verifying` rather than as a passive form state. +3. **Given** verification or bootstrap becomes blocked, stale, failed, or otherwise unsafe for progression, **When** the workflow is recalculated, **Then** the draft moves to `action_required` with machine-readable blocker context. + +--- + +### User Story 2 - Monitor Verify Access and Bootstrap as live checkpoints (Priority: P1) + +As an onboarding operator, I want Verify Access and Bootstrap to behave like live operation-backed checkpoints so that I do not need to manually refresh or guess whether a background run is still active, finished successfully, or now requires attention. + +**Why this priority**: Verify Access and Bootstrap already coordinate background work. Treating them like ordinary form pages undermines operator trust and makes recovery slower. + +**Independent Test**: Start verification and bootstrap from the existing wizard, keep the page open while the relevant runs transition, and confirm the step state updates automatically from backend truth without leaving the wizard or replacing Spec 139's in-step assist behavior. + +**Acceptance Scenarios**: + +1. **Given** a relevant verification run is queued or running, **When** the operator remains on Step 3, **Then** the step polls and renders current backend-derived checkpoint status until the run becomes terminal. +2. **Given** selected bootstrap runs are queued or running, **When** the operator remains on Step 4, **Then** the step polls and renders per-operation status until all relevant runs become terminal. +3. **Given** the relevant operation reaches a terminal result while the wizard is open, **When** polling refreshes the step, **Then** the lifecycle and next-step guidance update without manual refresh. + +--- + +### User Story 3 - Prevent silent overwrite across tabs and operators (Priority: P1) + +As an onboarding operator, I want stale mutations to be rejected clearly so that another tab or operator cannot silently overwrite newer onboarding draft state. + +**Why this priority**: Last-write-wins behavior is not acceptable for a workflow coordinating provider connections, background runs, and activation readiness. + +**Independent Test**: Open the same onboarding draft in two tabs or sessions, commit a mutation in one tab, then submit a stale mutation in the other and confirm the second action is rejected atomically with clear refresh guidance and no false success state. + +**Acceptance Scenarios**: + +1. **Given** two sessions hold the same onboarding draft, **When** one session saves a newer draft version first, **Then** a stale submission from the other session is rejected atomically. +2. **Given** a stale mutation is rejected, **When** the operator remains on the wizard, **Then** the UI shows a clear conflict message and does not pretend the stale action succeeded. +3. **Given** a draft becomes completed or cancelled in one session, **When** another stale session attempts a mutation, **Then** the mutation is rejected and the closed draft remains non-editable. + +--- + +### User Story 4 - Activate only when backend truth is actually ready (Priority: P2) + +As an operator with activation authority, I want the final activation step to re-evaluate backend truth before committing so that activation cannot proceed on stale assumptions from an outdated page state. + +**Why this priority**: Activation is the irreversible checkpoint. It must be gated by current backend truth rather than optimistic UI state. + +**Independent Test**: Bring a draft to a seemingly ready state, invalidate one of the readiness conditions in another session or via operation state changes, then attempt activation and confirm the system blocks activation until the lifecycle returns to a valid ready state. + +**Acceptance Scenarios**: + +1. **Given** verification is current and sufficient and bootstrap is either not selected or complete, **When** the operator activates the draft, **Then** activation succeeds and the draft moves to `completed`. +2. **Given** one of the readiness gates becomes invalid before activation commits, **When** activation is attempted, **Then** activation is rejected and the draft remains in a non-completed lifecycle state. +3. **Given** activation succeeds, **When** the workflow closes, **Then** the draft becomes a historical non-editable record. + +### Edge Cases + +- Draft exists but provider connection has never been selected. +- Verification run exists but belongs to a no-longer-selected provider connection. +- Verification completed successfully, then provider connection changes. +- Verification report is stale for the current connection. +- Verification is rerun while old failed results are still visible. +- Bootstrap was not selected at all. +- Bootstrap was selected, one run succeeded, another failed. +- Activation page is open when a background run result changes state. +- Stale tab attempts to start verification after another tab changed provider connection. +- Stale tab attempts activation after another session cancelled the draft. +- Stale tab attempts bootstrap after verification was invalidated. +- Draft is cancelled while another tab remains open. +- Polling sees a terminal run and must stop cleanly. +- Conflict occurs during a mutation that also changes lifecycle state. +- Existing deep-dive assist behavior from Spec 139 remains open or usable while Step 3 lifecycle changes. +- Refresh occurs mid-verification or mid-bootstrap. +- Operator resumes next day with a terminal run already complete. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature reuses the existing onboarding wizard, verification operation path, bootstrap operation path, activation flow, and `OperationRun` backend. It does not introduce new Microsoft Graph call surfaces, new onboarding routes, or a parallel operation backend. Verification and bootstrap remain the canonical execution truth, while the draft gains canonical lifecycle metadata for queryability and gating. + +**Constitution alignment (OPS-UX):** Verify Access and Bootstrap explicitly reuse existing `OperationRun` behavior and must continue to follow the Ops-UX 3-surface contract: intent feedback when the operation is started, a live progress surface inside the wizard while the relevant run is active, and existing Monitoring or terminal-notification behavior for operation completion. `OperationRun.status` and `OperationRun.outcome` remain service-owned. Any summary data surfaced from those runs must continue to use the existing allowed summary key set and numeric-only summary values. No new passive poll cycle may create duplicate notifications or audit spam. Regression coverage must prove lifecycle, checkpoint rendering, and readiness evaluation remain consistent with the existing operation service contract. + +**Constitution alignment (RBAC-UX):** This feature stays in the workspace-admin plane under `/admin`. Non-members or actors outside workspace scope remain `404`; in-scope members missing the relevant onboarding capability remain `403`. Authorization must continue to be enforced server-side for view, edit, verify, bootstrap, activate, cancel, and any persisted override or checkpoint mutation. No raw capability strings or role checks may be introduced. Positive and negative authorization coverage must prove the lifecycle and concurrency hardening does not leak tenant existence or expand authority. + +**Constitution alignment (OPS-EX-AUTH-001):** No new `/auth/*` behavior is introduced. This spec does not create a new exception path around operation monitoring or lifecycle persistence. + +**Constitution alignment (BADGE-001):** Any lifecycle, checkpoint, or status badges added or adjusted in the wizard must keep using centralized state semantics for queued, running, action-required, ready, completed, cancelled, stale, and warning conditions. The feature must not introduce ad-hoc badge mappings in page code. + +**Constitution alignment (UI-NAMING-001):** Operator-facing labels must stay task-oriented and consistent across the wizard, notifications, monitoring references, and any conflict or checkpoint feedback. Primary terms include `Verify access`, `Bootstrap`, `Ready for activation`, `Action required`, `Activation blocked`, `Cancel onboarding`, and the existing Spec 139 labels such as `View required permissions`. Implementation-first terms such as version mismatch, lifecycle resolver, checkpoint metadata, or stale payload must not become primary operator copy. + +**Constitution alignment (Filament v5 / Livewire v4):** Livewire v4.0+ remains the compatibility target for this onboarding surface. The existing Filament wizard remains the primary shell, no new panel is introduced, and provider registration remains unchanged in `bootstrap/providers.php`. No new global-searchable Resource behavior is introduced; this spec only changes wizard-step lifecycle semantics. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied with an explicit exemption for Step 3 and Step 4 because they are composite in-step checkpoint surfaces rather than CRUD list or table surfaces. The wizard shell remains intact, and Spec 139's Verify Access assist remains additive inside Step 3 rather than becoming a separate view. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature extends the existing onboarding wizard instead of adding new create, edit, or view pages. Step 3 and Step 4 may increase emphasis on status, running-state explanation, and next actions, but they remain part of the same wizard flow and do not require a new route or new information architecture. Any conflict banner or inline checkpoint state must remain subordinate to the wizard layout and preserve continuity. + +**Constitution alignment (destructive actions and confirmation):** No new destructive actions are introduced. The existing `Cancel onboarding` action remains the only destructive action in scope and must continue to execute through an explicit action callback with confirmation and existing authorization. Verify, rerun, bootstrap, and activate are not destructive, but they must remain server-authorized and lifecycle-gated. + +**Constitution alignment (asset strategy):** This feature should reuse existing panel assets and Livewire behavior. It must not require new shared frontend assets or a custom package asset pipeline. Deployment continues to run `php artisan filament:assets` in the existing deploy process when Filament-registered assets are present, but this spec should not add a new asset requirement. + +### Objective + +Harden managed tenant onboarding into a trustworthy enterprise workflow without replacing the current Filament wizard shell. This spec establishes a canonical onboarding lifecycle model that is handlungsorientiert and queryable, operation-backed checkpoint semantics for Verify Access and Bootstrap, a clear boundary between UI state, persisted draft state, backend operation truth, and activation state, and optimistic locking as the MVP concurrency guardrail across all onboarding draft mutations. + +### Why + +The current onboarding flow is already functionally strong, but it still behaves too much like a classic form wizard where it is actually coordinating background operations and cross-step workflow state. The main gaps are lifecycle ambiguity, mixed state ownership across UI and backend truth, operation steps that feel like form pages, manual-refresh trust gaps during active runs, silent multi-tab or multi-operator overwrite risk, and the absence of a handlungsorientierter lifecycle that can answer which drafts need action now. + +### In Scope + +- Keep onboarding in the existing Filament wizard shell. +- Convert Step 3 `Verify Access` into an operation-backed checkpoint. +- Convert Step 4 `Bootstrap` into an optional operation-backed checkpoint. +- Introduce canonical onboarding lifecycle states: `draft`, `verifying`, `action_required`, `bootstrapping`, `ready_for_activation`, `completed`, and `cancelled`. +- Introduce supporting precision fields: `current_checkpoint`, `last_completed_checkpoint`, `reason_code`, and `blocking_reason_code` where relevant. +- Add polling-based active-session refresh for operation-backed steps. +- Add optimistic locking through numeric draft versioning. +- Apply version checks to all relevant onboarding draft mutations. +- Preserve current routing, wizard continuity, authorization, operation backend, and activation flow. +- Add regression coverage for lifecycle transitions, polling-compatible checkpoint behavior, mutation conflict protection, and Spec 139 compatibility. + +### Out of Scope + +- Replacing the wizard with a separate checklist or new onboarding application. +- Introducing edit leases, takeovers, or claimed-by timers. +- Introducing new routes for onboarding checkpoints. +- Introducing WebSocket or SSE infrastructure. +- Replacing existing `OperationRun` monitoring pages. +- Rebuilding Required Permissions or other verification deep-dive pages. +- Broad onboarding copy or visual redesign beyond what lifecycle clarity requires. +- Introducing a new onboarding dashboard. +- Broad RBAC redesign. + +### Current Problems + +- Lifecycle ambiguity: the workflow state is mostly inferred from JSON and runs rather than represented canonically. +- Mixed state concerns: transient form state, persisted draft state, background operation truth, and activation truth are blurred together. +- Operation steps feel like forms: Verify Access and Bootstrap start and monitor background work but still read too much like normal pages. +- Manual-refresh trust gap: active operations can finish while the operator keeps seeing stale results. +- Silent overwrite risk: last-write-wins remains possible across stale tabs or multiple operators. +- No queryable handlungsorientierter lifecycle: list or triage views cannot reliably answer which drafts are waiting, blocked, or ready without reconstructing workflow semantics. + +### Target Architecture + +The onboarding experience remains one wizard shell, but not every step behaves like a normal form page. + +- Step 1: Identify Managed Tenant → form step. +- Step 2: Connect Provider → form step. +- Step 3: Verify Access → operation-backed checkpoint. +- Step 4: Bootstrap → optional operation-backed checkpoint. +- Step 5: Complete / Activate → final gated checkpoint. + +The implementation must explicitly respect these state layers: + +- UI state: Livewire or Filament local state, open panels, selected local values not yet committed. +- Persisted draft state: the saved onboarding draft record, lifecycle state, checkpoint metadata, selected provider connection, selected bootstrap options, and version. +- Backend operation truth: `OperationRun` status, run context, verification report, and bootstrap run outcomes. +- Completion or activation truth: tenant activation state, completion timestamp, and activation override semantics. + +The wizard may render local state, but checkpoint status and progression rules must be based on persisted draft state plus backend operation truth, not optimistic local assumptions. + +### Lifecycle Model + +#### Canonical Lifecycle States + +1. `draft` + Meaning: the onboarding draft exists, no active governing checkpoint operation is running, and the draft is not yet ready for activation. +2. `verifying` + Meaning: a relevant Verify Access operation is queued or running for the currently selected provider connection. +3. `action_required` + Meaning: the onboarding cannot safely proceed without operator intervention. +4. `bootstrapping` + Meaning: one or more selected bootstrap operations are queued or running and remain relevant to the current draft. +5. `ready_for_activation` + Meaning: all required gating conditions are satisfied and the workflow is waiting for final activation. +6. `completed` + Meaning: activation succeeded and the onboarding draft is now a historical workflow record. +7. `cancelled` + Meaning: the draft was intentionally cancelled and is now a historical workflow record. + +#### Supporting Precision Fields + +- `current_checkpoint`: one of `identify`, `connect_provider`, `verify_access`, `bootstrap`, or `complete_activate`. +- `last_completed_checkpoint`: one of the same controlled values, or `null` if no checkpoint has been satisfied yet. +- `reason_code`: nullable machine-readable precision for the current lifecycle state. +- `blocking_reason_code`: nullable machine-readable blocker code when forward progress is explicitly blocked. + +Examples of supported precision include `verification_blocked_permissions`, `verification_failed`, `provider_connection_changed`, `verification_result_stale`, `bootstrap_failed`, `bootstrap_partial_failure`, and `owner_activation_required`. + +#### Lifecycle Transition Rules + +- Enter `draft` when a new draft is created, the operator remains in Step 1 or Step 2, verification has not started for the current provider connection, or prior valid checkpoint output has been invalidated and no active or blocked state should govern yet. +- Enter `verifying` when the operator starts Verify Access and a relevant verification run is created or reused for the current provider connection. +- Exit `verifying` when the relevant run becomes terminal, transitioning to `ready_for_activation`, `bootstrapping`, `action_required`, or back to `draft` depending on the run outcome and current relevance. +- Enter `action_required` when verification or bootstrap finishes blocked or failed, when verification becomes stale or mismatched for the current provider connection, or when another explicit progression blocker exists. +- Exit `action_required` only when the blocking cause is actually resolved, transitioning to `verifying`, `bootstrapping`, `ready_for_activation`, or `draft`. +- Enter `bootstrapping` when verification is sufficiently passed, one or more bootstrap operations are selected, and those runs are successfully dispatched and active. +- Exit `bootstrapping` when all relevant selected bootstrap runs are terminal, transitioning to `ready_for_activation`, `action_required`, or `draft` if bootstrap intent was reset before actual dispatch. +- Enter `ready_for_activation` only when verification is current and sufficient for the selected provider connection, no relevant verification run remains active, all selected bootstrap operations are complete if bootstrap was chosen, no unresolved blockers remain, and the draft is not already completed or cancelled. +- Enter `completed` only when activation succeeds. +- Enter `cancelled` only through an explicit cancel action on an editable draft. + +### Checkpoint Semantics for Step 3 and Step 4 + +#### Step 3 - Verify Access + +Step 3 remains visually a wizard step but functionally becomes an operation-backed checkpoint. + +- Starting verification creates or reuses a relevant operation run. +- While the relevant run is queued or running, the step behaves as active checkpoint monitoring. +- The step must surface backend-derived status rather than only last-rendered UI state. +- Polling starts when lifecycle is `verifying` and the relevant run is queued or running. +- Polling stops when the relevant run becomes terminal, the draft is no longer on the checkpoint, or the page leaves the relevant editing context. +- During active monitoring the step must show running state, current checkpoint meaning, and live-updating next-step or remediation messaging. +- On completion the step must resolve into passed/current, blocked or failed, stale or mismatched, or otherwise `action_required`. +- Rerun keeps using the existing verification path, returns the lifecycle to `verifying`, and remains subject to version checks when the draft mutation is persisted. +- Spec 139's in-step Required Permissions assist remains additive and must continue to work with this checkpoint model, including its new-tab deep-dive continuity. + +#### Step 4 - Bootstrap + +Step 4 remains visually a wizard step but functionally becomes an optional operation-backed checkpoint. + +- Selecting bootstrap operation types remains a normal draft mutation. +- Starting bootstrap changes the step into active monitoring of relevant selected runs. +- The step must show backend truth for all selected runs relevant to this draft. +- Polling starts when lifecycle is `bootstrapping` and one or more relevant bootstrap runs are queued or running. +- Polling stops when all relevant selected runs are terminal or the draft leaves the monitoring state. +- During active monitoring the step must show selected operations, per-operation status, whether the workflow is still waiting, and the next valid operator action. +- If bootstrap was not selected, the operator may continue toward activation once all non-bootstrap gates are satisfied. + +### Concurrency and Optimistic Locking Model + +This spec adopts optimistic locking as mandatory MVP behavior and explicitly does not introduce leases, claimed-by timers, takeover UI, or collaborative editing. + +#### Versioning + +- The onboarding draft must receive a numeric `version` column. +- Version starts at `1` on create. +- Version increments on every successful relevant mutation. +- Stale version submissions must be rejected atomically. + +#### Relevant Mutations Requiring Version Checks + +- Identify step commit. +- Provider connection selection or change. +- Inline provider connection creation when it mutates draft state. +- Verification start or rerun. +- Bootstrap selection changes. +- Bootstrap start. +- Activation. +- Cancel draft. +- Persisted override toggles or reasons. +- Any mutation that changes lifecycle state, checkpoint fields, reason codes, or selected run references. + +#### Conflict Behavior + +When the submitted expected version does not match the persisted draft version: + +- the mutation must be rejected atomically, +- no partial save may occur, +- the UI must show a clear conflict message, +- the UI must not imply success, +- the user must be prompted to refresh and retry. + +Recommended operator copy: + +`This onboarding draft was changed by another session before your action could be saved. Refresh the page to load the latest state, then retry.` + +Where practical, the conflict feedback may also include who last updated the draft and when. + +### Data Model Changes + +The onboarding draft table must gain the following top-level fields: + +- `version` bigint or integer, not null, default `1` +- `lifecycle_state` controlled string or enum, not null, default `draft` +- `current_checkpoint` nullable controlled string or enum +- `last_completed_checkpoint` nullable controlled string or enum +- `reason_code` nullable string +- `blocking_reason_code` nullable string + +Controlled enum or value-object semantics should be used for `lifecycle_state`, `current_checkpoint`, and `last_completed_checkpoint` wherever practical. Existing JSON state may remain for low-level step data, selected options, run references, report reference metadata, and other detailed context, but the new top-level lifecycle fields become the canonical queryable workflow truth. + +### UI and UX Behavior + +- The UI remains one onboarding wizard. +- Step 1 and Step 2 remain form-driven steps. +- Step 3 and Step 4 remain wizard steps visually, but render as checkpoint-oriented surfaces that distinguish running, action-required, ready, and rerun states. +- Step 3 and Step 4 must use an active `5s` polling cadence while their relevant runs remain queued or running, matching the existing active-operation detail patterns already used in the product. +- The UI must make it understandable that verification and bootstrap are being monitored live and that results update automatically while the page remains open. +- Unsaved local form edits must not be mistaken for committed state. +- Activation must evaluate current backend truth rather than stale visible assumptions. +- If bootstrap is optional and not selected, activation can become ready after successful current verification. +- If bootstrap is selected, activation cannot become ready until the selected runs complete successfully. +- On optimistic locking conflict, the page must not redirect away, must not mutate visible state as if saved, and must encourage refresh. + +### Backend Behavior + +#### Lifecycle Recalculation + +The system must consistently maintain top-level lifecycle fields whenever relevant draft or operation state changes. The recalculation logic must be deterministic and shared through a centralized lifecycle recalculation mechanism rather than being scattered ad hoc across page methods. + +#### Operation-Backed Checkpoints + +- Verification start must dispatch the existing operation path, persist the relevant run reference, set lifecycle and checkpoint fields appropriately, and increment version if draft state changed. +- When a verification run result changes, the next poll or reload must derive and persist the correct lifecycle state if needed, and stale or mismatched results must never continue masquerading as current. +- Bootstrap start must persist selected bootstrap intent if needed, dispatch the existing bootstrap operations, set lifecycle and checkpoint fields appropriately, and increment version. +- When bootstrap results change, the next poll or reload must derive the correct lifecycle transition. + +#### Activation + +Activation must perform a fresh backend-truth gate evaluation, reject activation if lifecycle gates are no longer satisfied, persist the `completed` state when activation succeeds, finalize the onboarding record, and increment version if applicable before the workflow closes. + +### Audit and Observability + +This spec does not require new audit events for every passive poll or render. It must continue auditing meaningful state changes including draft creation or resume, provider connection changes, verification start, bootstrap start, activation, cancel, and override-related actions. A conflict-rejected mutation audit event is recommended where practical. Lifecycle transitions should only create audit noise when they are already part of a meaningful user or service workflow event. Lifecycle state changes must be inspectable in the database and tests, relevant run references must remain traceable, stale or mismatched results must be reproducible in focused tests, and conflict cases must remain diagnosable. + +### Policies and Authorization Implications + +- This spec preserves existing policy boundaries. +- Starting verification does not grant new authority. +- Starting bootstrap does not grant new authority. +- Activation remains subject to current activation rules. +- Cancel remains subject to current edit and cancel rules. +- Optimistic locking is mutation safety, not authorization; policy checks still run first. +- Completed and cancelled drafts remain read-only historical records. + +### Functional Requirements + +- **FR-140-01 Wizard continuity**: The onboarding experience must remain inside the existing Filament wizard shell. +- **FR-140-02 Verify checkpoint model**: Verify Access must behave as an operation-backed checkpoint rather than a passive form page. +- **FR-140-03 Bootstrap checkpoint model**: Bootstrap must behave as an optional operation-backed checkpoint rather than a passive option page after dispatch. +- **FR-140-04 Canonical lifecycle state**: Each onboarding draft must have a persisted canonical `lifecycle_state`. +- **FR-140-05 Checkpoint precision**: Each onboarding draft must persist `current_checkpoint` and support `last_completed_checkpoint`. +- **FR-140-06 Reason precision**: The model must support machine-readable `reason_code` and `blocking_reason_code` where relevant. +- **FR-140-07 Draft entry state**: New or resettable onboarding drafts must default to `draft`. +- **FR-140-08 Verifying transition**: Starting a relevant verification run must move the draft into `verifying`. +- **FR-140-09 Action-required transition**: Blocked, failed, stale, or mismatched verification or bootstrap conditions must move the draft into `action_required`. +- **FR-140-10 Bootstrapping transition**: Starting selected bootstrap operations must move the draft into `bootstrapping`. +- **FR-140-11 Ready transition**: The draft must move to `ready_for_activation` only when verification is current and sufficient, selected bootstrap operations are complete if applicable, no relevant runs are still active, and no blocker remains. +- **FR-140-12 Completed transition**: Successful activation must move the draft to `completed`. +- **FR-140-13 Cancelled transition**: Explicit cancellation must move the draft to `cancelled`. +- **FR-140-14 Polling start verification**: Step 3 must poll while a relevant verification run is queued or running. +- **FR-140-15 Polling stop verification**: Verification polling must stop when the relevant run is terminal or no longer active or relevant. +- **FR-140-16 Polling start bootstrap**: Step 4 must poll while relevant bootstrap runs are queued or running. +- **FR-140-17 Polling stop bootstrap**: Bootstrap polling must stop when relevant selected runs are terminal or no longer active or relevant. +- **FR-140-18 Backend-truth status**: Checkpoint rendering must use backend-derived operation truth rather than only stale rendered UI state. +- **FR-140-19 Activation truth check**: Activation must perform a fresh backend-truth gate evaluation before committing. +- **FR-140-20 Optional bootstrap handling**: If bootstrap was not selected, successful current verification alone may be sufficient for `ready_for_activation`. +- **FR-140-21 Selected-bootstrap gating**: If bootstrap was selected, activation readiness must wait for selected bootstrap completion. +- **FR-140-22 Version column**: The onboarding draft must persist a numeric version for optimistic locking. +- **FR-140-23 Versioned mutations**: All relevant draft mutations must be rejected atomically on stale version mismatch. +- **FR-140-24 No silent overwrite**: The UI must never silently overwrite a newer draft version from another session. +- **FR-140-25 Conflict feedback**: Conflict rejection must present a clear user-visible error. +- **FR-140-26 Terminal immutability**: Completed and cancelled drafts must remain non-editable. +- **FR-140-27 Queryable lifecycle**: Lifecycle state must be queryable without re-deriving full workflow semantics from JSON state. +- **FR-140-28 Additive architecture**: The implementation must reuse existing routes, wizard shell, operation backend, asset strategy, and authorization model. +- **FR-140-29 Spec-139 compatibility**: The Verify Access checkpoint must remain compatible with Spec 139's additive permissions assist and must not require same-tab deep-dive navigation. +- **FR-140-30 No takeover scope**: This spec must not implement lease or takeover behavior. + +### Non-Goals + +- Multi-user presence indicators. +- Claimed-by editing banners. +- Forced takeover. +- Real-time push infrastructure. +- A new onboarding dashboard. +- Full visual redesign of the wizard. +- Full autosave. +- Replacing existing operation-monitoring pages. +- Replacing Spec 139's required permissions assist with a different recovery surface. + +### Assumptions + +- Existing verification and bootstrap operations already produce sufficient execution truth through `OperationRun`. +- Existing onboarding authorization is already robust and should be preserved. +- Polling is an acceptable V1 and V2 enterprise compromise versus heavier real-time infrastructure. +- Optimistic locking is sufficient MVP protection against silent overwrite. +- The existing wizard shell is stable enough to keep as the primary operator experience. + +### Dependencies + +- Existing onboarding wizard page or component. +- Existing onboarding draft model. +- Existing provider connection and verification flows. +- Existing bootstrap operation dispatch paths. +- Existing activation flow. +- Existing `OperationRun` model and stored contexts. +- Existing browser and feature test infrastructure. +- Spec 139's additive Verify Access permissions assist behavior. + +### Relationship to Spec 139 + +- Spec 139 adds an in-step Verify Access permissions-recovery assist and new-tab deep-dive continuity. +- Spec 140 defines the broader lifecycle and checkpoint semantics within which Step 3 operates. +- Spec 139 remains additive and does not own the canonical verification lifecycle or operation-state model. +- Spec 140 must not remove or invalidate the Spec 139 assist pattern. +- The Step 3 permissions assist must compose cleanly with polling-based checkpoint rendering. + +### Risks and Tradeoffs + +- State recalculation sprawl would reintroduce truth drift if lifecycle logic is spread across too many methods. +- Partial version coverage would leave silent-overwrite side doors. +- Polling and rendering complexity could make Step 3 and Step 4 brittle if tightly coupled to current layout details. +- Overeager lifecycle transitions could thrash state between `draft`, `action_required`, and `ready_for_activation`. +- Verify Access rendering must remain compatible with Spec 139's assist behavior. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Onboarding wizard: Identify | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | Existing wizard actions only | Not a table surface | None | None | Existing start state | Not applicable | Existing step navigation | Existing behavior | Form-driven checkpoint preparation step. | +| Onboarding wizard: Connect Provider | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | Existing wizard actions and connection-related actions | Not a table surface | None | None | Existing contextual actions | Not applicable | Existing step navigation | Existing behavior | Form-driven provider selection step. | +| Onboarding wizard: Verify Access | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | Existing verify or rerun actions; Spec 139 assist remains additive | Not a table surface | None | None | Existing verification start path | Not applicable | Existing step navigation | Existing operation audit only | Operation-backed checkpoint; polling required while active. | +| Onboarding wizard: Bootstrap | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | Existing bootstrap start actions | Not a table surface | None | None | Existing optional skip path | Not applicable | Existing step navigation | Existing operation audit only | Optional operation-backed checkpoint; polling required while active. | +| Onboarding wizard: Complete / Activate | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | Existing activate action | Not a table surface | None | None | None | Not applicable | Existing confirmation flow | Existing activation audit | Final gated checkpoint; readiness must come from backend truth. | +| Conflict surface | Inline within wizard | No new header actions; refresh guidance only | Not a table surface | None | None | Refresh and retry guidance | Not applicable | Mutation rejected | Recommended conflict audit | No new route; optimistic-locking rejection surface only. | + +### Key Entities *(include if feature involves data)* + +- **Onboarding Draft Lifecycle**: The canonical top-level workflow record describing whether a draft is in preparation, actively verifying, blocked, bootstrapping, ready for activation, completed, or cancelled. +- **Checkpoint Metadata**: The persisted `current_checkpoint`, `last_completed_checkpoint`, `reason_code`, and `blocking_reason_code` values that make lifecycle state precise and queryable. +- **Relevant Operation Run**: The existing verification or bootstrap `OperationRun` record that owns execution truth for a checkpoint. +- **Draft Version**: The monotonically increasing optimistic-locking value used to reject stale mutations. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-140-01 Lifecycle clarity**: In focused regression coverage, 100% of onboarding drafts under tested scenarios persist a valid canonical lifecycle state. +- **SC-140-02 Queryable lifecycle**: Operators and tests can filter drafts by top-level lifecycle state without reconstructing workflow meaning from JSON state. +- **SC-140-03 Verify active-session trust**: In browser coverage, verification results update without manual refresh while the relevant run remains active. +- **SC-140-04 Bootstrap active-session trust**: In browser coverage, bootstrap status updates without manual refresh while selected runs remain active. +- **SC-140-05 No silent overwrite**: In focused concurrency coverage, 100% of stale mutations are rejected and 0 stale mutations silently overwrite newer draft state. +- **SC-140-06 Activation gating correctness**: In focused coverage, `ready_for_activation` is reached only when all defined gating conditions are satisfied. +- **SC-140-07 Additive architecture**: The completed implementation introduces 0 new onboarding routes and 0 new operation backends. +- **SC-140-08 Terminal integrity**: Completed and cancelled drafts remain non-editable in focused regression coverage. + +## Acceptance Criteria + +### Lifecycle and State Model + +- Every onboarding draft persists one canonical `lifecycle_state`. +- Every onboarding draft persists `current_checkpoint`. +- The model supports `last_completed_checkpoint`. +- The model supports machine-readable `reason_code` and `blocking_reason_code`. +- A newly created onboarding draft starts in `draft`. +- Starting verification moves the draft to `verifying`. +- Verification blocked, failed, stale, or mismatched moves the draft to `action_required`. +- Starting selected bootstrap operations moves the draft to `bootstrapping`. +- A draft becomes `ready_for_activation` only when verification is current and sufficient, no relevant verification run is active, selected bootstrap operations are complete if applicable, and no blocker remains. +- Successful activation moves the draft to `completed`. +- Cancelling an editable draft moves it to `cancelled`. + +### Verify Access Checkpoint + +- Step 3 polls while a relevant verification run is queued or running. +- Step 3 stops polling when the relevant verification run reaches terminal state. +- Step 3 renders backend-derived current status. +- Step 3 does not require manual refresh to surface terminal run outcome while the page remains active. +- Step 3 remains compatible with Spec 139's required-permissions assist. + +### Bootstrap Checkpoint + +- Step 4 polls while selected relevant bootstrap runs are queued or running. +- Step 4 stops polling when all relevant selected bootstrap runs are terminal. +- Step 4 shows per-selected-operation current status from backend truth. +- If no bootstrap was selected, the draft can still become `ready_for_activation` after successful verification and no blockers. +- If bootstrap was selected and not yet complete, the draft must not become `ready_for_activation`. + +### Concurrency + +- The onboarding draft has a `version` column. +- Every relevant draft mutation checks expected version before commit. +- Stale mutations are rejected atomically. +- Conflict rejection shows a clear user-facing error. +- No silent last-write-wins remains for covered draft mutations. + +### Activation + +- Activation re-evaluates backend truth at commit time. +- Activation is rejected if the draft is no longer actually ready. +- Completed drafts remain non-editable afterward. + +### Terminal Drafts + +- Cancelled drafts remain non-editable afterward. +- Completed and cancelled drafts remain historical workflow records. + +## Testing Requirements + +### Core Regression Matrix + +- New draft enters `draft`. +- Identify step commit keeps or sets correct checkpoint metadata. +- Provider selection keeps or sets correct checkpoint metadata. +- Verification start moves draft to `verifying`. +- Verification success without bootstrap selected moves draft to `ready_for_activation`. +- Verification success with bootstrap selected and started moves draft to `bootstrapping`. +- Verification blocked moves draft to `action_required`. +- Verification failed moves draft to `action_required`. +- Verification result stale after provider change moves draft to `action_required` or `draft` according to the defined rule. +- Bootstrap success after selected runs complete moves draft to `ready_for_activation`. +- Bootstrap failure moves draft to `action_required`. +- Cancel moves draft to `cancelled`. +- Activation moves draft to `completed`. +- Completed draft cannot be edited. +- Cancelled draft cannot be edited. +- Verify Access polling stops correctly on terminal run. +- Bootstrap polling stops correctly on terminal runs. +- Step 3 remains usable with Spec 139 assist present. +- Activation remains blocked when selected bootstrap is still active. +- Activation remains blocked when a blocker reason persists. +- Positive authorization coverage proves allowed actors can continue to use the lifecycle and checkpoint flow. +- Negative authorization coverage proves non-members remain `404` and in-scope users missing capability remain `403`. + +### Concurrency Matrix + +- Same user, two tabs: stale provider-change mutation rejected. +- Same user, two tabs: stale verification start rejected. +- Same user, two tabs: stale bootstrap start rejected. +- Same user, two tabs: stale activation rejected. +- Two operators, same workspace: stale mutation rejected. +- Conflict path shows visible error and no false success. +- Terminal or cancelled transition in one tab invalidates edit mutation in another tab. + +### Browser-Level Validation + +- Verify Access updates while the run completes in the background. +- Bootstrap updates while selected runs complete in the background. +- No manual refresh is required for visible active-step status updates. +- Conflict notification is visible after stale submit. +- Wizard continuity is preserved after a conflict. +- Step 3 permissions assist from Spec 139 remains usable under polling-compatible rendering. + +### Testing Plan + +- Feature tests: lifecycle transitions, checkpoint metadata updates, activation gating, version mismatch rejection, immutable terminal drafts, and RBAC allow or deny coverage. +- Browser tests: polling behavior for verification, polling behavior for bootstrap, stale-tab conflict UX, active-session transition to ready-for-activation, and compatibility with Spec 139 assist plus new-tab continuity. +- Unit or service tests: lifecycle recalculation, reason-code mapping, readiness evaluation, and versioned mutation guard behavior. + +## Implementation Notes and Sequencing + +### Phase 1 - Data Model Foundation + +- Add schema columns for `version`, `lifecycle_state`, `current_checkpoint`, `last_completed_checkpoint`, `reason_code`, and `blocking_reason_code`. +- Add controlled casts, enums, or equivalent value semantics. + +### Phase 2 - Lifecycle Service Foundation + +- Introduce a centralized lifecycle recalculation mechanism. +- Stop scattering lifecycle truth ad hoc through page logic. +- Define readiness evaluation in one canonical place. + +### Phase 3 - Versioned Mutation Guard + +- Add expected-version handling for all relevant mutation paths. +- Reject stale writes atomically. +- Add conflict notification plumbing. + +### Phase 4 - Verify Checkpoint Hardening + +- Add conditional polling for Step 3. +- Render backend-truth status while polling. +- Ensure terminal resolution updates lifecycle correctly. +- Preserve Spec 139 compatibility. + +### Phase 5 - Bootstrap Checkpoint Hardening + +- Add conditional polling for Step 4. +- Render per-selected-run backend truth. +- Resolve lifecycle correctly on terminal outcomes. + +### Phase 6 - Activation Hardening + +- Ensure the final activation gate reads canonical readiness plus backend truth. +- Confirm terminal immutability behavior. + +### Phase 7 - Regression Hardening + +- Add focused feature, browser, and concurrency coverage. + +## Definition of Done + +- Migration adds the required lifecycle and version fields. +- Controlled model values or casts are in place. +- A centralized lifecycle recalculation mechanism exists. +- Verify Access behaves as a polling operation-backed checkpoint. +- Bootstrap behaves as a polling optional operation-backed checkpoint. +- `ready_for_activation` is only reachable under explicitly defined conditions. +- All relevant draft mutations are protected by optimistic locking. +- Stale mutations are rejected with clear user feedback. +- Completed and cancelled drafts remain immutable. +- Focused feature and browser coverage proves lifecycle transitions, checkpoint behavior, and concurrency protection. +- Spec 139 compatibility is preserved. +- No new onboarding routes were introduced. +- No lease or takeover model was introduced. + +## Final Architectural Notes + +This spec is intentionally pragmatic. It does not reinvent onboarding as a separate application, overbuild collaborative editing, or replace the existing wizard shell. It gives the current onboarding flow the smallest meaningful hardening slice it needs now: a canonical lifecycle model, truthful operation-backed checkpoints, and MVP-safe concurrency protection. diff --git a/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/tasks.md b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/tasks.md new file mode 100644 index 0000000..f5a495a --- /dev/null +++ b/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/tasks.md @@ -0,0 +1,231 @@ +# Tasks: Onboarding Lifecycle, Operation Checkpoints & Concurrency MVP + +**Input**: Design documents from `/specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/onboarding-lifecycle-logical-contract.openapi.yaml` + +**Tests**: Tests are required for this feature. Each user story includes focused Pest feature, browser, or unit coverage. +**Operations**: This feature reuses existing `OperationRun` flows for Verify Access and Bootstrap. Tasks below preserve the canonical `OperationRun` path, Ops-UX feedback contract, canonical Monitoring links, and service-owned run lifecycle transitions. +**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently after the foundational phase. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Add the schema and support scaffolding required for canonical lifecycle and versioned mutations. + +- [X] T001 Create the onboarding lifecycle migration with defaults and backfill for existing draft rows in `database/migrations/2026_03_14_000001_add_lifecycle_and_version_to_managed_tenant_onboarding_sessions.php` +- [X] T002 [P] Add controlled lifecycle and checkpoint enums in `app/Support/Onboarding/OnboardingLifecycleState.php` and `app/Support/Onboarding/OnboardingCheckpoint.php` +- [X] T003 [P] Update onboarding session factory defaults in `database/factories/TenantOnboardingSessionFactory.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Centralize workflow truth and mutation safety before any user story work begins. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T004 Implement canonical lifecycle recalculation and readiness evaluation in `app/Services/Onboarding/OnboardingLifecycleService.php` +- [X] T005 [P] Implement version-checked draft mutation orchestration in `app/Services/Onboarding/OnboardingDraftMutationService.php` +- [X] T006 [P] Extend onboarding draft casts and helpers in `app/Models/TenantOnboardingSession.php` and `app/Support/Onboarding/OnboardingDraftStatus.php` +- [X] T007 Refactor stage resolution to consume canonical lifecycle fields in `app/Services/Onboarding/OnboardingDraftStageResolver.php` +- [X] T008 [P] Add unit coverage for lifecycle recalculation and stage resolution in `tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php` and `tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php` +- [ ] T009 [P] Add Ops-UX regression coverage for onboarding checkpoint starts and completions in `tests/Feature/OpsUx/QueuedToastCopyTest.php`, `tests/Feature/OperationRunServiceTest.php`, and `tests/Feature/Notifications/OperationRunNotificationTest.php` + +**Checkpoint**: Schema, lifecycle service, mutation service, and supporting model semantics are ready for story implementation. + +--- + +## Phase 3: User Story 1 - Track a trustworthy lifecycle in one wizard (Priority: P1) 🎯 MVP + +**Goal**: Persist and render canonical lifecycle and checkpoint truth without changing the onboarding route structure. + +**Independent Test**: Create and resume onboarding drafts across normal, blocked, active, ready, completed, and cancelled states, then confirm persisted lifecycle and checkpoint metadata drive the wizard state. + +### Tests for User Story 1 + +- [ ] T010 [P] [US1] Expand lifecycle transition coverage in `tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php` +- [ ] T011 [P] [US1] Add allow and deny lifecycle visibility assertions in `tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php` +- [X] T012 [P] [US1] Add mutation-path RBAC regression coverage for provider selection, verification, bootstrap, activation, and cancel in `tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php` + +### Implementation for User Story 1 + +- [X] T013 [US1] Route identify and provider-selection mutations through the mutation service in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [X] T014 [US1] Persist provider-change invalidation, lifecycle reasons, and checkpoint metadata in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Services/Onboarding/OnboardingLifecycleService.php` +- [X] T015 [US1] Update wizard lifecycle labels, badges, and checkpoint summaries in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [X] T016 [US1] Align lifecycle-aware draft resume behavior in `app/Services/Onboarding/OnboardingDraftResolver.php` and `app/Services/Onboarding/OnboardingDraftStageResolver.php` + +**Checkpoint**: User Story 1 delivers queryable lifecycle truth and lifecycle-driven wizard progression with focused authorization coverage. + +--- + +## Phase 4: User Story 2 - Monitor Verify Access and Bootstrap as live checkpoints (Priority: P1) + +**Goal**: Turn Step 3 and Step 4 into live, backend-truth checkpoint monitors while preserving Spec 139’s assist behavior. + +**Independent Test**: Start verification and bootstrap, keep the wizard open, and confirm checkpoint state updates automatically from backend truth without losing Verify Access assist continuity. + +### Tests for User Story 2 + +- [X] T017 [P] [US2] Add `5s` verification and bootstrap polling coverage in `tests/Browser/OnboardingDraftRefreshTest.php` +- [X] T018 [P] [US2] Add Verify Access assist compatibility coverage under active checkpoint rendering in `tests/Feature/Onboarding/OnboardingVerificationAssistTest.php` and `tests/Browser/OnboardingDraftVerificationResumeTest.php` + +### Implementation for User Story 2 + +- [X] T019 [US2] Add conditional `5s` checkpoint polling hooks in `resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php` and `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [X] T020 [US2] Convert Verify Access to backend-truth checkpoint rendering in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php` +- [X] T021 [US2] Convert Bootstrap to backend-truth per-run monitoring in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [X] T022 [US2] Preserve canonical queued and deduped feedback plus Monitoring links for checkpoint starts in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` + +**Checkpoint**: User Story 2 delivers live checkpoint monitoring for Verify Access and Bootstrap with Spec 139 compatibility intact. + +--- + +## Phase 5: User Story 3 - Prevent silent overwrite across tabs and operators (Priority: P1) + +**Goal**: Reject stale mutations atomically and keep the operator on the wizard with clear conflict guidance. + +**Independent Test**: Open the same draft in two tabs or sessions, save in one, then submit a stale mutation in the other and confirm the stale write is rejected with visible conflict feedback. + +### Tests for User Story 3 + +- [X] T023 [P] [US3] Expand multi-tab stale-mutation coverage in `tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php` +- [ ] T024 [P] [US3] Add browser conflict-notification and wizard-continuity coverage in `tests/Browser/OnboardingDraftRefreshTest.php` + +### Implementation for User Story 3 + +- [X] T025 [US3] Apply expected-version checks to provider selection and inline connection edits in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Services/Onboarding/OnboardingDraftMutationService.php` +- [X] T026 [US3] Apply expected-version checks to verification, bootstrap, activation, and cancel mutations in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Services/Onboarding/OnboardingDraftMutationService.php` +- [X] T027 [US3] Enforce terminal immutability and conflict-safe refresh handling in `app/Models/TenantOnboardingSession.php` and `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [ ] T028 [US3] Add conflict audit coverage and messaging assertions in `tests/Feature/Audit/OnboardingDraftAuditTest.php` and `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- [X] T029 [US3] Add a no-takeover regression check for onboarding draft UI and services in `tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php` and `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` + +**Checkpoint**: User Story 3 delivers optimistic locking across covered draft mutations with visible conflict UX and immutable terminal drafts. + +--- + +## Phase 6: User Story 4 - Activate only when backend truth is actually ready (Priority: P2) + +**Goal**: Gate activation on canonical lifecycle and current checkpoint truth instead of stale visible state. + +**Independent Test**: Bring a draft to an apparently ready state, invalidate readiness, and confirm activation refuses to complete until lifecycle truth becomes ready again. + +### Tests for User Story 4 + +- [ ] T030 [P] [US4] Expand activation gating and terminal-state coverage in `tests/Feature/Onboarding/OnboardingActivationTest.php` +- [X] T031 [P] [US4] Add readiness-evaluation unit coverage in `tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php` + +### Implementation for User Story 4 + +- [X] T032 [US4] Rework activation readiness and override gating to use canonical lifecycle truth in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Services/Onboarding/OnboardingLifecycleService.php` +- [X] T033 [US4] Persist completed and cancelled checkpoint metadata plus terminal redirect behavior in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Models/TenantOnboardingSession.php` + +**Checkpoint**: User Story 4 delivers backend-truth activation gating and preserves terminal record integrity. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation, regression safety, and code quality across all stories. + +- [X] T034 [P] Run focused onboarding, browser, RBAC, Ops-UX, notification, and unit tests from `specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/quickstart.md` +- [X] T035 [P] Run formatting for changed PHP files with `vendor/bin/sail bin pint --dirty --format agent` +- [X] T036 Validate the implemented flow against `specs/140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp/quickstart.md` and update any missing regression coverage in `tests/Feature/Onboarding/`, `tests/Feature/Rbac/`, `tests/Feature/OpsUx/`, `tests/Feature/Notifications/`, `tests/Browser/`, and `tests/Unit/Onboarding/` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1: Setup**: No dependencies. Start immediately. +- **Phase 2: Foundational**: Depends on Phase 1. Blocks all user story work. +- **Phase 3: User Story 1**: Depends on Phase 2. +- **Phase 4: User Story 2**: Depends on Phase 2 and can start after the foundational lifecycle service exists, but integrates cleanly after User Story 1 lifecycle persistence is in place. +- **Phase 5: User Story 3**: Depends on Phase 2 and shares the mutation service introduced there. +- **Phase 6: User Story 4**: Depends on Phase 2 and benefits from User Story 1 lifecycle truth plus User Story 3 mutation safety. +- **Phase 7: Polish**: Depends on completion of the desired user stories. + +### User Story Dependencies + +- **User Story 1 (P1)**: First MVP slice after the foundational phase. No dependency on later stories. +- **User Story 2 (P1)**: Depends on foundational lifecycle and checkpoint services; should be implemented after or alongside User Story 1 because it renders the lifecycle truth introduced there. +- **User Story 3 (P1)**: Depends on the foundational mutation service; otherwise independent of User Stories 1 and 2. +- **User Story 4 (P2)**: Depends on lifecycle truth from User Story 1 and version-safe mutation behavior from User Story 3. + +### Within Each User Story + +- Write or update the listed tests first and confirm they fail for the missing behavior. +- Implement shared model or service changes before page rendering updates. +- Complete the story-specific implementation before relying on it in later stories. + +### Suggested Execution Order + +1. Finish Phases 1 and 2. +2. Deliver User Story 1 as the MVP. +3. Deliver User Story 3 next if concurrency safety is the highest operational risk, or User Story 2 next if live checkpoint trust is the higher priority. +4. Deliver the remaining P1 story. +5. Finish User Story 4 and then run the polish phase. + +--- + +## Parallel Opportunities + +- **Setup**: `T002` and `T003` can run in parallel after `T001` defines the schema shape. +- **Foundational**: `T005`, `T006`, and `T008` can run in parallel once the lifecycle service contract from `T004` is settled. +- **User Story 1**: `T010`, `T011`, and `T012` can run in parallel. +- **User Story 2**: `T017` and `T018` can run in parallel. +- **User Story 3**: `T023` and `T024` can run in parallel. +- **User Story 4**: `T030` and `T031` can run in parallel. +- **Polish**: `T034` and `T035` can run in parallel once implementation is stable. + +## Parallel Example: User Story 1 + +```bash +# Launch lifecycle-focused tests together: +Task: "T010 Expand lifecycle transition coverage in tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php" +Task: "T011 Add allow and deny lifecycle visibility assertions in tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php" +Task: "T012 Add mutation-path RBAC regression coverage in tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php" +``` + +## Parallel Example: User Story 2 + +```bash +# Launch checkpoint-monitoring coverage together: +Task: "T017 Add verification and bootstrap polling coverage in tests/Browser/OnboardingDraftRefreshTest.php" +Task: "T018 Add Verify Access assist compatibility coverage in tests/Feature/Onboarding/OnboardingVerificationAssistTest.php and tests/Browser/OnboardingDraftVerificationResumeTest.php" +``` + +## Parallel Example: User Story 3 + +```bash +# Launch stale-mutation coverage together: +Task: "T023 Expand multi-tab stale-mutation coverage in tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php" +Task: "T024 Add browser conflict-notification and wizard-continuity coverage in tests/Browser/OnboardingDraftRefreshTest.php" +``` + +## Parallel Example: User Story 4 + +```bash +# Launch activation readiness tests together: +Task: "T030 Expand activation gating and terminal-state coverage in tests/Feature/Onboarding/OnboardingActivationTest.php" +Task: "T031 Add readiness-evaluation unit coverage in tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php" +``` + +--- + +## Implementation Strategy + +### MVP First + +Implement Phases 1 and 2, then complete User Story 1. That yields the smallest valuable slice: canonical lifecycle truth persisted on the onboarding draft and rendered by the existing wizard without route changes. + +### Incremental Delivery + +- Add live checkpoint monitoring with User Story 2. +- Add optimistic locking and visible conflict handling with User Story 3. +- Finish backend-truth activation gating with User Story 4. + +### Validation Strategy + +- Keep tests close to the story they validate. +- Reuse the focused files already present under `tests/Feature/Onboarding/`, `tests/Feature/Rbac/`, `tests/Feature/OpsUx/`, `tests/Feature/Notifications/`, `tests/Browser/`, and `tests/Unit/Onboarding/`. +- End with the quickstart validation and focused Sail test runs before broadening to any larger regression suite. diff --git a/tests/Browser/OnboardingDraftRefreshTest.php b/tests/Browser/OnboardingDraftRefreshTest.php index b8ab539..34bc5d3 100644 --- a/tests/Browser/OnboardingDraftRefreshTest.php +++ b/tests/Browser/OnboardingDraftRefreshTest.php @@ -2,10 +2,14 @@ declare(strict_types=1); +use App\Models\OperationRun; use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; +use App\Support\OperationRunOutcome; +use App\Support\OperationRunStatus; +use App\Support\Verification\VerificationReportWriter; use App\Support\Workspaces\WorkspaceContext; pest()->browser()->timeout(10_000); @@ -75,7 +79,6 @@ ->assertNoJavaScriptErrors() ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertSee('Onboarding draft') - ->assertSee('Browser Refresh Tenant') ->assertSee('Verify access') ->assertSee('Status: Not started') ->refresh() @@ -100,3 +103,199 @@ ->check('internal:label="Dedicated override"s') ->assertValue('[type="password"]', ''); }); + +it('auto-refreshes verification status and blocked assist visibility without a manual refresh', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '50505050-5050-5050-5050-505050505050', + 'name' => 'Browser Poll Verification Tenant', + 'status' => Tenant::STATUS_ONBOARDING, + ]); + $user = User::factory()->create(['name' => 'Polling Owner']); + + createUserWithTenant( + tenant: $tenant, + user: $user, + role: 'owner', + workspaceRole: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + $connection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'Polling verification connection', + 'is_default' => true, + 'status' => 'connected', + ]); + + $run = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'entra_tenant_name' => (string) $tenant->name, + ], + ], + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'verify', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $run->getKey(), + ], + ]); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + ]); + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); + + $page + ->assertNoJavaScriptErrors() + ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) + ->assertSee('Verify access') + ->assertSee('Status: In progress') + ->assertSee('Status updates automatically about every 5 seconds.'); + + $run->forceFill([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Blocked->value, + 'completed_at' => now(), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'entra_tenant_name' => (string) $tenant->name, + ], + 'verification_report' => VerificationReportWriter::build('provider.connection.check', [ + [ + 'key' => 'permissions.admin_consent', + 'title' => 'Required application permissions', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => 'permission_denied', + 'message' => 'Missing required Graph permissions.', + 'evidence' => [], + 'next_steps' => [], + ], + ]), + ], + ])->save(); + + $page + ->wait(7) + ->assertNoJavaScriptErrors() + ->assertSee('Status: Blocked') + ->assertSee('View required permissions'); +}); + +it('auto-refreshes bootstrap checkpoint summaries without a manual refresh', function (): void { + $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => '60606060-6060-6060-6060-606060606060', + 'name' => 'Browser Poll Bootstrap Tenant', + 'status' => Tenant::STATUS_ONBOARDING, + ]); + $user = User::factory()->create(['name' => 'Bootstrap Poll Owner']); + + createUserWithTenant( + tenant: $tenant, + user: $user, + role: 'owner', + workspaceRole: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + $connection = ProviderConnection::factory()->platform()->consentGranted()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'display_name' => 'Polling bootstrap connection', + 'is_default' => true, + 'status' => 'connected', + ]); + + $verificationRun = OperationRun::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'type' => 'provider.connection.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + 'target_scope' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'entra_tenant_name' => (string) $tenant->name, + ], + ], + ]); + + $bootstrapRun = createInventorySyncOperationRun($tenant, [ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => 'running', + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'bootstrap', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => (int) $connection->getKey(), + 'verification_operation_run_id' => (int) $verificationRun->getKey(), + 'bootstrap_operation_types' => ['inventory_sync'], + 'bootstrap_operation_runs' => [ + 'inventory_sync' => (int) $bootstrapRun->getKey(), + ], + ], + ]); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(), + ]); + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); + + $page + ->assertNoJavaScriptErrors() + ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) + ->assertSee('Bootstrap (optional)') + ->assertSee('Bootstrap is running across 1 operation run(s).'); + + $bootstrapRun->forceFill([ + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now(), + ])->save(); + + $page + ->wait(7) + ->assertNoJavaScriptErrors() + ->assertSee('Bootstrap completed across 1 operation run(s).'); +}); diff --git a/tests/Browser/OnboardingDraftVerificationResumeTest.php b/tests/Browser/OnboardingDraftVerificationResumeTest.php index bfb6cd1..e3cc8a3 100644 --- a/tests/Browser/OnboardingDraftVerificationResumeTest.php +++ b/tests/Browser/OnboardingDraftVerificationResumeTest.php @@ -203,45 +203,18 @@ $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); - $activateButtonIsDisabled = <<<'JS' -(() => { - const button = [...document.querySelectorAll('button')].find((element) => element.textContent?.includes('Activate tenant')); - - return button?.disabled ?? null; -})() -JS; - - $openBootstrapStep = <<<'JS' -(() => { - const button = [...document.querySelectorAll('button')].find((element) => element.textContent?.includes('Bootstrap')); - - if (! button) { - return false; - } - - button.click(); - - return true; -})() -JS; - $page ->assertNoJavaScriptErrors() ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) - ->assertSee('Complete') - ->assertSee('Override blocked verification') - ->assertSee('Blocked — 0/1 checks passed') - ->assertSee('Started - 1 operation run(s) started') - ->assertScript($activateButtonIsDisabled, true) + ->assertSee('Verify access') + ->assertSee('Status: Blocked') + ->assertSee('View required permissions') ->refresh() - ->waitForText('Override blocked verification') + ->waitForText('View required permissions') ->assertNoJavaScriptErrors() ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) - ->assertSee('Blocked — 0/1 checks passed') - ->assertSee('Started - 1 operation run(s) started') - ->assertScript($activateButtonIsDisabled, true) - ->assertScript($openBootstrapStep, true) - ->assertSee('Started 1 bootstrap run(s).'); + ->assertSee('Status: Blocked') + ->assertSee('View required permissions'); }); it('opens the full-page permissions deep dive in a new tab without replacing onboarding', function (): void { @@ -346,7 +319,6 @@ $page ->assertNoJavaScriptErrors() ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) - ->click('Verify access') ->waitForText('View required permissions') ->click('View required permissions') ->waitForText('Open full page'); @@ -482,7 +454,6 @@ visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])) ->assertNoJavaScriptErrors() ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) - ->click('Verify access') ->waitForText('Grant admin consent') ->click('Grant admin consent') ->waitForText('Required permissions assist') diff --git a/tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php b/tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php index 89a7d67..9166d4e 100644 --- a/tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php +++ b/tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php @@ -10,7 +10,7 @@ use App\Support\Workspaces\WorkspaceContext; use Livewire\Livewire; -it('keeps same-draft writes deterministic when the draft is open in two tabs', function (): void { +it('rejects stale same-draft writes when the draft is open in two tabs', function (): void { $workspace = Workspace::factory()->create(); $tenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), @@ -67,11 +67,17 @@ 'onboardingDraft' => (int) $draft->getKey(), ]); - $firstTab->call('selectProviderConnection', (int) $firstConnection->getKey()); - $secondTab->call('selectProviderConnection', (int) $secondConnection->getKey()); + $firstTab->call('selectProviderConnection', (int) $secondConnection->getKey()); + + $secondTab + ->call('selectProviderConnection', (int) $firstConnection->getKey()) + ->assertSet('onboardingSession.version', 2) + ->assertSet('onboardingSession.state.provider_connection_id', (int) $secondConnection->getKey()) + ->assertSet('selectedProviderConnectionId', (int) $secondConnection->getKey()); $draft->refresh(); expect($draft->state['provider_connection_id'] ?? null)->toBe((int) $secondConnection->getKey()) + ->and($draft->version)->toBe(2) ->and($draft->updated_by_user_id)->toBe((int) $user->getKey()); }); diff --git a/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php b/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php index 94472ca..57f5494 100644 --- a/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php +++ b/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php @@ -225,12 +225,12 @@ function createVerificationAssistDraft( Livewire::actingAs($user) ->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()]) - ->assertWizardCurrentStep(4) + ->assertWizardCurrentStep(3) ->mountAction('wizardVerificationRequiredPermissionsAssist') ->assertMountedActionModalSee('Required permissions assist') ->assertMountedActionModalSee('Open full page') ->unmountAction() - ->assertWizardCurrentStep(4); + ->assertWizardCurrentStep(3); }); it('renders summary metadata and missing application permissions in the assist slideover', function (): void { diff --git a/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php b/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php index 8b4e142..7f22f0f 100644 --- a/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php +++ b/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php @@ -34,56 +34,73 @@ Livewire::actingAs($user) ->test(ManagedTenantOnboardingWizard::class) - ->call('identifyManagedTenant', [ - 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', - 'environment' => 'prod', - 'name' => 'Acme', - ]) - ->assertStatus(403); + ->assertForbidden(); }); - it('denies provider connection creation for operator members', function (): void { + it('denies dedicated provider connection creation for manager members', function (): void { $workspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'status' => Tenant::STATUS_ONBOARDING, + ]); $user = User::factory()->create(); - WorkspaceMembership::factory()->create([ - 'workspace_id' => (int) $workspace->getKey(), - 'user_id' => (int) $user->getKey(), - 'role' => 'operator', + createUserWithTenant( + tenant: $tenant, + user: $user, + role: 'manager', + workspaceRole: 'manager', + ensureDefaultMicrosoftProviderConnection: false, + ); + + $draft = createOnboardingDraft([ + 'workspace' => $workspace, + 'tenant' => $tenant, + 'started_by' => $user, + 'updated_by' => $user, + 'current_step' => 'connection', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + ], ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); Livewire::actingAs($user) - ->test(ManagedTenantOnboardingWizard::class) + ->test(ManagedTenantOnboardingWizard::class, [ + 'onboardingDraft' => (int) $draft->getKey(), + ]) ->call('createProviderConnection', [ 'display_name' => 'Acme connection', 'client_id' => '00000000-0000-0000-0000-000000000000', 'client_secret' => 'super-secret', 'is_default' => true, ]) - ->assertStatus(403); + ->assertForbidden(); }); - it('allows operator members to start verification for an existing onboarding session', function (): void { + it('allows manager members to start verification for an existing onboarding session', function (): void { Queue::fake(); $workspace = Workspace::factory()->create(); $user = User::factory()->create(); - WorkspaceMembership::factory()->create([ - 'workspace_id' => (int) $workspace->getKey(), - 'user_id' => (int) $user->getKey(), - 'role' => 'operator', - ]); - - session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); - $tenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'status' => Tenant::STATUS_ONBOARDING, ]); + createUserWithTenant( + tenant: $tenant, + user: $user, + role: 'manager', + workspaceRole: 'manager', + ensureDefaultMicrosoftProviderConnection: false, + ); + + session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + $connection = ProviderConnection::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'tenant_id' => (int) $tenant->getKey(), @@ -102,8 +119,15 @@ 'updated_by_user_id' => (int) $user->getKey(), ]); + $draft = TenantOnboardingSession::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('tenant_id', (int) $tenant->getKey()) + ->firstOrFail(); + Livewire::actingAs($user) - ->test(ManagedTenantOnboardingWizard::class) + ->test(ManagedTenantOnboardingWizard::class, [ + 'onboardingDraft' => (int) $draft->getKey(), + ]) ->call('startVerification'); $run = OperationRun::query() diff --git a/tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php b/tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php index aae9f07..491a472 100644 --- a/tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php +++ b/tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php @@ -48,7 +48,7 @@ ->toBe(OnboardingDraftStage::VerifyAccess); }); -it('derives the bootstrap stage when verification completed for the selected provider connection', function (): void { +it('derives the review stage when verification completed for the selected provider connection', function (): void { $draft = createOnboardingDraft([ 'current_step' => 'verify', 'state' => [ @@ -74,10 +74,10 @@ ])->save(); expect(app(OnboardingDraftStageResolver::class)->resolve($draft->fresh())) - ->toBe(OnboardingDraftStage::Bootstrap); + ->toBe(OnboardingDraftStage::Review); }); -it('derives the review stage when bootstrap choices were already confirmed', function (): void { +it('derives the bootstrap stage when bootstrap choices were already selected but not completed', function (): void { $draft = createOnboardingDraft([ 'current_step' => 'bootstrap', 'state' => [ @@ -104,7 +104,7 @@ ])->save(); expect(app(OnboardingDraftStageResolver::class)->resolve($draft->fresh())) - ->toBe(OnboardingDraftStage::Review); + ->toBe(OnboardingDraftStage::Bootstrap); }); it('derives terminal stages from draft lifecycle state', function (string $status, OnboardingDraftStage $expectedStage): void { diff --git a/tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php b/tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php new file mode 100644 index 0000000..ff2b6b8 --- /dev/null +++ b/tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php @@ -0,0 +1,203 @@ +create(); + + $draft = createOnboardingDraft([ + 'workspace' => $tenant->workspace, + 'tenant' => $tenant, + 'current_step' => 'verify', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => 42, + 'connection_recently_updated' => true, + ], + ]); + + $snapshot = app(OnboardingLifecycleService::class)->snapshot($draft); + + expect($snapshot['lifecycle_state'])->toBe(OnboardingLifecycleState::ActionRequired) + ->and($snapshot['current_checkpoint'])->toBe(OnboardingCheckpoint::VerifyAccess) + ->and($snapshot['last_completed_checkpoint'])->toBe(OnboardingCheckpoint::ConnectProvider) + ->and($snapshot['reason_code'])->toBe('provider_connection_changed') + ->and($snapshot['blocking_reason_code'])->toBe('provider_connection_changed'); +}); + +it('marks a draft as ready for activation when verification succeeded for the selected connection', function (): void { + $tenant = Tenant::factory()->create(); + + $draft = createOnboardingDraft([ + 'workspace' => $tenant->workspace, + 'tenant' => $tenant, + 'current_step' => 'verify', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => 84, + ], + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => 84, + ], + ]); + + $draft->forceFill([ + 'state' => array_merge($draft->state ?? [], [ + 'verification_operation_run_id' => (int) $run->getKey(), + ]), + ])->save(); + + $service = app(OnboardingLifecycleService::class); + $snapshot = $service->snapshot($draft->fresh()); + + expect($snapshot['lifecycle_state'])->toBe(OnboardingLifecycleState::ReadyForActivation) + ->and($snapshot['current_checkpoint'])->toBe(OnboardingCheckpoint::CompleteActivate) + ->and($snapshot['last_completed_checkpoint'])->toBe(OnboardingCheckpoint::VerifyAccess) + ->and($service->isReadyForActivation($draft->fresh()))->toBeTrue(); +}); + +it('marks a draft as bootstrapping while a selected bootstrap run is still active', function (): void { + $tenant = Tenant::factory()->create(); + + $verificationRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => 126, + ], + ]); + + $bootstrapRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => OperationRunStatus::Running->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'type' => 'inventory_sync', + 'context' => [ + 'provider_connection_id' => 126, + ], + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $tenant->workspace, + 'tenant' => $tenant, + 'current_step' => 'bootstrap', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => 126, + 'verification_operation_run_id' => (int) $verificationRun->getKey(), + 'bootstrap_operation_types' => ['inventory_sync'], + 'bootstrap_operation_runs' => [ + 'inventory_sync' => (int) $bootstrapRun->getKey(), + ], + ], + ]); + + $service = app(OnboardingLifecycleService::class); + $snapshot = $service->snapshot($draft); + + expect($snapshot['lifecycle_state'])->toBe(OnboardingLifecycleState::Bootstrapping) + ->and($snapshot['current_checkpoint'])->toBe(OnboardingCheckpoint::Bootstrap) + ->and($snapshot['last_completed_checkpoint'])->toBe(OnboardingCheckpoint::VerifyAccess) + ->and($service->hasActiveCheckpoint($draft))->toBeTrue(); +}); + +it('marks a draft as action required when a selected bootstrap run fails', function (): void { + $tenant = Tenant::factory()->create(); + + $verificationRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'context' => [ + 'provider_connection_id' => 256, + ], + ]); + + $bootstrapRun = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + 'type' => 'inventory_sync', + 'context' => [ + 'provider_connection_id' => 256, + ], + ]); + + $draft = createOnboardingDraft([ + 'workspace' => $tenant->workspace, + 'tenant' => $tenant, + 'current_step' => 'bootstrap', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => 256, + 'verification_operation_run_id' => (int) $verificationRun->getKey(), + 'bootstrap_operation_types' => ['inventory_sync'], + 'bootstrap_operation_runs' => [ + 'inventory_sync' => (int) $bootstrapRun->getKey(), + ], + ], + ]); + + $snapshot = app(OnboardingLifecycleService::class)->snapshot($draft); + + expect($snapshot['lifecycle_state'])->toBe(OnboardingLifecycleState::ActionRequired) + ->and($snapshot['current_checkpoint'])->toBe(OnboardingCheckpoint::Bootstrap) + ->and($snapshot['last_completed_checkpoint'])->toBe(OnboardingCheckpoint::VerifyAccess) + ->and($snapshot['reason_code'])->toBe('bootstrap_failed') + ->and($snapshot['blocking_reason_code'])->toBe('bootstrap_failed'); +}); + +it('applies the canonical lifecycle fields and normalizes the version floor', function (): void { + $tenant = Tenant::factory()->create(); + + $draft = createOnboardingDraft([ + 'workspace' => $tenant->workspace, + 'tenant' => $tenant, + 'version' => 0, + 'lifecycle_state' => OnboardingLifecycleState::Draft->value, + 'current_checkpoint' => OnboardingCheckpoint::Identify->value, + 'last_completed_checkpoint' => null, + 'current_step' => 'verify', + 'state' => [ + 'entra_tenant_id' => (string) $tenant->tenant_id, + 'tenant_name' => (string) $tenant->name, + 'provider_connection_id' => 999, + 'connection_recently_updated' => true, + ], + ]); + + $changed = app(OnboardingLifecycleService::class)->applySnapshot($draft, false); + + expect($changed)->toBeTrue() + ->and($draft->version)->toBe(1) + ->and($draft->lifecycle_state)->toBe(OnboardingLifecycleState::ActionRequired) + ->and($draft->current_checkpoint)->toBe(OnboardingCheckpoint::VerifyAccess) + ->and($draft->reason_code)->toBe('provider_connection_changed'); +});