365 lines
15 KiB
PHP
365 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\ReviewPublicationResolution;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPublicationResolutionCase;
|
|
use App\Models\User;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Support\Audit\AuditActionId;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
|
|
final class ReviewPublicationResolutionService
|
|
{
|
|
public function __construct(
|
|
private readonly ReviewPublicationReadinessEvaluator $evaluator,
|
|
private readonly ReviewPublicationStepPlanner $planner,
|
|
private readonly WorkspaceAuditLogger $auditLogger,
|
|
) {}
|
|
|
|
public function openOrResume(EnvironmentReview $review, User $actor, string $sourceSurface = 'environment_review_detail'): ?ReviewPublicationResolutionCase
|
|
{
|
|
Gate::forUser($actor)->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, $actor);
|
|
|
|
$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, $actor);
|
|
|
|
$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), $actor);
|
|
|
|
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<string, mixed> $readiness
|
|
*/
|
|
private function syncCase(
|
|
ReviewPublicationResolutionCase $case,
|
|
EnvironmentReview $review,
|
|
array $readiness,
|
|
?User $actor = null,
|
|
): 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 (! in_array($step->status, [
|
|
ReviewPublicationResolutionStepStatus::Completed->value,
|
|
ReviewPublicationResolutionStepStatus::Failed->value,
|
|
ReviewPublicationResolutionStepStatus::Running->value,
|
|
], true)) {
|
|
$step->completed_at = null;
|
|
$step->failed_at = null;
|
|
$step->started_at = null;
|
|
}
|
|
|
|
if ($step->status !== ReviewPublicationResolutionStepStatus::Completed->value) {
|
|
$step->completed_at = null;
|
|
}
|
|
|
|
if ($step->status !== ReviewPublicationResolutionStepStatus::Failed->value) {
|
|
$step->failed_at = null;
|
|
}
|
|
|
|
if ($step->status === ReviewPublicationResolutionStepStatus::Completed->value && $previousStatus !== ReviewPublicationResolutionStepStatus::Completed->value) {
|
|
if ($step->completed_at === null) {
|
|
$step->completed_at = now();
|
|
}
|
|
|
|
if ($step->exists && $previousStatus !== null) {
|
|
$this->recordAudit(
|
|
case: $case,
|
|
action: AuditActionId::ReviewPublicationResolutionStepCompleted,
|
|
actor: $actor,
|
|
metadata: $this->safeProofAuditMetadata($stepPlan),
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
$step->completed_at = null;
|
|
$step->failed_at = null;
|
|
|
|
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<string, mixed> $readiness
|
|
* @return array<string, mixed>
|
|
*/
|
|
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),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $stepPlan
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function safeProofAuditMetadata(array $stepPlan): array
|
|
{
|
|
return [
|
|
'step_key' => (string) $stepPlan['step_key'],
|
|
'proof_type' => is_string($stepPlan['proof_type'] ?? null) ? $stepPlan['proof_type'] : null,
|
|
'proof_id' => is_numeric($stepPlan['proof_id'] ?? null) ? (int) $stepPlan['proof_id'] : null,
|
|
'proof_status' => is_string($stepPlan['proof_status'] ?? null) ? $stepPlan['proof_status'] : null,
|
|
'proof_currentness' => (string) data_get($stepPlan, 'metadata.proof_currentness', ''),
|
|
'proof_usability' => (string) data_get($stepPlan, 'metadata.proof_usability', ''),
|
|
'proof_visibility' => (string) data_get($stepPlan, 'metadata.proof_visibility', ''),
|
|
'proof_reason_code' => (string) data_get($stepPlan, 'metadata.proof_reason_code', ''),
|
|
'proof_evaluated_at' => data_get($stepPlan, 'metadata.proof_evaluated_at'),
|
|
];
|
|
}
|
|
}
|