TenantAtlas/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.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

497 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ReviewPackStatus;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class CustomerReviewWorkspace extends Page implements HasTable
{
use InteractsWithTable;
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
private const string SOURCE_SURFACE = 'customer_review_workspace';
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Customer reviews';
protected static ?int $navigationSort = 44;
protected static ?string $title = 'Customer Review Workspace';
protected static ?string $slug = 'reviews/workspace';
protected string $view = 'filament.pages.reviews.customer-review-workspace';
public static function tenantPrefilterUrl(Tenant $tenant): string
{
$tenantIdentifier = filled($tenant->external_id)
? (string) $tenant->external_id
: (string) $tenant->getKey();
return static::getUrl(panel: 'admin').'?'.http_build_query([
'tenant' => $tenantIdentifier,
]);
}
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public function mount(): void
{
$this->authorizePageAccess();
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
}
protected function getHeaderActions(): array
{
return [
Action::make('clear_filters')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(function (): void {
$this->clearWorkspaceFilters();
}),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->workspaceQuery())
->defaultSort('name')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->columns([
TextColumn::make('name')->label('Tenant')->searchable()->sortable(),
TextColumn::make('latest_review')
->label('Latest review')
->badge()
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
->wrap(),
TextColumn::make('finding_summary')
->label('Key findings')
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(),
TextColumn::make('accepted_risk_summary')
->label('Accepted risks')
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(),
TextColumn::make('published_at')
->label('Published')
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime()
->placeholder('—'),
TextColumn::make('review_pack_state')
->label('Review pack')
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->query(function (Builder $query, array $data): Builder {
$tenantId = $data['value'] ?? null;
return is_numeric($tenantId)
? $query->whereKey((int) $tenantId)
: $query;
})
->searchable(),
])
->actions([
Action::make('open_latest_review')
->label('Open latest review')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
Action::make('download_review_pack')
->label('Download review pack')
->icon('heroicon-o-arrow-down-tray')
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
->openUrlInNewTab()
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
])
->bulkActions([])
->emptyStateHeading('No entitled tenants match this view')
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.'
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.')
->emptyStateActions([
Action::make('clear_filters_empty')
->label('Clear filters')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(fn (): mixed => $this->clearWorkspaceFilters()),
]);
}
/**
* @return array<int, Tenant>
*/
public function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
throw new NotFoundHttpException;
}
if ($this->authorizedTenants() === []) {
throw new NotFoundHttpException;
}
}
private function workspaceQuery(): Builder
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return Tenant::query()->whereRaw('1 = 0');
}
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
private function defaultTenantFilter(): ?string
{
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
? (string) $tenantId
: null;
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant', request()->query('tenant_id'));
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function hasActiveFilters(): bool
{
return $this->currentTenantFilterId() !== null;
}
private function clearWorkspaceFilters(): void
{
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilters();
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return is_numeric($workspaceId)
? Workspace::query()->whereKey((int) $workspaceId)->first()
: null;
}
private function latestPublishedReview(Tenant $tenant): ?TenantReview
{
$review = $tenant->tenantReviews->first();
return $review instanceof TenantReview ? $review : null;
}
private function latestReviewUrl(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return null;
}
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([
self::DETAIL_CONTEXT_QUERY_KEY => 1,
]);
}
private function latestReviewPack(Tenant $tenant): ?ReviewPack
{
$review = $this->latestPublishedReview($tenant);
$pack = $review?->currentExportReviewPack;
return $pack instanceof ReviewPack ? $pack : null;
}
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
{
$user = auth()->user();
$pack = $this->latestReviewPack($tenant);
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
return null;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return null;
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return null;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => self::SOURCE_SURFACE,
]);
}
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
{
return $this->latestPublishedReview($tenant)?->published_at;
}
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
{
$review = $this->latestPublishedReview($tenant);
return $review instanceof TenantReview
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
: null;
}
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
{
$presenter = app(ArtifactTruthPresenter::class);
$review = $this->latestPublishedReview($tenant);
$truth = $this->reviewTruth($tenant);
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
return null;
}
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
}
private function latestReviewStateLabel(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review';
}
private function latestReviewStateColor(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
}
private function latestReviewStateIcon(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
}
private function latestReviewStateIconColor(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
}
private function reviewOutcomeDescription(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
$summary = is_array($review->summary) ? $review->summary : [];
$findingOutcomes = $summary['finding_outcomes'] ?? null;
if (! is_array($findingOutcomes)) {
return $primaryReason;
}
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingOutcomeSummary === null) {
return $primaryReason;
}
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
}
private function findingSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$summary = is_array($review->summary) ? $review->summary : [];
$findingCount = (int) ($summary['finding_count'] ?? 0);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingCount === 0) {
return 'No findings recorded in the published review.';
}
if ($terminalOutcomes === null) {
return sprintf('%d findings summarized in the published review.', $findingCount);
}
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes);
}
private function acceptedRiskSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return 'No published review available yet';
}
$summary = is_array($review->summary) ? $review->summary : [];
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
return match (true) {
$statusMarkedCount === 0 => 'No accepted risks recorded.',
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount),
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount),
default => sprintf('%d accepted risks are on record.', $statusMarkedCount),
};
}
private function reviewPackAvailability(Tenant $tenant): string
{
$pack = $this->latestReviewPack($tenant);
if (! $pack instanceof ReviewPack) {
return 'Unavailable';
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return 'Unavailable';
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return 'Unavailable';
}
return 'Available';
}
}