TenantAtlas/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
ahmido 9f6985291e feat: implement spec 192 record page header discipline (#226)
## Summary
- implement Spec 192 across the targeted Filament record, detail, and edit pages with explicit action-surface inventory and guard coverage
- add the focused Spec 192 browser smoke, feature tests, and spec artifacts under `specs/192-record-header-discipline`
- improve unhandled promise rejection diagnostics by correlating 419s to the underlying Livewire request URL
- disable panel-wide database notification polling on the admin, tenant, and system panels and cover the mitigation with focused tests

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php tests/Feature/Filament/FilamentNotificationsAssetsTest.php tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php tests/Feature/Filament/AdminSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- manual integrated-browser verification of the Spec 192 surfaces and the notification-polling mitigation

## Notes
- Livewire v4 / Filament v5 compliance remains unchanged.
- Provider registration stays in `bootstrap/providers.php`.
- No Global Search behavior was expanded.
- No destructive action confirmation semantics were relaxed.
- The full test suite was not run in this PR.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #226
2026-04-11 21:20:41 +00:00

287 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewTenantReview extends ViewRecord
{
protected static string $resource = TenantReviewResource::class;
protected function resolveRecord(int|string $key): Model
{
return TenantReviewResource::resolveScopedRecordOrFail($key);
}
protected function authorizeAccess(): void
{
$tenant = TenantReviewResource::panelTenantContext();
$record = $this->getRecord();
$user = auth()->user();
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
abort(404);
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
abort(404);
}
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! $user->can('view', $record)) {
abort(403);
}
}
protected function getHeaderActions(): array
{
$secondaryActions = $this->secondaryLifecycleActions();
return array_values(array_filter([
$this->primaryLifecycleAction(),
Actions\ActionGroup::make($secondaryActions)
->label('More')
->icon('heroicon-m-ellipsis-vertical')
->color('gray')
->visible(fn (): bool => $secondaryActions !== []),
Actions\ActionGroup::make([
$this->archiveReviewAction(),
])
->label('Danger')
->icon('heroicon-o-archive-box')
->color('danger')
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
]));
}
private function primaryLifecycleAction(): ?Actions\Action
{
return match ($this->primaryLifecycleActionName()) {
'refresh_review' => $this->refreshReviewAction(),
'publish_review' => $this->publishReviewAction(),
'export_executive_pack' => $this->exportExecutivePackAction(),
default => null,
};
}
private function primaryLifecycleActionName(): ?string
{
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
return 'export_executive_pack';
}
if ((string) $this->record->status === TenantReviewStatus::Ready->value) {
return 'publish_review';
}
if ($this->record->isMutable()) {
return 'refresh_review';
}
return null;
}
/**
* @return list<Actions\Action>
*/
private function secondaryLifecycleActions(): array
{
return array_values(array_filter(array_map(
fn (string $name): ?Actions\Action => match ($name) {
'refresh_review' => $this->refreshReviewAction(),
'publish_review' => $this->publishReviewAction(),
'export_executive_pack' => $this->exportExecutivePackAction(),
'create_next_review' => $this->createNextReviewAction(),
default => null,
},
$this->secondaryLifecycleActionNames(),
)));
}
/**
* @return array<int, string>
*/
private function secondaryLifecycleActionNames(): array
{
$names = [];
if ($this->record->isMutable()) {
$names[] = 'refresh_review';
$names[] = 'publish_review';
}
if (in_array((string) $this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true)) {
$names[] = 'export_executive_pack';
}
if ($this->record->isPublished()) {
$names[] = 'create_next_review';
}
return array_values(array_filter(
$names,
fn (string $name): bool => $name !== $this->primaryLifecycleActionName(),
));
}
private function refreshReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('refresh_review')
->label('Refresh review')
->icon('heroicon-o-arrow-path')
->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewService::class)->refresh($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
return;
}
Notification::make()->success()->title('Refresh review queued')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function publishReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('publish_review')
->label('Publish review')
->icon('heroicon-o-check-badge')
->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
return;
}
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
Notification::make()->success()->title('Review published')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function exportExecutivePackAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->color('primary')
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function createNextReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('create_next_review')
->label('Create next review')
->icon('heroicon-o-document-duplicate')
->hidden(fn (): bool => ! $this->record->isPublished())
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
return;
}
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function archiveReviewAction(): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make('archive_review')
->label('Archive review')
->icon('heroicon-o-archive-box')
->color('danger')
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
->requiresConfirmation()
->action(function (): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
$this->refreshFormData(['status', 'archived_at']);
Notification::make()->success()->title('Review archived')->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
}