Some checks failed
Main Confidence / confidence (push) Failing after 54s
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
497 lines
18 KiB
PHP
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';
|
|
}
|
|
} |