TenantAtlas/app/Services/Onboarding/OnboardingLifecycleService.php
ahmido b0a724acef feat: harden canonical run viewer and onboarding draft state (#173)
## Summary
- harden the canonical operation run viewer so mismatched, missing, archived, onboarding, and selector-excluded tenant context no longer invalidates authorized canonical run viewing
- extend canonical route, header-context, deep-link, and presentation coverage for Spec 144 and add the full spec artifact set under `specs/144-canonical-operation-viewer-context-decoupling/`
- harden onboarding draft provider-connection resume logic so stale persisted provider connections fall back to the connect-provider step instead of resuming invalid state
- add architecture-audit follow-up candidate material and prompt assets for the next governance hardening wave

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Feature/144/CanonicalOperationViewerContextMismatchTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/OpsUx/OperateHubShellTest.php tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Monitoring/HeaderContextBarTest.php tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Unit/Onboarding/OnboardingDraftStageResolverTest.php tests/Unit/Onboarding/OnboardingLifecycleServiceTest.php`

## Notes
- branch: `144-canonical-operation-viewer-context-decoupling`
- base: `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #173
2026-03-15 18:32:04 +00:00

760 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\ProviderConnection;
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 : [];
$providerConnectionId = $this->normalizeInteger($state['provider_connection_id'] ?? $state['selected_provider_connection_id'] ?? null);
if ($providerConnectionId === null || $draft->tenant_id === null) {
return null;
}
$exists = ProviderConnection::query()
->whereKey($providerConnectionId)
->where('workspace_id', (int) $draft->workspace_id)
->where('tenant_id', (int) $draft->tenant_id)
->exists();
return $exists ? $providerConnectionId : 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;
}
}