TenantAtlas/apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
ahmido aacd82849a
Some checks failed
Main Confidence / confidence (push) Failing after 54s
feat(reviews): add CustomerReviewWorkspace with audit logging and RBAC enforcement (#289)
Add `CustomerReviewWorkspace` page for tenant pre-filtered reviews
Add customer workspace links to `EvidenceSnapshotResource`, `ReviewPackResource`, and `TenantReviewResource`
Implement audit logging for `TenantReviewOpened` and `ReviewPackDownloaded` actions
Update ReviewPack download controller to enforce tenant-scoped RBAC
Add tests for ReviewPack download authorization and audit logging

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #289
2026-04-28 07:15:41 +00:00

381 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
use Filament\Actions;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewTenantReview extends ViewRecord
{
protected static string $resource = TenantReviewResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$this->auditCustomerWorkspaceOpen();
}
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->isCustomerWorkspaceView() && ! $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 ($this->isCustomerWorkspaceView()) {
return null;
}
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
{
if ($this->isCustomerWorkspaceView()) {
return [];
}
$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
{
$rule = GovernanceActionCatalog::rule('refresh_review');
return UiEnforcement::forAction(
Actions\Action::make('refresh_review')
->label($rule->canonicalLabel)
->icon('heroicon-o-arrow-path')
->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->action(function () use ($rule): 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($rule->successTitle)->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->apply();
}
private function publishReviewAction(): Actions\Action
{
$rule = GovernanceActionCatalog::rule('publish_review');
return UiEnforcement::forAction(
Actions\Action::make('publish_review')
->label($rule->canonicalLabel)
->icon('heroicon-o-check-badge')
->color('primary')
->hidden(fn (): bool => ! $this->record->isMutable())
->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([
Textarea::make('publish_reason')
->label('Publication reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (array $data) use ($rule): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
try {
app(TenantReviewLifecycleService::class)->publish(
$this->record,
$user,
(string) ($data['publish_reason'] ?? ''),
);
} 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($rule->successTitle)->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function exportExecutivePackAction(): Actions\Action
{
$action = 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))
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
return $action;
}
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
{
$rule = GovernanceActionCatalog::rule('archive_review');
return UiEnforcement::forAction(
Actions\Action::make('archive_review')
->label($rule->canonicalLabel)
->icon('heroicon-o-archive-box')
->color('danger')
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
->requiresConfirmation()
->modalHeading($rule->modalHeading)
->modalDescription($rule->modalDescription)
->form([
Textarea::make('archive_reason')
->label('Archive reason')
->rows(4)
->required()
->maxLength(2000),
])
->action(function (array $data) use ($rule): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
app(TenantReviewLifecycleService::class)->archive(
$this->record,
$user,
(string) ($data['archive_reason'] ?? ''),
);
$this->refreshFormData(['status', 'archived_at']);
Notification::make()->success()->title($rule->successTitle)->send();
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
private function isCustomerWorkspaceView(): bool
{
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
}
private function auditCustomerWorkspaceOpen(): void
{
if (! $this->isCustomerWorkspaceView()) {
return;
}
$user = auth()->user();
$tenant = $this->record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::TenantReviewOpened,
context: [
'metadata' => [
'review_id' => (int) $this->record->getKey(),
'source_surface' => 'customer_review_workspace',
],
],
actor: $user,
resourceType: 'tenant_review',
resourceId: (string) $this->record->getKey(),
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
tenant: $tenant,
);
}
}