## 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
681 lines
29 KiB
PHP
681 lines
29 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Filament\Resources;
|
||
|
||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||
use App\Filament\Resources\FindingResource;
|
||
use App\Models\FindingException;
|
||
use App\Models\FindingExceptionEvidenceReference;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Models\Workspace;
|
||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||
use App\Services\Findings\FindingExceptionService;
|
||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||
use App\Support\Auth\Capabilities;
|
||
use App\Support\Badges\BadgeDomain;
|
||
use App\Support\Badges\BadgeRenderer;
|
||
use App\Support\Filament\FilterOptionCatalog;
|
||
use App\Support\Filament\TablePaginationProfiles;
|
||
use App\Support\Navigation\RelatedContextEntry;
|
||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||
use BackedEnum;
|
||
use Filament\Actions\Action;
|
||
use Filament\Facades\Filament;
|
||
use Filament\Forms\Components\DateTimePicker;
|
||
use Filament\Forms\Components\Repeater;
|
||
use Filament\Forms\Components\Select;
|
||
use Filament\Forms\Components\Textarea;
|
||
use Filament\Forms\Components\TextInput;
|
||
use Filament\Infolists\Components\RepeatableEntry;
|
||
use Filament\Infolists\Components\TextEntry;
|
||
use Filament\Infolists\Components\ViewEntry;
|
||
use Filament\Notifications\Notification;
|
||
use Filament\Resources\Resource;
|
||
use Filament\Schemas\Components\Section;
|
||
use Filament\Schemas\Schema;
|
||
use Filament\Tables;
|
||
use Filament\Tables\Filters\SelectFilter;
|
||
use Filament\Tables\Table;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use InvalidArgumentException;
|
||
use UnitEnum;
|
||
|
||
class FindingExceptionResource extends Resource
|
||
{
|
||
use InteractsWithTenantOwnedRecords;
|
||
use ResolvesPanelTenantContext;
|
||
|
||
protected static ?string $model = FindingException::class;
|
||
|
||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||
|
||
protected static bool $isGloballySearchable = false;
|
||
|
||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
||
|
||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||
|
||
protected static ?string $navigationLabel = 'Risk exceptions';
|
||
|
||
protected static ?int $navigationSort = 60;
|
||
|
||
public static function shouldRegisterNavigation(): bool
|
||
{
|
||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||
return false;
|
||
}
|
||
|
||
return parent::shouldRegisterNavigation();
|
||
}
|
||
|
||
public static function canViewAny(): bool
|
||
{
|
||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||
$user = auth()->user();
|
||
|
||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||
return false;
|
||
}
|
||
|
||
if (! $user->canAccessTenant($tenant)) {
|
||
return false;
|
||
}
|
||
|
||
return $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant);
|
||
}
|
||
|
||
public static function canView(Model $record): bool
|
||
{
|
||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||
$user = auth()->user();
|
||
|
||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||
return false;
|
||
}
|
||
|
||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
|
||
return false;
|
||
}
|
||
|
||
return ! $record instanceof FindingException
|
||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||
}
|
||
|
||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||
{
|
||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||
->satisfy(ActionSurfaceSlot::ListHeader, 'List header links back to findings where exception requests originate.')
|
||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes renewal and revocation only, while linked finding and approval-queue navigation move into contextual related context.');
|
||
}
|
||
|
||
public static function getEloquentQuery(): Builder
|
||
{
|
||
return static::getTenantOwnedEloquentQuery()
|
||
->with(static::relationshipsForView());
|
||
}
|
||
|
||
/**
|
||
* @return array{active: int, expiring: int, expired: int, pending: int, total: int}
|
||
*/
|
||
public static function exceptionStatsForCurrentTenant(): array
|
||
{
|
||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||
|
||
if (! $tenant instanceof Tenant) {
|
||
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
||
}
|
||
|
||
$counts = FindingException::query()
|
||
->where('tenant_id', (int) $tenant->getKey())
|
||
->where('workspace_id', (int) $tenant->workspace_id)
|
||
->selectRaw('count(*) as total')
|
||
->selectRaw("count(*) filter (where status = 'active') as active")
|
||
->selectRaw("count(*) filter (where status = 'expiring') as expiring")
|
||
->selectRaw("count(*) filter (where status = 'expired') as expired")
|
||
->selectRaw("count(*) filter (where status = 'pending') as pending")
|
||
->first();
|
||
|
||
return [
|
||
'active' => (int) ($counts?->active ?? 0),
|
||
'expiring' => (int) ($counts?->expiring ?? 0),
|
||
'expired' => (int) ($counts?->expired ?? 0),
|
||
'pending' => (int) ($counts?->pending ?? 0),
|
||
'total' => (int) ($counts?->total ?? 0),
|
||
];
|
||
}
|
||
|
||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||
{
|
||
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
||
}
|
||
|
||
public static function form(Schema $schema): Schema
|
||
{
|
||
return $schema;
|
||
}
|
||
|
||
public static function infolist(Schema $schema): Schema
|
||
{
|
||
return $schema->schema([
|
||
Section::make('Exception')
|
||
->schema([
|
||
TextEntry::make('status')
|
||
->badge()
|
||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
||
TextEntry::make('current_validity_state')
|
||
->label('Validity')
|
||
->badge()
|
||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||
TextEntry::make('governance_warning')
|
||
->label('Governance warning')
|
||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
||
->columnSpanFull()
|
||
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
|
||
TextEntry::make('tenant.name')->label('Tenant'),
|
||
TextEntry::make('finding_summary')
|
||
->label('Finding')
|
||
->state(fn (FindingException $record): string => static::findingSummary($record)),
|
||
TextEntry::make('requester.name')->label('Requested by')->placeholder('—'),
|
||
TextEntry::make('owner.name')->label('Owner')->placeholder('—'),
|
||
TextEntry::make('approver.name')->label('Approved by')->placeholder('—'),
|
||
TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'),
|
||
TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'),
|
||
TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'),
|
||
TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'),
|
||
TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'),
|
||
TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(),
|
||
TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(),
|
||
TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(),
|
||
])
|
||
->columns(2),
|
||
Section::make('Decision history')
|
||
->schema([
|
||
RepeatableEntry::make('decisions')
|
||
->hiddenLabel()
|
||
->schema([
|
||
TextEntry::make('decision_type')->label('Decision'),
|
||
TextEntry::make('actor.name')->label('Actor')->placeholder('—'),
|
||
TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'),
|
||
TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(),
|
||
])
|
||
->columns(3),
|
||
]),
|
||
Section::make('Related context')
|
||
->schema([
|
||
ViewEntry::make('related_context')
|
||
->label('')
|
||
->view('filament.infolists.entries.related-context')
|
||
->state(fn (FindingException $record): array => static::relatedContextEntries($record))
|
||
->columnSpanFull(),
|
||
])
|
||
->columnSpanFull(),
|
||
Section::make('Evidence references')
|
||
->schema([
|
||
RepeatableEntry::make('evidenceReferences')
|
||
->hiddenLabel()
|
||
->schema([
|
||
TextEntry::make('label')->label('Label'),
|
||
TextEntry::make('source_type')->label('Source'),
|
||
TextEntry::make('source_id')->label('Source ID')->placeholder('—'),
|
||
TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'),
|
||
TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'),
|
||
TextEntry::make('summary_payload')
|
||
->label('Summary')
|
||
->state(function (FindingExceptionEvidenceReference $record): ?string {
|
||
if ($record->summary_payload === [] || $record->summary_payload === null) {
|
||
return null;
|
||
}
|
||
|
||
return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null;
|
||
})
|
||
->placeholder('—')
|
||
->columnSpanFull(),
|
||
])
|
||
->columns(2),
|
||
])
|
||
->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
public static function relatedContextEntries(FindingException $record): array
|
||
{
|
||
$entries = [];
|
||
|
||
if ($record->finding && $record->tenant instanceof Tenant) {
|
||
$entries[] = RelatedContextEntry::available(
|
||
key: 'finding',
|
||
label: 'Finding',
|
||
value: static::findingSummary($record),
|
||
secondaryValue: 'Return to the linked finding detail.',
|
||
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||
targetKind: 'direct_record',
|
||
priority: 10,
|
||
actionLabel: 'Open finding',
|
||
contextBadge: 'Governance',
|
||
)->toArray();
|
||
}
|
||
|
||
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
|
||
$entries[] = RelatedContextEntry::available(
|
||
key: 'approval_queue',
|
||
label: 'Approval queue',
|
||
value: 'Review pending exception requests',
|
||
secondaryValue: 'Return to the queue for the rest of this tenant’s governance workload.',
|
||
targetUrl: static::approvalQueueUrl($record->tenant),
|
||
targetKind: 'canonical_page',
|
||
priority: 20,
|
||
actionLabel: 'Open approval queue',
|
||
contextBadge: 'Queue',
|
||
)->toArray();
|
||
}
|
||
|
||
return $entries;
|
||
}
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->defaultSort('requested_at', 'desc')
|
||
->paginated(TablePaginationProfiles::resource())
|
||
->persistFiltersInSession()
|
||
->persistSearchInSession()
|
||
->persistSortInSession()
|
||
->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record]))
|
||
->columns([
|
||
Tables\Columns\TextColumn::make('status')
|
||
->badge()
|
||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
||
->sortable(),
|
||
Tables\Columns\TextColumn::make('current_validity_state')
|
||
->label('Validity')
|
||
->badge()
|
||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||
->sortable(),
|
||
Tables\Columns\TextColumn::make('finding.severity')
|
||
->label('Severity')
|
||
->badge()
|
||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity))
|
||
->placeholder('—')
|
||
->sortable(),
|
||
Tables\Columns\TextColumn::make('finding_summary')
|
||
->label('Finding')
|
||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
||
->searchable()
|
||
->wrap()
|
||
->limit(60),
|
||
Tables\Columns\TextColumn::make('governance_warning')
|
||
->label('Governance warning')
|
||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
||
->wrap(),
|
||
Tables\Columns\TextColumn::make('requester.name')
|
||
->label('Requested by')
|
||
->placeholder('—'),
|
||
Tables\Columns\TextColumn::make('owner.name')
|
||
->label('Owner')
|
||
->placeholder('—'),
|
||
Tables\Columns\TextColumn::make('review_due_at')
|
||
->label('Review due')
|
||
->dateTime()
|
||
->placeholder('—')
|
||
->sortable()
|
||
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->review_due_at)),
|
||
Tables\Columns\TextColumn::make('expires_at')
|
||
->label('Expires')
|
||
->dateTime()
|
||
->placeholder('—')
|
||
->sortable()
|
||
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->expires_at)),
|
||
Tables\Columns\TextColumn::make('requested_at')
|
||
->label('Requested')
|
||
->dateTime()
|
||
->sortable(),
|
||
])
|
||
->filters([
|
||
SelectFilter::make('status')
|
||
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
||
SelectFilter::make('current_validity_state')
|
||
->label('Validity')
|
||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||
SelectFilter::make('finding_severity')
|
||
->label('Finding severity')
|
||
->options(FilterOptionCatalog::findingSeverities())
|
||
->query(fn (Builder $query, array $data): Builder => filled($data['value'])
|
||
? $query->whereHas('finding', fn (Builder $q) => $q->where('severity', $data['value']))
|
||
: $query),
|
||
])
|
||
->actions([
|
||
Action::make('renew_exception')
|
||
->label('Renew exception')
|
||
->icon('heroicon-o-arrow-path')
|
||
->color('warning')
|
||
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed())
|
||
->requiresConfirmation()
|
||
->form([
|
||
Select::make('owner_user_id')
|
||
->label('Owner')
|
||
->required()
|
||
->options(fn (): array => static::tenantMemberOptions())
|
||
->searchable(),
|
||
Textarea::make('request_reason')
|
||
->label('Renewal reason')
|
||
->rows(4)
|
||
->required()
|
||
->maxLength(2000),
|
||
DateTimePicker::make('review_due_at')
|
||
->label('Review due at')
|
||
->required()
|
||
->seconds(false),
|
||
DateTimePicker::make('expires_at')
|
||
->label('Requested expiry')
|
||
->seconds(false),
|
||
Repeater::make('evidence_references')
|
||
->label('Evidence references')
|
||
->schema([
|
||
TextInput::make('label')
|
||
->label('Label')
|
||
->required()
|
||
->maxLength(255),
|
||
TextInput::make('source_type')
|
||
->label('Source type')
|
||
->required()
|
||
->maxLength(255),
|
||
TextInput::make('source_id')
|
||
->label('Source ID')
|
||
->maxLength(255),
|
||
TextInput::make('source_fingerprint')
|
||
->label('Fingerprint')
|
||
->maxLength(255),
|
||
DateTimePicker::make('measured_at')
|
||
->label('Measured at')
|
||
->seconds(false),
|
||
])
|
||
->defaultItems(0)
|
||
->collapsed(),
|
||
])
|
||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
||
$user = auth()->user();
|
||
|
||
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
|
||
abort(404);
|
||
}
|
||
|
||
try {
|
||
$service->renew($record, $user, $data);
|
||
} catch (InvalidArgumentException $exception) {
|
||
Notification::make()
|
||
->title('Renewal request failed')
|
||
->body($exception->getMessage())
|
||
->danger()
|
||
->send();
|
||
|
||
return;
|
||
}
|
||
|
||
Notification::make()
|
||
->title('Renewal request submitted')
|
||
->success()
|
||
->send();
|
||
}),
|
||
Action::make('revoke_exception')
|
||
->label('Revoke exception')
|
||
->icon('heroicon-o-no-symbol')
|
||
->color('danger')
|
||
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked())
|
||
->requiresConfirmation()
|
||
->form([
|
||
Textarea::make('revocation_reason')
|
||
->label('Revocation reason')
|
||
->rows(4)
|
||
->required()
|
||
->maxLength(2000),
|
||
])
|
||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
||
$user = auth()->user();
|
||
|
||
if (! $user instanceof User) {
|
||
abort(404);
|
||
}
|
||
|
||
try {
|
||
$service->revoke($record, $user, $data);
|
||
} catch (InvalidArgumentException $exception) {
|
||
Notification::make()
|
||
->title('Exception revocation failed')
|
||
->body($exception->getMessage())
|
||
->danger()
|
||
->send();
|
||
|
||
return;
|
||
}
|
||
|
||
Notification::make()
|
||
->title('Exception revoked')
|
||
->success()
|
||
->send();
|
||
}),
|
||
])
|
||
->bulkActions([])
|
||
->emptyStateHeading('No exceptions match this view')
|
||
->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.')
|
||
->emptyStateIcon('heroicon-o-shield-exclamation')
|
||
->emptyStateActions([
|
||
Action::make('open_findings')
|
||
->label('Open findings')
|
||
->icon('heroicon-o-arrow-top-right-on-square')
|
||
->color('gray')
|
||
->url(fn (): string => FindingResource::getUrl('index')),
|
||
]);
|
||
}
|
||
|
||
public static function getPages(): array
|
||
{
|
||
return [
|
||
'index' => Pages\ListFindingExceptions::route('/'),
|
||
'view' => Pages\ViewFindingException::route('/{record}'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string|array<int|string, mixed>>
|
||
*/
|
||
private static function relationshipsForView(): array
|
||
{
|
||
return [
|
||
'tenant',
|
||
'requester',
|
||
'owner',
|
||
'approver',
|
||
'currentDecision',
|
||
'decisions.actor',
|
||
'evidenceReferences',
|
||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string>
|
||
*/
|
||
private static function tenantMemberOptions(): array
|
||
{
|
||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||
|
||
if (! $tenant instanceof Tenant) {
|
||
return [];
|
||
}
|
||
|
||
return \App\Models\TenantMembership::query()
|
||
->where('tenant_id', (int) $tenant->getKey())
|
||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
||
->orderBy('users.name')
|
||
->pluck('users.name', 'users.id')
|
||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||
->all();
|
||
}
|
||
|
||
private static function findingSummary(FindingException $record): string
|
||
{
|
||
$finding = $record->finding;
|
||
|
||
if (! $finding instanceof \App\Models\Finding) {
|
||
return 'Finding #'.$record->finding_id;
|
||
}
|
||
|
||
$displayName = $finding->resolvedSubjectDisplayName();
|
||
$findingType = $finding->finding_type;
|
||
|
||
$parts = [];
|
||
|
||
if (is_string($displayName) && trim($displayName) !== '') {
|
||
$parts[] = trim($displayName);
|
||
}
|
||
|
||
if (is_string($findingType) && trim($findingType) !== '') {
|
||
$label = str_replace('_', ' ', trim($findingType));
|
||
$parts[] = '('.ucfirst($label).')';
|
||
}
|
||
|
||
if ($parts !== []) {
|
||
return implode(' ', $parts);
|
||
}
|
||
|
||
return 'Finding #'.$record->finding_id;
|
||
}
|
||
|
||
public static function relativeTimeDescription(mixed $date): ?string
|
||
{
|
||
if (! $date instanceof \DateTimeInterface) {
|
||
return null;
|
||
}
|
||
|
||
$carbon = \Illuminate\Support\Carbon::instance($date);
|
||
|
||
if ($carbon->isToday()) {
|
||
return 'Today';
|
||
}
|
||
|
||
if ($carbon->isPast()) {
|
||
return $carbon->diffForHumans();
|
||
}
|
||
|
||
if ($carbon->isTomorrow()) {
|
||
return 'Tomorrow';
|
||
}
|
||
|
||
$daysUntil = (int) now()->startOfDay()->diffInDays($carbon->startOfDay());
|
||
|
||
if ($daysUntil <= 14) {
|
||
return 'In '.$daysUntil.' days';
|
||
}
|
||
|
||
return $carbon->diffForHumans();
|
||
}
|
||
|
||
private static function canManageRecord(FindingException $record): bool
|
||
{
|
||
$user = auth()->user();
|
||
|
||
return $user instanceof User
|
||
&& $record->tenant instanceof Tenant
|
||
&& $user->canAccessTenant($record->tenant)
|
||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||
}
|
||
|
||
private static function governanceWarning(FindingException $record): ?string
|
||
{
|
||
$finding = $record->relationLoaded('finding')
|
||
? $record->finding
|
||
: $record->finding()->withSubjectDisplayName()->first();
|
||
|
||
if (! $finding instanceof \App\Models\Finding) {
|
||
return null;
|
||
}
|
||
|
||
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
|
||
}
|
||
|
||
private static function governanceWarningColor(FindingException $record): string
|
||
{
|
||
$finding = $record->relationLoaded('finding')
|
||
? $record->finding
|
||
: $record->finding()->withSubjectDisplayName()->first();
|
||
|
||
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||
return 'warning';
|
||
}
|
||
|
||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||
return 'warning';
|
||
}
|
||
|
||
return 'danger';
|
||
}
|
||
|
||
public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null): bool
|
||
{
|
||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||
$user = auth()->user();
|
||
|
||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||
return false;
|
||
}
|
||
|
||
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||
|
||
if (! $workspace instanceof Workspace) {
|
||
return false;
|
||
}
|
||
|
||
/** @var WorkspaceCapabilityResolver $resolver */
|
||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||
|
||
return $resolver->isMember($user, $workspace)
|
||
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||
}
|
||
|
||
public static function approvalQueueUrl(?Tenant $tenant = null): ?string
|
||
{
|
||
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||
|
||
if (! $tenant instanceof Tenant) {
|
||
return null;
|
||
}
|
||
|
||
return route('admin.finding-exceptions.open-queue', [
|
||
'tenant' => (string) $tenant->external_id,
|
||
]);
|
||
}
|
||
}
|