## Summary - implement Spec 143 tenant lifecycle, operability, and tenant-context semantics across chooser, tenant management, onboarding, and canonical operation viewers - add centralized tenant lifecycle and operability support types, audit action coverage, and lifecycle-aware badge and action handling - add feature and unit coverage for tenant chooser eligibility, global search scoping, canonical operation access, onboarding authorization, and lifecycle presentation ## Testing - vendor/bin/sail artisan test --compact - vendor/bin/sail bin pint --dirty --format agent Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #172
747 lines
27 KiB
PHP
747 lines
27 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Onboarding;
|
|
|
|
use App\Filament\Support\VerificationReportViewer;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantOnboardingSession;
|
|
use App\Services\Tenants\TenantOperabilityService;
|
|
use App\Support\Onboarding\OnboardingCheckpoint;
|
|
use App\Support\Onboarding\OnboardingLifecycleState;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Verification\VerificationReportOverall;
|
|
|
|
class OnboardingLifecycleService
|
|
{
|
|
public function __construct(
|
|
private readonly TenantOperabilityService $tenantOperabilityService,
|
|
) {}
|
|
|
|
public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $incrementVersion = false): TenantOnboardingSession
|
|
{
|
|
$freshDraft = TenantOnboardingSession::query()->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<int, array{
|
|
* type: string,
|
|
* run_id: int|null,
|
|
* status: string|null,
|
|
* outcome: string|null,
|
|
* is_active: bool,
|
|
* is_failure: bool,
|
|
* is_partial_failure: bool,
|
|
* is_completed: bool
|
|
* }>
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
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<int, array{
|
|
* type: string,
|
|
* run_id: int|null,
|
|
* status: string|null,
|
|
* outcome: string|null,
|
|
* is_active: bool,
|
|
* is_failure: bool,
|
|
* is_partial_failure: bool,
|
|
* is_completed: bool
|
|
* }>
|
|
* }
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
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<int, string> $selectedTypes
|
|
* @return array<string, int>
|
|
*/
|
|
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;
|
|
}
|
|
}
|