TenantAtlas/apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationResolutionService.php
ahmido ba7622a158 feat: implement ReviewPublicationResolutionWorkflow (Spec 386) (#457)
## Summary\n- Implements the ReviewPublicationResolutionWorkflow for Spec 386.\n- Adds resolution case/step persistence, policies, services, audit action IDs, and Filament integration.\n- Updates specs, UI/UX documentation, screenshots, and Pest coverage.\n\n## Tests\n- Not run during this handoff; branch was already clean and pushed.\n\n## Target\n- Base: platform-dev\n- Head/topic: 386-review-publication-resolution-workflow-v1

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #457
2026-06-18 21:06:20 +00:00

315 lines
13 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);
$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<string, mixed> $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<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),
];
}
}