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); } public function canResumeDraft(TenantOnboardingSession $draft): bool { if (! $draft->isWorkflowResumable()) { return false; } $tenant = $draft->tenant; if (! $tenant instanceof Tenant) { return true; } return $this->tenantOperabilityService->canResumeOnboarding($tenant); } public function syncLinkedTenantAfterCancellation(TenantOnboardingSession $draft): ?Tenant { $tenant = $draft->tenant; if (! $tenant instanceof Tenant) { return null; } if ($tenant->trashed() || $tenant->status !== Tenant::STATUS_ONBOARDING || ! $draft->isCancelled()) { return null; } $hasOtherResumableDrafts = TenantOnboardingSession::query() ->where('workspace_id', (int) $draft->workspace_id) ->where('tenant_id', (int) $tenant->getKey()) ->whereKeyNot((int) $draft->getKey()) ->resumable() ->exists(); if ($hasOtherResumableDrafts) { return null; } $tenant->forceFill(['status' => Tenant::STATUS_DRAFT])->save(); return $tenant->fresh(); } 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; } }