TenantAtlas/app/Services/TenantReviews/TenantReviewReadinessGate.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

138 lines
4.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\TenantReviews;
use App\Models\TenantReview;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
use Illuminate\Support\Collection;
final class TenantReviewReadinessGate
{
/**
* @param iterable<array<string, mixed>> $sections
* @return list<string>
*/
public function blockersForSections(iterable $sections): array
{
$blockers = [];
foreach ($sections as $section) {
$required = (bool) ($section['required'] ?? false);
$state = (string) ($section['completeness_state'] ?? TenantReviewCompletenessState::Missing->value);
$title = (string) ($section['title'] ?? 'Review section');
if (! $required) {
continue;
}
if ($state === TenantReviewCompletenessState::Missing->value) {
$blockers[] = sprintf('%s is missing.', $title);
}
if ($state === TenantReviewCompletenessState::Stale->value) {
$blockers[] = sprintf('%s is stale and must be refreshed before publication.', $title);
}
}
return array_values(array_unique($blockers));
}
/**
* @param iterable<array<string, mixed>> $sections
*/
public function completenessForSections(iterable $sections): TenantReviewCompletenessState
{
$states = collect($sections)
->map(static fn (array $section): string => (string) ($section['completeness_state'] ?? TenantReviewCompletenessState::Missing->value))
->values();
if ($states->isEmpty()) {
return TenantReviewCompletenessState::Missing;
}
if ($states->contains(TenantReviewCompletenessState::Missing->value)) {
return TenantReviewCompletenessState::Missing;
}
if ($states->contains(TenantReviewCompletenessState::Stale->value)) {
return TenantReviewCompletenessState::Stale;
}
if ($states->contains(TenantReviewCompletenessState::Partial->value)) {
return TenantReviewCompletenessState::Partial;
}
return TenantReviewCompletenessState::Complete;
}
/**
* @param iterable<array<string, mixed>> $sections
*/
public function statusForSections(iterable $sections): TenantReviewStatus
{
return $this->blockersForSections($sections) === []
? TenantReviewStatus::Ready
: TenantReviewStatus::Draft;
}
/**
* @return list<string>
*/
public function blockersForReview(TenantReview $review): array
{
$sections = $review->relationLoaded('sections')
? $review->sections
: $review->sections()->get();
return $this->blockersForSections($sections->map(static function ($section): array {
return [
'title' => (string) $section->title,
'required' => (bool) $section->required,
'completeness_state' => (string) $section->completeness_state,
];
})->all());
}
public function canPublish(TenantReview $review): bool
{
if (! $review->isMutable()) {
return false;
}
return $this->blockersForReview($review) === [];
}
public function canExport(TenantReview $review): bool
{
if (! in_array($review->statusEnum(), [
TenantReviewStatus::Ready,
TenantReviewStatus::Published,
], true)) {
return false;
}
return $this->blockersForReview($review) === [];
}
/**
* @param iterable<array<string, mixed>> $sections
* @return array<string, int>
*/
public function sectionStateCounts(iterable $sections): array
{
$counts = collect($sections)
->groupBy(static fn (array $section): string => (string) ($section['completeness_state'] ?? TenantReviewCompletenessState::Missing->value))
->map(static fn (Collection $group): int => $group->count());
return [
'complete' => (int) ($counts[TenantReviewCompletenessState::Complete->value] ?? 0),
'partial' => (int) ($counts[TenantReviewCompletenessState::Partial->value] ?? 0),
'missing' => (int) ($counts[TenantReviewCompletenessState::Missing->value] ?? 0),
'stale' => (int) ($counts[TenantReviewCompletenessState::Stale->value] ?? 0),
];
}
}