Implements Spec 075 (V1.5) on top of Spec 074. Highlights - Deterministic report fingerprint (sha256) + previous_report_id linkage - Viewer change indicator: "No changes" vs "Changed" when previous exists - Check acknowledgements (fail|warn|block) with capability-first auth, confirmation, and audit event - Verify-step UX polish (issues-first, primary CTA) Testing - Focused Pest coverage for fingerprint, previous resolver, change indicator, acknowledgements, badge semantics, DB-only viewer guard. Notes - Viewing remains DB-only (no external calls while rendering). Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #93
188 lines
6.1 KiB
PHP
188 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Verification;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\VerificationCheckAcknowledgement;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Verification\VerificationReportSanitizer;
|
|
use App\Support\Verification\VerificationReportSchema;
|
|
use App\Support\Verification\VerificationCheckStatus;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use InvalidArgumentException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
final class VerificationCheckAcknowledgementService
|
|
{
|
|
public function __construct(
|
|
private readonly WorkspaceAuditLogger $audit,
|
|
) {}
|
|
|
|
public function acknowledge(
|
|
Tenant $tenant,
|
|
OperationRun $run,
|
|
string $checkKey,
|
|
string $ackReason,
|
|
?string $expiresAt,
|
|
User $actor,
|
|
): VerificationCheckAcknowledgement {
|
|
if (! $actor->canAccessTenant($tenant)) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
Gate::forUser($actor)->authorize(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $tenant);
|
|
|
|
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
if ((int) $run->workspace_id !== (int) $tenant->workspace_id) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
$checkKey = trim($checkKey);
|
|
if ($checkKey === '') {
|
|
throw new InvalidArgumentException('check_key is required.');
|
|
}
|
|
|
|
$ackReason = trim($ackReason);
|
|
if ($ackReason === '') {
|
|
throw new InvalidArgumentException('ack_reason is required.');
|
|
}
|
|
|
|
if (mb_strlen($ackReason) > 160) {
|
|
throw new InvalidArgumentException('ack_reason must be at most 160 characters.');
|
|
}
|
|
|
|
$report = $this->reportForRun($run);
|
|
$check = $this->findCheckByKey($report, $checkKey);
|
|
|
|
$status = $check['status'] ?? null;
|
|
|
|
if (! is_string($status) || ! in_array($status, [VerificationCheckStatus::Fail->value, VerificationCheckStatus::Warn->value], true)) {
|
|
throw new InvalidArgumentException('Only failing or warning checks can be acknowledged.');
|
|
}
|
|
|
|
$reasonCode = $check['reason_code'] ?? null;
|
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
|
throw new InvalidArgumentException('Check reason_code is required.');
|
|
}
|
|
|
|
$expiresAtParsed = null;
|
|
|
|
if ($expiresAt !== null && trim($expiresAt) !== '') {
|
|
try {
|
|
$expiresAtParsed = CarbonImmutable::parse($expiresAt);
|
|
} catch (\Throwable) {
|
|
throw new InvalidArgumentException('expires_at must be a valid date-time.');
|
|
}
|
|
|
|
if ($expiresAtParsed->isBefore(CarbonImmutable::now())) {
|
|
throw new InvalidArgumentException('expires_at must be in the future.');
|
|
}
|
|
}
|
|
|
|
$acknowledgedAt = CarbonImmutable::now();
|
|
|
|
try {
|
|
$ack = VerificationCheckAcknowledgement::create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'check_key' => $checkKey,
|
|
'ack_reason' => $ackReason,
|
|
'expires_at' => $expiresAtParsed,
|
|
'acknowledged_at' => $acknowledgedAt,
|
|
'acknowledged_by_user_id' => (int) $actor->getKey(),
|
|
]);
|
|
} catch (QueryException $e) {
|
|
$ack = VerificationCheckAcknowledgement::query()
|
|
->where('operation_run_id', (int) $run->getKey())
|
|
->where('check_key', $checkKey)
|
|
->first();
|
|
|
|
if (! $ack instanceof VerificationCheckAcknowledgement) {
|
|
throw $e;
|
|
}
|
|
|
|
return $ack;
|
|
}
|
|
|
|
if ($ack->wasRecentlyCreated) {
|
|
$workspace = $tenant->workspace;
|
|
|
|
if ($workspace !== null) {
|
|
$this->audit->log(
|
|
workspace: $workspace,
|
|
action: AuditActionId::VerificationCheckAcknowledged->value,
|
|
context: [
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'report_id' => (int) $run->getKey(),
|
|
'flow' => (string) $run->type,
|
|
'check_key' => $checkKey,
|
|
'reason_code' => $reasonCode,
|
|
],
|
|
actor: $actor,
|
|
resourceType: 'operation_run',
|
|
resourceId: (string) $run->getKey(),
|
|
);
|
|
}
|
|
}
|
|
|
|
return $ack;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reportForRun(OperationRun $run): array
|
|
{
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$report = $context['verification_report'] ?? null;
|
|
|
|
if (! is_array($report)) {
|
|
throw new InvalidArgumentException('Verification report is missing.');
|
|
}
|
|
|
|
$report = VerificationReportSanitizer::sanitizeReport($report);
|
|
|
|
if (! VerificationReportSchema::isValidReport($report)) {
|
|
throw new InvalidArgumentException('Verification report is invalid.');
|
|
}
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $report
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function findCheckByKey(array $report, string $checkKey): array
|
|
{
|
|
$checks = $report['checks'] ?? null;
|
|
$checks = is_array($checks) ? $checks : [];
|
|
|
|
foreach ($checks as $check) {
|
|
if (! is_array($check)) {
|
|
continue;
|
|
}
|
|
|
|
if (($check['key'] ?? null) === $checkKey) {
|
|
return $check;
|
|
}
|
|
}
|
|
|
|
throw new InvalidArgumentException('Check not found in verification report.');
|
|
}
|
|
}
|
|
|