TenantAtlas/app/Services/TenantReviews/TenantReviewService.php
ahmido a4f2629493 feat: add tenant review layer (#185)
## Summary
- add the tenant review domain with tenant-scoped review library, canonical workspace review register, lifecycle actions, and review-derived executive pack export
- extend review pack, operations, audit, capability, and badge infrastructure to support review composition, publication, export, and recurring review cycles
- add product backlog and audit documentation updates for tenant review and semantic-clarity follow-up candidates

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact --filter="TenantReview"`
- `CI=1 vendor/bin/sail artisan test --compact`

## Notes
- Livewire v4+ compliant via existing Filament v5 stack
- panel providers remain in `bootstrap/providers.php` via existing Laravel 12 structure; no provider registration moved to `bootstrap/app.php`
- `TenantReviewResource` is not globally searchable, so the Filament edit/view global-search constraint does not apply
- destructive review actions use action handlers with confirmation and policy enforcement

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #185
2026-03-21 22:03:01 +00:00

251 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantReviews;
use App\Jobs\ComposeTenantReviewJob;
use App\Models\EvidenceSnapshot;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\OperationRunService;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunType;
use App\Support\TenantReviewStatus;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
final class TenantReviewService
{
public function __construct(
private readonly OperationRunService $operationRuns,
private readonly WorkspaceAuditLogger $auditLogger,
private readonly TenantReviewComposer $composer,
private readonly TenantReviewFingerprint $fingerprint,
) {}
public function create(Tenant $tenant, EvidenceSnapshot $snapshot, User $user): TenantReview
{
return $this->queueComposition(
tenant: $tenant,
snapshot: $snapshot,
user: $user,
existingReview: null,
auditAction: AuditActionId::TenantReviewCreated,
);
}
public function refresh(TenantReview $review, User $user, ?EvidenceSnapshot $snapshot = null): TenantReview
{
$tenant = $review->tenant;
if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Review tenant could not be resolved.');
}
$snapshot ??= $this->resolveLatestSnapshot($tenant) ?? $review->evidenceSnapshot;
return $this->queueComposition(
tenant: $tenant,
snapshot: $snapshot,
user: $user,
existingReview: $review,
auditAction: AuditActionId::TenantReviewRefreshed,
);
}
public function compose(TenantReview $review): TenantReview
{
$review->loadMissing(['tenant', 'evidenceSnapshot.items']);
$snapshot = $review->evidenceSnapshot;
if (! $snapshot instanceof EvidenceSnapshot) {
throw new InvalidArgumentException('Review evidence snapshot could not be resolved.');
}
$payload = $this->composer->compose($snapshot, $review);
DB::transaction(function () use ($review, $payload, $snapshot): void {
$review->forceFill([
'fingerprint' => $payload['fingerprint'],
'completeness_state' => $payload['completeness_state'],
'status' => $payload['status'],
'summary' => $payload['summary'],
'generated_at' => now(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
])->save();
$review->sections()->delete();
foreach ($payload['sections'] as $section) {
$review->sections()->create([
'workspace_id' => (int) $review->workspace_id,
'tenant_id' => (int) $review->tenant_id,
'section_key' => $section['section_key'],
'title' => $section['title'],
'sort_order' => $section['sort_order'],
'required' => $section['required'],
'completeness_state' => $section['completeness_state'],
'source_snapshot_fingerprint' => $section['source_snapshot_fingerprint'],
'summary_payload' => $section['summary_payload'],
'render_payload' => $section['render_payload'],
'measured_at' => $section['measured_at'],
]);
}
});
return $review->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack']);
}
public function resolveLatestSnapshot(Tenant $tenant): ?EvidenceSnapshot
{
return EvidenceSnapshot::query()
->forTenant((int) $tenant->getKey())
->current()
->latest('generated_at')
->latest('id')
->first();
}
public function activeCompositionRun(Tenant $tenant, ?EvidenceSnapshot $snapshot = null): ?OperationRun
{
$snapshot ??= $this->resolveLatestSnapshot($tenant);
if (! $snapshot instanceof EvidenceSnapshot) {
return null;
}
return $this->operationRuns->findCanonicalRunWithIdentity(
tenant: $tenant,
type: OperationRunType::TenantReviewCompose->value,
identityInputs: [
'tenant_id' => (int) $tenant->getKey(),
'snapshot_id' => (int) $snapshot->getKey(),
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
],
);
}
private function queueComposition(
Tenant $tenant,
?EvidenceSnapshot $snapshot,
User $user,
?TenantReview $existingReview,
AuditActionId $auditAction,
): TenantReview {
if (! $snapshot instanceof EvidenceSnapshot) {
throw new InvalidArgumentException('An eligible evidence snapshot is required.');
}
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
throw new InvalidArgumentException('Evidence snapshot does not belong to the target tenant.');
}
$fingerprint = $this->fingerprint->forSnapshot($tenant, $snapshot);
$review = $existingReview;
if (! $review instanceof TenantReview) {
$existing = $this->findExistingMutableReview($tenant, $fingerprint);
if ($existing instanceof TenantReview) {
return $existing->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
}
}
$operationRun = $this->operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::TenantReviewCompose->value,
identityInputs: [
'tenant_id' => (int) $tenant->getKey(),
'snapshot_id' => (int) $snapshot->getKey(),
'fingerprint' => $fingerprint,
],
context: [
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'review_fingerprint' => $fingerprint,
'review_id' => $existingReview?->getKey(),
],
initiator: $user,
);
$review ??= TenantReview::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'operation_run_id' => (int) $operationRun->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'fingerprint' => $fingerprint,
'status' => TenantReviewStatus::Draft->value,
'completeness_state' => (string) $snapshot->completeness_state,
'summary' => [
'evidence_basis' => [
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
],
'publish_blockers' => [],
'has_ready_export' => false,
'last_requested_at' => now()->toIso8601String(),
],
]);
if ($existingReview instanceof TenantReview) {
$existingReview->forceFill([
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'operation_run_id' => (int) $operationRun->getKey(),
'fingerprint' => $fingerprint,
'status' => TenantReviewStatus::Draft->value,
])->save();
$review = $existingReview->refresh();
}
if ($operationRun->wasRecentlyCreated) {
$this->operationRuns->dispatchOrFail($operationRun, function () use ($review, $operationRun): void {
ComposeTenantReviewJob::dispatch(
tenantReviewId: (int) $review->getKey(),
operationRunId: (int) $operationRun->getKey(),
);
});
}
$this->auditLogger->log(
workspace: $tenant->workspace,
action: $auditAction,
context: [
'metadata' => [
'review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'status' => (string) $review->status,
'fingerprint' => $fingerprint,
],
],
actor: $user,
resourceType: 'tenant_review',
resourceId: (string) $review->getKey(),
targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()),
operationRunId: (int) $operationRun->getKey(),
tenant: $tenant,
);
return $review->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
}
private function findExistingMutableReview(Tenant $tenant, string $fingerprint): ?TenantReview
{
return TenantReview::query()
->forTenant((int) $tenant->getKey())
->mutable()
->where('fingerprint', $fingerprint)
->latest('id')
->first();
}
}