## 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
287 lines
9.8 KiB
PHP
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();
|
|
}
|
|
}
|