authorize('view', $review); Gate::forUser($actor)->authorize('refresh', $review); $review->loadMissing(['tenant', 'workspace']); $readiness = $this->evaluator->evaluate($review); $activeCase = $this->activeCaseForReview($review); if (! $activeCase instanceof ReviewPublicationResolutionCase && ! (bool) $readiness['has_publication_blockers']) { return null; } return DB::transaction(function () use ($review, $actor, $sourceSurface, $readiness): ReviewPublicationResolutionCase { $lockedCases = ReviewPublicationResolutionCase::query() ->forReview($review) ->where('action_key', ReviewPublicationResolutionCase::ACTION_KEY) ->active() ->lockForUpdate() ->get(); $case = $lockedCases->firstWhere('readiness_fingerprint', $readiness['fingerprint']); foreach ($lockedCases as $lockedCase) { if ($case instanceof ReviewPublicationResolutionCase && (int) $lockedCase->getKey() === (int) $case->getKey()) { continue; } $lockedCase->forceFill([ 'status' => ReviewPublicationResolutionCaseStatus::Superseded->value, 'superseded_at' => now(), ])->save(); $this->recordAudit( case: $lockedCase, action: AuditActionId::ReviewPublicationResolutionSuperseded, actor: $actor, metadata: [ 'superseded_by_readiness_fingerprint' => (string) $readiness['fingerprint'], 'environment_review_id' => (int) $review->getKey(), ], ); } if ($case instanceof ReviewPublicationResolutionCase) { $case->forceFill([ 'assigned_to_user_id' => (int) $actor->getKey(), 'metadata' => array_replace(is_array($case->metadata) ? $case->metadata : [], [ 'last_source_surface' => $sourceSurface, ]), ])->save(); $case = $this->syncCase($case, $review, $readiness); $this->recordAudit( case: $case, action: AuditActionId::ReviewPublicationResolutionResumed, actor: $actor, metadata: [ 'current_step_key' => $case->current_step_key, 'status' => $case->status, ], ); return $case; } $case = ReviewPublicationResolutionCase::query()->create([ 'workspace_id' => (int) $review->workspace_id, 'managed_environment_id' => (int) $review->managed_environment_id, 'environment_review_id' => (int) $review->getKey(), 'action_key' => ReviewPublicationResolutionCase::ACTION_KEY, 'status' => ReviewPublicationResolutionCaseStatus::Open->value, 'readiness_fingerprint' => (string) $readiness['fingerprint'], 'created_by_user_id' => (int) $actor->getKey(), 'assigned_to_user_id' => (int) $actor->getKey(), 'started_at' => now(), 'last_evaluated_at' => now(), 'summary' => $this->caseSummary($readiness, null), 'metadata' => [ 'source_surface' => $sourceSurface, 'readiness_contract' => 'review_publication_resolution.v1', ], ]); $case = $this->syncCase($case, $review, $readiness); $this->recordAudit( case: $case, action: AuditActionId::ReviewPublicationResolutionCreated, actor: $actor, metadata: [ 'current_step_key' => $case->current_step_key, 'status' => $case->status, ], ); return $case; }); } public function refreshCase(ReviewPublicationResolutionCase $case, ?User $actor = null): ReviewPublicationResolutionCase { $case->loadMissing('environmentReview'); $review = $case->environmentReview; if (! $review instanceof EnvironmentReview) { return $case; } $previousStatus = (string) $case->status; $case = $this->syncCase($case, $review, $this->evaluator->evaluate($review)); if ($case->status === ReviewPublicationResolutionCaseStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionCaseStatus::Completed->value) { $this->recordAudit( case: $case, action: AuditActionId::ReviewPublicationResolutionCompleted, actor: $actor, metadata: [ 'environment_review_id' => (int) $review->getKey(), 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ], ); } return $case; } public function cancel(ReviewPublicationResolutionCase $case, User $actor): ReviewPublicationResolutionCase { Gate::forUser($actor)->authorize('cancel', $case); $case->forceFill([ 'status' => ReviewPublicationResolutionCaseStatus::Cancelled->value, 'cancelled_at' => now(), ])->save(); $case->steps() ->whereNotIn('status', [ ReviewPublicationResolutionStepStatus::Completed->value, ReviewPublicationResolutionStepStatus::Superseded->value, ]) ->update([ 'status' => ReviewPublicationResolutionStepStatus::Superseded->value, 'superseded_at' => now(), 'updated_at' => now(), ]); $this->recordAudit( case: $case, action: AuditActionId::ReviewPublicationResolutionCancelled, actor: $actor, metadata: [ 'environment_review_id' => (int) $case->environment_review_id, ], ); return $case->fresh(['steps.operationRun', 'environmentReview.tenant']); } public function activeCaseForReview(EnvironmentReview $review): ?ReviewPublicationResolutionCase { return ReviewPublicationResolutionCase::query() ->forReview($review) ->where('action_key', ReviewPublicationResolutionCase::ACTION_KEY) ->active() ->latest('updated_at') ->latest('id') ->first(); } public function recordAudit( ReviewPublicationResolutionCase $case, AuditActionId $action, ?User $actor = null, array $metadata = [], ?OperationRun $operationRun = null, ): void { $case->loadMissing(['tenant.workspace', 'environmentReview']); $tenant = $case->tenant; if (! $tenant instanceof ManagedEnvironment) { return; } $this->auditLogger->log( workspace: $tenant->workspace, action: $action, context: [ 'metadata' => array_filter(array_replace([ 'case_id' => (int) $case->getKey(), 'environment_review_id' => (int) $case->environment_review_id, 'status' => (string) $case->status, 'current_step_key' => is_string($case->current_step_key) ? $case->current_step_key : null, 'readiness_fingerprint' => (string) $case->readiness_fingerprint, ], $metadata), static fn (mixed $value): bool => $value !== null && $value !== ''), ], actor: $actor, resourceType: 'review_publication_resolution_case', resourceId: (string) $case->getKey(), targetLabel: sprintf('Review publication resolution case #%d', (int) $case->getKey()), operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null, tenant: $tenant, ); } /** * @param array $readiness */ private function syncCase( ReviewPublicationResolutionCase $case, EnvironmentReview $review, array $readiness, ): ReviewPublicationResolutionCase { $plan = $this->planner->plan($review, $readiness, $case->exists ? $case : null); foreach ($plan['steps'] as $stepPlan) { $step = $case->steps()->firstOrNew(['step_key' => (string) $stepPlan['step_key']]); $previousStatus = $step->exists ? (string) $step->status : null; $step->fill([ 'position' => (int) $stepPlan['position'], 'status' => (string) $stepPlan['status'], 'primary_action_key' => $stepPlan['primary_action_key'], 'operation_run_id' => $stepPlan['operation_run_id'], 'proof_type' => $stepPlan['proof_type'], 'proof_id' => $stepPlan['proof_id'], 'proof_status' => $stepPlan['proof_status'], 'summary' => $stepPlan['summary'], 'metadata' => $stepPlan['metadata'], ]); if ($step->status === ReviewPublicationResolutionStepStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Completed->value) { if ($step->completed_at === null) { $step->completed_at = now(); } } if ($step->status === ReviewPublicationResolutionStepStatus::Failed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Failed->value) { if ($step->failed_at === null) { $step->failed_at = now(); } } if ($step->status === ReviewPublicationResolutionStepStatus::Running->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Running->value) { if ($step->started_at === null) { $step->started_at = now(); } } $step->save(); } $caseStatus = $plan['case_status']->value; $case->forceFill([ 'status' => $caseStatus, 'current_step_key' => $plan['current_step_key'], 'readiness_fingerprint' => (string) $readiness['fingerprint'], 'last_evaluated_at' => now(), 'completed_at' => $caseStatus === ReviewPublicationResolutionCaseStatus::Completed->value ? ($case->completed_at ?? now()) : null, 'summary' => $this->caseSummary($readiness, $plan['current_step_key']), ])->save(); return $case->fresh(['steps.operationRun', 'environmentReview.tenant', 'tenant.workspace']); } /** * @param array $readiness * @return array */ private function caseSummary(array $readiness, ?string $currentStepKey): array { return [ 'title' => 'Resolve review publication blockers', 'reason' => 'Publication is waiting on review readiness, evidence, or output proof.', 'impact' => 'Operators can resolve each prerequisite without publishing automatically.', 'current_step_key' => $currentStepKey, 'publication_blocker_count' => count((array) ($readiness['publication_blockers'] ?? [])), 'publication_blockers' => array_slice((array) ($readiness['publication_blockers'] ?? []), 0, 5), 'missing_report_dimensions' => array_values((array) ($readiness['missing_report_dimensions'] ?? [])), 'evidence_state' => (string) ($readiness['evidence_state'] ?? ''), 'review_status' => (string) ($readiness['review_status'] ?? ''), 'review_completeness_state' => (string) ($readiness['review_completeness_state'] ?? ''), 'guidance_state' => (string) ($readiness['guidance_state'] ?? ''), 'has_ready_export' => (bool) ($readiness['has_ready_export'] ?? false), ]; } }