merge: resolve copilot instructions conflict with dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 48s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 48s
This commit is contained in:
commit
a6836a0f3f
6
.github/agents/copilot-instructions.md
vendored
6
.github/agents/copilot-instructions.md
vendored
@ -228,8 +228,12 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
|
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` (221-findings-operator-inbox)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` (221-findings-operator-inbox)
|
||||||
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
|
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
|
||||||
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement` (222-findings-intake-team-queue)
|
||||||
|
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, `audit_logs`, and workspace session context; no schema changes planned (222-findings-intake-team-queue)
|
||||||
- TypeScript 5.9, Astro 6, Node.js 20+ + Astro, astro-icon, Tailwind CSS v4, Playwright 1.59 (223-astrodeck-website-rebuild)
|
- TypeScript 5.9, Astro 6, Node.js 20+ + Astro, astro-icon, Tailwind CSS v4, Playwright 1.59 (223-astrodeck-website-rebuild)
|
||||||
- File-based route files, Astro content collections under `src/content`, public assets, and planning documents under `specs/223-astrodeck-website-rebuild`; no database (223-astrodeck-website-rebuild)
|
- File-based route files, Astro content collections under `src/content`, public assets, and planning documents under `specs/223-astrodeck-website-rebuild`; no database (223-astrodeck-website-rebuild)
|
||||||
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource` (224-findings-notifications-escalation)
|
||||||
|
- PostgreSQL via existing `findings`, `alert_rules`, `alert_deliveries`, `notifications`, `tenant_memberships`, and `audit_logs`; no schema changes planned (224-findings-notifications-escalation)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -265,6 +269,8 @@ ## Code Style
|
|||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 223-astrodeck-website-rebuild: Added TypeScript 5.9, Astro 6, Node.js 20+ + Astro, astro-icon, Tailwind CSS v4, Playwright 1.59
|
- 223-astrodeck-website-rebuild: Added TypeScript 5.9, Astro 6, Node.js 20+ + Astro, astro-icon, Tailwind CSS v4, Playwright 1.59
|
||||||
|
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
|
||||||
|
- 222-findings-intake-team-queue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
|
||||||
- 221-findings-operator-inbox: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext`
|
- 221-findings-operator-inbox: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext`
|
||||||
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
|
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@ -0,0 +1,775 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Findings;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
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 App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
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\ConflictHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class FindingsIntakeQueue extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Findings intake';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'findings/intake';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.findings.findings-intake-queue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $visibleTenants = null;
|
||||||
|
|
||||||
|
private ?Workspace $workspace = null;
|
||||||
|
|
||||||
|
public string $queueView = 'unassigned';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
|
->withListRowPrimaryActionLimit(1)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the shared-unassigned scope fixed and expose only a tenant-prefilter clear action when needed.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The intake queue keeps Claim finding inline and does not render a secondary More menu on rows.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The intake queue does not expose bulk actions in v1.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state stays calm and offers exactly one recovery CTA per branch.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page while Claim finding remains the only inline safe shortcut.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->queueView = $this->resolveRequestedQueueView();
|
||||||
|
$this->authorizePageAccess();
|
||||||
|
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
[],
|
||||||
|
request(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->applyRequestedTenantPrefilter();
|
||||||
|
$this->mountInteractsWithTable();
|
||||||
|
$this->normalizeTenantFilterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('clear_tenant_filter')
|
||||||
|
->label('Clear tenant filter')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||||
|
->action(fn (): mixed => $this->clearTenantFilter()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(fn (): Builder => $this->queueViewQuery())
|
||||||
|
->paginated(TablePaginationProfiles::customPage())
|
||||||
|
->persistFiltersInSession()
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('tenant.name')
|
||||||
|
->label('Tenant'),
|
||||||
|
TextColumn::make('subject_display_name')
|
||||||
|
->label('Finding')
|
||||||
|
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
||||||
|
->description(fn (Finding $record): ?string => $this->ownerContext($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
||||||
|
->description(fn (Finding $record): ?string => $this->reopenedCue($record)),
|
||||||
|
TextColumn::make('due_at')
|
||||||
|
->label('Due')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—')
|
||||||
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
|
||||||
|
TextColumn::make('intake_reason')
|
||||||
|
->label('Queue reason')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Finding $record): string => $this->queueReason($record))
|
||||||
|
->color(fn (Finding $record): string => $this->queueReasonColor($record)),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
$this->claimAction(),
|
||||||
|
])
|
||||||
|
->bulkActions([])
|
||||||
|
->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record))
|
||||||
|
->emptyStateHeading(fn (): string => $this->emptyState()['title'])
|
||||||
|
->emptyStateDescription(fn (): string => $this->emptyState()['body'])
|
||||||
|
->emptyStateIcon(fn (): string => $this->emptyState()['icon'])
|
||||||
|
->emptyStateActions($this->emptyStateActions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function appliedScope(): array
|
||||||
|
{
|
||||||
|
$tenant = $this->filteredTenant();
|
||||||
|
$queueView = $this->currentQueueView();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_scoped' => true,
|
||||||
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
||||||
|
'queue_view' => $queueView,
|
||||||
|
'queue_view_label' => $this->queueViewLabel($queueView),
|
||||||
|
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
|
||||||
|
'tenant_label' => $tenant?->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function queueViews(): array
|
||||||
|
{
|
||||||
|
$queueView = $this->currentQueueView();
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'key' => 'unassigned',
|
||||||
|
'label' => 'Unassigned',
|
||||||
|
'fixed' => true,
|
||||||
|
'active' => $queueView === 'unassigned',
|
||||||
|
'badge_count' => (clone $this->filteredQueueQuery(queueView: 'unassigned', applyOrdering: false))->count(),
|
||||||
|
'url' => $this->queueUrl(['view' => null]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'needs_triage',
|
||||||
|
'label' => 'Needs triage',
|
||||||
|
'fixed' => true,
|
||||||
|
'active' => $queueView === 'needs_triage',
|
||||||
|
'badge_count' => (clone $this->filteredQueueQuery(queueView: 'needs_triage', applyOrdering: false))->count(),
|
||||||
|
'url' => $this->queueUrl(['view' => 'needs_triage']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{visible_unassigned: int, visible_needs_triage: int, visible_overdue: int}
|
||||||
|
*/
|
||||||
|
public function summaryCounts(): array
|
||||||
|
{
|
||||||
|
$visibleQuery = $this->filteredQueueQuery(queueView: 'unassigned', applyOrdering: false);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'visible_unassigned' => (clone $visibleQuery)->count(),
|
||||||
|
'visible_needs_triage' => (clone $this->filteredQueueQuery(queueView: 'needs_triage', applyOrdering: false))->count(),
|
||||||
|
'visible_overdue' => (clone $visibleQuery)
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function emptyState(): array
|
||||||
|
{
|
||||||
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
|
return [
|
||||||
|
'title' => 'No intake findings match this tenant scope',
|
||||||
|
'body' => 'Your current tenant filter is hiding shared intake work that is still visible elsewhere in this workspace.',
|
||||||
|
'icon' => 'heroicon-o-funnel',
|
||||||
|
'action_name' => 'clear_tenant_filter_empty',
|
||||||
|
'action_label' => 'Clear tenant filter',
|
||||||
|
'action_kind' => 'clear_tenant_filter',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'Shared intake is clear',
|
||||||
|
'body' => 'No visible unassigned findings currently need first routing across your entitled tenants. Open your personal queue if you want to continue with claimed work.',
|
||||||
|
'icon' => 'heroicon-o-inbox-stack',
|
||||||
|
'action_name' => 'open_my_findings_empty',
|
||||||
|
'action_label' => 'Open my findings',
|
||||||
|
'action_kind' => 'url',
|
||||||
|
'action_url' => MyFindingsInbox::getUrl(panel: 'admin'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedTableFilters(): void
|
||||||
|
{
|
||||||
|
$this->normalizeTenantFilterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearTenantFilter(): void
|
||||||
|
{
|
||||||
|
$this->removeTableFilter('tenant_id');
|
||||||
|
$this->resetTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
public function visibleTenants(): array
|
||||||
|
{
|
||||||
|
if ($this->visibleTenants !== null) {
|
||||||
|
return $this->visibleTenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenants = $this->authorizedTenants();
|
||||||
|
|
||||||
|
if (! $user instanceof User || $tenants === []) {
|
||||||
|
return $this->visibleTenants = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
$resolver->primeMemberships(
|
||||||
|
$user,
|
||||||
|
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->visibleTenants = array_values(array_filter(
|
||||||
|
$tenants,
|
||||||
|
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function claimAction(): Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forTableAction(
|
||||||
|
Action::make('claim')
|
||||||
|
->label('Claim finding')
|
||||||
|
->icon('heroicon-o-user-plus')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (Finding $record): bool => $record->assignee_user_id === null && in_array((string) $record->status, Finding::openStatuses(), true))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Claim finding')
|
||||||
|
->modalDescription(function (?Finding $record = null): string {
|
||||||
|
$findingLabel = $record?->resolvedSubjectDisplayName() ?? ($record instanceof Finding ? 'Finding #'.$record->getKey() : 'this finding');
|
||||||
|
$tenantLabel = $record?->tenant?->name ?? 'this tenant';
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Claim "%s" in %s? It will move into your personal queue, while the accountable owner and lifecycle state stay unchanged.',
|
||||||
|
$findingLabel,
|
||||||
|
$tenantLabel,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->modalSubmitActionLabel('Claim finding')
|
||||||
|
->action(function (Finding $record): void {
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$claimedFinding = app(FindingWorkflowService::class)->claim($record, $tenant, $user);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Finding claimed')
|
||||||
|
->body('The finding left shared intake and is now assigned to you.')
|
||||||
|
->actions([
|
||||||
|
Action::make('open_my_findings')
|
||||||
|
->label('Open my findings')
|
||||||
|
->url(MyFindingsInbox::getUrl(panel: 'admin')),
|
||||||
|
Action::make('open_finding')
|
||||||
|
->label('Open finding')
|
||||||
|
->url($this->findingDetailUrl($claimedFinding)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
} catch (ConflictHttpException) {
|
||||||
|
Notification::make()
|
||||||
|
->warning()
|
||||||
|
->title('Finding already claimed')
|
||||||
|
->body('Another operator claimed this finding first. The intake queue has been refreshed.')
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
if (method_exists($this, 'unmountAction')) {
|
||||||
|
$this->unmountAction();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
fn () => null,
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePageAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->visibleTenants() === []) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Tenant>
|
||||||
|
*/
|
||||||
|
private 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 = $user->tenants()
|
||||||
|
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('tenants.status', 'active')
|
||||||
|
->orderBy('tenants.name')
|
||||||
|
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspace(): ?Workspace
|
||||||
|
{
|
||||||
|
if ($this->workspace instanceof Workspace) {
|
||||||
|
return $this->workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueBaseQuery(): Builder
|
||||||
|
{
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
$tenantIds = array_map(
|
||||||
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||||
|
$this->visibleTenants(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return Finding::query()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Finding::query()
|
||||||
|
->with(['tenant', 'ownerUser', 'assigneeUser'])
|
||||||
|
->withSubjectDisplayName()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||||
|
->whereNull('assignee_user_id')
|
||||||
|
->whereIn('status', Finding::openStatuses());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueViewQuery(): Builder
|
||||||
|
{
|
||||||
|
return $this->filteredQueueQuery(includeTenantFilter: false, queueView: $this->currentQueueView(), applyOrdering: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filteredQueueQuery(
|
||||||
|
bool $includeTenantFilter = true,
|
||||||
|
?string $queueView = null,
|
||||||
|
bool $applyOrdering = true,
|
||||||
|
): Builder {
|
||||||
|
$query = $this->queueBaseQuery();
|
||||||
|
$resolvedQueueView = $queueView ?? $this->queueView;
|
||||||
|
|
||||||
|
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterId()) !== null) {
|
||||||
|
$query->where('tenant_id', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolvedQueueView === 'needs_triage') {
|
||||||
|
$query->whereIn('status', [
|
||||||
|
Finding::STATUS_NEW,
|
||||||
|
Finding::STATUS_REOPENED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $applyOrdering) {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->orderByRaw(
|
||||||
|
"case
|
||||||
|
when due_at is not null and due_at < ? then 0
|
||||||
|
when status = ? then 1
|
||||||
|
when status = ? then 2
|
||||||
|
else 3
|
||||||
|
end asc",
|
||||||
|
[now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW],
|
||||||
|
)
|
||||||
|
->orderByRaw('case when due_at is null then 1 else 0 end asc')
|
||||||
|
->orderBy('due_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function tenantFilterOptions(): array
|
||||||
|
{
|
||||||
|
return collect($this->visibleTenants())
|
||||||
|
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => (string) $tenant->name,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyRequestedTenantPrefilter(): void
|
||||||
|
{
|
||||||
|
$requestedTenant = request()->query('tenant');
|
||||||
|
|
||||||
|
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->visibleTenants() 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeTenantFilterState(): void
|
||||||
|
{
|
||||||
|
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
|
||||||
|
|
||||||
|
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->currentTenantFilterId() !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeTableFilter('tenant_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function currentQueueFiltersState(): array
|
||||||
|
{
|
||||||
|
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
|
||||||
|
|
||||||
|
return array_replace_recursive(
|
||||||
|
is_array($persisted) ? $persisted : [],
|
||||||
|
$this->tableFilters ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentTenantFilterId(): ?int
|
||||||
|
{
|
||||||
|
$tenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
|
||||||
|
|
||||||
|
if (! is_numeric($tenantFilter)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int) $tenantFilter;
|
||||||
|
|
||||||
|
foreach ($this->visibleTenants() as $tenant) {
|
||||||
|
if ((int) $tenant->getKey() === $tenantId) {
|
||||||
|
return $tenantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filteredTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
$tenantId = $this->currentTenantFilterId();
|
||||||
|
|
||||||
|
if (! is_int($tenantId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->visibleTenants() as $tenant) {
|
||||||
|
if ((int) $tenant->getKey() === $tenantId) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeVisibleTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
if (! $activeTenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->visibleTenants() as $tenant) {
|
||||||
|
if ($tenant->is($activeTenant)) {
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantPrefilterSource(): string
|
||||||
|
{
|
||||||
|
$tenant = $this->filteredTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeTenant = $this->activeVisibleTenant();
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
||||||
|
return 'active_tenant_context';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'explicit_filter';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownerContext(Finding $record): ?string
|
||||||
|
{
|
||||||
|
if ($record->owner_user_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Owner: '.FindingResource::accountableOwnerDisplayFor($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reopenedCue(Finding $record): ?string
|
||||||
|
{
|
||||||
|
if ($record->reopened_at === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Reopened';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueReason(Finding $record): string
|
||||||
|
{
|
||||||
|
return in_array((string) $record->status, [
|
||||||
|
Finding::STATUS_NEW,
|
||||||
|
Finding::STATUS_REOPENED,
|
||||||
|
], true)
|
||||||
|
? 'Needs triage'
|
||||||
|
: 'Unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueReasonColor(Finding $record): string
|
||||||
|
{
|
||||||
|
return $this->queueReason($record) === 'Needs triage'
|
||||||
|
? 'warning'
|
||||||
|
: 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantFilterAloneExcludesRows(): bool
|
||||||
|
{
|
||||||
|
if ($this->currentTenantFilterId() === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((clone $this->filteredQueueQuery())->exists()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (clone $this->filteredQueueQuery(includeTenantFilter: false))->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findingDetailUrl(Finding $record): string
|
||||||
|
{
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
|
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
return new CanonicalNavigationContext(
|
||||||
|
sourceSurface: 'findings.intake',
|
||||||
|
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
||||||
|
tenantId: $this->currentTenantFilterId(),
|
||||||
|
backLinkLabel: 'Back to findings intake',
|
||||||
|
backLinkUrl: $this->queueUrl(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueUrl(array $overrides = []): string
|
||||||
|
{
|
||||||
|
$resolvedTenant = array_key_exists('tenant', $overrides)
|
||||||
|
? $overrides['tenant']
|
||||||
|
: $this->filteredTenant()?->external_id;
|
||||||
|
$resolvedView = array_key_exists('view', $overrides)
|
||||||
|
? $overrides['view']
|
||||||
|
: $this->currentQueueView();
|
||||||
|
|
||||||
|
return static::getUrl(
|
||||||
|
panel: 'admin',
|
||||||
|
parameters: array_filter([
|
||||||
|
'tenant' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
||||||
|
'view' => $resolvedView === 'needs_triage' ? 'needs_triage' : null,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRequestedQueueView(): string
|
||||||
|
{
|
||||||
|
$requestedView = request()->query('view');
|
||||||
|
|
||||||
|
return $requestedView === 'needs_triage'
|
||||||
|
? 'needs_triage'
|
||||||
|
: 'unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentQueueView(): string
|
||||||
|
{
|
||||||
|
return $this->queueView === 'needs_triage'
|
||||||
|
? 'needs_triage'
|
||||||
|
: 'unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueViewLabel(string $queueView): string
|
||||||
|
{
|
||||||
|
return $queueView === 'needs_triage'
|
||||||
|
? 'Needs triage'
|
||||||
|
: 'Unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Action>
|
||||||
|
*/
|
||||||
|
private function emptyStateActions(): array
|
||||||
|
{
|
||||||
|
$emptyState = $this->emptyState();
|
||||||
|
$action = Action::make((string) $emptyState['action_name'])
|
||||||
|
->label((string) $emptyState['action_label'])
|
||||||
|
->icon('heroicon-o-arrow-right')
|
||||||
|
->color('gray');
|
||||||
|
|
||||||
|
if (($emptyState['action_kind'] ?? null) === 'clear_tenant_filter') {
|
||||||
|
return [
|
||||||
|
$action->action(fn (): mixed => $this->clearTenantFilter()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
$action->url((string) $emptyState['action_url']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $query
|
||||||
|
*/
|
||||||
|
private function appendQuery(string $url, array $query): string
|
||||||
|
{
|
||||||
|
if ($query === []) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,6 +74,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The personal findings inbox exposes row click as the only inspect path and does not render a secondary More menu.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm, explains scope boundaries, and offers exactly one recovery CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm, explains scope boundaries, and offers exactly one recovery CTA.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page.');
|
||||||
|
|||||||
@ -246,10 +246,22 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'detail');
|
||||||
|
$body = 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.';
|
||||||
|
|
||||||
|
if ($reasonEnvelope !== null) {
|
||||||
|
$body = trim(sprintf(
|
||||||
|
'%s %s %s',
|
||||||
|
$body,
|
||||||
|
rtrim($reasonEnvelope->operatorLabel, '.'),
|
||||||
|
$reasonEnvelope->shortExplanation,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Blocked by prerequisite',
|
'title' => 'Blocked by prerequisite',
|
||||||
'body' => 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.',
|
'body' => $body,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -234,7 +234,8 @@ public static function table(Table $table): Table
|
|||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('event_type')
|
TextColumn::make('event_type')
|
||||||
->label('Event')
|
->label('Event')
|
||||||
->badge(),
|
->badge()
|
||||||
|
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
|
||||||
TextColumn::make('severity')
|
TextColumn::make('severity')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||||
|
|||||||
@ -380,6 +380,10 @@ public static function eventTypeOptions(): array
|
|||||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||||
|
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
||||||
|
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
||||||
|
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
||||||
|
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -764,7 +764,8 @@ public static function table(Table $table): Table
|
|||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->placeholder('—')
|
->placeholder('—')
|
||||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record)),
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('ownerUser.name')
|
Tables\Columns\TextColumn::make('ownerUser.name')
|
||||||
->label('Accountable owner')
|
->label('Accountable owner')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@ -773,7 +774,10 @@ public static function table(Table $table): Table
|
|||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->since()
|
||||||
|
->label('Created')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\Filter::make('open')
|
Tables\Filters\Filter::make('open')
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Alerts\AlertDispatchService;
|
use App\Services\Alerts\AlertDispatchService;
|
||||||
|
use App\Services\Findings\FindingNotificationService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -21,6 +22,7 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class EvaluateAlertsJob implements ShouldQueue
|
class EvaluateAlertsJob implements ShouldQueue
|
||||||
@ -32,7 +34,11 @@ public function __construct(
|
|||||||
public ?int $operationRunId = null,
|
public ?int $operationRunId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(AlertDispatchService $dispatchService, OperationRunService $operationRuns): void
|
public function handle(
|
||||||
|
AlertDispatchService $dispatchService,
|
||||||
|
OperationRunService $operationRuns,
|
||||||
|
FindingNotificationService $findingNotificationService,
|
||||||
|
): void
|
||||||
{
|
{
|
||||||
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
||||||
|
|
||||||
@ -67,6 +73,8 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
|||||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||||
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
||||||
];
|
];
|
||||||
|
$dueSoonFindings = $this->dueSoonFindings((int) $workspace->getKey());
|
||||||
|
$overdueFindings = $this->overdueFindings((int) $workspace->getKey());
|
||||||
|
|
||||||
$createdDeliveries = 0;
|
$createdDeliveries = 0;
|
||||||
|
|
||||||
@ -74,13 +82,33 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
|||||||
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
|
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($dueSoonFindings as $finding) {
|
||||||
|
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
||||||
|
$createdDeliveries += $result['external_delivery_count'];
|
||||||
|
|
||||||
|
if ($result['direct_delivery_status'] === 'sent') {
|
||||||
|
$createdDeliveries++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($overdueFindings as $finding) {
|
||||||
|
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||||
|
$createdDeliveries += $result['external_delivery_count'];
|
||||||
|
|
||||||
|
if ($result['direct_delivery_status'] === 'sent') {
|
||||||
|
$createdDeliveries++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$processedEventCount = count($events) + $dueSoonFindings->count() + $overdueFindings->count();
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
$operationRuns->updateRun(
|
||||||
$operationRun,
|
$operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'total' => count($events),
|
'total' => $processedEventCount,
|
||||||
'processed' => count($events),
|
'processed' => $processedEventCount,
|
||||||
'created' => $createdDeliveries,
|
'created' => $createdDeliveries,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -101,6 +129,45 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Finding>
|
||||||
|
*/
|
||||||
|
private function dueSoonFindings(int $workspaceId): Collection
|
||||||
|
{
|
||||||
|
$now = CarbonImmutable::now('UTC');
|
||||||
|
|
||||||
|
return Finding::query()
|
||||||
|
->with('tenant')
|
||||||
|
->withSubjectDisplayName()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->openWorkflow()
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '>', $now)
|
||||||
|
->where('due_at', '<=', $now->addHours(24))
|
||||||
|
->orderBy('due_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Finding>
|
||||||
|
*/
|
||||||
|
private function overdueFindings(int $workspaceId): Collection
|
||||||
|
{
|
||||||
|
$now = CarbonImmutable::now('UTC');
|
||||||
|
|
||||||
|
return Finding::query()
|
||||||
|
->with('tenant')
|
||||||
|
->withSubjectDisplayName()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->openWorkflow()
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', $now)
|
||||||
|
->orderBy('due_at')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
|
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
|
||||||
{
|
{
|
||||||
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
||||||
|
|||||||
@ -28,6 +28,14 @@ class AlertRule extends Model
|
|||||||
|
|
||||||
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
|
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
|
||||||
|
|
||||||
|
public const string EVENT_FINDINGS_ASSIGNED = 'findings.assigned';
|
||||||
|
|
||||||
|
public const string EVENT_FINDINGS_REOPENED = 'findings.reopened';
|
||||||
|
|
||||||
|
public const string EVENT_FINDINGS_DUE_SOON = 'findings.due_soon';
|
||||||
|
|
||||||
|
public const string EVENT_FINDINGS_OVERDUE = 'findings.overdue';
|
||||||
|
|
||||||
public const string TENANT_SCOPE_ALL = 'all';
|
public const string TENANT_SCOPE_ALL = 'all';
|
||||||
|
|
||||||
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
||||||
|
|||||||
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Notifications\Findings;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
final class FindingEventNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $event
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly Finding $finding,
|
||||||
|
private readonly Tenant $tenant,
|
||||||
|
private readonly array $event,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toDatabase(object $notifiable): array
|
||||||
|
{
|
||||||
|
$message = FilamentNotification::make()
|
||||||
|
->title($this->title())
|
||||||
|
->body($this->body())
|
||||||
|
->actions([
|
||||||
|
Action::make('open_finding')
|
||||||
|
->label('Open finding')
|
||||||
|
->url(FindingResource::getUrl(
|
||||||
|
'view',
|
||||||
|
['record' => $this->finding],
|
||||||
|
panel: 'tenant',
|
||||||
|
tenant: $this->tenant,
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
->getDatabaseMessage();
|
||||||
|
|
||||||
|
$message['finding_event'] = [
|
||||||
|
'event_type' => (string) ($this->event['event_type'] ?? ''),
|
||||||
|
'finding_id' => (int) $this->finding->getKey(),
|
||||||
|
'recipient_reason' => data_get($this->event, 'metadata.recipient_reason'),
|
||||||
|
'fingerprint_key' => (string) ($this->event['fingerprint_key'] ?? ''),
|
||||||
|
'due_cycle_key' => $this->event['due_cycle_key'] ?? null,
|
||||||
|
'tenant_name' => $this->tenant->getFilamentName(),
|
||||||
|
'severity' => (string) ($this->event['severity'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function title(): string
|
||||||
|
{
|
||||||
|
$title = trim((string) ($this->event['title'] ?? 'Finding update'));
|
||||||
|
|
||||||
|
return $title !== '' ? $title : 'Finding update';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function body(): string
|
||||||
|
{
|
||||||
|
$body = trim((string) ($this->event['body'] ?? 'A finding needs follow-up.'));
|
||||||
|
$recipientReason = $this->recipientReasonCopy((string) data_get($this->event, 'metadata.recipient_reason', ''));
|
||||||
|
|
||||||
|
return trim($body.' '.$recipientReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recipientReasonCopy(string $reason): string
|
||||||
|
{
|
||||||
|
return match ($reason) {
|
||||||
|
'new_assignee' => 'You are the new assignee.',
|
||||||
|
'current_assignee' => 'You are the current assignee.',
|
||||||
|
'current_owner' => 'You are the accountable owner.',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
@ -177,6 +178,7 @@ public function panel(Panel $panel): Panel
|
|||||||
InventoryCoverage::class,
|
InventoryCoverage::class,
|
||||||
TenantRequiredPermissions::class,
|
TenantRequiredPermissions::class,
|
||||||
WorkspaceSettings::class,
|
WorkspaceSettings::class,
|
||||||
|
FindingsIntakeQueue::class,
|
||||||
MyFindingsInbox::class,
|
MyFindingsInbox::class,
|
||||||
FindingExceptionsQueue::class,
|
FindingExceptionsQueue::class,
|
||||||
ReviewRegister::class,
|
ReviewRegister::class,
|
||||||
|
|||||||
@ -186,6 +186,8 @@ private function buildPayload(array $event): array
|
|||||||
return [
|
return [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
|
'event_type' => trim((string) ($event['event_type'] ?? '')),
|
||||||
|
'fingerprint_key' => trim((string) ($event['fingerprint_key'] ?? '')),
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,389 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Findings;
|
||||||
|
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Notifications\Findings\FindingEventNotification;
|
||||||
|
use App\Services\Alerts\AlertDispatchService;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class FindingNotificationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AlertDispatchService $alertDispatchService,
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{
|
||||||
|
* event_type: string,
|
||||||
|
* fingerprint_key: string,
|
||||||
|
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
|
||||||
|
* external_delivery_count: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function dispatch(Finding $finding, string $eventType, array $context = []): array
|
||||||
|
{
|
||||||
|
$finding = $this->reloadFinding($finding);
|
||||||
|
$tenant = $finding->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return $this->dispatchResult(
|
||||||
|
eventType: $eventType,
|
||||||
|
fingerprintKey: '',
|
||||||
|
directDeliveryStatus: 'no_recipient',
|
||||||
|
externalDeliveryCount: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->shouldSuppressEvent($finding, $eventType, $context)) {
|
||||||
|
return $this->dispatchResult(
|
||||||
|
eventType: $eventType,
|
||||||
|
fingerprintKey: $this->fingerprintFor($finding, $eventType, $context),
|
||||||
|
directDeliveryStatus: 'suppressed',
|
||||||
|
externalDeliveryCount: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolution = $this->resolveRecipient($finding, $eventType, $context);
|
||||||
|
$event = $this->buildEventEnvelope($finding, $tenant, $eventType, $resolution['reason'], $context);
|
||||||
|
|
||||||
|
$directDeliveryStatus = $this->dispatchDirectNotification($finding, $tenant, $event, $resolution['user_id']);
|
||||||
|
$externalDeliveryCount = $this->dispatchExternalCopies($finding, $event);
|
||||||
|
|
||||||
|
return $this->dispatchResult(
|
||||||
|
eventType: $eventType,
|
||||||
|
fingerprintKey: (string) $event['fingerprint_key'],
|
||||||
|
directDeliveryStatus: $directDeliveryStatus,
|
||||||
|
externalDeliveryCount: $externalDeliveryCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array{user_id: ?int, reason: ?string}
|
||||||
|
*/
|
||||||
|
private function resolveRecipient(Finding $finding, string $eventType, array $context): array
|
||||||
|
{
|
||||||
|
return match ($eventType) {
|
||||||
|
AlertRule::EVENT_FINDINGS_ASSIGNED => [
|
||||||
|
'user_id' => $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id),
|
||||||
|
'reason' => 'new_assignee',
|
||||||
|
],
|
||||||
|
AlertRule::EVENT_FINDINGS_REOPENED => $this->preferredRecipient(
|
||||||
|
preferredUserId: $this->normalizeId($finding->assignee_user_id),
|
||||||
|
preferredReason: 'current_assignee',
|
||||||
|
fallbackUserId: $this->normalizeId($finding->owner_user_id),
|
||||||
|
fallbackReason: 'current_owner',
|
||||||
|
),
|
||||||
|
AlertRule::EVENT_FINDINGS_DUE_SOON => $this->preferredRecipient(
|
||||||
|
preferredUserId: $this->normalizeId($finding->assignee_user_id),
|
||||||
|
preferredReason: 'current_assignee',
|
||||||
|
fallbackUserId: $this->normalizeId($finding->owner_user_id),
|
||||||
|
fallbackReason: 'current_owner',
|
||||||
|
),
|
||||||
|
AlertRule::EVENT_FINDINGS_OVERDUE => $this->preferredRecipient(
|
||||||
|
preferredUserId: $this->normalizeId($finding->owner_user_id),
|
||||||
|
preferredReason: 'current_owner',
|
||||||
|
fallbackUserId: $this->normalizeId($finding->assignee_user_id),
|
||||||
|
fallbackReason: 'current_assignee',
|
||||||
|
),
|
||||||
|
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildEventEnvelope(
|
||||||
|
Finding $finding,
|
||||||
|
Tenant $tenant,
|
||||||
|
string $eventType,
|
||||||
|
?string $recipientReason,
|
||||||
|
array $context,
|
||||||
|
): array {
|
||||||
|
$severity = strtolower(trim((string) $finding->severity));
|
||||||
|
$summary = $finding->resolvedSubjectDisplayName() ?? 'Finding #'.(int) $finding->getKey();
|
||||||
|
$title = $this->eventLabel($eventType);
|
||||||
|
$fingerprintKey = $this->fingerprintFor($finding, $eventType, $context);
|
||||||
|
$dueCycleKey = $this->dueCycleKey($finding, $eventType);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'workspace_id' => (int) $finding->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'severity' => $severity,
|
||||||
|
'title' => $title,
|
||||||
|
'body' => $this->eventBody($eventType, $tenant, $summary, ucfirst($severity)),
|
||||||
|
'fingerprint_key' => $fingerprintKey,
|
||||||
|
'due_cycle_key' => $dueCycleKey,
|
||||||
|
'metadata' => [
|
||||||
|
'tenant_name' => $tenant->getFilamentName(),
|
||||||
|
'summary' => $summary,
|
||||||
|
'recipient_reason' => $recipientReason,
|
||||||
|
'owner_user_id' => $this->normalizeId($finding->owner_user_id),
|
||||||
|
'assignee_user_id' => $this->normalizeId($finding->assignee_user_id),
|
||||||
|
'due_at' => $this->optionalIso8601($finding->due_at),
|
||||||
|
'reopened_at' => $this->optionalIso8601($finding->reopened_at),
|
||||||
|
'severity_label' => ucfirst($severity),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $event
|
||||||
|
*/
|
||||||
|
private function dispatchDirectNotification(Finding $finding, Tenant $tenant, array $event, ?int $userId): string
|
||||||
|
{
|
||||||
|
if (! is_int($userId) || $userId <= 0) {
|
||||||
|
return 'no_recipient';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->find($userId);
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return 'no_recipient';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return 'suppressed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)) {
|
||||||
|
return 'suppressed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->alreadySentDirectNotification($user, (string) $event['fingerprint_key'])) {
|
||||||
|
return 'deduped';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->notify(new FindingEventNotification($finding, $tenant, $event));
|
||||||
|
|
||||||
|
return 'sent';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $event
|
||||||
|
*/
|
||||||
|
private function dispatchExternalCopies(Finding $finding, array $event): int
|
||||||
|
{
|
||||||
|
$workspace = Workspace::query()->whereKey((int) $finding->workspace_id)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->alertDispatchService->dispatchEvent($workspace, $event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function alreadySentDirectNotification(User $user, string $fingerprintKey): bool
|
||||||
|
{
|
||||||
|
if ($fingerprintKey === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->notifications()
|
||||||
|
->where('type', FindingEventNotification::class)
|
||||||
|
->where('data->finding_event->fingerprint_key', $fingerprintKey)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reloadFinding(Finding $finding): Finding
|
||||||
|
{
|
||||||
|
$fresh = Finding::query()
|
||||||
|
->with('tenant')
|
||||||
|
->withSubjectDisplayName()
|
||||||
|
->find($finding->getKey());
|
||||||
|
|
||||||
|
if ($fresh instanceof Finding) {
|
||||||
|
return $fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding->loadMissing('tenant');
|
||||||
|
|
||||||
|
return $finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function shouldSuppressEvent(Finding $finding, string $eventType, array $context): bool
|
||||||
|
{
|
||||||
|
return match ($eventType) {
|
||||||
|
AlertRule::EVENT_FINDINGS_ASSIGNED => ! $finding->hasOpenStatus()
|
||||||
|
|| $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) === null,
|
||||||
|
AlertRule::EVENT_FINDINGS_DUE_SOON, AlertRule::EVENT_FINDINGS_OVERDUE => ! $finding->hasOpenStatus()
|
||||||
|
|| ! $finding->due_at instanceof CarbonInterface,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
|
private function fingerprintFor(Finding $finding, string $eventType, array $context): string
|
||||||
|
{
|
||||||
|
$findingId = (int) $finding->getKey();
|
||||||
|
|
||||||
|
return match ($eventType) {
|
||||||
|
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
|
||||||
|
'finding:%d:%s:assignee:%d:updated:%s',
|
||||||
|
$findingId,
|
||||||
|
$eventType,
|
||||||
|
$this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) ?? 0,
|
||||||
|
$this->optionalIso8601($finding->updated_at) ?? 'none',
|
||||||
|
),
|
||||||
|
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
|
||||||
|
'finding:%d:%s:reopened:%s',
|
||||||
|
$findingId,
|
||||||
|
$eventType,
|
||||||
|
$this->optionalIso8601($finding->reopened_at) ?? 'none',
|
||||||
|
),
|
||||||
|
AlertRule::EVENT_FINDINGS_DUE_SOON,
|
||||||
|
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
|
||||||
|
'finding:%d:%s:due:%s',
|
||||||
|
$findingId,
|
||||||
|
$eventType,
|
||||||
|
$this->dueCycleKey($finding, $eventType) ?? 'none',
|
||||||
|
),
|
||||||
|
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dueCycleKey(Finding $finding, string $eventType): ?string
|
||||||
|
{
|
||||||
|
if (! in_array($eventType, [
|
||||||
|
AlertRule::EVENT_FINDINGS_DUE_SOON,
|
||||||
|
AlertRule::EVENT_FINDINGS_OVERDUE,
|
||||||
|
], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->optionalIso8601($finding->due_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function eventLabel(string $eventType): string
|
||||||
|
{
|
||||||
|
return match ($eventType) {
|
||||||
|
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
||||||
|
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
||||||
|
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
||||||
|
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
||||||
|
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function eventBody(string $eventType, Tenant $tenant, string $summary, string $severityLabel): string
|
||||||
|
{
|
||||||
|
return match ($eventType) {
|
||||||
|
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
|
||||||
|
'%s in %s was assigned. %s severity.',
|
||||||
|
$summary,
|
||||||
|
$tenant->getFilamentName(),
|
||||||
|
$severityLabel,
|
||||||
|
),
|
||||||
|
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
|
||||||
|
'%s in %s reopened and needs follow-up. %s severity.',
|
||||||
|
$summary,
|
||||||
|
$tenant->getFilamentName(),
|
||||||
|
$severityLabel,
|
||||||
|
),
|
||||||
|
AlertRule::EVENT_FINDINGS_DUE_SOON => sprintf(
|
||||||
|
'%s in %s is due within 24 hours. %s severity.',
|
||||||
|
$summary,
|
||||||
|
$tenant->getFilamentName(),
|
||||||
|
$severityLabel,
|
||||||
|
),
|
||||||
|
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
|
||||||
|
'%s in %s is overdue. %s severity.',
|
||||||
|
$summary,
|
||||||
|
$tenant->getFilamentName(),
|
||||||
|
$severityLabel,
|
||||||
|
),
|
||||||
|
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{user_id: ?int, reason: ?string}
|
||||||
|
*/
|
||||||
|
private function preferredRecipient(
|
||||||
|
?int $preferredUserId,
|
||||||
|
string $preferredReason,
|
||||||
|
?int $fallbackUserId,
|
||||||
|
string $fallbackReason,
|
||||||
|
): array {
|
||||||
|
if (is_int($preferredUserId) && $preferredUserId > 0) {
|
||||||
|
return [
|
||||||
|
'user_id' => $preferredUserId,
|
||||||
|
'reason' => $preferredReason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($fallbackUserId) && $fallbackUserId > 0) {
|
||||||
|
return [
|
||||||
|
'user_id' => $fallbackUserId,
|
||||||
|
'reason' => $fallbackReason,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => null,
|
||||||
|
'reason' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeId(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = (int) $value;
|
||||||
|
|
||||||
|
return $normalized > 0 ? $normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function optionalIso8601(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! $value instanceof CarbonInterface) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* event_type: string,
|
||||||
|
* fingerprint_key: string,
|
||||||
|
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
|
||||||
|
* external_delivery_count: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function dispatchResult(
|
||||||
|
string $eventType,
|
||||||
|
string $fingerprintKey,
|
||||||
|
string $directDeliveryStatus,
|
||||||
|
int $externalDeliveryCount,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'fingerprint_key' => $fingerprintKey,
|
||||||
|
'direct_delivery_status' => $directDeliveryStatus,
|
||||||
|
'external_delivery_count' => $externalDeliveryCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@
|
|||||||
namespace App\Services\Findings;
|
namespace App\Services\Findings;
|
||||||
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\AlertRule;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -17,6 +18,7 @@
|
|||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
final class FindingWorkflowService
|
final class FindingWorkflowService
|
||||||
@ -25,6 +27,7 @@ public function __construct(
|
|||||||
private readonly FindingSlaPolicy $slaPolicy,
|
private readonly FindingSlaPolicy $slaPolicy,
|
||||||
private readonly AuditLogger $auditLogger,
|
private readonly AuditLogger $auditLogger,
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
private readonly FindingNotificationService $findingNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||||
@ -107,6 +110,7 @@ public function assign(
|
|||||||
throw new InvalidArgumentException('Only open findings can be assigned.');
|
throw new InvalidArgumentException('Only open findings can be assigned.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$beforeAssigneeUserId = is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null;
|
||||||
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
|
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
|
||||||
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
|
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
|
||||||
|
|
||||||
@ -123,7 +127,7 @@ public function assign(
|
|||||||
afterAssigneeUserId: $assigneeUserId,
|
afterAssigneeUserId: $assigneeUserId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->mutateAndAudit(
|
$updatedFinding = $this->mutateAndAudit(
|
||||||
finding: $finding,
|
finding: $finding,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
@ -141,6 +145,63 @@ public function assign(
|
|||||||
$record->owner_user_id = $ownerUserId;
|
$record->owner_user_id = $ownerUserId;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($assigneeUserId !== null && $assigneeUserId !== $beforeAssigneeUserId) {
|
||||||
|
$this->findingNotificationService->dispatch(
|
||||||
|
$updatedFinding,
|
||||||
|
AlertRule::EVENT_FINDINGS_ASSIGNED,
|
||||||
|
['assignee_user_id' => $assigneeUserId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updatedFinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function claim(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||||
|
{
|
||||||
|
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_ASSIGN]);
|
||||||
|
|
||||||
|
$assigneeUserId = (int) $actor->getKey();
|
||||||
|
$ownerUserId = is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null;
|
||||||
|
$changeClassification = $this->responsibilityChangeClassification(
|
||||||
|
beforeOwnerUserId: $ownerUserId,
|
||||||
|
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
|
||||||
|
afterOwnerUserId: $ownerUserId,
|
||||||
|
afterAssigneeUserId: $assigneeUserId,
|
||||||
|
);
|
||||||
|
$changeSummary = $this->responsibilityChangeSummary(
|
||||||
|
beforeOwnerUserId: $ownerUserId,
|
||||||
|
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
|
||||||
|
afterOwnerUserId: $ownerUserId,
|
||||||
|
afterAssigneeUserId: $assigneeUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->mutateAndAudit(
|
||||||
|
finding: $finding,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
action: AuditActionId::FindingAssigned,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'assignee_user_id' => $assigneeUserId,
|
||||||
|
'owner_user_id' => $ownerUserId,
|
||||||
|
'responsibility_change_classification' => $changeClassification,
|
||||||
|
'responsibility_change_summary' => $changeSummary,
|
||||||
|
'claim_self_service' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
mutate: function (Finding $record) use ($assigneeUserId): void {
|
||||||
|
if (! in_array((string) $record->status, Finding::openStatuses(), true)) {
|
||||||
|
throw new ConflictHttpException('Finding is no longer claimable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->assignee_user_id !== null) {
|
||||||
|
throw new ConflictHttpException('Finding is already assigned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->assignee_user_id = $assigneeUserId;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function responsibilityChangeClassification(
|
public function responsibilityChangeClassification(
|
||||||
@ -393,7 +454,7 @@ public function reopenBySystem(
|
|||||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
||||||
|
|
||||||
return $this->mutateAndAudit(
|
$reopenedFinding = $this->mutateAndAudit(
|
||||||
finding: $finding,
|
finding: $finding,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: null,
|
actor: null,
|
||||||
@ -424,6 +485,10 @@ public function reopenBySystem(
|
|||||||
actorType: AuditActorType::System,
|
actorType: AuditActorType::System,
|
||||||
operationRunId: $operationRunId,
|
operationRunId: $operationRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->findingNotificationService->dispatch($reopenedFinding, AlertRule::EVENT_FINDINGS_REOPENED);
|
||||||
|
|
||||||
|
return $reopenedFinding;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -94,6 +94,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
}
|
}
|
||||||
|
|
||||||
$assignment = BaselineTenantAssignment::query()
|
$assignment = BaselineTenantAssignment::query()
|
||||||
|
->with('baselineProfile')
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($path === '/admin/findings/my-work') {
|
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake'], true)) {
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
@ -119,7 +119,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
str_starts_with($path, '/admin/w/')
|
str_starts_with($path, '/admin/w/')
|
||||||
|| str_starts_with($path, '/admin/workspaces')
|
|| str_starts_with($path, '/admin/workspaces')
|
||||||
|| str_starts_with($path, '/admin/operations')
|
|| str_starts_with($path, '/admin/operations')
|
||||||
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work'], true)
|
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work', '/admin/findings/intake'], true)
|
||||||
) {
|
) {
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
@ -261,6 +261,10 @@ private function adminPathRequiresTenantSelection(string $path): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, '/admin/findings/intake')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
|
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -294,6 +294,8 @@ private static function operationAliases(): array
|
|||||||
new OperationTypeAlias('policy_version.force_delete', 'policy_version.force_delete', 'canonical', true),
|
new OperationTypeAlias('policy_version.force_delete', 'policy_version.force_delete', 'canonical', true),
|
||||||
new OperationTypeAlias('alerts.evaluate', 'alerts.evaluate', 'canonical', true),
|
new OperationTypeAlias('alerts.evaluate', 'alerts.evaluate', 'canonical', true),
|
||||||
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
|
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
|
||||||
|
new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true),
|
||||||
|
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
|
||||||
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
||||||
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
||||||
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
||||||
|
|||||||
@ -217,29 +217,26 @@ private function myFindingsSignal(int $workspaceId, Collection $accessibleTenant
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$openAssignedCount = $visibleTenantIds === []
|
$assignedCounts = $visibleTenantIds === []
|
||||||
? 0
|
? null
|
||||||
: (int) $this->scopeToVisibleTenants(
|
: $this->scopeToVisibleTenants(
|
||||||
Finding::query(),
|
Finding::query(),
|
||||||
$workspaceId,
|
$workspaceId,
|
||||||
$visibleTenantIds,
|
$visibleTenantIds,
|
||||||
)
|
)
|
||||||
->where('assignee_user_id', (int) $user->getKey())
|
->where('assignee_user_id', (int) $user->getKey())
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
->count();
|
->selectRaw('count(*) as open_assigned_count')
|
||||||
|
->selectRaw('sum(case when due_at is not null and due_at < ? then 1 else 0 end) as overdue_assigned_count', [now()])
|
||||||
|
->first();
|
||||||
|
|
||||||
$overdueAssignedCount = $visibleTenantIds === []
|
$openAssignedCount = is_numeric($assignedCounts?->open_assigned_count)
|
||||||
? 0
|
? (int) $assignedCounts->open_assigned_count
|
||||||
: (int) $this->scopeToVisibleTenants(
|
: 0;
|
||||||
Finding::query(),
|
|
||||||
$workspaceId,
|
$overdueAssignedCount = is_numeric($assignedCounts?->overdue_assigned_count)
|
||||||
$visibleTenantIds,
|
? (int) $assignedCounts->overdue_assigned_count
|
||||||
)
|
: 0;
|
||||||
->where('assignee_user_id', (int) $user->getKey())
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '<', now())
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$isCalm = $openAssignedCount === 0;
|
$isCalm = $openAssignedCount === 0;
|
||||||
|
|
||||||
@ -1434,10 +1431,9 @@ private function canManageWorkspaces(Workspace $workspace, User $user): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||||
|
$role = $this->workspaceCapabilityResolver->getRole($user, $workspace);
|
||||||
|
|
||||||
return $user->workspaceMemberships()
|
return $role !== null && in_array($role->value, $roles, true);
|
||||||
->whereIn('role', $roles)
|
|
||||||
->exists();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function tenantRouteKey(Tenant $tenant): string
|
private function tenantRouteKey(Tenant $tenant): string
|
||||||
|
|||||||
@ -140,6 +140,34 @@ public function reopened(): static
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ownedBy(?int $userId): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'owner_user_id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignedTo(?int $userId): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'assignee_user_id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dueWithinHours(int $hours): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'due_at' => now()->addHours($hours),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function overdueByHours(int $hours = 1): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'due_at' => now()->subHours($hours),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for closed findings.
|
* State for closed findings.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,103 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
@php($scope = $this->appliedScope())
|
||||||
|
@php($summary = $this->summaryCounts())
|
||||||
|
@php($queueViews = $this->queueViews())
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-warning-200 bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:border-warning-700/60 dark:bg-warning-950/40 dark:text-warning-300">
|
||||||
|
<x-filament::icon icon="heroicon-o-inbox-stack" class="h-3.5 w-3.5" />
|
||||||
|
Shared unassigned work
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
||||||
|
Findings intake
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
Review visible unassigned open findings across entitled tenants in one queue. Tenant context can narrow the view, but the intake scope stays fixed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
|
||||||
|
Visible unassigned
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-3xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $summary['visible_unassigned'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Visible unassigned intake rows after the current tenant scope.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-warning-200 bg-warning-50/70 p-4 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.14em] text-warning-700 dark:text-warning-200">
|
||||||
|
Needs triage
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-3xl font-semibold text-warning-950 dark:text-warning-100">
|
||||||
|
{{ $summary['visible_needs_triage'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-warning-800 dark:text-warning-200">
|
||||||
|
Visible `new` and `reopened` intake rows that still need first routing.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-danger-200 bg-danger-50/70 p-4 shadow-sm dark:border-danger-700/50 dark:bg-danger-950/30">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.14em] text-danger-700 dark:text-danger-200">
|
||||||
|
Overdue
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-3xl font-semibold text-danger-950 dark:text-danger-100">
|
||||||
|
{{ $summary['visible_overdue'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-danger-800 dark:text-danger-200">
|
||||||
|
Intake rows that are already past due.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
|
||||||
|
Applied scope
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $scope['queue_view_label'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
@if (($scope['tenant_prefilter_source'] ?? 'none') === 'active_tenant_context')
|
||||||
|
Tenant prefilter from active context:
|
||||||
|
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
|
||||||
|
@elseif (($scope['tenant_prefilter_source'] ?? 'none') === 'explicit_filter')
|
||||||
|
Tenant filter applied:
|
||||||
|
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
|
||||||
|
@else
|
||||||
|
All visible tenants are currently included.
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach ($queueViews as $queueView)
|
||||||
|
<a
|
||||||
|
href="{{ $queueView['url'] }}"
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $queueView['active'] ? 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:text-gray-950 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:border-white/20 dark:hover:text-white' }}"
|
||||||
|
>
|
||||||
|
<span>{{ $queueView['label'] }}</span>
|
||||||
|
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs font-semibold dark:bg-white/10">
|
||||||
|
{{ $queueView['badge_count'] }}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] uppercase tracking-[0.12em] opacity-70">Fixed</span>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -0,0 +1,284 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
|
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||||
|
use App\Filament\Resources\AlertRuleResource;
|
||||||
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Notifications\Findings\FindingEventNotification;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Services\Findings\FindingNotificationService;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('exposes the four finding notification events in the existing alert rule options', function (): void {
|
||||||
|
expect(AlertRuleResource::eventTypeOptions())->toMatchArray([
|
||||||
|
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
||||||
|
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
||||||
|
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
||||||
|
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers a direct finding notification without requiring a matching alert rule', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||||
|
|
||||||
|
expect($result['direct_delivery_status'])->toBe('sent')
|
||||||
|
->and($result['external_delivery_count'])->toBe(0)
|
||||||
|
->and($assignee->notifications()->where('type', FindingEventNotification::class)->count())->toBe(1)
|
||||||
|
->and(AlertDelivery::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fans out matching external copies through the existing alert delivery pipeline', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$workspaceId = (int) $tenant->workspace_id;
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule = AlertRule::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
||||||
|
'minimum_severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'is_enabled' => true,
|
||||||
|
'cooldown_seconds' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule->destinations()->attach($destination->getKey(), [
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'due_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||||
|
|
||||||
|
$delivery = AlertDelivery::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($result['direct_delivery_status'])->toBe('sent')
|
||||||
|
->and($result['external_delivery_count'])->toBe(1)
|
||||||
|
->and($delivery)->not->toBeNull()
|
||||||
|
->and($delivery?->event_type)->toBe(AlertRule::EVENT_FINDINGS_OVERDUE)
|
||||||
|
->and(data_get($delivery?->payload, 'title'))->toBe('Finding overdue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inherits minimum severity tenant scoping and cooldown suppression for finding alert copies', function (): void {
|
||||||
|
[$ownerA, $tenantA] = createUserWithTenant(role: 'owner');
|
||||||
|
$workspaceId = (int) $tenantA->workspace_id;
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
[$ownerB] = createUserWithTenant(tenant: $tenantB, role: 'owner');
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule = AlertRule::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
||||||
|
'minimum_severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'tenant_scope_mode' => AlertRule::TENANT_SCOPE_ALLOWLIST,
|
||||||
|
'tenant_allowlist' => [(int) $tenantA->getKey()],
|
||||||
|
'is_enabled' => true,
|
||||||
|
'cooldown_seconds' => 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule->destinations()->attach($destination->getKey(), [
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mediumFinding = Finding::factory()->for($tenantA)->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'owner_user_id' => (int) $ownerA->getKey(),
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'due_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scopedOutFinding = Finding::factory()->for($tenantB)->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'owner_user_id' => (int) $ownerB->getKey(),
|
||||||
|
'severity' => Finding::SEVERITY_CRITICAL,
|
||||||
|
'due_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$trackedFinding = Finding::factory()->for($tenantA)->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'owner_user_id' => (int) $ownerA->getKey(),
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'due_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(app(FindingNotificationService::class)->dispatch($mediumFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0)
|
||||||
|
->and(app(FindingNotificationService::class)->dispatch($scopedOutFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0);
|
||||||
|
|
||||||
|
app(FindingNotificationService::class)->dispatch($trackedFinding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||||
|
app(FindingNotificationService::class)->dispatch($trackedFinding->fresh(), AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||||
|
|
||||||
|
$deliveries = AlertDelivery::query()
|
||||||
|
->where('workspace_id', $workspaceId)
|
||||||
|
->where('event_type', AlertRule::EVENT_FINDINGS_OVERDUE)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
expect($deliveries)->toHaveCount(2)
|
||||||
|
->and($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED)
|
||||||
|
->and($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inherits quiet hours deferral for finding alert copies', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$workspaceId = (int) $tenant->workspace_id;
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule = AlertRule::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'event_type' => AlertRule::EVENT_FINDINGS_ASSIGNED,
|
||||||
|
'minimum_severity' => Finding::SEVERITY_LOW,
|
||||||
|
'is_enabled' => true,
|
||||||
|
'cooldown_seconds' => 0,
|
||||||
|
'quiet_hours_enabled' => true,
|
||||||
|
'quiet_hours_start' => '00:00',
|
||||||
|
'quiet_hours_end' => '23:59',
|
||||||
|
'quiet_hours_timezone' => 'UTC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rule->destinations()->attach($destination->getKey(), [
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $owner->getKey(),
|
||||||
|
'severity' => Finding::SEVERITY_LOW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||||
|
$delivery = AlertDelivery::query()->latest('id')->first();
|
||||||
|
|
||||||
|
expect($result['external_delivery_count'])->toBe(1)
|
||||||
|
->and($delivery)->not->toBeNull()
|
||||||
|
->and($delivery?->status)->toBe(AlertDelivery::STATUS_DEFERRED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders finding event labels and filters in the existing alert deliveries viewer', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
$workspaceId = (int) $tenant->workspace_id;
|
||||||
|
|
||||||
|
$rule = AlertRule::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'is_enabled' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$delivery = AlertDelivery::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'alert_rule_id' => (int) $rule->getKey(),
|
||||||
|
'alert_destination_id' => (int) $destination->getKey(),
|
||||||
|
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
||||||
|
'payload' => [
|
||||||
|
'title' => 'Finding overdue',
|
||||||
|
'body' => 'A finding is overdue and needs follow-up.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListAlertDeliveries::class)
|
||||||
|
->filterTable('event_type', AlertRule::EVENT_FINDINGS_OVERDUE)
|
||||||
|
->assertCanSeeTableRecords([$delivery])
|
||||||
|
->assertSee('Finding overdue');
|
||||||
|
|
||||||
|
expect(AlertRuleResource::eventTypeLabel(AlertRule::EVENT_FINDINGS_OVERDUE))->toBe('Finding overdue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves alerts read and mutation boundaries for the existing admin surfaces', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'readonly',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(AlertRuleResource::getUrl('create', panel: 'admin'))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||||
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$resolver->shouldReceive('can')->andReturnFalse();
|
||||||
|
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$outsider = User::factory()->create();
|
||||||
|
app()->forgetInstance(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
$this->actingAs($outsider)
|
||||||
|
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
@ -194,3 +194,41 @@ function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): arr
|
|||||||
->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key'])
|
->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key'])
|
||||||
->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']);
|
->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps aggregate sla due alerts separate from finding-level due soon reminders', function (): void {
|
||||||
|
$now = CarbonImmutable::parse('2026-04-22T12:00:00Z');
|
||||||
|
CarbonImmutable::setTestNow($now);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'due_at' => $now->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'severity' => Finding::SEVERITY_CRITICAL,
|
||||||
|
'due_at' => $now->addHours(6),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$events = invokeSlaDueEvents($workspaceId, $now->subDay());
|
||||||
|
|
||||||
|
expect($events)->toHaveCount(1)
|
||||||
|
->and($events[0]['event_type'])->toBe(AlertRule::EVENT_SLA_DUE)
|
||||||
|
->and($events[0]['metadata'])->toMatchArray([
|
||||||
|
'overdue_total' => 1,
|
||||||
|
'overdue_by_severity' => [
|
||||||
|
'critical' => 0,
|
||||||
|
'high' => 1,
|
||||||
|
'medium' => 0,
|
||||||
|
'low' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('redirects intake visits without workspace context into the existing workspace chooser flow', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$workspaceA = Workspace::factory()->create();
|
||||||
|
$workspaceB = Workspace::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceA->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceB->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
|
||||||
|
->assertRedirect('/admin/choose-workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for users outside the active workspace on the intake route', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 for workspace members with no currently viewable findings scope anywhere', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses hidden-tenant findings and keeps their detail route not found', function (): void {
|
||||||
|
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$hiddenTenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$visibleFinding = Finding::factory()->for($visibleTenant)->create([
|
||||||
|
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$hiddenFinding = Finding::factory()->for($hiddenTenant)->create([
|
||||||
|
'workspace_id' => (int) $hiddenTenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(FindingsIntakeQueue::class)
|
||||||
|
->assertCanSeeTableRecords([$visibleFinding])
|
||||||
|
->assertCanNotSeeTableRecords([$hiddenFinding]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $hiddenFinding], panel: 'tenant', tenant: $hiddenTenant))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps inspect access while disabling claim for members without assign capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(FindingsIntakeQueue::class)
|
||||||
|
->assertCanSeeTableRecords([$finding])
|
||||||
|
->assertTableActionVisible('claim', $finding)
|
||||||
|
->assertTableActionDisabled('claim', $finding)
|
||||||
|
->callTableAction('claim', $finding);
|
||||||
|
|
||||||
|
expect($finding->refresh()->assignee_user_id)->toBeNull();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('mounts the claim confirmation and moves the finding into my findings without changing owner or lifecycle', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'manager', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
createUserWithTenant($tenant, $owner, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->reopened()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'subject_external_id' => 'claimable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(FindingsIntakeQueue::class)
|
||||||
|
->assertTableActionVisible('claim', $finding)
|
||||||
|
->mountTableAction('claim', $finding)
|
||||||
|
->callMountedTableAction()
|
||||||
|
->assertCanNotSeeTableRecords([$finding]);
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect((int) $finding->assignee_user_id)->toBe((int) $user->getKey())
|
||||||
|
->and((int) $finding->owner_user_id)->toBe((int) $owner->getKey())
|
||||||
|
->and($finding->status)->toBe(Finding::STATUS_REOPENED);
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('resource_type', 'finding')
|
||||||
|
->where('resource_id', (string) $finding->getKey())
|
||||||
|
->where('action', AuditActionId::FindingAssigned->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and(data_get($audit?->metadata ?? [], 'assignee_user_id'))->toBe((int) $user->getKey())
|
||||||
|
->and(data_get($audit?->metadata ?? [], 'owner_user_id'))->toBe((int) $owner->getKey());
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(MyFindingsInbox::class)
|
||||||
|
->assertCanSeeTableRecords([$finding]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a stale claim after another operator already claimed the finding first', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$operatorA, $tenant] = createUserWithTenant($tenant, role: 'manager', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$operatorB = User::factory()->create();
|
||||||
|
createUserWithTenant($tenant, $operatorB, role: 'manager', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($operatorA);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($operatorA)
|
||||||
|
->test(FindingsIntakeQueue::class)
|
||||||
|
->mountTableAction('claim', $finding);
|
||||||
|
|
||||||
|
app(FindingWorkflowService::class)->claim($finding, $tenant, $operatorB);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->callMountedTableAction();
|
||||||
|
|
||||||
|
expect((int) $finding->refresh()->assignee_user_id)->toBe((int) $operatorB->getKey());
|
||||||
|
|
||||||
|
Livewire::actingAs($operatorA)
|
||||||
|
->test(FindingsIntakeQueue::class)
|
||||||
|
->assertCanNotSeeTableRecords([$finding]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AuditLog::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('resource_type', 'finding')
|
||||||
|
->where('resource_id', (string) $finding->getKey())
|
||||||
|
->where('action', AuditActionId::FindingAssigned->value)
|
||||||
|
->count()
|
||||||
|
)->toBe(1);
|
||||||
|
});
|
||||||
347
apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
Normal file
347
apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
function findingsIntakeActingUser(string $role = 'owner', string $workspaceRole = 'readonly'): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
|
||||||
|
|
||||||
|
test()->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
return [$user, $tenant];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingsIntakePage(?User $user = null, array $query = [])
|
||||||
|
{
|
||||||
|
if ($user instanceof User) {
|
||||||
|
test()->actingAs($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdminPanelContext();
|
||||||
|
|
||||||
|
$factory = $query === []
|
||||||
|
? Livewire::actingAs(auth()->user())
|
||||||
|
: Livewire::withQueryParams($query)->actingAs(auth()->user());
|
||||||
|
|
||||||
|
return $factory->test(FindingsIntakeQueue::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding
|
||||||
|
{
|
||||||
|
return Finding::factory()->for($tenant)->create(array_merge([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'owner_user_id' => null,
|
||||||
|
'subject_external_id' => fake()->uuid(),
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows only visible unassigned open findings and exposes fixed queue view counts', function (): void {
|
||||||
|
[$user, $tenantA] = findingsIntakeActingUser();
|
||||||
|
$tenantA->forceFill(['name' => 'Alpha Tenant'])->save();
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Tenant Bravo',
|
||||||
|
]);
|
||||||
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$hiddenTenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Hidden Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$otherAssignee = User::factory()->create();
|
||||||
|
createUserWithTenant($tenantA, $otherAssignee, role: 'operator', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$otherOwner = User::factory()->create();
|
||||||
|
createUserWithTenant($tenantB, $otherOwner, role: 'owner', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$visibleNew = makeIntakeFinding($tenantA, [
|
||||||
|
'subject_external_id' => 'visible-new',
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$visibleReopened = makeIntakeFinding($tenantB, [
|
||||||
|
'subject_external_id' => 'visible-reopened',
|
||||||
|
'status' => Finding::STATUS_REOPENED,
|
||||||
|
'reopened_at' => now()->subHours(6),
|
||||||
|
'owner_user_id' => (int) $otherOwner->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$visibleTriaged = makeIntakeFinding($tenantA, [
|
||||||
|
'subject_external_id' => 'visible-triaged',
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$visibleInProgress = makeIntakeFinding($tenantB, [
|
||||||
|
'subject_external_id' => 'visible-progress',
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assignedOpen = makeIntakeFinding($tenantA, [
|
||||||
|
'subject_external_id' => 'assigned-open',
|
||||||
|
'assignee_user_id' => (int) $otherAssignee->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'subject_external_id' => 'acknowledged',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$terminal = Finding::factory()->for($tenantA)->resolved()->create([
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'subject_external_id' => 'terminal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$hidden = makeIntakeFinding($hiddenTenant, [
|
||||||
|
'subject_external_id' => 'hidden-intake',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = findingsIntakePage($user)
|
||||||
|
->assertCanSeeTableRecords([$visibleNew, $visibleReopened, $visibleTriaged, $visibleInProgress])
|
||||||
|
->assertCanNotSeeTableRecords([$assignedOpen, $acknowledged, $terminal, $hidden])
|
||||||
|
->assertSee('Owner: '.$otherOwner->name)
|
||||||
|
->assertSee('Needs triage')
|
||||||
|
->assertSee('Unassigned');
|
||||||
|
|
||||||
|
expect($component->instance()->summaryCounts())->toBe([
|
||||||
|
'visible_unassigned' => 4,
|
||||||
|
'visible_needs_triage' => 2,
|
||||||
|
'visible_overdue' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
|
||||||
|
|
||||||
|
expect($queueViews['unassigned']['badge_count'])->toBe(4)
|
||||||
|
->and($queueViews['unassigned']['active'])->toBeTrue()
|
||||||
|
->and($queueViews['needs_triage']['badge_count'])->toBe(2)
|
||||||
|
->and($queueViews['needs_triage']['active'])->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the active tenant prefilter and lets the operator clear it without dropping intake scope', function (): void {
|
||||||
|
[$user, $tenantA] = findingsIntakeActingUser();
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Beta Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$findingA = makeIntakeFinding($tenantA, [
|
||||||
|
'subject_external_id' => 'tenant-a',
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
$findingB = makeIntakeFinding($tenantB, [
|
||||||
|
'subject_external_id' => 'tenant-b',
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||||
|
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = findingsIntakePage($user)
|
||||||
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||||
|
->assertCanSeeTableRecords([$findingB])
|
||||||
|
->assertCanNotSeeTableRecords([$findingA])
|
||||||
|
->assertActionVisible('clear_tenant_filter');
|
||||||
|
|
||||||
|
expect($component->instance()->appliedScope())->toBe([
|
||||||
|
'workspace_scoped' => true,
|
||||||
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
||||||
|
'queue_view' => 'unassigned',
|
||||||
|
'queue_view_label' => 'Unassigned',
|
||||||
|
'tenant_prefilter_source' => 'active_tenant_context',
|
||||||
|
'tenant_label' => $tenantB->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component->callAction('clear_tenant_filter')
|
||||||
|
->assertCanSeeTableRecords([$findingA, $findingB]);
|
||||||
|
|
||||||
|
expect($component->instance()->appliedScope())->toBe([
|
||||||
|
'workspace_scoped' => true,
|
||||||
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
||||||
|
'queue_view' => 'unassigned',
|
||||||
|
'queue_view_label' => 'Unassigned',
|
||||||
|
'tenant_prefilter_source' => 'none',
|
||||||
|
'tenant_label' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the needs triage view active when clearing the tenant prefilter', function (): void {
|
||||||
|
[$user, $tenantA] = findingsIntakeActingUser();
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Beta Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$tenantATriage = makeIntakeFinding($tenantA, [
|
||||||
|
'subject_external_id' => 'tenant-a-triage',
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
$tenantBTriage = makeIntakeFinding($tenantB, [
|
||||||
|
'subject_external_id' => 'tenant-b-triage',
|
||||||
|
'status' => Finding::STATUS_REOPENED,
|
||||||
|
'reopened_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
$tenantBBacklog = makeIntakeFinding($tenantB, [
|
||||||
|
'subject_external_id' => 'tenant-b-backlog',
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||||
|
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = findingsIntakePage($user, ['view' => 'needs_triage'])
|
||||||
|
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||||
|
->assertCanSeeTableRecords([$tenantBTriage])
|
||||||
|
->assertCanNotSeeTableRecords([$tenantATriage, $tenantBBacklog]);
|
||||||
|
|
||||||
|
expect($component->instance()->appliedScope())->toBe([
|
||||||
|
'workspace_scoped' => true,
|
||||||
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
||||||
|
'queue_view' => 'needs_triage',
|
||||||
|
'queue_view_label' => 'Needs triage',
|
||||||
|
'tenant_prefilter_source' => 'active_tenant_context',
|
||||||
|
'tenant_label' => $tenantB->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component->callAction('clear_tenant_filter')
|
||||||
|
->assertCanSeeTableRecords([$tenantBTriage, $tenantATriage], inOrder: true)
|
||||||
|
->assertCanNotSeeTableRecords([$tenantBBacklog]);
|
||||||
|
|
||||||
|
expect($component->instance()->appliedScope())->toBe([
|
||||||
|
'workspace_scoped' => true,
|
||||||
|
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
||||||
|
'queue_view' => 'needs_triage',
|
||||||
|
'queue_view_label' => 'Needs triage',
|
||||||
|
'tenant_prefilter_source' => 'none',
|
||||||
|
'tenant_label' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
|
||||||
|
|
||||||
|
expect($queueViews['unassigned']['active'])->toBeFalse()
|
||||||
|
->and($queueViews['needs_triage']['active'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates needs triage from the remaining backlog and keeps deterministic urgency ordering', function (): void {
|
||||||
|
[$user, $tenant] = findingsIntakeActingUser();
|
||||||
|
|
||||||
|
$overdue = makeIntakeFinding($tenant, [
|
||||||
|
'subject_external_id' => 'overdue',
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reopened = makeIntakeFinding($tenant, [
|
||||||
|
'subject_external_id' => 'reopened',
|
||||||
|
'status' => Finding::STATUS_REOPENED,
|
||||||
|
'reopened_at' => now()->subHours(2),
|
||||||
|
'due_at' => now()->addDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newFinding = makeIntakeFinding($tenant, [
|
||||||
|
'subject_external_id' => 'new-finding',
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'due_at' => now()->addDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$remainingBacklog = makeIntakeFinding($tenant, [
|
||||||
|
'subject_external_id' => 'remaining-backlog',
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->addHours(12),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$undatedBacklog = makeIntakeFinding($tenant, [
|
||||||
|
'subject_external_id' => 'undated-backlog',
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'due_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
findingsIntakePage($user)
|
||||||
|
->assertCanSeeTableRecords([$overdue, $reopened, $newFinding, $remainingBacklog, $undatedBacklog], inOrder: true);
|
||||||
|
|
||||||
|
findingsIntakePage($user, ['view' => 'needs_triage'])
|
||||||
|
->assertCanSeeTableRecords([$reopened, $newFinding], inOrder: true)
|
||||||
|
->assertCanNotSeeTableRecords([$overdue, $remainingBacklog, $undatedBacklog]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds tenant detail drilldowns with intake continuity', function (): void {
|
||||||
|
[$user, $tenant] = findingsIntakeActingUser();
|
||||||
|
|
||||||
|
$finding = makeIntakeFinding($tenant, [
|
||||||
|
'subject_external_id' => 'continuity',
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = findingsIntakePage($user, [
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
'view' => 'needs_triage',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$detailUrl = $component->instance()->getTable()->getRecordUrl($finding);
|
||||||
|
|
||||||
|
expect($detailUrl)->toContain(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
||||||
|
->and($detailUrl)->toContain('nav%5Bback_label%5D=Back+to+findings+intake');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get($detailUrl)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Back to findings intake');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both intake empty-state branches with the correct single recovery action', function (): void {
|
||||||
|
[$user, $tenantA] = findingsIntakeActingUser();
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Work Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
makeIntakeFinding($tenantB, [
|
||||||
|
'subject_external_id' => 'available-elsewhere',
|
||||||
|
]);
|
||||||
|
|
||||||
|
findingsIntakePage($user, [
|
||||||
|
'tenant' => (string) $tenantA->external_id,
|
||||||
|
])
|
||||||
|
->assertSee('No intake findings match this tenant scope')
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']);
|
||||||
|
|
||||||
|
Finding::query()->delete();
|
||||||
|
session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
findingsIntakePage($user)
|
||||||
|
->assertSee('Shared intake is clear')
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['open_my_findings_empty']);
|
||||||
|
});
|
||||||
@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\Findings\FindingEventNotification;
|
||||||
|
use App\Services\Findings\FindingNotificationService;
|
||||||
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
function latestFindingNotificationFor(User $user): ?\Illuminate\Notifications\DatabaseNotification
|
||||||
|
{
|
||||||
|
return $user->notifications()
|
||||||
|
->where('type', FindingEventNotification::class)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingNotificationCountFor(User $user, string $eventType): int
|
||||||
|
{
|
||||||
|
return $user->notifications()
|
||||||
|
->where('type', FindingEventNotification::class)
|
||||||
|
->get()
|
||||||
|
->filter(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === $eventType)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runEvaluateAlertsForWorkspace(int $workspaceId): void
|
||||||
|
{
|
||||||
|
$operationRun = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'alerts.evaluate',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId, (int) $operationRun->getKey());
|
||||||
|
$job->handle(
|
||||||
|
app(\App\Services\Alerts\AlertDispatchService::class),
|
||||||
|
app(\App\Services\OperationRunService::class),
|
||||||
|
app(FindingNotificationService::class),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('emits assignment notifications only when a new assignee is committed', function (): void {
|
||||||
|
[$owner, $tenant] = $this->actingAsFindingOperator();
|
||||||
|
$firstAssignee = User::factory()->create(['name' => 'First Assignee']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $firstAssignee, role: 'operator');
|
||||||
|
|
||||||
|
$secondAssignee = User::factory()->create(['name' => 'Second Assignee']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $secondAssignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED, [
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workflow = app(FindingWorkflowService::class);
|
||||||
|
|
||||||
|
$workflow->assign(
|
||||||
|
finding: $finding,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $owner,
|
||||||
|
assigneeUserId: (int) $firstAssignee->getKey(),
|
||||||
|
ownerUserId: (int) $owner->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$firstNotification = latestFindingNotificationFor($firstAssignee);
|
||||||
|
|
||||||
|
expect($firstNotification)->not->toBeNull()
|
||||||
|
->and(data_get($firstNotification?->data, 'title'))->toBe('Finding assigned')
|
||||||
|
->and(data_get($firstNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||||
|
|
||||||
|
$workflow->assign(
|
||||||
|
finding: $finding->fresh(),
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $owner,
|
||||||
|
assigneeUserId: (int) $firstAssignee->getKey(),
|
||||||
|
ownerUserId: (int) $secondAssignee->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$workflow->assign(
|
||||||
|
finding: $finding->fresh(),
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $owner,
|
||||||
|
assigneeUserId: null,
|
||||||
|
ownerUserId: (int) $secondAssignee->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findingNotificationCountFor($firstAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1);
|
||||||
|
|
||||||
|
$workflow->assign(
|
||||||
|
finding: $finding->fresh(),
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $owner,
|
||||||
|
assigneeUserId: (int) $secondAssignee->getKey(),
|
||||||
|
ownerUserId: (int) $secondAssignee->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$secondNotification = latestFindingNotificationFor($secondAssignee);
|
||||||
|
|
||||||
|
expect($secondNotification)->not->toBeNull()
|
||||||
|
->and(data_get($secondNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
||||||
|
->and(findingNotificationCountFor($secondAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes repeated reopen dispatches for the same reopen occurrence', function (): void {
|
||||||
|
$now = CarbonImmutable::parse('2026-04-22T09:30:00Z');
|
||||||
|
CarbonImmutable::setTestNow($now);
|
||||||
|
|
||||||
|
[$owner, $tenant] = $this->actingAsFindingOperator();
|
||||||
|
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED, [
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reopened = app(FindingWorkflowService::class)->reopenBySystem(
|
||||||
|
finding: $finding,
|
||||||
|
tenant: $tenant,
|
||||||
|
reopenedAt: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1);
|
||||||
|
|
||||||
|
app(FindingNotificationService::class)->dispatch($reopened->fresh(), AlertRule::EVENT_FINDINGS_REOPENED);
|
||||||
|
|
||||||
|
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends due soon and overdue notifications once per due cycle and resets when due_at changes', function (): void {
|
||||||
|
$now = CarbonImmutable::parse('2026-04-22T10:00:00Z');
|
||||||
|
CarbonImmutable::setTestNow($now);
|
||||||
|
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$workspaceId = (int) $tenant->workspace_id;
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$assignee = User::factory()->create(['name' => 'Due Soon Operator']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$dueSoonFinding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
'due_at' => $now->addHours(6),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$overdueFinding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'due_at' => $now->subHours(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$closedFinding = Finding::factory()->for($tenant)->closed()->create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
'due_at' => $now->subHours(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
runEvaluateAlertsForWorkspace($workspaceId);
|
||||||
|
runEvaluateAlertsForWorkspace($workspaceId);
|
||||||
|
|
||||||
|
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(1)
|
||||||
|
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
|
||||||
|
|
||||||
|
expect($assignee->notifications()
|
||||||
|
->where('type', FindingEventNotification::class)
|
||||||
|
->get()
|
||||||
|
->contains(fn ($notification): bool => (int) data_get($notification->data, 'finding_event.finding_id') === (int) $closedFinding->getKey()))
|
||||||
|
->toBeFalse();
|
||||||
|
|
||||||
|
$dueSoonFinding->forceFill([
|
||||||
|
'due_at' => $now->addHours(12),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$overdueFinding->forceFill([
|
||||||
|
'due_at' => $now->addDay()->subHour(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
runEvaluateAlertsForWorkspace($workspaceId);
|
||||||
|
|
||||||
|
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(2)
|
||||||
|
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
|
||||||
|
|
||||||
|
$dueSoonFinding->forceFill([
|
||||||
|
'due_at' => $now->addDays(5),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow($now->addDays(2));
|
||||||
|
$overdueFinding->forceFill([
|
||||||
|
'due_at' => CarbonImmutable::now('UTC')->subHour(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
runEvaluateAlertsForWorkspace($workspaceId);
|
||||||
|
|
||||||
|
expect(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(2);
|
||||||
|
});
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\Findings\FindingEventNotification;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Findings\FindingNotificationService;
|
||||||
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
return $user->notifications()
|
||||||
|
->where('type', FindingEventNotification::class)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('uses the documented recipient precedence for assignment reopen due soon and overdue', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$service = app(FindingNotificationService::class);
|
||||||
|
|
||||||
|
$assignee = User::factory()->create(['name' => 'Assignee']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
'due_at' => now()->addHours(6),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||||
|
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
|
||||||
|
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
||||||
|
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||||
|
|
||||||
|
expect(dispatchedFindingNotificationsFor($assignee)
|
||||||
|
->pluck('data.finding_event.event_type')
|
||||||
|
->all())
|
||||||
|
->toContain(AlertRule::EVENT_FINDINGS_ASSIGNED, AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
||||||
|
|
||||||
|
expect(dispatchedFindingNotificationsFor($owner)
|
||||||
|
->pluck('data.finding_event.event_type')
|
||||||
|
->all())
|
||||||
|
->toContain(AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||||
|
|
||||||
|
$fallbackFinding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_REOPENED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'due_at' => now()->addHours(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_REOPENED);
|
||||||
|
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
||||||
|
|
||||||
|
$ownerEventTypes = dispatchedFindingNotificationsFor($owner)
|
||||||
|
->pluck('data.finding_event.event_type')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($ownerEventTypes)->toContain(AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses direct delivery when the preferred recipient loses tenant access', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$assignee = User::factory()->create(['name' => 'Removed Assignee']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_REOPENED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
TenantMembership::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('user_id', (int) $assignee->getKey())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
app(CapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
|
||||||
|
|
||||||
|
expect($result['direct_delivery_status'])->toBe('suppressed')
|
||||||
|
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
||||||
|
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not broaden delivery to the owner when the assignee is present but no longer entitled', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$assignee = User::factory()->create(['name' => 'Current Assignee']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
'due_at' => now()->addHours(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = \Mockery::mock(CapabilityResolver::class);
|
||||||
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$resolver->shouldReceive('can')
|
||||||
|
->andReturnUsing(function (User $user): bool {
|
||||||
|
return $user->name !== 'Current Assignee';
|
||||||
|
});
|
||||||
|
app()->instance(CapabilityResolver::class, $resolver);
|
||||||
|
|
||||||
|
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
||||||
|
|
||||||
|
expect($result['direct_delivery_status'])->toBe('suppressed')
|
||||||
|
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
||||||
|
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses owner-only assignment edits and assignee clears from creating direct notifications', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$replacementOwner = User::factory()->create(['name' => 'Replacement Owner']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workflow = app(FindingWorkflowService::class);
|
||||||
|
|
||||||
|
$workflow->assign(
|
||||||
|
finding: $finding,
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $owner,
|
||||||
|
assigneeUserId: (int) $assignee->getKey(),
|
||||||
|
ownerUserId: (int) $replacementOwner->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$workflow->assign(
|
||||||
|
finding: $finding->fresh(),
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $owner,
|
||||||
|
assigneeUserId: null,
|
||||||
|
ownerUserId: (int) $replacementOwner->getKey(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
||||||
|
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0)
|
||||||
|
->and(dispatchedFindingNotificationsFor($replacementOwner))->toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses due notifications for terminal findings', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->closed()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $owner->getKey(),
|
||||||
|
'due_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||||
|
|
||||||
|
expect($result['direct_delivery_status'])->toBe('suppressed')
|
||||||
|
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends one direct notification when owner and assignee are the same entitled user', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $owner->getKey(),
|
||||||
|
'due_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||||
|
|
||||||
|
expect($result['direct_delivery_status'])->toBe('sent')
|
||||||
|
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(1)
|
||||||
|
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner');
|
||||||
|
});
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\AlertRule;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\TenantMembership;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\Findings\FindingEventNotification;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Findings\FindingNotificationService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
it('stores a filament payload with one tenant finding deep link and recipient reason copy', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||||
|
|
||||||
|
$notification = $assignee->notifications()
|
||||||
|
->where('type', FindingEventNotification::class)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($notification)->not->toBeNull()
|
||||||
|
->and(data_get($notification?->data, 'format'))->toBe('filament')
|
||||||
|
->and(data_get($notification?->data, 'title'))->toBe('Finding assigned')
|
||||||
|
->and(data_get($notification?->data, 'actions.0.label'))->toBe('Open finding')
|
||||||
|
->and(data_get($notification?->data, 'actions.0.url'))
|
||||||
|
->toBe(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
||||||
|
->and(data_get($notification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
||||||
|
->and(data_get($notification?->data, 'finding_event.recipient_reason'))->toBe('new_assignee');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when a finding notification link is opened after tenant access is removed', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$assignee = User::factory()->create(['name' => 'Removed Operator']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||||
|
|
||||||
|
$url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url');
|
||||||
|
|
||||||
|
TenantMembership::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('user_id', (int) $assignee->getKey())
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
app(CapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
$this->actingAs($assignee)
|
||||||
|
->get($url)
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 when a finding notification link is opened by an in-scope member without findings view capability', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$assignee = User::factory()->create(['name' => 'Scoped Operator']);
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'owner_user_id' => (int) $owner->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||||
|
|
||||||
|
$url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url');
|
||||||
|
|
||||||
|
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
|
||||||
|
|
||||||
|
$this->actingAs($assignee);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$this->get($url)->assertForbidden();
|
||||||
|
});
|
||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
**Last reviewed**: 2026-04-21 (added `My Work` candidate family and aligned it with existing promoted work)
|
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224 and aligned the list)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -45,6 +45,8 @@ ## Promoted to Spec
|
|||||||
- Finding Ownership Semantics Clarification → Spec 219 (`finding-ownership-semantics`)
|
- Finding Ownership Semantics Clarification → Spec 219 (`finding-ownership-semantics`)
|
||||||
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
|
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
|
||||||
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
|
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
|
||||||
|
- Findings Intake & Team Queue v1 -> Spec 222 (`findings-intake-team-queue`)
|
||||||
|
- Findings Notifications & Escalation v1 → Spec 224 (`findings-notifications-escalation`)
|
||||||
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||||
@ -360,30 +362,6 @@ ### Tenant Operational Readiness & Status Truth Hierarchy
|
|||||||
|
|
||||||
> Findings execution layer cluster: complementary to existing Spec 154 (`finding-risk-acceptance`). Keep these split so prioritization can pull workflow semantics, operator work surfaces, alerts, external handoff, and later portfolio operating slices independently instead of collapsing them into one oversized "Findings v2" spec.
|
> Findings execution layer cluster: complementary to existing Spec 154 (`finding-risk-acceptance`). Keep these split so prioritization can pull workflow semantics, operator work surfaces, alerts, external handoff, and later portfolio operating slices independently instead of collapsing them into one oversized "Findings v2" spec.
|
||||||
|
|
||||||
### Findings Intake & Team Queue v1
|
|
||||||
- **Type**: workflow execution / team operations
|
|
||||||
- **Source**: findings execution layer candidate pack 2026-04-17; missing intake surface before personal assignment
|
|
||||||
- **Problem**: A personal inbox does not solve how new or unassigned findings enter the workflow. Operators need an intake surface before work is personally assigned.
|
|
||||||
- **Why it matters**: Without intake, backlog triage stays hidden in general-purpose lists and unassigned work becomes easy to ignore or duplicate.
|
|
||||||
- **Proposed direction**: Introduce unassigned and needs-triage views, an optional claim action, and basic shared-worklist conventions; use filters or tabs that clearly separate intake from active execution; make the difference between unowned backlog and personally assigned work explicit.
|
|
||||||
- **Explicit non-goals**: Full team model, capacity planning, auto-routing, and load-balancing logic.
|
|
||||||
- **Dependencies**: Ownership semantics, findings filters/tabs, open-status definitions.
|
|
||||||
- **Roadmap fit**: Findings Workflow v2; prerequisite for a broader team operating model.
|
|
||||||
- **Strategic sequencing**: Third, after personal inbox foundations exist.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Findings Notifications & Escalation v1
|
|
||||||
- **Type**: alerts / workflow execution
|
|
||||||
- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment metadata and actionable control loop
|
|
||||||
- **Problem**: Assignment, reopen, due, and overdue states currently risk becoming silent metadata unless operators keep polling findings views.
|
|
||||||
- **Why it matters**: Due dates without reminders or escalation are visibility, not control. Existing alert foundations only create operator value if findings workflow emits actionable events.
|
|
||||||
- **Proposed direction**: Add notifications for assignment, system-driven reopen, due-soon, and overdue states; introduce minimal escalation to owner or a defined role; explicitly consume the existing alert and notification infrastructure rather than building a findings-specific delivery system.
|
|
||||||
- **Explicit non-goals**: Multi-stage escalation chains, a large notification-preference center, and bidirectional ticket synchronization.
|
|
||||||
- **Dependencies**: Ownership semantics, operator inbox/intake surfaces, due/SLA logic, alert plumbing.
|
|
||||||
- **Roadmap fit**: Findings workflow hardening on top of the existing alerting foundation.
|
|
||||||
- **Strategic sequencing**: After inbox and intake exist so notifications land on meaningful destinations.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Assignment Hygiene & Stale Work Detection
|
### Assignment Hygiene & Stale Work Detection
|
||||||
- **Type**: workflow hardening / operations hygiene
|
- **Type**: workflow hardening / operations hygiene
|
||||||
- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis
|
- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis
|
||||||
|
|||||||
@ -1,199 +0,0 @@
|
|||||||
# Implementation Plan: Finding Ownership Semantics Clarification
|
|
||||||
|
|
||||||
**Branch**: `001-finding-ownership-semantics` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
|
|
||||||
**Input**: Feature specification from `/specs/001-finding-ownership-semantics/spec.md`
|
|
||||||
|
|
||||||
**Note**: The setup script reported a numeric-prefix collision with `001-rbac-onboarding`, but it still resolved the active branch and plan path correctly to this feature directory. Planning continues against the current branch path.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Clarify the meaning of finding owner versus finding assignee across the existing tenant findings list, detail surface, responsibility-update flows, and exception-request context without adding new persistence, capabilities, or workflow services. The implementation will reuse the existing `owner_user_id` and `assignee_user_id` fields, add a derived responsibility-state presentation layer on top of current data, tighten operator-facing copy and audit/feedback wording, preserve tenant-safe Filament behavior, and extend focused Pest + Livewire coverage for list/detail semantics, responsibility updates, and exception-owner boundary cases.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4.15 / Laravel 12
|
|
||||||
**Primary Dependencies**: Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
|
||||||
**Storage**: PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned
|
|
||||||
**Testing**: Pest v4 feature and Livewire component tests via `./vendor/bin/sail artisan test --compact`
|
|
||||||
**Validation Lanes**: fast-feedback, confidence
|
|
||||||
**Target Platform**: Laravel monolith in Sail/Docker locally; Dokploy-hosted Linux deployment for staging/production
|
|
||||||
**Project Type**: Laravel monolith / Filament admin application
|
|
||||||
**Performance Goals**: No new remote calls, no new queued work, no material list/detail query growth beyond current eager loading of owner, assignee, and exception-owner relations
|
|
||||||
**Constraints**: Keep responsibility state derived rather than persisted; preserve existing tenant membership validation; preserve deny-as-not-found tenant isolation; do not split capabilities or add new abstractions; keep destructive-action confirmations unchanged
|
|
||||||
**Scale/Scope**: 1 primary Filament resource, 1 model helper or equivalent derived-state mapping, 1 workflow/audit wording touchpoint, 1 exception-owner wording boundary, and 2 focused new/expanded feature test families
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: changed surfaces
|
|
||||||
- **Native vs custom classification summary**: native
|
|
||||||
- **Shared-family relevance**: existing tenant findings resource and exception-context surfaces only
|
|
||||||
- **State layers in scope**: page, detail, URL-query
|
|
||||||
- **Handling modes by drift class or surface**: review-mandatory
|
|
||||||
- **Repository-signal treatment**: review-mandatory
|
|
||||||
- **Special surface test profiles**: standard-native-filament
|
|
||||||
- **Required tests or manual smoke**: functional-core, state-contract
|
|
||||||
- **Exception path and spread control**: none; the feature reuses existing resource/table/infolist/action primitives and keeps exception-owner semantics local to the finding context
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
||||||
|
|
||||||
- Inventory-first: PASS. The feature only clarifies operator semantics on tenant-owned findings and exception artifacts; it does not change observed-state or snapshot truth.
|
|
||||||
- Read/write separation: PASS. Responsibility updates remain TenantPilot-only writes on existing finding records; no Microsoft tenant mutation is introduced. Existing destructive-like actions keep their current confirmation rules.
|
|
||||||
- Graph contract path: PASS / N/A. No Graph calls or contract-registry changes are involved.
|
|
||||||
- Deterministic capabilities: PASS. Existing canonical findings capabilities remain the source of truth; no new capability split is introduced.
|
|
||||||
- RBAC-UX: PASS. Tenant-context membership remains the isolation boundary. Non-members remain 404 and in-scope members missing `TENANT_FINDINGS_ASSIGN` remain 403 for responsibility mutations.
|
|
||||||
- Workspace isolation: PASS. The feature remains inside tenant-context findings flows and preserves existing workspace-context stabilization patterns.
|
|
||||||
- Global search: PASS. `FindingResource` already has a `view` page, so any existing global-search participation remains compliant. This feature does not add or remove global search.
|
|
||||||
- Tenant isolation: PASS. All reads and writes remain tenant-scoped and continue to restrict owner/assignee selections to current tenant members.
|
|
||||||
- Run observability / Ops-UX: PASS / N/A. No new long-running, queued, or remote work is introduced and no `OperationRun` behavior changes.
|
|
||||||
- Automation / data minimization: PASS / N/A. No new background automation or payload persistence is introduced.
|
|
||||||
- Test governance (TEST-GOV-001): PASS. The narrowest proving surface is feature-level Filament + workflow coverage with low fixture cost and no heavy-family expansion.
|
|
||||||
- Proportionality / no premature abstraction / persisted truth / behavioral state: PASS. Responsibility state remains derived from existing fields and does not create a persisted enum, new abstraction, or new table.
|
|
||||||
- UI semantics / few layers: PASS. The plan uses direct domain-to-UI mapping on `FindingResource` and an optional local helper on `Finding` rather than a new presenter or taxonomy layer.
|
|
||||||
- Badge semantics (BADGE-001): PASS with restraint. If a new responsibility badge or label is added, it stays local to findings semantics unless multiple consumers later prove centralization is necessary.
|
|
||||||
- Filament-native UI / action surface contract / UX-001: PASS. Existing native Filament tables, infolists, filters, selects, grouped actions, and modals remain the implementation path; the finding remains the sole primary inspect/open model and row click remains the canonical inspect affordance.
|
|
||||||
|
|
||||||
**Post-Phase-1 re-check**: PASS. The design keeps responsibility semantics derived, tenant-safe, and local to the existing findings resource and tests.
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: Feature
|
|
||||||
- **Affected validation lanes**: fast-feedback, confidence
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: The business truth is visible in Filament list/detail rendering, responsibility action behavior, and tenant-scoped audit feedback. Unit-only testing would miss the operator-facing semantics, while browser/heavy-governance coverage would add unnecessary cost.
|
|
||||||
- **Narrowest proving command(s)**:
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: Low. Existing tenant/user/finding factories and tenant membership helpers are sufficient. The only explicit context risk is tenant-panel routing and admin canonical tenant state.
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no; tests should keep tenant context explicit rather than broadening shared fixtures.
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: standard-native relief; explicit tenant-panel routing is required for authorization assertions.
|
|
||||||
- **Closing validation and reviewer handoff**: Re-run the two focused test files plus formatter on dirty files. Reviewers should verify owner versus assignee wording on list/detail surfaces, exception-owner separation, and 404/403 semantics for out-of-scope versus in-scope unauthorized users.
|
|
||||||
- **Budget / baseline / trend follow-up**: none
|
|
||||||
- **Review-stop questions**: lane fit, hidden fixture cost, accidental presenter growth, tenant-context drift in tests
|
|
||||||
- **Escalation path**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Why no dedicated follow-up spec is needed**: This work stays inside the existing findings responsibility contract. Only future queue/team-routing or capability-split work would justify a separate spec.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/001-finding-ownership-semantics/
|
|
||||||
├── plan.md
|
|
||||||
├── spec.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ └── finding-responsibility.openapi.yaml
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ └── Resources/
|
|
||||||
│ │ └── FindingResource.php # MODIFY: list/detail labels, derived responsibility state, filters, action help text, exception-owner wording
|
|
||||||
│ ├── Models/
|
|
||||||
│ │ └── Finding.php # MODIFY: local derived responsibility-state helper if needed
|
|
||||||
│ └── Services/
|
|
||||||
│ └── Findings/
|
|
||||||
│ ├── FindingWorkflowService.php # MODIFY: mutation feedback / audit wording for owner-only vs assignee-only changes
|
|
||||||
│ ├── FindingExceptionService.php # MODIFY: request-exception wording if the exception-owner boundary needs alignment
|
|
||||||
│ └── FindingRiskGovernanceResolver.php # MODIFY: next-action copy to reflect orphaned-accountability semantics
|
|
||||||
└── tests/
|
|
||||||
└── Feature/
|
|
||||||
├── Filament/
|
|
||||||
│ └── Resources/
|
|
||||||
│ └── FindingResourceOwnershipSemanticsTest.php # NEW: list/detail rendering, filters, exception-owner distinction, tenant-safe semantics
|
|
||||||
└── Findings/
|
|
||||||
├── FindingAssignmentAuditSemanticsTest.php # NEW: owner-only, assignee-only, combined update feedback/audit semantics
|
|
||||||
├── FindingWorkflowRowActionsTest.php # MODIFY: assignment form/help-text semantics and member validation coverage
|
|
||||||
└── FindingWorkflowServiceTest.php # MODIFY: audit metadata and responsibility-mutation expectations
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith. The implementation is a targeted semantics pass over the current findings resource and workflow tests; no new folders, packages, or service families are required.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| — | — | — |
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: Operators cannot tell whether a finding change transferred accountability, active remediation work, or only exception ownership.
|
|
||||||
- **Existing structure is insufficient because**: The existing fields and actions exist, but the current UI copy and derived next-step language do not establish a stable contract across list, detail, and exception flows.
|
|
||||||
- **Narrowest correct implementation**: Reuse the existing `owner_user_id` and `assignee_user_id` fields, derive responsibility state from them, and tighten wording on existing Filament surfaces and audit feedback.
|
|
||||||
- **Ownership cost created**: Low ongoing UI/test maintenance to keep future findings work aligned with the clarified contract.
|
|
||||||
- **Alternative intentionally rejected**: A new ownership framework, queue model, or capability split was rejected because the current product has not yet exhausted the simpler owner-versus-assignee model.
|
|
||||||
- **Release truth**: Current-release truth
|
|
||||||
|
|
||||||
## Phase 0 — Research (output: `research.md`)
|
|
||||||
|
|
||||||
See: [research.md](./research.md)
|
|
||||||
|
|
||||||
Research goals:
|
|
||||||
- Confirm the existing source of truth for owner, assignee, and exception owner.
|
|
||||||
- Confirm the smallest derived responsibility-state model that fits the current schema.
|
|
||||||
- Confirm the existing findings tests and Filament routing pitfalls to avoid false negatives.
|
|
||||||
- Confirm which operator-facing wording changes belong in resource copy versus workflow service feedback.
|
|
||||||
|
|
||||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
|
||||||
|
|
||||||
See:
|
|
||||||
- [data-model.md](./data-model.md)
|
|
||||||
- [contracts/finding-responsibility.openapi.yaml](./contracts/finding-responsibility.openapi.yaml)
|
|
||||||
- [quickstart.md](./quickstart.md)
|
|
||||||
|
|
||||||
Design focus:
|
|
||||||
- Keep responsibility truth on existing finding and finding-exception records.
|
|
||||||
- Model responsibility state as a derived projection over owner and assignee presence rather than a persisted enum.
|
|
||||||
- Preserve exception owner as a separate governance concept when shown from a finding context.
|
|
||||||
- Keep tenant membership validation and existing `FindingWorkflowService::assign()` semantics as the mutation boundary.
|
|
||||||
|
|
||||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
|
||||||
|
|
||||||
### Surface semantics pass
|
|
||||||
- Update the findings list column hierarchy so owner and assignee meaning is explicit at first scan.
|
|
||||||
- Add a derived responsibility-state label or equivalent summary on list/detail surfaces.
|
|
||||||
- Keep exception owner visibly separate from finding owner wherever both appear.
|
|
||||||
|
|
||||||
### Responsibility mutation clarity
|
|
||||||
- Add owner/assignee help text to assignment flows.
|
|
||||||
- Differentiate owner-only, assignee-only, and combined responsibility changes in operator feedback and audit-facing wording.
|
|
||||||
- Keep current tenant-member validation and open-finding restrictions unchanged.
|
|
||||||
|
|
||||||
### Personal-work and next-action alignment
|
|
||||||
- Add or refine personal-work filters so assignee-based work and owner-based accountability are explicitly separate.
|
|
||||||
- Update next-action copy for owner-missing states so assignee-only findings are treated as accountability gaps.
|
|
||||||
|
|
||||||
### Regression protection
|
|
||||||
- Add focused list/detail rendering tests for owner-only, assignee-only, both-set, same-user, and both-null states.
|
|
||||||
- Add focused responsibility-update tests for owner-only, assignee-only, and combined changes.
|
|
||||||
- Preserve tenant-context and authorization regression coverage using explicit Filament panel routing where needed.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
- Run the two focused Pest files and any directly modified sibling findings tests.
|
|
||||||
- Run Pint on dirty files through Sail.
|
|
||||||
|
|
||||||
## Constitution Check (Post-Design)
|
|
||||||
|
|
||||||
Re-check result: PASS. The design stays inside the existing findings domain, preserves tenant isolation and capability enforcement, avoids new persisted truth or semantic framework growth, and keeps responsibility state derived from current fields.
|
|
||||||
|
|
||||||
## Filament v5 Agent Output Contract
|
|
||||||
|
|
||||||
1. **Livewire v4.0+ compliance**: Yes. The feature only adjusts existing Filament v5 resources/pages/actions that already run on Livewire v4.0+.
|
|
||||||
2. **Provider registration location**: No new panel or service providers are needed. Existing Filament providers remain registered in `apps/platform/bootstrap/providers.php`.
|
|
||||||
3. **Global search**: `FindingResource` already has a `view` page via `getPages()`, so any existing global-search participation remains compliant. This feature does not enable new global search.
|
|
||||||
4. **Destructive actions and authorization**: No new destructive actions are introduced. Existing destructive-like findings actions remain server-authorized and keep `->requiresConfirmation()` where already required. Responsibility updates continue to enforce tenant membership and the canonical findings capability registry.
|
|
||||||
5. **Asset strategy**: No new frontend assets or published views. The feature uses existing Filament tables, infolists, filters, and action modals, so deployment asset handling stays unchanged and no new `filament:assets` step is added.
|
|
||||||
6. **Testing plan**: Cover the change with focused Pest feature tests for findings resource responsibility semantics, assignment/audit wording, and existing workflow regression surfaces. No browser or heavy-governance expansion is planned.
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
# Feature Specification: Finding Ownership Semantics Clarification
|
|
||||||
|
|
||||||
**Feature Branch**: `001-finding-ownership-semantics`
|
|
||||||
**Created**: 2026-04-20
|
|
||||||
**Status**: Draft
|
|
||||||
**Input**: User description: "Finding Ownership Semantics Clarification"
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: Open findings already store both an owner and an assignee, but the product does not yet state clearly which field represents accountability and which field represents active execution.
|
|
||||||
- **Today's failure**: Operators can see or change responsibility on a finding without being sure whether they transferred accountability, execution work, or only exception ownership, which makes triage slower and audit language less trustworthy.
|
|
||||||
- **User-visible improvement**: Findings surfaces make accountable owner, active assignee, and exception owner visibly distinct, so operators can route work faster and read ownership changes honestly.
|
|
||||||
- **Smallest enterprise-capable version**: Clarify the existing responsibility contract on current findings list/detail/action surfaces, add explicit derived responsibility states, and align audit and filter language without creating new roles, queues, or persistence.
|
|
||||||
- **Explicit non-goals**: No team queues, no escalation engine, no automatic reassignment hygiene, no new capability split, no new ownership framework, and no mandatory backfill before rollout.
|
|
||||||
- **Permanent complexity imported**: One explicit product contract for finding owner versus assignee, one derived responsibility-state vocabulary, and focused acceptance tests for mixed ownership contexts.
|
|
||||||
- **Why now**: The next findings execution slices depend on honest ownership semantics, and the ambiguity already affects triage, reassignment, and exception routing in today’s tenant workflow.
|
|
||||||
- **Why not local**: A one-surface copy fix would leave contradictory meaning across the findings list, detail view, assignment flows, personal work cues, and exception-request language.
|
|
||||||
- **Approval class**: Core Enterprise
|
|
||||||
- **Red flags triggered**: None after scope cut. This spec clarifies existing fields instead of introducing a new semantics axis.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: tenant
|
|
||||||
- **Primary Routes**: `/admin/t/{tenant}/findings`, `/admin/t/{tenant}/findings/{finding}`
|
|
||||||
- **Data Ownership**: Tenant-owned findings remain the source of truth; this spec also covers exception ownership only when it is shown or edited from a finding-context surface.
|
|
||||||
- **RBAC**: Tenant membership is required for visibility. Tenant findings view permission gates read access. Tenant findings assign permission gates owner and assignee changes. Non-members or cross-tenant requests remain deny-as-not-found. Members without mutation permission receive an explicit authorization failure.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| Tenant findings list and grouped bulk actions | yes | Native Filament + existing UI enforcement helpers | Same tenant work-queue family as other tenant resources | table, bulk-action modal, notification copy | no | Clarifies an existing resource instead of adding a new page |
|
|
||||||
| Finding detail and single-record action modals | yes | Native Filament infolist + action modals | Same finding resource family | detail, action modal, helper copy | no | Keeps one decision flow inside the current resource |
|
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator reviews the open backlog and decides who owns the outcome and who should act next | Severity, lifecycle status, due state, owner, assignee, and derived responsibility state | Full evidence, audit trail, run metadata, and exception details | Primary because it is the queue where responsibility is routed at scale | Follows the operator’s “what needs action now?” workflow instead of raw data lineage | Removes the need to open each record just to tell who is accountable versus merely assigned |
|
|
||||||
| Finding detail and single-record action modals | Secondary Context Surface | A tenant operator verifies one finding before reassigning work or requesting an exception | Finding summary, current owner, current assignee, exception owner when applicable, and next workflow action | Raw evidence, historical context, and additional governance details | Secondary because it resolves one case after the list has already identified the work item | Preserves the single-record decision loop without creating a separate ownership page | Avoids cross-page reconstruction when ownership and exception context must be compared together |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or update responsibility | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain inside grouped actions with confirmation where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, filters, severity, due state, workflow state | Findings / Finding | Accountable owner, active assignee, and whether the finding is assigned, owned-but-unassigned, or orphaned | none |
|
|
||||||
| Finding detail and single-record action modals | Record / Detail / Actions | View-first operational detail | Confirm or update responsibility for one finding | Finding | N/A - detail surface | Structured header actions on the existing record page | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, lifecycle state, due state, governance context | Findings / Finding | Finding owner, finding assignee, and exception owner stay visibly distinct in the same context | none |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route responsibility on open findings and separate accountable ownership from active work | Workflow queue | Who owns this outcome, who is doing the work, and what needs reassignment now? | Severity, lifecycle state, due state, owner, assignee, derived responsibility state, and personal-work cues | Raw evidence, run identifiers, historical audit details | lifecycle, severity, governance validity, responsibility state | TenantPilot only | Open finding, Assign, Triage, Start progress | Resolve, Close, Request exception |
|
|
||||||
| Finding detail and single-record action modals | Tenant operator or tenant manager | Verify and change responsibility for one finding without losing exception context | Detail/action surface | Is this finding owned by the right person, assigned to the right executor, and clearly separate from any exception owner? | Finding summary, owner, assignee, exception owner when present, due state, lifecycle state | Raw evidence payloads, extended audit history, related run metadata | lifecycle, governance validity, responsibility state | TenantPilot only | Assign, Start progress, Resolve | Close, Request exception |
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: no
|
|
||||||
- **New enum/state/reason family?**: no persisted family; only derived responsibility-state rules on existing fields
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: Operators cannot reliably distinguish accountability from active execution, which creates avoidable misrouting and weak audit interpretation.
|
|
||||||
- **Existing structure is insufficient because**: Two existing user fields and an exception-owner concept can already appear in the same workflow, but current copy does not establish a stable product contract across list, detail, and action surfaces.
|
|
||||||
- **Narrowest correct implementation**: Reuse the existing finding owner and assignee fields, define their meaning, and apply that meaning consistently to labels, filters, derived states, and audit copy.
|
|
||||||
- **Ownership cost**: Focused UI copy review, acceptance tests for role combinations, and ongoing discipline to keep future findings work aligned with the same contract.
|
|
||||||
- **Alternative intentionally rejected**: A broader ownership framework with separate role types, queues, or team routing was rejected because it adds durable complexity before the product has proven the simpler owner-versus-assignee contract.
|
|
||||||
- **Release truth**: Current-release truth. This spec clarifies the meaning of responsibility on live findings now and becomes the contract for later execution surfaces.
|
|
||||||
|
|
||||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|
||||||
|
|
||||||
- **Test purpose / classification**: Feature
|
|
||||||
- **Validation lane(s)**: fast-feedback, confidence
|
|
||||||
- **Why this classification and these lanes are sufficient**: The change is visible through tenant findings UI behavior, responsibility-state rendering, and audit-facing wording. Focused feature coverage proves the contract without introducing browser or heavy-governance breadth.
|
|
||||||
- **New or expanded test families**: Expand tenant findings resource coverage to include responsibility rendering, personal-work cues, and mixed-context exception-owner wording. Add focused assignment-audit coverage for owner-only, assignee-only, and combined changes.
|
|
||||||
- **Fixture / helper cost impact**: Low. Existing tenant, membership, user, and finding factories are sufficient; tests only need explicit responsibility combinations and tenant-scoped memberships.
|
|
||||||
- **Heavy-family visibility / justification**: none
|
|
||||||
- **Special surface test profile**: standard-native-filament
|
|
||||||
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for owner versus assignee visibility and authorization behavior.
|
|
||||||
- **Reviewer handoff**: Reviewers should confirm that one positive and one negative authorization case exist, that list and detail surfaces use the same vocabulary, and that audit-facing feedback distinguishes owner changes from assignee changes.
|
|
||||||
- **Budget / baseline / trend impact**: none
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Planned validation commands**:
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Route accountable ownership clearly (Priority: P1)
|
|
||||||
|
|
||||||
As a tenant operator, I want to tell immediately who owns a finding and who is actively assigned to it, so I can route remediation work without guessing which responsibility changed.
|
|
||||||
|
|
||||||
**Why this priority**: This is the smallest slice that fixes the core trust gap. Without it, every later execution surface inherits ambiguous meaning.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by loading the tenant findings list and detail pages with findings in owner-only, owner-plus-assignee, and no-owner states, and verifying that the first decision is possible without opening unrelated screens.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an open finding with an owner and no assignee, **When** an operator views the findings list, **Then** the record is shown as owned but unassigned rather than fully assigned.
|
|
||||||
2. **Given** an open finding with both an owner and an assignee, **When** an operator opens the finding detail, **Then** the accountable owner and active assignee are shown as separate roles.
|
|
||||||
3. **Given** an open finding with no owner, **When** an operator views the finding from the list or detail page, **Then** the accountability gap is surfaced as orphaned work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Reassign work without losing accountability (Priority: P2)
|
|
||||||
|
|
||||||
As a tenant manager, I want to change the assignee, the owner, or both in one responsibility update, so I can transfer execution work without silently transferring accountability.
|
|
||||||
|
|
||||||
**Why this priority**: Reassignment is the first mutation where the ambiguity causes operational mistakes and misleading audit history.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by performing responsibility updates on open findings and asserting separate outcomes for owner-only, assignee-only, and combined changes.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the assignee, **Then** the owner remains unchanged and the resulting feedback states that only the assignee changed.
|
|
||||||
2. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the owner, **Then** the assignee remains unchanged and the resulting feedback states that only the owner changed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Keep exception ownership separate (Priority: P3)
|
|
||||||
|
|
||||||
As a governance reviewer, I want exception ownership to remain visibly separate from finding ownership when both appear in one flow, so risk-acceptance work does not overwrite the meaning of the finding owner.
|
|
||||||
|
|
||||||
**Why this priority**: This is a narrower but important boundary that prevents the accepted-risk workflow from reintroducing the same ambiguity under a second owner label.
|
|
||||||
|
|
||||||
**Independent Test**: Can be tested by opening a finding that has, or is about to create, an exception record and verifying that finding owner and exception owner remain distinct in the same operator context.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a finding detail view that shows both finding responsibility and exception context, **When** the operator reviews ownership information, **Then** exception ownership is labeled separately from finding owner.
|
|
||||||
2. **Given** an operator starts an exception request from a finding, **When** the request asks for exception ownership, **Then** the form language makes clear that the selected person owns the exception artifact, not the finding itself.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- A finding may have the same user as both owner and assignee; the UI must show both roles as satisfied without implying duplication or error.
|
|
||||||
- A finding with an assignee but no owner is still an orphaned accountability case, not a healthy assigned state.
|
|
||||||
- Historical or imported findings may have both responsibility fields empty; they must render as orphaned without blocking rollout on a data backfill.
|
|
||||||
- When personal-work cues are shown, assignee-based work and owner-based accountability must not collapse into one ambiguous shortcut.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature changes tenant-scoped finding workflow semantics only. It introduces no Microsoft Graph calls, no scheduled or long-running work, and no new `OperationRun`. Responsibility mutations remain audited tenant-local writes.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** The affected authorization plane is tenant-context only. Cross-tenant and non-member access remain deny-as-not-found. Members without the findings assign permission continue to be blocked from responsibility mutations. The feature reuses the canonical capability registry and does not introduce raw role checks or a new capability split.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001 / Filament Action Surfaces / UX-001):** The feature reuses existing native Filament tables, infolist entries, filters, selects, modal actions, grouped actions, and notifications. No local badge taxonomy or custom action chrome is added. The action-surface contract remains satisfied: the finding is still the one and only primary inspect/open model, row click remains the canonical inspect affordance on the list, no redundant view action is introduced, and existing dangerous actions remain grouped and confirmed where already required.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001 / UI-SEM-001 / TEST-TRUTH-001):** Direct field labels alone are insufficient because finding owner, finding assignee, and exception owner can appear in the same operator flow. This feature resolves that ambiguity by tightening the product vocabulary on the existing domain truth instead of adding a new semantic layer or presenter family. Tests prove visible business consequences rather than thin indirection.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The system MUST treat finding owner as the accountable user responsible for ensuring that a finding reaches a governed terminal outcome.
|
|
||||||
- **FR-002**: The system MUST treat finding assignee as the user currently expected to perform or coordinate active remediation work, and any operator-facing use of `assigned` language MUST refer to this role only.
|
|
||||||
- **FR-003**: Authorized users MUST be able to set, change, or clear finding owner and finding assignee independently on open findings while continuing to restrict selectable people to members of the active tenant.
|
|
||||||
- **FR-004**: The system MUST derive responsibility state from the existing owner and assignee fields without adding a persisted lifecycle field. At minimum it MUST distinguish `owned but unassigned`, `assigned`, and `orphaned accountability`.
|
|
||||||
- **FR-005**: Findings list, finding detail, and responsibility-update flows MUST label owner and assignee distinctly, and any mixed context that also references exception ownership MUST label that role as exception owner.
|
|
||||||
- **FR-006**: When personal-work shortcuts or filters are shown on the tenant findings list, the system MUST expose separate, explicitly named cues for assignee-based work and owner-based accountability.
|
|
||||||
- **FR-007**: Responsibility-update feedback and audit-facing wording MUST state whether a mutation changed the owner, the assignee, or both.
|
|
||||||
- **FR-008**: Historical or imported findings with null or partial responsibility data MUST render using the derived responsibility contract without requiring a blocking data migration before rollout.
|
|
||||||
- **FR-009**: Exception ownership MUST remain a separate governance concept. Creating, viewing, or editing an exception from a finding context MUST NOT silently replace the meaning of the finding owner.
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Tenant findings list | `/admin/t/{tenant}/findings` | None added by this feature | Full-row open into finding detail | Primary open affordance plus `More` action group with `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Request exception` | Grouped actions including `Triage selected`, `Assign selected`, `Resolve selected`, and existing close/exception-safe bulk actions if already present | Existing findings empty state remains; no new CTA introduced | N/A | N/A | Yes for responsibility mutations and existing workflow mutations | Action Surface Contract satisfied. No empty groups or redundant view action introduced. |
|
|
||||||
| Finding detail | `/admin/t/{tenant}/findings/{finding}` | Existing record-level workflow actions only | Entered from the list row click; no separate inspect action added | Structured record actions retain `Assign`, `Start progress`, `Resolve`, `Close`, and `Request exception` | N/A | N/A | Existing view header actions only | N/A | Yes for responsibility mutations and existing workflow mutations | UI-FIL-001 satisfied through existing infolist and action modal primitives. No exemption needed. |
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Finding responsibility**: The visible responsibility contract attached to an open finding, consisting of accountable ownership, active execution assignment, and a derived responsibility state.
|
|
||||||
- **Finding owner**: The accountable person who owns the outcome of the finding and is expected to ensure it reaches a governed end state.
|
|
||||||
- **Finding assignee**: The person currently expected to perform or coordinate the remediation work on the finding.
|
|
||||||
- **Exception owner**: The accountable person for an exception artifact created from a finding; separate from finding owner whenever both appear in one operator context.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: In acceptance review, an authorized operator can identify owner, assignee, and responsibility state for any scripted open-finding example from the list or detail surface within 10 seconds.
|
|
||||||
- **SC-002**: 100% of covered responsibility combinations in automated acceptance tests, including same-person, owner-only, owner-plus-assignee, assignee-only, and both-empty cases, render the correct visible labels and derived state.
|
|
||||||
- **SC-003**: 100% of covered responsibility-update tests distinguish owner-only, assignee-only, and combined changes in operator feedback and audit-facing wording.
|
|
||||||
- **SC-004**: When personal-work shortcuts are present, an operator can isolate assignee-based work and owner-based accountability from the findings list in one interaction each.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- Existing finding owner and assignee fields remain the single source of truth for responsibility in this slice.
|
|
||||||
- Open findings may legitimately begin without an assignee while still needing an accountable owner.
|
|
||||||
- Membership hygiene for users who later lose tenant access is a separate follow-up concern and not solved by this clarification slice.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Introduce team, queue, or workgroup ownership.
|
|
||||||
- Add automatic escalation, reassignment, or inactivity timers.
|
|
||||||
- Split authorization into separate owner-edit and assignee-edit capabilities.
|
|
||||||
- Require a mandatory historical backfill before the clarified semantics can ship.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Spec 111, Findings Workflow + SLA, remains the lifecycle and assignment baseline that this spec clarifies.
|
|
||||||
- Spec 154, Finding Risk Acceptance, remains the exception governance baseline whose owner semantics must stay separate from finding owner.
|
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Findings Intake & Team Queue V1
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-21
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation passed on 2026-04-21 in the first review iteration.
|
||||||
|
- The repository template requires route, authorization, and surface-contract metadata; the spec still avoids code-level implementation, language, and architecture detail beyond those mandatory contract fields.
|
||||||
|
- No clarification markers remain, so the spec is ready for `/speckit.plan`.
|
||||||
@ -0,0 +1,470 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Findings Intake & Team Queue Surface Contract
|
||||||
|
version: 1.1.0
|
||||||
|
description: >-
|
||||||
|
Internal reference contract for the canonical Findings intake queue,
|
||||||
|
the narrow Claim finding shortcut, and intake continuity into tenant
|
||||||
|
finding detail and My Findings. The application continues to return
|
||||||
|
rendered HTML through Filament and Livewire. The vendor media types
|
||||||
|
below document the structured page and action models that must be
|
||||||
|
derivable before rendering. This is not a public API commitment.
|
||||||
|
paths:
|
||||||
|
/admin/findings/intake:
|
||||||
|
get:
|
||||||
|
summary: Canonical shared findings intake queue
|
||||||
|
description: >-
|
||||||
|
Returns the rendered admin-plane intake queue for visible unassigned
|
||||||
|
findings in the current workspace. The page always keeps the fixed
|
||||||
|
intake scope and may apply an active-tenant prefilter. Queue views are
|
||||||
|
limited to Unassigned and Needs triage.
|
||||||
|
responses:
|
||||||
|
'302':
|
||||||
|
description: Redirects into the existing workspace chooser flow when workspace context is not yet established
|
||||||
|
'200':
|
||||||
|
description: Rendered Findings intake page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.findings-intake+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingsIntakePage'
|
||||||
|
'403':
|
||||||
|
description: Workspace membership exists but no currently viewable findings scope exists for intake disclosure anywhere in the workspace
|
||||||
|
'404':
|
||||||
|
description: Workspace scope is not visible because membership is missing or out of scope
|
||||||
|
/admin/findings/intake/{finding}/claim:
|
||||||
|
post:
|
||||||
|
summary: Claim a visible intake finding into personal execution
|
||||||
|
description: >-
|
||||||
|
Logical contract for the Livewire-backed Claim finding row action after
|
||||||
|
the operator has reviewed the lightweight preview/confirmation content.
|
||||||
|
The server must re-check entitlement, assign capability, and current
|
||||||
|
assignee under lock before mutating the record.
|
||||||
|
parameters:
|
||||||
|
- name: finding
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Claim succeeded and the finding left intake
|
||||||
|
content:
|
||||||
|
application/vnd.tenantpilot.findings-intake-claim+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ClaimFindingResult'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in scope but lacks the existing findings assign capability
|
||||||
|
'404':
|
||||||
|
description: Workspace or tenant scope is not visible for the referenced finding
|
||||||
|
'409':
|
||||||
|
description: The row is no longer claimable because another operator claimed it first or it otherwise left intake scope before mutation
|
||||||
|
/admin/t/{tenant}/findings/{finding}:
|
||||||
|
get:
|
||||||
|
summary: Tenant finding detail with intake continuity support
|
||||||
|
description: >-
|
||||||
|
Returns the rendered tenant finding detail page. The logical contract
|
||||||
|
below documents only the continuity inputs required when the page is
|
||||||
|
opened from Findings intake.
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: finding
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: nav
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
style: deepObject
|
||||||
|
explode: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CanonicalNavigationContext'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered tenant finding detail page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.finding-detail-from-intake+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingDetailContinuation'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in scope but lacks the existing findings capability for the tenant detail destination
|
||||||
|
'404':
|
||||||
|
description: Tenant or finding is not visible because workspace or tenant entitlement is missing
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FindingsIntakePage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- header
|
||||||
|
- appliedScope
|
||||||
|
- queueViews
|
||||||
|
- summaryCounts
|
||||||
|
- rows
|
||||||
|
- emptyState
|
||||||
|
properties:
|
||||||
|
header:
|
||||||
|
$ref: '#/components/schemas/IntakeHeader'
|
||||||
|
appliedScope:
|
||||||
|
$ref: '#/components/schemas/IntakeAppliedScope'
|
||||||
|
queueViews:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/QueueViewDefinition'
|
||||||
|
summaryCounts:
|
||||||
|
$ref: '#/components/schemas/IntakeSummaryCounts'
|
||||||
|
rows:
|
||||||
|
description: >-
|
||||||
|
Rows are ordered overdue first, reopened second, new third, then
|
||||||
|
remaining unassigned backlog. Within each bucket, rows with due
|
||||||
|
dates sort by dueAt ascending, rows without due dates sort last,
|
||||||
|
and remaining ties sort by findingId descending.
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/IntakeFindingRow'
|
||||||
|
emptyState:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/IntakeEmptyState'
|
||||||
|
- type: 'null'
|
||||||
|
IntakeHeader:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Findings intake
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
clearTenantFilterAction:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/ActionLink'
|
||||||
|
- type: 'null'
|
||||||
|
IntakeAppliedScope:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- workspaceScoped
|
||||||
|
- fixedScope
|
||||||
|
- queueView
|
||||||
|
- tenantPrefilterSource
|
||||||
|
properties:
|
||||||
|
workspaceScoped:
|
||||||
|
type: boolean
|
||||||
|
fixedScope:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- visible_unassigned_open_findings_only
|
||||||
|
queueView:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- unassigned
|
||||||
|
- needs_triage
|
||||||
|
tenantPrefilterSource:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- active_tenant_context
|
||||||
|
- explicit_filter
|
||||||
|
- none
|
||||||
|
tenantLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
QueueViewDefinition:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- label
|
||||||
|
- fixed
|
||||||
|
- active
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- unassigned
|
||||||
|
- needs_triage
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
fixed:
|
||||||
|
type: boolean
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
badgeCount:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
minimum: 0
|
||||||
|
IntakeSummaryCounts:
|
||||||
|
type: object
|
||||||
|
description: Counts derived only from visible intake rows.
|
||||||
|
required:
|
||||||
|
- visibleUnassigned
|
||||||
|
- visibleNeedsTriage
|
||||||
|
- visibleOverdue
|
||||||
|
properties:
|
||||||
|
visibleUnassigned:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
visibleNeedsTriage:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
visibleOverdue:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
IntakeFindingRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- findingId
|
||||||
|
- tenantId
|
||||||
|
- tenantLabel
|
||||||
|
- summary
|
||||||
|
- severity
|
||||||
|
- status
|
||||||
|
- intakeReason
|
||||||
|
- dueState
|
||||||
|
- detailUrl
|
||||||
|
properties:
|
||||||
|
findingId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
tenantLabel:
|
||||||
|
type: string
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
$ref: '#/components/schemas/Badge'
|
||||||
|
status:
|
||||||
|
$ref: '#/components/schemas/Badge'
|
||||||
|
dueAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
dueState:
|
||||||
|
$ref: '#/components/schemas/DueState'
|
||||||
|
ownerLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
intakeReason:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Unassigned
|
||||||
|
- Needs triage
|
||||||
|
claimAction:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/ClaimFindingAffordance'
|
||||||
|
- type: 'null'
|
||||||
|
detailUrl:
|
||||||
|
type: string
|
||||||
|
navigationContext:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/CanonicalNavigationContext'
|
||||||
|
- type: 'null'
|
||||||
|
ClaimFindingAffordance:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- actionId
|
||||||
|
- label
|
||||||
|
- enabled
|
||||||
|
- requiresConfirmation
|
||||||
|
properties:
|
||||||
|
actionId:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- claim
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Claim finding
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
requiresConfirmation:
|
||||||
|
type: boolean
|
||||||
|
enum:
|
||||||
|
- true
|
||||||
|
confirmationTitle:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
confirmationBody:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
disabledReason:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
DueState:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- tone
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
tone:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- calm
|
||||||
|
- warning
|
||||||
|
- danger
|
||||||
|
IntakeEmptyState:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- body
|
||||||
|
- action
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- no_visible_intake_work
|
||||||
|
- active_tenant_prefilter_excludes_rows
|
||||||
|
action:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/OpenMyFindingsActionLink'
|
||||||
|
- $ref: '#/components/schemas/ClearTenantFilterActionLink'
|
||||||
|
ClaimFindingResult:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- findingId
|
||||||
|
- tenantId
|
||||||
|
- assigneeUserId
|
||||||
|
- auditActionId
|
||||||
|
- queueOutcome
|
||||||
|
- nextPrimaryAction
|
||||||
|
properties:
|
||||||
|
findingId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
assigneeUserId:
|
||||||
|
type: integer
|
||||||
|
auditActionId:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- finding.assigned
|
||||||
|
queueOutcome:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- removed_from_intake
|
||||||
|
nextPrimaryAction:
|
||||||
|
$ref: '#/components/schemas/OpenMyFindingsActionLink'
|
||||||
|
nextInspectAction:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/OpenFindingActionLink'
|
||||||
|
- type: 'null'
|
||||||
|
FindingDetailContinuation:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
Continuity payload for tenant finding detail when it is opened from the
|
||||||
|
Findings intake queue. The backLink is present whenever canonical intake
|
||||||
|
navigation context is provided and may be null only for direct entry
|
||||||
|
without intake continuity context.
|
||||||
|
required:
|
||||||
|
- findingId
|
||||||
|
- tenantId
|
||||||
|
properties:
|
||||||
|
findingId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
backLink:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/BackToFindingsIntakeActionLink'
|
||||||
|
- type: 'null'
|
||||||
|
CanonicalNavigationContext:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- source_surface
|
||||||
|
- canonical_route_name
|
||||||
|
properties:
|
||||||
|
source_surface:
|
||||||
|
type: string
|
||||||
|
canonical_route_name:
|
||||||
|
type: string
|
||||||
|
tenant_id:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
back_label:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
back_url:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
ActionLink:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- url
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
OpenMyFindingsActionLink:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ActionLink'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Open my findings
|
||||||
|
OpenFindingActionLink:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ActionLink'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Open finding
|
||||||
|
ClearTenantFilterActionLink:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ActionLink'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Clear tenant filter
|
||||||
|
BackToFindingsIntakeActionLink:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ActionLink'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Back to findings intake
|
||||||
|
Badge:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
color:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
204
specs/222-findings-intake-team-queue/data-model.md
Normal file
204
specs/222-findings-intake-team-queue/data-model.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# Data Model: Findings Intake & Team Queue V1
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add or modify persisted entities. It introduces three derived models:
|
||||||
|
|
||||||
|
- the canonical admin-plane `Findings intake` queue at `/admin/findings/intake`
|
||||||
|
- the fixed `Unassigned` and `Needs triage` queue-view state
|
||||||
|
- the post-claim handoff result that points the operator into the existing `My Findings` surface
|
||||||
|
|
||||||
|
All three remain projections over existing finding, tenant membership, workspace context, and audit truth.
|
||||||
|
|
||||||
|
## Existing Persistent Inputs
|
||||||
|
|
||||||
|
### 1. Finding
|
||||||
|
|
||||||
|
- Purpose: Tenant-owned workflow record representing current governance or remediation work.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `status`
|
||||||
|
- `severity`
|
||||||
|
- `due_at`
|
||||||
|
- `subject_display_name`
|
||||||
|
- `owner_user_id`
|
||||||
|
- `assignee_user_id`
|
||||||
|
- `reopened_at`
|
||||||
|
- `triaged_at`
|
||||||
|
- `in_progress_at`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `tenant()`
|
||||||
|
- `ownerUser()`
|
||||||
|
- `assigneeUser()`
|
||||||
|
|
||||||
|
Relevant existing semantics:
|
||||||
|
|
||||||
|
- `Finding::openStatuses()` defines intake inclusion and intentionally excludes `acknowledged`.
|
||||||
|
- `Finding::openStatusesForQuery()` remains relevant for `My Findings`, but not for intake.
|
||||||
|
- Spec 219 defines owner-versus-assignee meaning.
|
||||||
|
- Spec 221 defines the post-claim destination when a finding becomes assigned to the current user.
|
||||||
|
|
||||||
|
### 2. Tenant
|
||||||
|
|
||||||
|
- Purpose: Tenant boundary for queue disclosure, claim authorization, and tenant-plane detail drilldown.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `name`
|
||||||
|
- `external_id`
|
||||||
|
- `status`
|
||||||
|
|
||||||
|
### 3. TenantMembership And Capability Truth
|
||||||
|
|
||||||
|
- Purpose: Per-tenant entitlement and capability boundary for queue visibility and claim.
|
||||||
|
- Sources:
|
||||||
|
- `tenant_memberships`
|
||||||
|
- existing `CapabilityResolver`
|
||||||
|
- Key values used by this feature:
|
||||||
|
- tenant membership presence
|
||||||
|
- role-derived `TENANT_FINDINGS_VIEW`
|
||||||
|
- role-derived `TENANT_FINDINGS_ASSIGN`
|
||||||
|
|
||||||
|
Queue disclosure, tab badges, filter options, and claim affordances must only materialize for tenants where the actor remains a member and is authorized for the relevant finding capability.
|
||||||
|
|
||||||
|
### 4. Workspace Context
|
||||||
|
|
||||||
|
- Purpose: Active workspace selection in the admin plane.
|
||||||
|
- Source: Existing workspace session context, not a new persisted model for this feature.
|
||||||
|
- Effect on this feature:
|
||||||
|
- gates entry into the admin intake page
|
||||||
|
- constrains visible tenants to the current workspace
|
||||||
|
- feeds the default active-tenant prefilter through `CanonicalAdminTenantFilterState`
|
||||||
|
|
||||||
|
### 5. AuditLog
|
||||||
|
|
||||||
|
- Purpose: Existing audit record for security- and workflow-relevant mutations.
|
||||||
|
- Effect on this feature:
|
||||||
|
- successful claims write an audit entry through the existing `finding.assigned` action ID
|
||||||
|
- the audit payload records before/after assignment state, workspace, tenant, actor, and target finding
|
||||||
|
|
||||||
|
## Derived Presentation Entities
|
||||||
|
|
||||||
|
### 1. IntakeFindingRow
|
||||||
|
|
||||||
|
Logical row model for `/admin/findings/intake`.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `findingId` | Target finding identifier | `Finding.id` |
|
||||||
|
| `tenantId` | Tenant route scope for detail drilldown | `Finding.tenant_id` |
|
||||||
|
| `tenantLabel` | Tenant name visible in the queue | `Tenant.name` |
|
||||||
|
| `summary` | Operator-facing finding summary | `Finding.subject_display_name` plus existing fallback logic |
|
||||||
|
| `severity` | Severity badge value | `Finding.severity` |
|
||||||
|
| `status` | Lifecycle badge value | `Finding.status` |
|
||||||
|
| `dueAt` | Due date if present | `Finding.due_at` |
|
||||||
|
| `dueState` | Derived urgency label such as overdue or due soon | existing findings due-state logic |
|
||||||
|
| `ownerLabel` | Accountable owner when present | `ownerUser.name` |
|
||||||
|
| `intakeReason` | Why the row still belongs in shared intake | derived from lifecycle plus assignment truth |
|
||||||
|
| `detailUrl` | Tenant finding detail route | derived from tenant finding view route |
|
||||||
|
| `navigationContext` | Return-path payload back to intake | derived from `CanonicalNavigationContext` |
|
||||||
|
| `claimEnabled` | Whether the current actor may claim now | derived from assign capability and current claimable state |
|
||||||
|
|
||||||
|
Validation rules:
|
||||||
|
|
||||||
|
- Row inclusion requires all of the following:
|
||||||
|
- finding belongs to the current workspace
|
||||||
|
- finding belongs to a tenant the current user may inspect
|
||||||
|
- finding status is in `Finding::openStatuses()`
|
||||||
|
- `assignee_user_id` is `null`
|
||||||
|
- Already-assigned findings are excluded even if overdue or reopened.
|
||||||
|
- `acknowledged` findings are excluded.
|
||||||
|
- Hidden-tenant or capability-blocked findings produce no row, no count, no tab badge contribution, and no tenant filter option.
|
||||||
|
|
||||||
|
Derived queue reason:
|
||||||
|
|
||||||
|
- `Needs triage` when status is `new` or `reopened`
|
||||||
|
- `Unassigned` when status is `triaged` or `in_progress`
|
||||||
|
|
||||||
|
### 2. FindingsIntakeState
|
||||||
|
|
||||||
|
Logical state model for the intake page.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `workspaceId` | Current admin workspace scope |
|
||||||
|
| `queueView` | Fixed queue mode: `unassigned` or `needs_triage` |
|
||||||
|
| `tenantFilter` | Optional active-tenant prefilter, defaulted from canonical admin tenant context |
|
||||||
|
| `fixedScope` | Constant indicator that the page remains restricted to unassigned intake rows |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `queueView` is limited to `unassigned` and `needs_triage`.
|
||||||
|
- `tenantFilter` is clearable; `fixedScope` is not.
|
||||||
|
- `tenantFilter` values may only reference entitled tenants.
|
||||||
|
- Invalid or stale tenant filter state is discarded rather than widening visibility.
|
||||||
|
- Summary counts and tab badges reflect only visible intake rows after the active queue view and tenant prefilter are applied.
|
||||||
|
|
||||||
|
### 3. ClaimOutcome
|
||||||
|
|
||||||
|
Logical mutation result for `Claim finding`.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `findingId` | Claimed finding identifier | `Finding.id` |
|
||||||
|
| `tenantId` | Tenant scope of the claimed finding | `Finding.tenant_id` |
|
||||||
|
| `assigneeUserId` | New assignee after success | current user ID |
|
||||||
|
| `auditActionId` | Stable audit action identifier | existing `finding.assigned` |
|
||||||
|
| `nextPrimaryAction` | Primary handoff after success | `Open my findings` |
|
||||||
|
| `nextInspectAction` | Optional inspect fallback | existing tenant finding detail route |
|
||||||
|
|
||||||
|
Validation rules:
|
||||||
|
|
||||||
|
- Actor must remain a tenant member for the target finding.
|
||||||
|
- Actor must have `TENANT_FINDINGS_ASSIGN`.
|
||||||
|
- The locked record must still have `assignee_user_id = null` at mutation time.
|
||||||
|
- Claim leaves `owner_user_id` unchanged.
|
||||||
|
- Claim leaves workflow status unchanged.
|
||||||
|
- Success removes the row from intake immediately because the assignee is no longer null.
|
||||||
|
- Conflict does not mutate the row and must return honest feedback so the queue can refresh.
|
||||||
|
|
||||||
|
## State And Ordering Rules
|
||||||
|
|
||||||
|
### Intake inclusion order
|
||||||
|
|
||||||
|
1. Restrict to the current workspace.
|
||||||
|
2. Restrict to visible tenant IDs.
|
||||||
|
3. Restrict to `assignee_user_id IS NULL`.
|
||||||
|
4. Restrict to `Finding::openStatuses()`.
|
||||||
|
5. Apply the fixed queue view:
|
||||||
|
- `unassigned` keeps all included rows
|
||||||
|
- `needs_triage` keeps only `new` and `reopened`
|
||||||
|
6. Apply optional tenant prefilter.
|
||||||
|
7. Sort overdue rows first, reopened rows second, new rows third, then remaining backlog.
|
||||||
|
8. Within each bucket, rows with due dates sort by `dueAt` ascending, rows without due dates sort last, and remaining ties sort by `findingId` descending.
|
||||||
|
|
||||||
|
### Urgency semantics
|
||||||
|
|
||||||
|
- Overdue rows are the highest-priority bucket.
|
||||||
|
- Reopened non-overdue rows are the next bucket.
|
||||||
|
- New rows follow reopened rows.
|
||||||
|
- Triaged and in-progress unassigned rows remain visible in `Unassigned`, but never in `Needs triage`.
|
||||||
|
|
||||||
|
### Claim semantics
|
||||||
|
|
||||||
|
- Claim is not a lifecycle status transition.
|
||||||
|
- Claim performs one responsibility transition only: `assignee_user_id` moves from `null` to the current user.
|
||||||
|
- Owner accountability remains unchanged.
|
||||||
|
- Successful claim makes the finding eligible for `My Findings` immediately because the record is now assigned.
|
||||||
|
- Stale-row conflicts must fail before save when the locked record already has an assignee.
|
||||||
|
|
||||||
|
### Empty-state semantics
|
||||||
|
|
||||||
|
- If no visible intake rows exist anywhere in scope, the page shows a calm empty state with one CTA into `My Findings`.
|
||||||
|
- If the active tenant prefilter causes the empty state while other visible tenants still contain intake rows, the empty state must explain the tenant boundary and offer `Clear tenant filter`.
|
||||||
|
- Neither branch may reveal hidden tenant names or hidden queue quantities.
|
||||||
|
|
||||||
|
## Authorization-Sensitive Output
|
||||||
|
|
||||||
|
- Tenant labels, tab badges, filter values, rows, and counts are only derived from entitled tenants.
|
||||||
|
- Queue visibility remains workspace-context dependent.
|
||||||
|
- Claim affordances remain visible only inside in-scope membership context and must still enforce `403` server-side for members missing assign capability.
|
||||||
|
- Detail navigation remains tenant-scoped and must preserve existing `404` and `403` semantics on the destination.
|
||||||
|
- The derived queue state remains useful without revealing hidden tenant names, row counts, or empty-state hints.
|
||||||
237
specs/222-findings-intake-team-queue/plan.md
Normal file
237
specs/222-findings-intake-team-queue/plan.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
# Implementation Plan: Findings Intake & Team Queue V1
|
||||||
|
|
||||||
|
**Branch**: `222-findings-intake-team-queue` | **Date**: 2026-04-21 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/222-findings-intake-team-queue/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/222-findings-intake-team-queue/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan keeps the work inside the existing admin workspace shell, tenant-owned `Finding` truth, current tenant finding detail surface, and the already-shipped `My Findings` destination from Spec 221. The intended implementation is one new canonical `/admin` page plus one narrow self-claim shortcut. It does not add persistence, a team model, a second permission family, bulk claim, queue automation, or provider-side work.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add one canonical admin-plane intake queue at `/admin/findings/intake` that shows visible unassigned findings across the current workspace, separates `Unassigned` from the stricter `Needs triage` subset, and lets an authorized operator claim an item into the existing `/admin/findings/my-work` surface through a lightweight preview/confirmation flow without opening the broader assignment form first. Reuse the existing `MyFindingsInbox` page shape, `FindingResource` badge and navigation helpers, `CanonicalAdminTenantFilterState` for active-tenant prefiltering, `CanonicalNavigationContext` for intake-to-detail continuity, and `FindingWorkflowService` plus the current `finding.assigned` audit path, while adding one claim-specific stale-row guard so a delayed click cannot silently overwrite another operator's successful claim.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
|
||||||
|
**Primary Dependencies**: Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
|
||||||
|
**Storage**: PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, `audit_logs`, and workspace session context; no schema changes planned
|
||||||
|
**Testing**: Pest v4 feature tests with Livewire/Filament assertions; existing action-surface and Filament guard suites remain ambient CI protection
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment
|
||||||
|
**Project Type**: Laravel monolith inside the `wt-plattform` monorepo
|
||||||
|
**Performance Goals**: Keep intake rendering and claim DB-only, eager-load tenant and owner display context, avoid N+1 capability lookups across visible tenants, and keep first operator scan within the 10-second acceptance target
|
||||||
|
**Constraints**: No Graph calls, no new `OperationRun`, no new capabilities, no new persistence, no hidden-tenant leakage, no bulk claim, no new assets, no silent overwrite on stale claim attempts, and no expansion of lifecycle or ownership semantics
|
||||||
|
**Scale/Scope**: One admin page and Blade view, one derived intake query, one narrow workflow-service extension or guard for claim conflict handling, three focused feature suites, and ambient compliance with existing action-surface and Filament table guards
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament page, table, tab, badge, row-action, notification, and empty-state primitives only
|
||||||
|
- **Shared-family relevance**: findings workflow family and canonical admin findings pages
|
||||||
|
- **State layers in scope**: shell, page, detail, URL-query
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory
|
||||||
|
- **Special surface test profiles**: global-context-shell
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract
|
||||||
|
- **Exception path and spread control**: none; the queue stays list-first with one safe inline shortcut while dangerous lifecycle actions remain on existing tenant detail and tenant findings list surfaces
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | The queue remains a live derived view over `Finding` truth only; no snapshot or backup semantics are introduced |
|
||||||
|
| Read/write separation | PASS | PASS | The only write is the narrow `Claim` assignment shortcut; it uses a lightweight preview and explicit confirmation step, is TenantPilot-only, audit-logged, race-checked, and covered by focused tests while dangerous workflow mutations remain on existing tenant detail surfaces |
|
||||||
|
| Graph contract path | PASS | PASS | No Graph client, contract-registry, or provider API work is added |
|
||||||
|
| Deterministic capabilities / RBAC-UX | PASS | PASS | Workspace membership plus at least one currently viewable findings scope gates the admin page, per-tenant findings visibility gates rows and counts once inside the queue, `TENANT_FINDINGS_ASSIGN` gates claim, non-members stay `404`, workspace members with no currently viewable findings scope get `403`, and in-scope members missing claim capability remain `403` on execution |
|
||||||
|
| Workspace / tenant isolation | PASS | PASS | The admin queue is workspace-scoped, row disclosure remains tenant-entitlement checked, and drilldown stays on `/admin/t/{tenant}/findings/{finding}` with tenant-safe continuity |
|
||||||
|
| Run observability / Ops-UX | PASS | PASS | No long-running work, no `OperationRun`, and no queue/progress surface changes are introduced; the short DB-only claim write is audit-logged |
|
||||||
|
| Proportionality / no premature abstraction | PASS | PASS | The intake query stays page-local and the claim behavior extends existing workflow seams instead of creating a new queue framework or team-routing abstraction |
|
||||||
|
| Persisted truth / few layers | PASS | PASS | Intake views, queue reason, and summary counts remain derived from existing finding status, assignee, owner, due-date, and entitlement truth |
|
||||||
|
| Behavioral state discipline | PASS | PASS | `Unassigned` and `Needs triage` remain derived queue views; no new persisted status, reason family, or ownership role is introduced |
|
||||||
|
| Badge semantics (BADGE-001) | PASS | PASS | Severity and lifecycle cues reuse existing findings badge rendering and due-attention helpers |
|
||||||
|
| Filament-native UI (UI-FIL-001) | PASS | PASS | The design stays within Filament page, table, tabs, row actions, notifications, and empty-state primitives |
|
||||||
|
| Action surface / inspect model | PASS | PASS | Row click remains the primary inspect model, `Claim` is the only inline safe shortcut, and there is no redundant `View` or bulk action lane |
|
||||||
|
| Decision-first / OPSURF | PASS | PASS | The intake queue is the primary decision surface for pre-assignment routing and keeps diagnostics secondary behind finding detail |
|
||||||
|
| Test governance (TEST-GOV-001) | PASS | PASS | Proof remains in three focused feature suites with explicit lane fit and no new heavy-governance or browser family |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature remains inside Filament v5 patterns and Livewire v4-compatible page/table behavior only |
|
||||||
|
| Provider registration / global search / assets | PASS | PASS | Panel providers already live in `apps/platform/bootstrap/providers.php`; the new page only extends `AdminPanelProvider`, `FindingResource` global search is unchanged and already has a View page, and no new assets are introduced so the deploy `filament:assets` step is unchanged |
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Feature` for the admin intake page, authorization boundaries, and claim handoff behavior
|
||||||
|
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The feature risk is visible queue truth, tenant-safe filtering, `Needs triage` subset semantics, claim authorization, stale-row conflict handling, and handoff into an existing personal queue. Focused feature tests prove that integrated behavior without adding unit seams, browser coverage, or heavy-governance breadth.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsClaimHandoffTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: Moderate. The tests need one workspace, multiple visible and hidden tenants, owner-versus-assignee combinations, unassigned and assigned findings across open and terminal states, explicit active-tenant session context, and one stale-claim race scenario.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; the new suites should reuse `createUserWithTenant(...)` and `Finding::factory()` and keep any intake-specific helper local to the new tests
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: named special profile `global-context-shell` is required because the page depends on workspace context, active-tenant prefilter continuity, tenant-safe detail drilldown, and row-level capability visibility
|
||||||
|
- **Closing validation and reviewer handoff**: Reviewers should rely on the exact commands above and verify that hidden-tenant or capability-blocked findings never leak into rows, counts, tab badges, or tenant filter values; `acknowledged` findings stay out of intake; `Needs triage` remains limited to `new` and `reopened`; claim leaves owner and workflow status unchanged; and stale claims fail honestly instead of overwriting another operator's assignment.
|
||||||
|
- **Budget / baseline / trend follow-up**: none
|
||||||
|
- **Review-stop questions**: Did the intake query accidentally include assigned or `acknowledged` rows? Did claim mutate owner or lifecycle state? Did a second inline action or bulk lane appear? Did any new shared queue abstraction or team model appear without a second real consumer? Did stale-claim behavior degrade into silent overwrite?
|
||||||
|
- **Escalation path**: document-in-feature unless a second shared intake surface or reusable cross-tenant queue framework is introduced, in which case follow-up-spec or split
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: The feature remains bounded to one derived queue surface and one narrow claim path, with no new persistence, no new workflow family, and no structural test-cost center
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/222-findings-intake-team-queue/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── findings-intake-team-queue.logical.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── Pages/
|
||||||
|
│ │ │ └── Findings/
|
||||||
|
│ │ │ └── FindingsIntakeQueue.php
|
||||||
|
│ │ └── Resources/
|
||||||
|
│ │ └── FindingResource.php
|
||||||
|
│ ├── Providers/
|
||||||
|
│ │ └── Filament/
|
||||||
|
│ │ └── AdminPanelProvider.php
|
||||||
|
│ └── Services/
|
||||||
|
│ └── Findings/
|
||||||
|
│ └── FindingWorkflowService.php
|
||||||
|
├── resources/
|
||||||
|
│ └── views/
|
||||||
|
│ └── filament/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── findings/
|
||||||
|
│ └── findings-intake-queue.blade.php
|
||||||
|
└── tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Authorization/
|
||||||
|
│ └── FindingsIntakeAuthorizationTest.php
|
||||||
|
└── Findings/
|
||||||
|
├── FindingsClaimHandoffTest.php
|
||||||
|
└── FindingsIntakeQueueTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Standard Laravel monolith. The feature stays inside the existing admin panel provider, finding domain model, workflow service, and focused Pest feature suites. No new base directory, package, or persisted model is required.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| none | — | — |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Unassigned findings exist in the current workflow, but there is no single trustworthy workspace-safe queue for the pre-assignment backlog.
|
||||||
|
- **Existing structure is insufficient because**: Tenant-local findings lists and ad hoc filters still force tenant hopping and do not provide a clean handoff from shared backlog into the existing personal queue.
|
||||||
|
- **Narrowest correct implementation**: Add one admin intake page and one safe self-claim shortcut derived directly from existing finding lifecycle, due-state, ownership, and entitlement truth.
|
||||||
|
- **Ownership cost created**: One page/view pair, one small claim conflict guard inside the existing workflow service seam, and three focused feature suites.
|
||||||
|
- **Alternative intentionally rejected**: A broader team workboard, notification-first routing model, or queue framework was rejected because those shapes add durable workflow machinery before this smaller intake slice is proven useful.
|
||||||
|
- **Release truth**: Current-release truth. The work operationalizes an already-existing unassigned backlog now rather than preparing a later team-orchestration system.
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/222-findings-intake-team-queue/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Implement the intake queue as a new Filament admin page with slug `findings/intake` under the existing `AdminPanelProvider`, not as a tenant resource variant and not as a standalone controller route.
|
||||||
|
- Keep queue truth as a direct `Finding` query scoped by workspace, visible tenant IDs, `assignee_user_id IS NULL`, and `Finding::openStatuses()` rather than `Finding::openStatusesForQuery()`, because `acknowledged` rows are intentionally excluded from intake.
|
||||||
|
- Model `Unassigned` and `Needs triage` as fixed queue views inside the page shell, not as a new taxonomy, persisted preference, or generic filter framework.
|
||||||
|
- Reuse `CanonicalAdminTenantFilterState` for the active-tenant prefilter and `CanonicalNavigationContext` for intake-to-detail continuity and `Back to findings intake` behavior.
|
||||||
|
- Add `Claim finding` as a narrow row action that reuses the existing findings assign capability, success notification patterns, lightweight preview/confirmation content, and `finding.assigned` audit trail, but adds a claim-specific stale-row guard so delayed clicks fail honestly when another operator claimed the row first.
|
||||||
|
- Prove the feature with three focused Pest feature suites while relying on existing action-surface and Filament-table guards as ambient CI coverage.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/222-findings-intake-team-queue/`:
|
||||||
|
|
||||||
|
- `research.md`: routing, query, fixed-view, claim, and continuity decisions
|
||||||
|
- `data-model.md`: existing entities plus derived intake row, queue state, and claim outcome projections
|
||||||
|
- `contracts/findings-intake-team-queue.logical.openapi.yaml`: internal logical contract for intake rendering, claim, and continuity inputs
|
||||||
|
- `quickstart.md`: focused validation workflow for implementation and review
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- No schema migration is required; the queue, view counts, and empty states remain fully derived.
|
||||||
|
- The canonical implementation seam is one new admin page plus one narrow workflow-service claim guard and lightweight preview/confirmation surface, not a new shared queue or team-routing subsystem.
|
||||||
|
- Active tenant context remains canonical through `CanonicalAdminTenantFilterState`, while detail continuity remains canonical through `CanonicalNavigationContext`.
|
||||||
|
- Claim remains a TenantPilot-only assignee mutation. It leaves owner and lifecycle state unchanged, uses a lightweight preview/confirmation modal before execution, writes an audit entry, refuses silent overwrite under lock, and points the operator to the existing `My Findings` destination after success.
|
||||||
|
|
||||||
|
## Phase 1 Agent Context Update
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Constitution Check — Post-Design Re-evaluation
|
||||||
|
|
||||||
|
- PASS — the design stays inside existing admin, finding, and workflow seams with no new persistence, no Graph work, no `OperationRun`, no new capability family, and no new assets.
|
||||||
|
- PASS — Livewire v4.0+ and Filament v5 constraints remain satisfied, panel provider registration stays in `apps/platform/bootstrap/providers.php`, `FindingResource` global search remains unchanged and still has a View page, and the non-destructive `Claim` action still uses an explicit lightweight preview/confirmation flow to satisfy write-governance without expanding into a broader assignment form.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Add The Canonical Shared Intake Surface
|
||||||
|
|
||||||
|
**Goal**: Create one workspace-scoped intake queue under `/admin/findings/intake`.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` | Add a new Filament admin page with slug `findings/intake`, `HasTable`, workspace membership access checks, explicit `403` handling when no findings-viewable scope exists, visible-tenant resolution, active-tenant sync, and action-surface declaration for row-click inspect plus one safe `Claim` shortcut |
|
||||||
|
| A.2 | `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php` | Render the page shell, page description, fixed `Unassigned` and `Needs triage` view controls, native Filament table, and calm empty-state branches with either `Clear tenant filter` or `Open my findings` |
|
||||||
|
| A.3 | `apps/platform/app/Providers/Filament/AdminPanelProvider.php` | Register the new page in the existing admin panel; do not move provider registration because it already lives in `apps/platform/bootstrap/providers.php` |
|
||||||
|
|
||||||
|
### Phase B — Derive Intake Truth From Existing Finding Semantics
|
||||||
|
|
||||||
|
**Goal**: Keep inclusion, queue reason, and ordering aligned with Specs 111, 219, and 221.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` | Build the base query from `Finding`, restricted to the current workspace, visible tenant IDs, `assignee_user_id IS NULL`, and `Finding::openStatuses()` rather than `openStatusesForQuery()` |
|
||||||
|
| B.2 | `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` and `apps/platform/app/Filament/Resources/FindingResource.php` | Reuse existing severity, lifecycle, due-attention, and owner display helpers; derive queue reason as `Needs triage` for `new` and `reopened`, otherwise `Unassigned` |
|
||||||
|
| B.3 | `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` | Add visible summary counts, fixed queue-view metadata, capability-safe tenant filter options, and deterministic urgency ordering: overdue first, then reopened, then new, then remaining unassigned backlog, with due-date and finding-ID tie breaks |
|
||||||
|
|
||||||
|
### Phase C — Add The Safe Claim Shortcut Without Expanding Workflow Scope
|
||||||
|
|
||||||
|
**Goal**: Turn shared backlog into personal work in one short confirmed flow without inventing a second assignment system.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` | Add one inline `Claim finding` row action using `UiEnforcement`, lightweight preview/confirmation content, preserved visibility for in-scope members, and success/failure notifications that keep the queue honest |
|
||||||
|
| C.2 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | Add a claim-specific path or guard that reuses existing assignment semantics and `AuditActionId::FindingAssigned`, but under lock refuses any record whose assignee is no longer null before mutation |
|
||||||
|
| C.3 | `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` | After confirmed claim success, refresh the queue so the row disappears immediately and provide a clear next step into `My Findings` or the tenant finding detail without changing owner or workflow state |
|
||||||
|
|
||||||
|
### Phase D — Preserve Canonical Context And Continuity
|
||||||
|
|
||||||
|
**Goal**: Make intake behave like a first-class admin-plane queue rather than a detached filter.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` | Reuse `CanonicalAdminTenantFilterState` so the active tenant becomes the default prefilter and can be cleared without dropping the fixed intake scope or queue view |
|
||||||
|
| D.2 | `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` | Build row URLs to tenant finding detail with `CanonicalNavigationContext` carrying `Back to findings intake` continuity |
|
||||||
|
| D.3 | Existing tenant finding detail seam | Keep the existing tenant detail page as the deeper workflow surface and ensure it continues to consume navigation context without queue-specific detail forks |
|
||||||
|
|
||||||
|
### Phase E — Protect Visibility, Claim Safety, And Handoff Truth
|
||||||
|
|
||||||
|
**Goal**: Lock down queue inclusion, authorization, conflict handling, and continuity with focused regression coverage.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php` | Cover visible unassigned rows only, active-tenant prefilter behavior, fixed queue views, queue reason rendering, owner-context rendering, assigned and `acknowledged` exclusion, hidden-tenant suppression, deterministic ordering, empty states, and intake-to-detail continuity |
|
||||||
|
| E.2 | `apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php` | Cover workspace context recovery, non-member `404`, queue-access `403` when no currently viewable findings scope exists, disabled or rejected claim for members missing assign capability, and tenant-safe detail disclosure |
|
||||||
|
| E.3 | `apps/platform/tests/Feature/Findings/FindingsClaimHandoffTest.php` | Cover claim preview/confirmation rendering, successful claim, audit side effects, immediate queue removal, `My Findings` next-step alignment, and stale-row conflict refusal when another operator claims first |
|
||||||
|
| E.4 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus the focused Pest commands above | Run formatting and the narrowest proving suites before closing implementation |
|
||||||
158
specs/222-findings-intake-team-queue/quickstart.md
Normal file
158
specs/222-findings-intake-team-queue/quickstart.md
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
# Quickstart: Findings Intake & Team Queue V1
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that `/admin/findings/intake` gives the current user one trustworthy shared queue for visible unassigned findings, that `Needs triage` remains the strict `new` and `reopened` subset, and that `Claim finding` moves eligible work into `/admin/findings/my-work` through a lightweight preview and explicit confirmation without silent overwrite.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail if it is not already running.
|
||||||
|
2. Use a test user who is a member of one workspace and at least two tenants inside that workspace.
|
||||||
|
3. Seed or create findings for these cases:
|
||||||
|
- unassigned `new` finding in tenant A
|
||||||
|
- unassigned `reopened` finding in tenant B
|
||||||
|
- unassigned `triaged` finding in tenant A
|
||||||
|
- unassigned `in_progress` finding in tenant B
|
||||||
|
- unassigned finding with owner set but no assignee
|
||||||
|
- already-assigned open finding
|
||||||
|
- `acknowledged` finding with no assignee
|
||||||
|
- terminal finding
|
||||||
|
- unassigned finding in a hidden tenant
|
||||||
|
- unassigned finding in a tenant where the acting user can view findings but a second acting user lacks assign capability
|
||||||
|
- one claim-race fixture where another operator can successfully claim after the first queue render
|
||||||
|
4. Ensure `/admin/findings/my-work` already works for the acting workspace user.
|
||||||
|
|
||||||
|
## Focused Automated Verification
|
||||||
|
|
||||||
|
Run formatting first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the focused proving set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Feature/Findings/FindingsIntakeQueueTest.php \
|
||||||
|
tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Feature/Findings/FindingsClaimHandoffTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Validation Pass
|
||||||
|
|
||||||
|
### 1. Canonical intake route
|
||||||
|
|
||||||
|
Open `/admin/findings/intake`.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the page title and copy use findings-intake vocabulary,
|
||||||
|
- rows show tenant, finding summary, severity, lifecycle, due urgency, owner when present, and queue reason,
|
||||||
|
- the page is clearly fixed to shared unassigned work,
|
||||||
|
- a workspace member with no currently viewable findings scope receives `403` instead of a pseudo-empty queue,
|
||||||
|
- and already-assigned, `acknowledged`, terminal, hidden-tenant, and capability-blocked rows do not appear.
|
||||||
|
|
||||||
|
### 2. Fixed queue views
|
||||||
|
|
||||||
|
On the same page:
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- `Unassigned` shows all visible unassigned open findings,
|
||||||
|
- `Needs triage` shows only visible `new` and `reopened` findings,
|
||||||
|
- `triaged` and `in_progress` findings disappear from `Needs triage`,
|
||||||
|
- and already-assigned `reopened` findings never re-enter intake.
|
||||||
|
|
||||||
|
### 3. Active tenant prefilter
|
||||||
|
|
||||||
|
Set an active tenant context before opening the intake queue.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the queue defaults to that tenant,
|
||||||
|
- the fixed intake scope and selected queue view remain intact,
|
||||||
|
- a `Clear tenant filter` affordance is available,
|
||||||
|
- summary state stays consistent with the visible rows,
|
||||||
|
- and clearing the tenant filter returns the queue to all visible tenants without widening beyond intake truth.
|
||||||
|
|
||||||
|
### 4. Ordering and urgency
|
||||||
|
|
||||||
|
With overdue, reopened, new, and undated unassigned findings:
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- overdue rows appear first,
|
||||||
|
- reopened rows appear ahead of new rows,
|
||||||
|
- new rows appear ahead of the remaining triaged or in-progress unassigned backlog,
|
||||||
|
- rows with due dates sort ahead of rows without due dates inside the same bucket,
|
||||||
|
- and tie breaks remain deterministic.
|
||||||
|
|
||||||
|
### 5. Claim happy path
|
||||||
|
|
||||||
|
Claim an eligible row as a user with assign capability.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the preview clearly summarizes the tenant, finding, and assignee change before execution,
|
||||||
|
- the claim succeeds only after explicit confirmation,
|
||||||
|
- assignee becomes the current user,
|
||||||
|
- owner and workflow status stay unchanged,
|
||||||
|
- the row disappears from intake immediately,
|
||||||
|
- and the success path points clearly into `Open my findings` while row-open detail remains available for deeper context.
|
||||||
|
|
||||||
|
### 6. Claim forbidden path
|
||||||
|
|
||||||
|
Use a workspace member who can inspect findings but lacks assign capability.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- intake rows remain inspectable,
|
||||||
|
- claim is not successfully executable,
|
||||||
|
- the server rejects direct claim attempts with `403`,
|
||||||
|
- and the queue remains honest after the failed attempt.
|
||||||
|
|
||||||
|
### 7. Stale-row conflict path
|
||||||
|
|
||||||
|
Load intake as operator A, then claim the same finding as operator B before operator A clicks `Claim finding`.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- operator A does not overwrite operator B,
|
||||||
|
- the system reports the conflict honestly,
|
||||||
|
- the queue refreshes so the stale row disappears,
|
||||||
|
- and the audit trail reflects only the successful claim.
|
||||||
|
|
||||||
|
### 8. Detail continuity
|
||||||
|
|
||||||
|
Open a row from intake.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the destination is the existing tenant finding detail route,
|
||||||
|
- tenant scope is correct,
|
||||||
|
- and the page offers `Back to findings intake` continuity.
|
||||||
|
|
||||||
|
### 9. Empty-state behavior
|
||||||
|
|
||||||
|
Validate two empty states:
|
||||||
|
|
||||||
|
- no visible intake work anywhere
|
||||||
|
- no rows only because the active tenant prefilter narrows the queue
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the zero-visible-work branch stays calm and offers `Open my findings`,
|
||||||
|
- the tenant-prefilter branch explains the narrowing honestly instead of claiming the intake queue is globally empty,
|
||||||
|
- the tenant-prefilter branch offers only `Clear tenant filter`,
|
||||||
|
- and neither branch leaks hidden tenant information.
|
||||||
|
|
||||||
|
## Final Verification Notes
|
||||||
|
|
||||||
|
- The queue remains the shared pre-assignment surface; deeper workflow mutations stay on tenant finding detail and tenant findings list.
|
||||||
|
- Claim is not destructive, but it still uses a lightweight preview/confirmation step because write/change flows in this repo require explicit confirmation.
|
||||||
|
- If a reviewer can infer hidden tenant work, or if stale claim attempts can overwrite another operator's success, treat that as a release blocker.
|
||||||
57
specs/222-findings-intake-team-queue/research.md
Normal file
57
specs/222-findings-intake-team-queue/research.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Research: Findings Intake & Team Queue V1
|
||||||
|
|
||||||
|
## Decision 1: Implement the intake queue as an admin panel page with slug `findings/intake`
|
||||||
|
|
||||||
|
- **Decision**: Add a new Filament admin page under the existing `AdminPanelProvider` for the canonical route `/admin/findings/intake`.
|
||||||
|
- **Rationale**: The feature is an admin-plane, workspace-scoped decision surface, not a tenant-local variant of the existing findings resource. A page registration keeps the same Filament middleware, route shape, session handling, and Livewire page lifecycle already used by `MyFindingsInbox`.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Reuse the tenant-local `FindingResource` list with a preset filter. Rejected because it still forces tenant-first navigation and does not answer the cross-tenant intake question.
|
||||||
|
- Add a standalone controller route in `routes/web.php`. Rejected because this is a normal admin panel surface and should stay inside Filament routing and guardrails.
|
||||||
|
|
||||||
|
## Decision 2: Keep intake truth as a direct `Finding` query, but use `Finding::openStatuses()` instead of `openStatusesForQuery()`
|
||||||
|
|
||||||
|
- **Decision**: Build the intake queue from `Finding` records scoped by current workspace, visible tenant IDs, `assignee_user_id IS NULL`, and `Finding::openStatuses()`.
|
||||||
|
- **Rationale**: Spec 222 explicitly reuses Spec 111 open statuses for intake: `new`, `triaged`, `in_progress`, and `reopened`. The broader `openStatusesForQuery()` helper includes `acknowledged`, which belongs in existing tenant-local workflows but not in this shared intake queue.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Reuse `Finding::openStatusesForQuery()` exactly as `MyFindingsInbox` does. Rejected because it would leak `acknowledged` rows into intake and violate FR-003.
|
||||||
|
- Introduce a new shared intake-query service immediately. Rejected because there is still only one concrete intake consumer and the repo guidance prefers direct implementation until reuse pressure is real.
|
||||||
|
|
||||||
|
## Decision 3: Model `Unassigned` and `Needs triage` as fixed page-level views, not a new taxonomy or generic filter framework
|
||||||
|
|
||||||
|
- **Decision**: Represent `Unassigned` and `Needs triage` as fixed queue-view controls in the page shell, with `Needs triage` implemented as the strict subset of intake rows in `new` or `reopened` status.
|
||||||
|
- **Rationale**: The two views are product vocabulary, not a reusable classification framework. Page-local view state keeps the implementation explicit, honors the spec's fixed queue contract, and avoids adding new persisted preferences or a generic queue-view registry.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Add a reusable taxonomy or enum for queue view types. Rejected because two concrete values do not justify new semantic machinery.
|
||||||
|
- Model the views as ordinary optional filters only. Rejected because the spec treats them as fixed queue modes, not ad hoc filter combinations.
|
||||||
|
|
||||||
|
## Decision 4: Reuse canonical admin tenant filter and navigation context helpers
|
||||||
|
|
||||||
|
- **Decision**: Use `CanonicalAdminTenantFilterState` to apply the active tenant as the default prefilter and `CanonicalNavigationContext` to carry `Back to findings intake` continuity into tenant finding detail.
|
||||||
|
- **Rationale**: The repo already uses these helpers for canonical admin-plane filters and cross-surface back links. Reusing them keeps intake aligned with the existing admin shell, keeps row and count disclosure tenant-safe, and avoids adding another page-specific context mechanism. If a workspace member has no currently viewable findings scope anywhere, the queue should fail with `403` instead of pretending the backlog is simply empty.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Store active tenant and return-path state in a queue-local session structure. Rejected because the existing helpers already solve both problems and another state path would add drift.
|
||||||
|
- Depend on browser history for return links. Rejected because it is brittle across reloads, tabs, and copied URLs.
|
||||||
|
|
||||||
|
## Decision 5: Add `Claim finding` as a narrow row action that reuses assignment semantics, adds a lightweight preview/confirmation step, and adds a stale-row conflict guard
|
||||||
|
|
||||||
|
- **Decision**: Implement `Claim finding` as the single inline safe shortcut on the intake row, gated by `Capabilities::TENANT_FINDINGS_ASSIGN`, surfaced through a lightweight preview/confirmation modal, and routed through `FindingWorkflowService` plus the existing `finding.assigned` audit action while adding a claim-specific re-check that refuses rows whose assignee is no longer null under lock.
|
||||||
|
- **Rationale**: The current assignment service already owns audit logging, tenant ownership checks, and capability enforcement, but plain assign semantics would allow a stale queue click to overwrite a newer assignee and would not satisfy the repository write-flow governance. A lightweight preview/confirmation step plus a narrow guard inside the existing service seam solves both needs without inventing a second assignment system.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Reuse the existing `Assign` form unchanged. Rejected because the intake surface needs a lighter self-claim preview/confirmation flow, not a broader owner/assignee form.
|
||||||
|
- Reuse `assign()` without an extra guard. Rejected because it could silently overwrite another operator's successful claim, violating FR-014.
|
||||||
|
|
||||||
|
## Decision 6: Keep post-claim continuity explicit through `My Findings` rather than auto-redirecting away from intake
|
||||||
|
|
||||||
|
- **Decision**: Refresh the queue after a successful claim so the row disappears immediately, then provide a clear next step into `/admin/findings/my-work` while leaving row-open detail continuity available when the operator needs deeper context first.
|
||||||
|
- **Rationale**: The queue is still the canonical shared backlog surface. Auto-redirecting every successful claim would interrupt batch intake review, while a clear `Open my findings` next step satisfies the spec without hiding the updated queue truth.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Redirect to `My Findings` after every claim. Rejected because it would add navigation churn when multiple intake items are reviewed in sequence.
|
||||||
|
- Keep success feedback to a generic toast only. Rejected because the spec requires a clear next step into the existing personal execution destination.
|
||||||
|
|
||||||
|
## Decision 7: Prove the feature with three focused feature suites and rely on existing UI guard tests as ambient protection
|
||||||
|
|
||||||
|
- **Decision**: Add `FindingsIntakeQueueTest`, `FindingsIntakeAuthorizationTest`, and `FindingsClaimHandoffTest` as the focused proving suites while allowing existing action-surface and Filament-table guards to stay in their normal CI role.
|
||||||
|
- **Rationale**: The user-visible risk is queue truth, hidden-tenant isolation, claim authorization, stale-row conflict handling, and handoff continuity. Those are best proven with focused feature tests that exercise the real Livewire and Filament seams.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Add browser coverage. Rejected because the surface is straightforward and already well served by existing feature-test patterns.
|
||||||
|
- Add a new heavy-governance suite for this page. Rejected because the change is still one bounded feature surface, not a new cross-cutting UI family.
|
||||||
236
specs/222-findings-intake-team-queue/spec.md
Normal file
236
specs/222-findings-intake-team-queue/spec.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# Feature Specification: Findings Intake & Team Queue V1
|
||||||
|
|
||||||
|
**Feature Branch**: `222-findings-intake-team-queue`
|
||||||
|
**Created**: 2026-04-21
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Findings Intake & Team Queue v1"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Spec 221 created a trustworthy personal queue for assigned findings, but it did not solve where newly detected or otherwise unassigned findings enter the workflow before someone claims them.
|
||||||
|
- **Today's failure**: Unassigned backlog is still scattered across tenant-local findings pages and ad hoc filters. Operators cannot answer "what is still unowned right now?" from one shared workspace-safe surface, so intake work is easy to miss, duplicate, or defer accidentally.
|
||||||
|
- **User-visible improvement**: One shared intake queue shows visible unassigned open findings across tenants, separates triage-first backlog from later execution, and provides a direct handoff into personal work when an operator claims an item.
|
||||||
|
- **Smallest enterprise-capable version**: A canonical read-first intake page for unassigned open findings, fixed `Unassigned` and `Needs triage` views, one optional self-claim action, and tenant-safe drilldown into the existing finding detail and `My Findings` execution surface.
|
||||||
|
- **Explicit non-goals**: No team model, no capacity dashboard, no auto-routing, no escalation engine, no load balancing, no notifications, no bulk claim workflow, and no new permission system.
|
||||||
|
- **Permanent complexity imported**: One canonical intake page, one derived intake-query contract, one quick claim action reusing existing assignment semantics, one fixed queue vocabulary for `Unassigned` and `Needs triage`, and focused regression coverage for visibility, claim safety, and handoff continuity.
|
||||||
|
- **Why now**: Spec 219 clarified owner-versus-assignee meaning and Spec 221 made assigned work discoverable. The next smallest missing workflow slice is the shared pre-assignment intake surface that feeds those personal queues.
|
||||||
|
- **Why not local**: A tenant-local filter still forces tenant hopping and does not create one trustworthy workspace-wide intake queue or a consistent handoff from shared backlog into personal execution.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: None after scope cut. The spec adds one derived queue and one reuse-first claim action rather than a broader team-routing framework.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/findings/intake` as the new canonical shared intake queue
|
||||||
|
- `/admin/findings/my-work` as the existing personal execution destination after claim
|
||||||
|
- `/admin/t/{tenant}/findings` as the existing tenant findings list fallback
|
||||||
|
- `/admin/t/{tenant}/findings/{finding}` as the existing tenant finding detail drilldown
|
||||||
|
- **Data Ownership**: Tenant-owned findings remain the only source of truth. The intake queue is a derived cross-tenant view over existing finding status, assignee, owner, due state, and tenant-entitlement truth.
|
||||||
|
- **RBAC**: Workspace membership is required for the canonical intake page in the admin plane. Every visible row and count additionally requires tenant entitlement plus the existing findings view capability for the referenced tenant. The `Claim` action reuses the existing findings assign capability. Non-members and out-of-scope users remain deny-as-not-found. Members missing the required capability remain forbidden on protected actions.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: The intake page always keeps its fixed unassigned-open scope. When an active tenant context exists, the page additionally applies that tenant as a default prefilter while allowing the operator to clear only the tenant prefilter, not the intake scope itself.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Rows, counts, tenant filter values, claim affordances, and drilldown links are derived only from tenants the current user may already inspect. Hidden tenants contribute nothing to queue rows, counts, filter values, or empty-state hints.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Findings intake queue | yes | Native Filament page + existing table, filter, badge, and row-action primitives | Same findings workflow family as tenant findings list, finding detail, and `My Findings` | table, fixed queue views, claim action, return path | no | Read-first shared queue only; no new mutation family beyond claim |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Findings intake queue | Primary Decision Surface | The operator reviews unowned findings and decides what needs first triage or self-claim before personal execution begins | Tenant, finding summary, severity, lifecycle status, due or overdue state, owner when present, and why the row is still in intake | Full finding detail, evidence, audit trail, exception context, and tenant-local workflow actions after opening the finding | Primary because this is the missing shared entry point before work moves into `My Findings` | Aligns the first-routing workflow around one queue instead of tenant hopping | Removes repeated searches across tenant findings pages just to locate unassigned backlog |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Findings intake queue | List / Table / Bulk | Workflow queue / list-first canonical view | Claim a finding or open it for verification | Finding | required | One inline safe shortcut for `Claim`; fixed queue-view controls stay outside row-action noise | None on the queue; dangerous lifecycle actions remain on tenant-local finding detail | /admin/findings/intake | /admin/t/{tenant}/findings/{finding} | Active workspace, optional active-tenant prefilter, tenant column, fixed queue-view indicator | Findings intake / Finding | Which open findings are still unassigned, which ones still need first triage, and which tenant they belong to | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Findings intake queue | Tenant operator or tenant manager | Decide which unassigned finding needs first team attention and optionally claim it into personal execution | Shared workflow queue | What open findings are still unclaimed right now, and which ones should move into execution first? | Tenant, finding summary, severity, lifecycle status, due date or overdue state, owner when present, and queue reason (`Unassigned` or `Needs triage`) | Raw evidence, run context, exception history, and full audit trail | lifecycle, due urgency, severity, responsibility state | TenantPilot only | Claim finding, Open finding, Apply queue filters | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: no
|
||||||
|
- **New enum/state/reason family?**: no
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: Unassigned findings already exist, but there is no single trustworthy place to review and route that shared backlog before it becomes personal work.
|
||||||
|
- **Existing structure is insufficient because**: Tenant-local findings pages and ad hoc filters do not answer the cross-tenant intake question and do not provide a clean handoff from shared backlog into `My Findings`.
|
||||||
|
- **Narrowest correct implementation**: One derived intake page with fixed inclusion rules and one self-claim action that reuses the current assignment model and existing finding detail as the deeper workflow surface.
|
||||||
|
- **Ownership cost**: One cross-tenant queue query, one small queue vocabulary, one claim race-safety rule, and focused regression tests for visibility, claim authorization, and handoff continuity.
|
||||||
|
- **Alternative intentionally rejected**: A full team workboard, notifications-first intake model, or auto-routing engine was rejected because those shapes add durable workflow machinery before the product has proven the smaller intake queue.
|
||||||
|
- **Release truth**: Current-release truth. This slice makes existing findings workflow usable before later escalation and hygiene follow-ups land.
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||||
|
|
||||||
|
Canonical replacement is preferred over preservation.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: The change is proven by visible operator behavior on one canonical queue and one claim handoff into an existing surface. Focused feature coverage is sufficient to prove visibility, authorization, claim safety, and queue continuity without introducing browser or heavy-governance cost.
|
||||||
|
- **New or expanded test families**: Add focused coverage for the intake queue visibility contract, active-tenant prefilter behavior, `Needs triage` slice behavior, positive and negative claim authorization, and post-claim handoff into `My Findings`.
|
||||||
|
- **Fixture / helper cost impact**: Low to moderate. Tests need one workspace, multiple visible and hidden tenants, memberships, and findings in open and terminal states with explicit owner and assignee combinations.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: global-context-shell
|
||||||
|
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit assertions that hidden-tenant findings never leak into rows or filter values, that assigned findings never re-enter intake, that `Needs triage` remains a strict subset of unassigned open work, and that claim removes the row from intake before the operator continues in `My Findings`.
|
||||||
|
- **Reviewer handoff**: Reviewers must confirm that intake includes only unassigned open findings from visible tenants, that claim reuses the existing assign permission instead of inventing a new one, that stale rows cannot silently overwrite another operator's claim, and that tenant-prefilter empty states explain scope narrowing honestly.
|
||||||
|
- **Budget / baseline / trend impact**: none
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsClaimHandoffTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - See shared unassigned backlog in one queue (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want one shared intake queue across my visible tenants so I can see unassigned open findings before they disappear into tenant-local lists.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core missing workflow slice. If unassigned backlog remains scattered, later claim, escalation, or hygiene work starts from unreliable visibility.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding multiple visible and hidden tenants with assigned and unassigned findings, then verifying that the intake queue shows only visible unassigned open findings.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the current user can inspect multiple tenants with unassigned open findings, **When** the user opens the intake queue, **Then** the page shows only those visible unassigned open findings with tenant and urgency context.
|
||||||
|
2. **Given** an active tenant context exists, **When** the user opens the intake queue, **Then** the queue is prefiltered to that tenant while keeping the shared unassigned-open scope intact.
|
||||||
|
3. **Given** a finding is already assigned to any user, **When** the intake queue renders, **Then** that finding does not appear even if it is still open or overdue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Separate triage-first backlog from later shared backlog (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant manager, I want a fixed `Needs triage` view inside the intake queue so brand-new or reopened unassigned findings are not buried under older shared backlog.
|
||||||
|
|
||||||
|
**Why this priority**: Visibility alone is not enough. The shared queue must also show which rows still need first routing versus which ones are simply waiting for someone to claim them.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding unassigned findings in `new`, `reopened`, `triaged`, and `in_progress` states, then verifying the difference between the default unassigned view and the `Needs triage` subset.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** unassigned findings exist in `new`, `reopened`, `triaged`, and `in_progress` states, **When** the operator selects `Needs triage`, **Then** only `new` and `reopened` findings remain visible.
|
||||||
|
2. **Given** the same backlog, **When** the operator selects `Unassigned`, **Then** all unassigned open findings remain visible regardless of open workflow state.
|
||||||
|
3. **Given** a `reopened` finding is already assigned, **When** the operator selects `Needs triage`, **Then** that finding stays out of intake because the queue is strictly pre-assignment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Claim a finding into personal execution (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant operator, I want to claim an item from the shared intake queue so it leaves shared backlog and becomes personal work without opening a separate reassignment workflow first.
|
||||||
|
|
||||||
|
**Why this priority**: This is the smallest action that turns shared backlog into owned execution while keeping the intake surface calm and bounded.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by claiming an eligible intake item, verifying that assignment changes to the current user, that the row disappears from intake, and that the operator has a clear next step into `My Findings` or the existing finding detail.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an unassigned open finding is visible in intake and the operator has assign permission, **When** the operator confirms the claim after reviewing the lightweight preview, **Then** the finding assignee becomes the current user, the row leaves intake, and the operator can continue into `My Findings` or the finding detail.
|
||||||
|
2. **Given** a workspace member can view findings but lacks assign capability, **When** the intake queue renders, **Then** the finding remains inspectable but the operator cannot successfully claim it and the server rejects direct claim attempts with `403`.
|
||||||
|
3. **Given** another operator claims the same finding after the current queue has already loaded, **When** the current operator attempts to claim the stale row, **Then** the system refuses to overwrite the existing assignee and refreshes the queue truth honestly.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A finding may already have an owner but no assignee; it still belongs in intake because active execution is unclaimed.
|
||||||
|
- An active tenant prefilter may produce an empty queue while other visible tenants still contain intake items; the empty state must explain the tenant boundary instead of claiming that no intake work exists anywhere.
|
||||||
|
- A `reopened` finding assigned to a user must stay in personal execution surfaces, not re-enter intake, because the queue is strictly pre-assignment.
|
||||||
|
- Claim attempts can race; the mutation must re-check current assignee server-side and refuse silent overwrite.
|
||||||
|
- Hidden-tenant or capability-blocked findings must not influence queue rows, counts, tenant filter values, or empty-state hints.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature adds no Microsoft Graph calls and no new long-running work. It introduces one derived read surface and one DB-only claim mutation. Claim intentionally skips `OperationRun` because it is a short tenant-local assignment write, but it MUST present a lightweight pre-commit preview of the exact assignee change, require explicit confirmation before writing, and write an `AuditLog` entry that records actor, workspace, tenant, target finding, and before/after assignment state.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature operates in the admin `/admin` plane for the canonical intake queue and crosses into tenant-context detail routes at `/admin/t/{tenant}/findings/{finding}`. Tenant entitlement is enforced per referenced finding before disclosure and before claim. Non-members or out-of-scope users continue to receive `404`. Workspace members with at least one currently viewable findings scope may open the queue shell, but rows, counts, tenant filter values, and empty-state hints remain derived only from currently authorized tenant findings. Workspace members with no currently viewable findings scope for intake anywhere in the workspace receive `403` on queue access. Members with findings view but lacking findings assign capability may inspect rows but must receive `403` on claim attempts. No raw capability strings, role checks, or second permission system may be introduced. Global search behavior is unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The intake queue must use native Filament page, table, filter, badge, action, notification, and empty-state primitives or existing shared UI helpers. No page-local badge markup, local color language, or custom task-board chrome may be introduced for queue semantics.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The canonical operator-facing vocabulary is `Findings intake`, `Unassigned`, `Needs triage`, `Claim finding`, and `Open my findings`. The page is about findings workflow, not a generic task engine. Terms such as `task`, `work item`, or `queue record` must not replace the finding domain language in primary labels.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** The intake queue is a primary decision surface because it answers the team's first-routing question in one place before work becomes personal. `My Findings` remains the personal execution surface. Default-visible content must be enough to choose whether to claim or open a finding without reconstructing tenant context elsewhere.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** The intake queue has exactly one primary inspect model: the finding. Row click is required. There is no redundant `View` action. The only visible row mutation shortcut in v1 is `Claim`, because it is the smallest safe handoff into personal execution. Dangerous lifecycle actions remain on the existing tenant finding detail and tenant findings list rather than moving into the queue.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible queue content must stay operator-first: finding summary, tenant, severity, lifecycle state, due urgency, owner context, and intake reason before diagnostics. The `Claim` action is a `TenantPilot only` mutation on assignment metadata and does not imply provider-side change. Raw evidence, run context, and exception history remain secondary behind finding detail.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct reuse of a tenant-local `Unassigned` filter is insufficient because it does not create one workspace-safe shared intake surface or a clear handoff into `My Findings`. This feature still avoids new semantic infrastructure by deriving intake directly from existing open-status, owner, assignee, due-date, and entitlement truth. Tests must prove the business consequences: shared visibility, pre-assignment boundaries, claim safety, and handoff continuity.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST provide a canonical shared findings intake queue at `/admin/findings/intake` for the current workspace member.
|
||||||
|
- **FR-002**: The intake queue MUST include only findings that are in an open workflow status, have no current assignee, and belong to tenants the current user is entitled and currently authorized to inspect.
|
||||||
|
- **FR-003**: Open workflow status for intake MUST reuse the existing findings contract from Spec 111: `new`, `triaged`, `in_progress`, and `reopened`.
|
||||||
|
- **FR-004**: The intake queue MUST expose fixed queue views for `Unassigned` and `Needs triage`. `Unassigned` includes all visible intake rows. `Needs triage` is a strict subset limited to visible intake rows in `new` or `reopened` status.
|
||||||
|
- **FR-005**: Queue rows MUST show at minimum the tenant, finding summary, severity, lifecycle status, due date or overdue state, owner when present, and why the finding is still in intake.
|
||||||
|
- **FR-006**: Findings that already have an assignee MUST NOT appear in intake, including overdue or reopened findings. Assigned work remains in personal or tenant-local execution surfaces.
|
||||||
|
- **FR-007**: When an active tenant context exists, the intake queue MUST apply that tenant as a default prefilter and allow the operator to clear only that tenant prefilter to return to all visible tenants.
|
||||||
|
- **FR-008**: Intake rows, counts, tenant filter values, and queue summary state MUST be derived only from findings the current user is entitled and currently authorized to inspect and MUST NOT leak hidden or capability-blocked tenant scope through labels, counts, filter values, or empty-state hints.
|
||||||
|
- **FR-009**: Authorized operators with the existing findings assign capability MUST be able to claim an intake item through a lightweight pre-commit preview and explicit confirmation step. Claim sets `assignee = current user`, leaves owner and workflow status unchanged, and writes an audit entry for the assignment change.
|
||||||
|
- **FR-010**: Successful claim MUST remove the finding from the shared intake queue immediately and provide a clear next step into the existing `My Findings` route or the finding detail view.
|
||||||
|
- **FR-011**: Members who can inspect findings but lack assign capability MAY open rows from intake but MUST NOT be able to claim them. Server-side enforcement remains `403` for in-scope members lacking capability and `404` for non-members or out-of-scope users.
|
||||||
|
- **FR-012**: Opening a row from intake MUST navigate to the existing tenant finding detail for the correct tenant and preserve a return path back to intake.
|
||||||
|
- **FR-013**: The intake queue MUST render calm empty states. If the active tenant prefilter alone causes the queue to become empty while other visible tenants still contain intake items, the empty-state CTA MUST clear the tenant prefilter. If no visible intake items exist at all, the empty state MUST explain that shared backlog is clear and offer one clear CTA into `My Findings`.
|
||||||
|
- **FR-014**: Claim attempts MUST re-check the current assignee server-side at mutation time and MUST NOT silently overwrite another operator's successful claim.
|
||||||
|
- **FR-015**: The feature MUST reuse the owner-versus-assignee contract from Spec 219 and MUST NOT introduce a team model, queue-specific lifecycle states, new ownership roles, or a second permission family.
|
||||||
|
- **FR-016**: Default queue ordering MUST surface urgent intake first: overdue rows first, then `reopened`, then `new`, then remaining unassigned backlog. Within each bucket, rows with due dates sort by `due_at` ascending, rows without due dates sort last, and remaining ties sort by finding ID descending.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Findings intake queue | `/admin/findings/intake` | `Clear tenant filter` only when an active tenant prefilter is applied | Full-row open to `/admin/t/{tenant}/findings/{finding}` | `Claim` only when the current user may assign and the finding is still unassigned, with lightweight preview and explicit confirmation before execution | none | `Clear tenant filter` for tenant-prefilter-empty state; otherwise `Open my findings` | n/a | n/a | yes for successful claim | Action Surface Contract satisfied. One inspect model only, no redundant `View`, no dangerous queue actions, and no empty action groups. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Intake finding**: An open tenant-owned finding with no current assignee that the current user is entitled and authorized to inspect.
|
||||||
|
- **Findings intake queue**: A derived canonical shared queue over intake findings that emphasizes first routing before personal execution.
|
||||||
|
- **Needs triage view**: The strict subset of intake findings that are still in `new` or `reopened` status and therefore need first routing attention.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: In acceptance review, an operator can determine within 10 seconds from `/admin/findings/intake` whether visible unassigned findings exist and whether the current tenant filter is narrowing the queue.
|
||||||
|
- **SC-002**: 100% of covered automated tests show only visible unassigned open findings in the intake queue and exclude all assigned, terminal, hidden-tenant, or capability-blocked rows.
|
||||||
|
- **SC-003**: 100% of covered automated tests show that `Needs triage` contains only `new` and `reopened` intake rows while `Unassigned` continues to show the full visible intake backlog.
|
||||||
|
- **SC-004**: From the intake queue, an authorized operator can claim an eligible finding in one short confirmed flow and the finding leaves shared intake immediately without losing a clear next step into personal execution.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 111 remains the authoritative contract for findings open-status semantics and due-state behavior.
|
||||||
|
- Spec 219 remains the authoritative contract for owner-versus-assignee meaning.
|
||||||
|
- Spec 221 remains the canonical personal destination for claimed finding work.
|
||||||
|
- The existing tenant finding detail remains the canonical deeper workflow surface for triage, reassignment, resolve, close, and exception actions beyond the new intake claim shortcut.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introduce a full team workboard, team metrics, or capacity planning
|
||||||
|
- Add notifications, reminders, or escalation from the intake queue
|
||||||
|
- Add bulk claim, bulk triage, or a second mutation lane on the intake surface
|
||||||
|
- Introduce automatic routing, load balancing, delegation, or fallback-to-role logic
|
||||||
|
- Reframe the findings domain as a generic task or ticketing system
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 111, Findings Workflow V2 + SLA, remains the lifecycle and open-status baseline.
|
||||||
|
- Spec 219, Finding Ownership Semantics Clarification, remains the accountability and assignment baseline.
|
||||||
|
- Spec 221, Findings Operator Inbox V1, remains the personal execution destination after claim.
|
||||||
210
specs/222-findings-intake-team-queue/tasks.md
Normal file
210
specs/222-findings-intake-team-queue/tasks.md
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
# Tasks: Findings Intake & Team Queue V1
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/222-findings-intake-team-queue/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/findings-intake-team-queue.logical.openapi.yaml`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Required. This feature changes runtime behavior on a new Filament/Livewire queue surface and a claim mutation path, so Pest coverage must be added in `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`, and `apps/platform/tests/Feature/Findings/FindingsClaimHandoffTest.php`.
|
||||||
|
**Operations**: No new `OperationRun` is introduced. The queue remains DB-only and read-first, while `Claim finding` must reuse the existing `finding.assigned` audit flow in `apps/platform/app/Services/Findings/FindingWorkflowService.php`.
|
||||||
|
**RBAC**: The intake queue lives on the admin `/admin` plane and must preserve workspace chooser or resume behavior when workspace context is missing, workspace-membership `404` semantics for out-of-scope users, tenant-safe row and count disclosure inside the queue, and existing `404` and `403` behavior on drilldown to `/admin/t/{tenant}/findings/{finding}` and claim execution.
|
||||||
|
**UI / Surface Guardrails**: `Findings intake` remains the primary decision surface for pre-assignment routing. `/admin/findings/my-work` remains the personal execution destination after claim, and the intake queue must not become a second dangerous-action hub.
|
||||||
|
**Filament UI Action Surfaces**: `FindingsIntakeQueue` gets one primary inspect model, one conditional `Clear tenant filter` header action, one inline safe `Claim finding` row action with lightweight preview and explicit confirmation, no bulk actions, and one empty-state CTA per branch.
|
||||||
|
**Badges**: Existing finding severity, lifecycle, and due-state semantics remain authoritative. No page-local badge mappings are introduced.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3`, because US2 extends the shared queue behavior established in US1, US1 plus US2 make up the recommended first releasable intake slice, and US3 depends on the queue truth from US1 but not on every refinement from US2.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
||||||
|
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||||
|
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||||
|
- [x] The declared surface test profile or `standard-native-filament` relief is explicit.
|
||||||
|
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Queue Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Prepare the new admin intake files and focused regression suites used across all stories.
|
||||||
|
|
||||||
|
- [x] T001 [P] Create the new intake page scaffold in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- [x] T002 [P] Create the new intake page view scaffold in `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`
|
||||||
|
- [x] T003 [P] Create focused Pest scaffolding in `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`, and `apps/platform/tests/Feature/Findings/FindingsClaimHandoffTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The new intake surface and focused test files exist and are ready for shared implementation work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Route And Scope Seams)
|
||||||
|
|
||||||
|
**Purpose**: Establish the canonical admin route, page access shell, and base workspace and tenant scoping every story depends on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [x] T004 Register `FindingsIntakeQueue` in `apps/platform/app/Providers/Filament/AdminPanelProvider.php` and define the page slug and admin-plane access shell in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- [x] T005 Implement workspace-membership gating, visible-tenant resolution, capability-primed tenant disclosure, and active-tenant filter synchronization in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- [x] T006 Add foundational page-entry coverage for workspace chooser or resume behavior, non-member `404`, queue-access `403` when no currently viewable findings scope exists, and base queue disclosure boundaries in `apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The canonical intake route exists, the page is workspace-scoped, and tenant-safe base access rules are covered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - See Shared Unassigned Backlog In One Queue (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Give the current user one trustworthy cross-tenant queue for visible unassigned open findings.
|
||||||
|
|
||||||
|
**Independent Test**: Seed multiple visible and hidden tenants with unassigned, assigned, `acknowledged`, and terminal findings, then verify `/admin/findings/intake` shows only visible unassigned rows from `Finding::openStatuses()`.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T007 [P] [US1] Add visible-unassigned queue coverage, active-tenant prefilter behavior, assigned and `acknowledged` exclusion, terminal exclusion, hidden-tenant suppression, and both empty-state branches in `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`
|
||||||
|
- [x] T008 [P] [US1] Add workspace-context recovery, positive tenant-safe queue disclosure for members with viewable findings scope, and protected destination authorization coverage in `apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T009 [US1] Implement the base intake query with workspace scope, visible tenant IDs, `assignee_user_id IS NULL`, `Finding::openStatuses()`, and eager-loaded row context in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- [x] T010 [US1] Implement tenant, finding summary, severity, lifecycle, due-state, owner-context, and explicit tenant-prefilter-empty versus backlog-clear empty-state rendering with the correct CTA branch in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` and `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`
|
||||||
|
- [x] T011 [US1] Implement capability-safe tenant filter options, applied-scope metadata, visible summary counts, and the conditional `Clear tenant filter` header action in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional and the intake page answers “what is still unassigned right now?” without tenant hopping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Separate Triage-First Backlog From Later Shared Backlog (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make the queue distinguish `Needs triage` from the broader unassigned backlog and surface urgent intake in a deterministic order.
|
||||||
|
|
||||||
|
**Independent Test**: Seed unassigned findings in `new`, `reopened`, `triaged`, and `in_progress` states, then verify the difference between `Unassigned` and `Needs triage`, along with overdue and reopened ordering and intake-to-detail continuity.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T012 [P] [US2] Add fixed `Unassigned` versus `Needs triage` coverage, queue-reason rendering, and view-specific count coverage in `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`
|
||||||
|
- [x] T013 [US2] Add deterministic urgency ordering and intake-to-detail continuity coverage in `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T014 [US2] Implement fixed `Unassigned` and `Needs triage` queue views plus derived queue-reason labels in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` and `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`
|
||||||
|
- [x] T015 [US2] Implement deterministic overdue, reopened, new, and remaining-backlog ordering plus view-specific summary state in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- [x] T016 [US2] Build intake row URLs with `CanonicalNavigationContext` so tenant finding detail preserves `Back to findings intake` continuity from `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional and the queue now highlights what still needs first routing versus what is simply waiting to be claimed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Claim A Finding Into Personal Execution (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Let an authorized operator claim a visible intake row into `/admin/findings/my-work` without opening a broader reassignment workflow first.
|
||||||
|
|
||||||
|
**Independent Test**: Claim an eligible intake row, verify assignee becomes the current user, owner and workflow status stay unchanged, the row leaves intake immediately, and a stale or unauthorized claim fails honestly.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T017 [P] [US3] Add claim preview/confirmation, successful claim, audit side-effect, immediate intake removal, `Open my findings` handoff, and stale-row conflict coverage in `apps/platform/tests/Feature/Findings/FindingsClaimHandoffTest.php`
|
||||||
|
- [x] T018 [P] [US3] Add members-without-assign-capability claim rejection and inspect-allowed coverage in `apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T019 [US3] Implement the `Claim finding` row action with lightweight pre-commit preview and explicit confirmation, visibility rules, and success or error notifications in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- [x] T020 [US3] Add the claim-specific locked-assignee guard while reusing existing assignment audit semantics in `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||||
|
- [x] T021 [US3] Align post-confirmation claim queue refresh and the personal-work handoff state in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` and `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional and the shared queue now hands work into personal execution without silent overwrite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finish guardrail alignment, formatting, and focused validation across the full feature.
|
||||||
|
|
||||||
|
- [x] T022 Review operator-facing copy, action-surface discipline, and no-bulk-action guardrail alignment in `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`, `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, and `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||||
|
- [x] T023 Run formatting for `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`, `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`, and `apps/platform/tests/Feature/Findings/FindingsClaimHandoffTest.php` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [x] T024 Run the focused verification workflow from `specs/222-findings-intake-team-queue/quickstart.md` against `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php`, and `apps/platform/tests/Feature/Findings/FindingsClaimHandoffTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately and prepares the new page, view, and focused Pest files.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the canonical route and base access shell exist.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion and establishes the base queue behavior required for the first releasable slice.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same queue surface with fixed views and ordering behavior and completes the recommended MVP cut.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on User Story 1 because claim requires established intake truth, but it does not require User Story 2 to be finished first.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: No dependencies beyond Foundational.
|
||||||
|
- **US2**: Builds directly on the intake surface introduced in US1.
|
||||||
|
- **US3**: Builds on the intake surface and audit-safe assignment semantics from US1, but is independent of the triage-view refinements in US2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the story tests first and confirm they fail before implementation is considered complete.
|
||||||
|
- Keep page-query and authorization behavior in `FindingsIntakeQueue.php` authoritative before adjusting Blade copy.
|
||||||
|
- Finish story-level verification before moving to the next priority slice.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001`, `T002`, and `T003` can run in parallel during Setup.
|
||||||
|
- `T007` and `T008` can run in parallel for User Story 1.
|
||||||
|
- `T017` and `T018` can run in parallel for User Story 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 1 tests in parallel
|
||||||
|
T007 apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
|
||||||
|
T008 apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# No recommended parallel split inside US2 after queue-truth coverage was consolidated
|
||||||
|
T012 apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
|
||||||
|
T013 apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 3 tests in parallel
|
||||||
|
T017 apps/platform/tests/Feature/Findings/FindingsClaimHandoffTest.php
|
||||||
|
T018 apps/platform/tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Stories 1 And 2)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Complete Phase 4: User Story 2.
|
||||||
|
5. Validate the intake queue against the focused US1 and US2 tests before widening the slice.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Ship US1 to establish the canonical shared intake queue.
|
||||||
|
2. Add US2 to separate triage-first backlog from later shared backlog.
|
||||||
|
3. Add US3 to hand eligible rows into `My Findings` safely.
|
||||||
|
4. Finish with copy review, formatting, and the focused verification pack.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One contributor can scaffold the page and view while another prepares the focused Pest suites.
|
||||||
|
2. After Foundational work lands, one contributor can drive queue visibility and another can harden authorization boundaries.
|
||||||
|
3. Once US1 is stable, US2 queue-view refinements and US3 claim behavior can proceed in parallel if the shared page seam is coordinated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared.
|
||||||
|
- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories.
|
||||||
|
- The suggested MVP scope is Phase 1 through Phase 4.
|
||||||
|
- All implementation tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Findings Notifications & Escalation v1
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-22
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validated against the existing findings workflow, ownership, inbox, intake, and alerts foundations.
|
||||||
|
- No clarification markers remain.
|
||||||
@ -0,0 +1,349 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Findings Notifications & Escalation Surface Contract
|
||||||
|
version: 1.0.0
|
||||||
|
summary: Logical internal contract for Spec 224 delivery, alert-rule exposure, and finding deep links.
|
||||||
|
description: |
|
||||||
|
This contract documents the structured payloads and UI-facing surfaces that Spec 224 must satisfy.
|
||||||
|
It is intentionally logical rather than public-API only: the feature reuses existing Filament resources,
|
||||||
|
database notifications, and background jobs instead of introducing a new public controller namespace.
|
||||||
|
servers:
|
||||||
|
- url: https://logical.internal
|
||||||
|
description: Non-routable placeholder used to describe internal repository contracts.
|
||||||
|
paths:
|
||||||
|
/internal/findings/notification-events:
|
||||||
|
post:
|
||||||
|
summary: Dispatch one finding event to direct personal delivery and optional external alert copies.
|
||||||
|
description: |
|
||||||
|
Logical internal contract implemented by the bounded finding-notification delivery seam.
|
||||||
|
It normalizes one finding event, resolves at most one entitled direct recipient, writes one
|
||||||
|
Filament-compatible database notification when appropriate, and forwards the same event to the
|
||||||
|
existing workspace alert dispatch pipeline.
|
||||||
|
operationId: dispatchFindingNotificationEvent
|
||||||
|
x-not-public-http: true
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/vnd.tenantpilot.finding-notification-event+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingNotificationEventDispatch'
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Event accepted and evaluated for direct and optional external delivery.
|
||||||
|
content:
|
||||||
|
application/vnd.tenantpilot.finding-notification-dispatch-result+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingNotificationDispatchResult'
|
||||||
|
/admin/alert-rules:
|
||||||
|
get:
|
||||||
|
summary: Existing alert-rule surfaces expose the four new finding event types.
|
||||||
|
description: |
|
||||||
|
Existing Filament resource pages continue to render HTML, while this logical media type documents
|
||||||
|
the event-type options that must appear in create, edit, and filter flows.
|
||||||
|
operationId: viewFindingAlertRuleOptions
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Alert-rule surfaces show the finding event options and reuse the existing delivery families.
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.finding-alert-rule-options+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingAlertRuleOptionsSurface'
|
||||||
|
'403':
|
||||||
|
description: Workspace operator lacks permission to view alert-rule configuration.
|
||||||
|
'404':
|
||||||
|
description: Workspace operator is not a member of the active workspace or the alert surface is outside their visible scope.
|
||||||
|
/admin/alert-deliveries:
|
||||||
|
get:
|
||||||
|
summary: Existing alert-delivery history can filter and label finding event copies.
|
||||||
|
operationId: viewFindingAlertDeliveries
|
||||||
|
parameters:
|
||||||
|
- name: event_type
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingEventType'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Alert-delivery viewer surfaces show finding-event labels and safe summaries.
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.finding-alert-deliveries+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingAlertDeliveriesSurface'
|
||||||
|
'403':
|
||||||
|
description: Workspace operator lacks permission to inspect alert deliveries.
|
||||||
|
'404':
|
||||||
|
description: Workspace operator is not a member of the active workspace or the alert-delivery surface is outside their visible scope.
|
||||||
|
/admin/t/{tenant}/findings/{finding}:
|
||||||
|
get:
|
||||||
|
summary: The direct notification action opens the existing tenant finding detail route.
|
||||||
|
operationId: openFindingFromNotification
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: finding
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Existing finding detail renders for an entitled tenant operator.
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.finding-notification-context+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingNotificationContext'
|
||||||
|
'403':
|
||||||
|
description: Recipient still has tenant visibility but lacks current capability to inspect the finding.
|
||||||
|
'404':
|
||||||
|
description: Recipient no longer has tenant or record visibility.
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FindingEventType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- findings.assigned
|
||||||
|
- findings.reopened
|
||||||
|
- findings.due_soon
|
||||||
|
- findings.overdue
|
||||||
|
FindingSeverity:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- low
|
||||||
|
- medium
|
||||||
|
- high
|
||||||
|
- critical
|
||||||
|
RecipientReason:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- new_assignee
|
||||||
|
- current_assignee
|
||||||
|
- current_owner
|
||||||
|
DirectRecipient:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- userId
|
||||||
|
- reason
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
$ref: '#/components/schemas/RecipientReason'
|
||||||
|
FindingNotificationEventDispatch:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- eventType
|
||||||
|
- workspaceId
|
||||||
|
- tenantId
|
||||||
|
- findingId
|
||||||
|
- severity
|
||||||
|
- title
|
||||||
|
- body
|
||||||
|
- fingerprintKey
|
||||||
|
- metadata
|
||||||
|
properties:
|
||||||
|
eventType:
|
||||||
|
$ref: '#/components/schemas/FindingEventType'
|
||||||
|
workspaceId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
findingId:
|
||||||
|
type: integer
|
||||||
|
severity:
|
||||||
|
$ref: '#/components/schemas/FindingSeverity'
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
directRecipient:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/DirectRecipient'
|
||||||
|
- type: 'null'
|
||||||
|
fingerprintKey:
|
||||||
|
type: string
|
||||||
|
dueCycleKey:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tenantName
|
||||||
|
- recipientReason
|
||||||
|
properties:
|
||||||
|
tenantName:
|
||||||
|
type: string
|
||||||
|
recipientReason:
|
||||||
|
$ref: '#/components/schemas/RecipientReason'
|
||||||
|
ownerUserId:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
assigneeUserId:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
dueAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
reopenedAt:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
format: date-time
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
allOf:
|
||||||
|
- if:
|
||||||
|
properties:
|
||||||
|
eventType:
|
||||||
|
enum:
|
||||||
|
- findings.due_soon
|
||||||
|
- findings.overdue
|
||||||
|
then:
|
||||||
|
required:
|
||||||
|
- dueCycleKey
|
||||||
|
properties:
|
||||||
|
dueCycleKey:
|
||||||
|
type: string
|
||||||
|
- if:
|
||||||
|
properties:
|
||||||
|
eventType:
|
||||||
|
enum:
|
||||||
|
- findings.assigned
|
||||||
|
- findings.reopened
|
||||||
|
then:
|
||||||
|
properties:
|
||||||
|
dueCycleKey:
|
||||||
|
type: 'null'
|
||||||
|
FindingNotificationDispatchResult:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- eventType
|
||||||
|
- fingerprintKey
|
||||||
|
- directDeliveryStatus
|
||||||
|
- externalDeliveryCount
|
||||||
|
properties:
|
||||||
|
eventType:
|
||||||
|
$ref: '#/components/schemas/FindingEventType'
|
||||||
|
fingerprintKey:
|
||||||
|
type: string
|
||||||
|
directDeliveryStatus:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- sent
|
||||||
|
- suppressed
|
||||||
|
- deduped
|
||||||
|
- no_recipient
|
||||||
|
externalDeliveryCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
externalDeliveryStatuses:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- queued
|
||||||
|
- deferred
|
||||||
|
- suppressed
|
||||||
|
- none
|
||||||
|
EventTypeOption:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- value
|
||||||
|
- label
|
||||||
|
properties:
|
||||||
|
value:
|
||||||
|
$ref: '#/components/schemas/FindingEventType'
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
FindingAlertRuleOptionsSurface:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- eventTypes
|
||||||
|
- existingDeliveryFamiliesReused
|
||||||
|
properties:
|
||||||
|
eventTypes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EventTypeOption'
|
||||||
|
existingDeliveryFamiliesReused:
|
||||||
|
type: boolean
|
||||||
|
notes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
FindingAlertDeliveryRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- deliveryId
|
||||||
|
- eventType
|
||||||
|
- label
|
||||||
|
- status
|
||||||
|
properties:
|
||||||
|
deliveryId:
|
||||||
|
type: integer
|
||||||
|
eventType:
|
||||||
|
$ref: '#/components/schemas/FindingEventType'
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
tenantId:
|
||||||
|
type:
|
||||||
|
- integer
|
||||||
|
- 'null'
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
destinationName:
|
||||||
|
type: string
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
FindingAlertDeliveriesSurface:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- filterEventTypes
|
||||||
|
- rows
|
||||||
|
properties:
|
||||||
|
filterEventTypes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EventTypeOption'
|
||||||
|
rows:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/FindingAlertDeliveryRow'
|
||||||
|
FindingNotificationContext:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- findingId
|
||||||
|
- tenantId
|
||||||
|
- eventType
|
||||||
|
- recipientReason
|
||||||
|
properties:
|
||||||
|
findingId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
eventType:
|
||||||
|
$ref: '#/components/schemas/FindingEventType'
|
||||||
|
recipientReason:
|
||||||
|
$ref: '#/components/schemas/RecipientReason'
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
204
specs/224-findings-notifications-escalation/data-model.md
Normal file
204
specs/224-findings-notifications-escalation/data-model.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# Data Model: Findings Notifications & Escalation v1
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature introduces no new persisted business entity. Existing finding truth, alert rules, alert deliveries, database notifications, and tenant-membership or capability truth remain canonical. The new work is a bounded derived-event layer over those existing records.
|
||||||
|
|
||||||
|
## Existing Persistent Entities
|
||||||
|
|
||||||
|
### Finding
|
||||||
|
|
||||||
|
**Purpose**: Canonical tenant-scoped finding truth for ownership, lifecycle, severity, and due-date evaluation.
|
||||||
|
|
||||||
|
**Key fields used by this feature**:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `severity`
|
||||||
|
- `status`
|
||||||
|
- `due_at`
|
||||||
|
- `sla_days`
|
||||||
|
- `owner_user_id`
|
||||||
|
- `assignee_user_id`
|
||||||
|
- `reopened_at`
|
||||||
|
- `resolved_at`
|
||||||
|
- `closed_at`
|
||||||
|
- `finding_type`
|
||||||
|
- `subject_type`
|
||||||
|
- `subject_external_id`
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
|
||||||
|
- belongs to one tenant
|
||||||
|
- belongs to one workspace through tenant ownership
|
||||||
|
- may reference one current owner user
|
||||||
|
- may reference one current assignee user
|
||||||
|
|
||||||
|
**Rules relevant to notifications**:
|
||||||
|
|
||||||
|
- Only open findings participate in assignment, due-soon, and overdue notification evaluation.
|
||||||
|
- Terminal findings suppress due-soon and overdue delivery even if they previously entered a reminder window.
|
||||||
|
- The current due cycle is keyed by `due_at`; `reopened_at` remains explanatory lifecycle context and only matters when the existing lifecycle recalculates `due_at`. No extra reminder-state field is added.
|
||||||
|
- Existing aggregate `sla_due` alerts remain separate and are not replaced by finding-level delivery.
|
||||||
|
|
||||||
|
### AlertRule
|
||||||
|
|
||||||
|
**Purpose**: Workspace-scoped configuration for optional external delivery copies.
|
||||||
|
|
||||||
|
**Key fields used by this feature**:
|
||||||
|
|
||||||
|
- `workspace_id`
|
||||||
|
- `event_type`
|
||||||
|
- `min_severity`
|
||||||
|
- `destination_ids`
|
||||||
|
- `cooldown_minutes`
|
||||||
|
- `quiet_hours`
|
||||||
|
- `enabled`
|
||||||
|
|
||||||
|
**Rules relevant to notifications**:
|
||||||
|
|
||||||
|
- The feature adds four new `event_type` values only.
|
||||||
|
- A direct personal notification does not depend on an alert rule.
|
||||||
|
- External copies still require an enabled matching rule and destination.
|
||||||
|
|
||||||
|
### AlertDelivery
|
||||||
|
|
||||||
|
**Purpose**: Existing persisted artifact for external-copy dispatch outcomes.
|
||||||
|
|
||||||
|
**Key fields used by this feature**:
|
||||||
|
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `event_type`
|
||||||
|
- `status`
|
||||||
|
- `destination_snapshot`
|
||||||
|
- `payload`
|
||||||
|
- `fingerprint`
|
||||||
|
- `suppressed_reason`
|
||||||
|
|
||||||
|
**Rules relevant to notifications**:
|
||||||
|
|
||||||
|
- Finding-level external copies reuse the same delivery pipeline, cooldown, suppression, and quiet-hours semantics as other alerts.
|
||||||
|
- Delivery-history viewing remains read-only and only gains the new event labels and safe summaries.
|
||||||
|
|
||||||
|
### Database Notification (`notifications` table)
|
||||||
|
|
||||||
|
**Purpose**: Existing persisted artifact for direct in-app notification delivery.
|
||||||
|
|
||||||
|
**Key fields used by this feature**:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `type`
|
||||||
|
- `notifiable_type`
|
||||||
|
- `notifiable_id`
|
||||||
|
- `data`
|
||||||
|
- `read_at`
|
||||||
|
- `created_at`
|
||||||
|
|
||||||
|
**Rules relevant to notifications**:
|
||||||
|
|
||||||
|
- The feature stores direct-delivery metadata and the deterministic `fingerprint_key` inside `data`; no new table is introduced.
|
||||||
|
- The persisted payload remains Filament-compatible so the existing notification drawer can render it unchanged.
|
||||||
|
|
||||||
|
### Tenant Membership and User Entitlement Context
|
||||||
|
|
||||||
|
**Purpose**: Current authorization truth for whether a resolved direct recipient may still inspect the tenant and finding at send time.
|
||||||
|
|
||||||
|
**Key inputs used by this feature**:
|
||||||
|
|
||||||
|
- `tenant_memberships.tenant_id`
|
||||||
|
- `tenant_memberships.user_id`
|
||||||
|
- `User::canAccessTenant($tenant)`
|
||||||
|
- `CapabilityResolver::can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)` or the existing findings-view equivalent used by the implementation seam
|
||||||
|
|
||||||
|
**Rules relevant to notifications**:
|
||||||
|
|
||||||
|
- Direct delivery is suppressed when the resolved recipient is no longer entitled.
|
||||||
|
- Open-time route authorization remains authoritative even after send-time validation.
|
||||||
|
|
||||||
|
## Derived Models
|
||||||
|
|
||||||
|
### FindingNotificationEvent
|
||||||
|
|
||||||
|
**Purpose**: Canonical derived event envelope used by both direct personal delivery and optional external alert copies.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
|
||||||
|
- `event_type`: one of `findings.assigned`, `findings.reopened`, `findings.due_soon`, `findings.overdue`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `finding_id`
|
||||||
|
- `severity`
|
||||||
|
- `title`
|
||||||
|
- `body`
|
||||||
|
- `recipient_reason`: one of `new_assignee`, `current_assignee`, `current_owner`
|
||||||
|
- `resolved_recipient_user_id`: nullable
|
||||||
|
- `fingerprint_key`
|
||||||
|
- `due_cycle_key`: nullable, derived from current `due_at`
|
||||||
|
- `metadata`: object with finding summary, owner and assignee ids, due date, reopen timestamp, and deep-link-safe context
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
|
||||||
|
- Event type must be one of the four new finding events.
|
||||||
|
- `recipient_reason` must match the event-specific precedence rule.
|
||||||
|
- `fingerprint_key` must deterministically distinguish the specific assignment change, reopen occurrence, or due cycle.
|
||||||
|
- `due_cycle_key` is required for `findings.due_soon` and `findings.overdue`, and omitted or null for assignment and reopen.
|
||||||
|
|
||||||
|
### RecipientResolutionResult
|
||||||
|
|
||||||
|
**Purpose**: Bounded contract that picks at most one direct recipient from existing owner and assignee truth without creating a second ownership model.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
|
||||||
|
- `user_id`: nullable
|
||||||
|
- `reason`: one of `new_assignee`, `current_assignee`, `current_owner`
|
||||||
|
- `is_entitled`: boolean
|
||||||
|
- `suppression_reason`: nullable string
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- `findings.assigned` resolves to the new assignee only.
|
||||||
|
- `findings.reopened` resolves to current assignee, else current owner.
|
||||||
|
- `findings.due_soon` resolves to current assignee, else current owner.
|
||||||
|
- `findings.overdue` resolves to current owner, else current assignee.
|
||||||
|
- A recipient who is not currently entitled becomes a suppression result, not a broadened-delivery fallback.
|
||||||
|
|
||||||
|
### DirectFindingNotificationMessage
|
||||||
|
|
||||||
|
**Purpose**: Filament database-notification payload rendered in the existing notification drawer.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
|
||||||
|
- `format = filament`
|
||||||
|
- `title`
|
||||||
|
- `body`
|
||||||
|
- `actions[0].label = Open finding`
|
||||||
|
- `actions[0].url = /admin/t/{tenant}/findings/{finding}`
|
||||||
|
- `finding_event.event_type`
|
||||||
|
- `finding_event.recipient_reason`
|
||||||
|
- `finding_event.fingerprint_key`
|
||||||
|
- `finding_event.tenant_name`
|
||||||
|
- `finding_event.severity`
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- One notification row represents one direct delivery to one entitled user.
|
||||||
|
- The payload must explain why the operator received the notification.
|
||||||
|
- The payload must not include hidden-tenant data beyond what the recipient is entitled to inspect.
|
||||||
|
|
||||||
|
## Event Matrix
|
||||||
|
|
||||||
|
| Event type | Trigger | Recipient precedence | Fingerprint components | Suppression rules |
|
||||||
|
|------------|---------|----------------------|------------------------|-------------------|
|
||||||
|
| `findings.assigned` | An open finding is assigned to a new assignee | new assignee | finding id + target assignee id + assignment change marker | suppress for owner-only changes, assignee clears, no-op saves, terminal findings, or non-entitled recipient |
|
||||||
|
| `findings.reopened` | A terminal finding is reopened by system detection | current assignee, else current owner | finding id + reopened occurrence marker | suppress for manual reopen, missing recipient, or non-entitled recipient |
|
||||||
|
| `findings.due_soon` | An open finding first enters the 24-hour pre-due window for the current due cycle | current assignee, else current owner | finding id + current `due_at` + event type | suppress for terminal findings, missing `due_at`, no entitled recipient, or duplicate within the same due cycle |
|
||||||
|
| `findings.overdue` | An open finding first becomes overdue for the current due cycle | current owner, else current assignee | finding id + current `due_at` + event type | suppress for terminal findings, no entitled recipient, or duplicate within the same due cycle |
|
||||||
|
|
||||||
|
## Persistence Boundaries
|
||||||
|
|
||||||
|
- No new table, enum-backed persistence, or reminder-state model is introduced.
|
||||||
|
- `notifications.data` stores direct-delivery fingerprint metadata only as a delivery artifact.
|
||||||
|
- `alert_deliveries` stores external-copy artifacts only as it already does today.
|
||||||
|
- `Finding` remains the sole business-truth model for ownership, lifecycle, and due-cycle resets.
|
||||||
253
specs/224-findings-notifications-escalation/plan.md
Normal file
253
specs/224-findings-notifications-escalation/plan.md
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
# Implementation Plan: Findings Notifications & Escalation v1
|
||||||
|
|
||||||
|
**Branch**: `224-findings-notifications-escalation` | **Date**: 2026-04-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/224-findings-notifications-escalation/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/224-findings-notifications-escalation/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan keeps the work inside the existing findings workflow, workspace alerting, and Filament database-notification primitives. The intended implementation adds four finding event types, one narrow finding-notification service, one database notification class, and focused extensions to the existing alert evaluation and alert-management surfaces. It does not add a new table, a notification center, a preference system, a second findings queue, or a generic workflow engine.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Extend the existing workspace Alerts event vocabulary with `findings.assigned`, `findings.reopened`, `findings.due_soon`, and `findings.overdue`, then add a narrow `FindingNotificationService` that sends one entitlement-safe direct database notification to the currently responsible operator and forwards the same event into `AlertDispatchService` for optional external copies. Emit assignment and system-reopen events from `FindingWorkflowService` after committed mutations, evaluate due-soon and overdue windows inside the existing `EvaluateAlertsJob` cadence, reuse the existing `notifications` table and Filament database-notification drawer for direct delivery, and keep finding follow-up on the existing tenant finding detail route.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
|
||||||
|
**Primary Dependencies**: Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
|
||||||
|
**Storage**: PostgreSQL via existing `findings`, `alert_rules`, `alert_deliveries`, `notifications`, `tenant_memberships`, and `audit_logs`; no schema changes planned
|
||||||
|
**Testing**: Pest v4 feature tests with Filament/Livewire assertions and notification-payload checks
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment
|
||||||
|
**Project Type**: Laravel monolith inside the `wt-plattform` monorepo
|
||||||
|
**Performance Goals**: Keep direct notification dispatch and external alert-copy generation DB-backed and queue-safe, avoid N+1 tenant or recipient lookups, and keep due-window scans bounded to workspace-scoped open findings with due-date indexes already present
|
||||||
|
**Constraints**: No new persisted notification-truth table, no notification-preference center, no new capability family, no hidden-tenant leakage, no manual-reopen notification in v1, no repeated overdue spam within the same due cycle, and no new frontend assets
|
||||||
|
**Scale/Scope**: Four new event types, one narrow finding-notification service, one new database notification class, extensions to two existing alert resources, one extension to the existing alerts evaluation job, and four focused feature suites
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament resources and existing database-notification primitives only
|
||||||
|
- **Shared-family relevance**: workspace alert configuration and delivery-history family, existing admin and tenant database-notification family, existing tenant finding detail family
|
||||||
|
- **State layers in scope**: shell, detail
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory
|
||||||
|
- **Special surface test profiles**: standard-native-filament, global-context-shell
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract
|
||||||
|
- **Exception path and spread control**: none; the feature extends existing alert resources and notification primitives rather than creating a new page family
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | All event production stays derived from existing `Finding` lifecycle, ownership, severity, and due-date truth; notifications and alert deliveries remain delivery artifacts only |
|
||||||
|
| Read/write separation | PASS | PASS | The feature adds no new operator mutation surface; assignment and reopen writes stay inside existing workflow actions, while due reminders remain scheduled evaluation side effects |
|
||||||
|
| Graph contract path | PASS | PASS | No Microsoft Graph call paths or contract-registry changes are introduced |
|
||||||
|
| Deterministic capabilities / RBAC-UX | PASS | PASS | Workspace-scoped alert configuration remains capability-gated, direct recipients are re-validated against current tenant membership plus findings-view capability at send time, non-members remain `404`, and in-scope capability failures remain `403` |
|
||||||
|
| Workspace / tenant isolation | PASS | PASS | Alert rules and alert deliveries stay workspace-scoped, while direct notifications always deep-link to tenant-scoped finding detail and must not expose hidden tenant data |
|
||||||
|
| Run observability / Ops-UX | PASS | PASS | Scheduled due-event evaluation stays inside the existing `alerts.evaluate` cadence; no `OperationRun` notification semantics are changed and no queued/running DB notifications are introduced |
|
||||||
|
| Proportionality / no premature abstraction | PASS | PASS | The plan allows one narrow `FindingNotificationService` because direct-recipient resolution, dedupe, payload composition, and external alert-copy forwarding would otherwise be duplicated across workflow mutations and scheduled due evaluation |
|
||||||
|
| Persisted truth / few layers | PASS | PASS | No new table or persisted workflow state is added; existing `notifications` JSONB and `alert_deliveries` rows remain the only delivery artifacts |
|
||||||
|
| Behavioral state discipline | PASS | PASS | The four new values are delivery event types, not a new finding lifecycle or responsibility taxonomy |
|
||||||
|
| Filament-native UI (UI-FIL-001) | PASS | PASS | Alert rules and alert deliveries remain native Filament resources; direct notifications use existing Filament database-notification payloads |
|
||||||
|
| Decision-first / action-surface contract | PASS | PASS | Notifications stay as secondary drill-in entry points, alert rules remain config-first, and alert deliveries remain read-only diagnostics |
|
||||||
|
| Test governance (TEST-GOV-001) | PASS | PASS | Proof stays in focused feature suites for event production, routing, alert-rule integration, and deep-link safety, with no browser or heavy-governance expansion |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature uses existing Filament v5 resources and Livewire v4-compatible database notifications only |
|
||||||
|
| Provider registration / global search / assets | PASS | PASS | Panel providers already live in `apps/platform/bootstrap/providers.php`; no globally searchable resource is added or changed; no new assets are required, so the existing deploy `filament:assets` step remains unchanged |
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Feature` for finding-event production, direct-recipient routing, alert-rule UI integration, and deep-link authorization behavior
|
||||||
|
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The main risk is integrated workflow behavior: which event fires, who gets it, whether direct delivery leaks scope, whether external copies remain optional, and whether alert-management surfaces expose the new event types coherently. Focused feature tests prove that without adding unit-only abstractions or browser cost.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Alerts/SlaDueAlertTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need workspace and tenant context, current and removed memberships, owner-versus-assignee combinations, existing alert rules and destinations, notification-table assertions, and time-travel across due windows.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; any notification-specific helper should stay local to the new tests and reuse existing `createUserWithTenant(...)`, `Finding::factory()`, and alert destination factories
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: `standard-native-filament` for alert resources and `global-context-shell` for database notifications that bridge admin shell context to tenant finding detail
|
||||||
|
- **Closing validation and reviewer handoff**: Reviewers should rely on the exact commands above and verify that owner-only changes do not emit assignment notifications, manual reopen still emits nothing, due-soon and overdue stay one-per-due-cycle, same-user owner/assignee resolution creates one notification, direct delivery suppresses when entitlement is lost, and external alert copies only appear when a matching alert rule exists.
|
||||||
|
- **Budget / baseline / trend follow-up**: none
|
||||||
|
- **Review-stop questions**: Did the implementation introduce new persistence, a preference layer, or a generic workflow-notification engine? Did any path leak hidden tenant information in the notification title, body, or action URL? Did due evaluation widen beyond the existing alert-evaluation cadence without need? Did alert-rule resource changes stay native and read clearly?
|
||||||
|
- **Escalation path**: document-in-feature unless a second delivery abstraction, a preference center, or a new findings-specific notification surface is proposed, in which case split or follow up with a dedicated spec
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: This feature remains bounded to four concrete event types and one direct-delivery contract built on existing infrastructure
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/224-findings-notifications-escalation/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── findings-notifications-escalation.logical.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ └── Resources/
|
||||||
|
│ │ ├── AlertDeliveryResource.php
|
||||||
|
│ │ └── AlertRuleResource.php
|
||||||
|
│ ├── Jobs/
|
||||||
|
│ │ └── Alerts/
|
||||||
|
│ │ └── EvaluateAlertsJob.php
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── AlertRule.php
|
||||||
|
│ ├── Notifications/
|
||||||
|
│ │ └── Findings/
|
||||||
|
│ │ └── FindingEventNotification.php
|
||||||
|
│ └── Services/
|
||||||
|
│ ├── Alerts/
|
||||||
|
│ │ └── AlertDispatchService.php
|
||||||
|
│ └── Findings/
|
||||||
|
│ ├── FindingNotificationService.php
|
||||||
|
│ ├── FindingSlaPolicy.php
|
||||||
|
│ └── FindingWorkflowService.php
|
||||||
|
├── database/
|
||||||
|
│ └── factories/
|
||||||
|
│ └── FindingFactory.php
|
||||||
|
└── tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Alerts/
|
||||||
|
│ └── FindingsAlertRuleIntegrationTest.php
|
||||||
|
├── Findings/
|
||||||
|
│ ├── FindingsNotificationEventTest.php
|
||||||
|
│ └── FindingsNotificationRoutingTest.php
|
||||||
|
└── Notifications/
|
||||||
|
└── FindingNotificationLinkTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Standard Laravel monolith. The feature stays inside existing finding workflow, alerting, and notification seams. No new base directory, panel, or persisted model is required.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| none | — | — |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Finding assignment, automatic reopen, and due-state changes remain silent unless operators keep polling findings pages.
|
||||||
|
- **Existing structure is insufficient because**: `FindingWorkflowService` knows about ownership changes but not delivery, `EvaluateAlertsJob` knows about workspace alert events but not direct responsible-user delivery, and the existing alert-rule UI cannot express these finding-specific workflow events yet.
|
||||||
|
- **Narrowest correct implementation**: Add four event types, extend the existing alert-rule and delivery viewer labels, create one narrow `FindingNotificationService` to unify recipient resolution plus direct and external delivery, and emit events only from existing workflow and alert-evaluation seams.
|
||||||
|
- **Ownership cost created**: One service, one notification class, incremental logic in `FindingWorkflowService` and `EvaluateAlertsJob`, and four focused feature suites.
|
||||||
|
- **Alternative intentionally rejected**: A new `FindingNotificationDelivery` table or generic workflow-notification engine. Both add persistence or framework complexity that current-release truth does not require because existing `notifications` and `alert_deliveries` already capture delivery artifacts.
|
||||||
|
- **Release truth**: Current-release truth. The feature closes an existing workflow loop now rather than preparing a later escalation framework.
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/224-findings-notifications-escalation/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Add the four new finding event types to the existing `AlertRule` constant registry and `AlertRuleResource::eventTypeOptions()` instead of creating a second event catalog.
|
||||||
|
- Reuse existing Filament database notifications on the admin panel rather than building a findings-specific notification surface or page.
|
||||||
|
- Emit `findings.assigned` and `findings.reopened` from `FindingWorkflowService` after the write transaction commits so delivery does not race an uncommitted finding state.
|
||||||
|
- Evaluate `findings.due_soon` and `findings.overdue` inside the existing `EvaluateAlertsJob` workspace cadence rather than adding a second scheduler, command, or `OperationRun` family.
|
||||||
|
- Use the existing `notifications` table `data` payload to store a finding-event fingerprint for direct-delivery dedupe; do not add a new persistence model.
|
||||||
|
- Reuse `FindingResource::getUrl(..., panel: 'tenant', tenant: $tenant)` for notification deep links and re-check entitlement before sending any direct notification.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/224-findings-notifications-escalation/`:
|
||||||
|
|
||||||
|
- `research.md`: event-registry, delivery, dedupe, and evaluation-seam decisions
|
||||||
|
- `data-model.md`: existing entities plus the derived finding-event and direct-notification payload models
|
||||||
|
- `contracts/findings-notifications-escalation.logical.openapi.yaml`: internal logical contract for finding-event dispatch, alert-rule event exposure, alert-delivery viewing, and finding deep-link payloads
|
||||||
|
- `quickstart.md`: focused validation workflow for implementation and review
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- No schema migration is required; direct notifications use the existing `notifications` table and external copies use existing `alert_deliveries` rows.
|
||||||
|
- The canonical new seam is one narrow `FindingNotificationService`, not a reusable workflow-notification framework.
|
||||||
|
- Direct-recipient dedupe uses a fingerprint stored in the existing database notification payload; due-cycle reset keys are derived from the current `due_at` value.
|
||||||
|
- Alert rules remain optional for external copies; direct responsible-user notifications do not depend on any matching alert rule.
|
||||||
|
- Existing tenant finding detail remains the only follow-up surface, and current `404` versus `403` route behavior remains authoritative at open time.
|
||||||
|
|
||||||
|
## Phase 1 Agent Context Update
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Constitution Check — Post-Design Re-evaluation
|
||||||
|
|
||||||
|
- PASS — the design remains inside current findings, alerts, and database-notification seams with no new persistence, no Graph work, no new capability family, and no new frontend assets.
|
||||||
|
- PASS — Livewire v4.0+ and Filament v5 constraints remain satisfied, panel provider registration stays in `apps/platform/bootstrap/providers.php`, no globally searchable resource behavior changes, and no new destructive action path is introduced.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Extend the existing alert-event vocabulary and operator labels
|
||||||
|
|
||||||
|
**Goal**: Teach existing alert-management surfaces about the four new finding workflow events.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `apps/platform/app/Models/AlertRule.php` | Add the four new `EVENT_FINDINGS_*` constants alongside the existing alert event types |
|
||||||
|
| A.2 | `apps/platform/app/Filament/Resources/AlertRuleResource.php` | Extend `eventTypeOptions()` and `eventTypeLabel()` with operator-facing labels for assignment, reopened, due soon, and overdue events |
|
||||||
|
| A.3 | `apps/platform/app/Filament/Resources/AlertDeliveryResource.php` | Ensure list, view, and filter surfaces continue to render the new event labels cleanly through the existing event-type label seam without adding a new delivery viewer |
|
||||||
|
|
||||||
|
### Phase B — Add one narrow finding-notification delivery seam on existing primitives
|
||||||
|
|
||||||
|
**Goal**: Send one entitlement-safe direct database notification and one optional external alert copy from the same event envelope.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `apps/platform/app/Services/Findings/FindingNotificationService.php` | Add a focused service that builds finding-event payloads, resolves the direct recipient by the spec precedence rules, checks current tenant entitlement plus findings-view capability, computes a delivery fingerprint, suppresses duplicates by querying existing database notifications, and forwards the same event array to `AlertDispatchService` for external copies |
|
||||||
|
| B.2 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Add a database notification class that returns Filament notification payloads with tenant-safe title/body copy, recipient-reason copy, one finding-detail action URL, and embedded metadata including the event type and fingerprint |
|
||||||
|
| B.3 | `apps/platform/app/Services/Alerts/AlertDispatchService.php` | Keep the existing workspace alert-copy path intact and only absorb any payload-shape normalization needed for the new finding event titles, body copy, and metadata |
|
||||||
|
|
||||||
|
### Phase C — Emit assignment and automatic-reopen notifications from existing finding workflow mutations
|
||||||
|
|
||||||
|
**Goal**: Turn current write seams into finding-event producers without changing workflow truth.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | After committed `assign(...)` mutations, compare before and after owner and assignee state, suppress no-op, owner-only, and assignee-clear transitions, and dispatch `findings.assigned` only when a new assignee is set on an open finding |
|
||||||
|
| C.2 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | After committed `reopenBySystem(...)` mutations, dispatch `findings.reopened` using the refreshed finding state; keep manual `reopen(...)` out of scope for v1 delivery |
|
||||||
|
| C.3 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/database/factories/FindingFactory.php` | Preserve existing due-date reset and reopen semantics so notification due-cycle logic stays derived from the recalculated `due_at` value, with reopen metadata remaining explanatory rather than becoming a second cycle key |
|
||||||
|
|
||||||
|
### Phase D — Evaluate due-soon and overdue events inside the existing alerts evaluation cadence
|
||||||
|
|
||||||
|
**Goal**: Keep scheduled due reminders and escalations inside the already-established workspace alert window.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php` | Add finding-level due-soon and overdue candidate queries over workspace-scoped open findings with `due_at`, using the existing window semantics and a fixed v1 due-soon horizon of 24 hours |
|
||||||
|
| D.2 | `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php` | For each candidate finding event, call `FindingNotificationService` so direct delivery and optional external alert copies stay consistent and no second dispatch pipeline appears |
|
||||||
|
| D.3 | `apps/platform/app/Services/Findings/FindingNotificationService.php` | Define due-cycle fingerprints from the current `due_at` value so due-soon and overdue notifications emit once per cycle and reset only when `due_at` is recalculated by existing lifecycle semantics |
|
||||||
|
|
||||||
|
### Phase E — Preserve tenant-safe deep links and existing notification shell behavior
|
||||||
|
|
||||||
|
**Goal**: Reuse the current notification drawer and finding detail route without widening visibility.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Build finding action URLs with `FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)` so links target the tenant panel explicitly |
|
||||||
|
| E.2 | Existing admin panel notification configuration | Rely on the already-configured `->databaseNotifications()` behavior in `AdminPanelProvider`; do not add polling, a new page, or a new notification surface |
|
||||||
|
| E.3 | Existing finding detail route behavior | Keep current route authorization authoritative so an operator who lost access after send time still receives the existing `404` or `403` outcome instead of leaked detail |
|
||||||
|
|
||||||
|
### Phase F — Protect event truth, routing, and link safety with focused regression coverage
|
||||||
|
|
||||||
|
**Goal**: Lock down event production, recipient precedence, alert-rule integration, and tenant-safe finding drilldown.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| F.1 | `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php` | Cover assignment-event production, system-reopen event production, rapid reassignment and repeated automatic-reopen dedupe, due-soon and overdue evaluation windows, terminal-finding suppression, and one-per-due-cycle behavior |
|
||||||
|
| F.2 | `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php` | Cover recipient precedence, same-user owner and assignee dedupe, owner-only assignment suppression, entitlement-loss suppression, and no direct delivery without a current eligible recipient |
|
||||||
|
| F.3 | `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php` and `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php` | Cover alert-rule event-type option exposure, `ALERTS_VIEW` read versus `ALERTS_MANAGE` mutation boundaries, inherited Alerts v1 external-copy behavior including minimum severity, tenant scoping, cooldown, quiet hours, and dedupe, delivery-history labels and filters, the rule-free case where direct personal delivery still occurs, and non-regression of existing aggregate `sla_due` behavior |
|
||||||
|
| F.4 | `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php` | Cover database notification payload shape, explicit tenant-panel URLs, and finding-detail open behavior with correct tenant-safe `404` and `403` semantics |
|
||||||
|
| F.5 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus the focused Pest commands above | Run formatting and the narrowest proving suites before closing implementation |
|
||||||
106
specs/224-findings-notifications-escalation/quickstart.md
Normal file
106
specs/224-findings-notifications-escalation/quickstart.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# Quickstart: Findings Notifications & Escalation v1
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start the local platform stack.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Work with a workspace that has at least one tenant, two tenant users, and existing findings data.
|
||||||
|
|
||||||
|
3. Ensure you can create or edit alert rules and inspect alert deliveries in the admin panel.
|
||||||
|
|
||||||
|
4. Remember that Filament database notification polling is intentionally disabled in this repo, so reload the page or reopen the notification drawer after each trigger when validating manually.
|
||||||
|
|
||||||
|
## Automated Validation
|
||||||
|
|
||||||
|
Run formatting and the narrowest proving suites for this feature:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Alerts/SlaDueAlertTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Validation Flow
|
||||||
|
|
||||||
|
### 1. Confirm alert-rule event types are available
|
||||||
|
|
||||||
|
1. Open the admin panel alert-rule create or edit form.
|
||||||
|
2. Verify the event selector contains:
|
||||||
|
- `Finding assigned`
|
||||||
|
- `Finding reopened`
|
||||||
|
- `Finding due soon`
|
||||||
|
- `Finding overdue`
|
||||||
|
3. Save a rule that targets one of the new event types and confirm the Alert deliveries viewer can filter and label it correctly.
|
||||||
|
|
||||||
|
### 1b. Validate admin alert-surface RBAC semantics
|
||||||
|
|
||||||
|
1. As a workspace member with `ALERTS_VIEW` but without `ALERTS_MANAGE`, confirm the existing alert-rule and alert-delivery read surfaces open successfully while alert-rule mutation stays forbidden.
|
||||||
|
2. As an in-scope workspace member without `ALERTS_VIEW`, confirm the existing alert-rule and alert-delivery view surfaces return `403`.
|
||||||
|
3. As a non-member or wrong-workspace user, confirm the existing alert-rule and alert-delivery surfaces return `404`.
|
||||||
|
|
||||||
|
### 2. Validate direct assignment notification
|
||||||
|
|
||||||
|
1. Start with an open finding that has no assignee or a different assignee.
|
||||||
|
2. Assign the finding to a new entitled operator.
|
||||||
|
3. Reload the shell and open the database notification drawer.
|
||||||
|
4. Confirm the new assignee receives exactly one notification with:
|
||||||
|
- the finding vocabulary
|
||||||
|
- an explanation that the finding was assigned to them
|
||||||
|
- one `Open finding` action
|
||||||
|
5. Confirm owner-only changes, assignee clears, and no-op saves emit no assignment notification.
|
||||||
|
|
||||||
|
### 3. Validate automatic reopen notification
|
||||||
|
|
||||||
|
1. Start with a terminal finding that still has an assignee or owner.
|
||||||
|
2. Trigger the existing system path that reopens the finding through recurring detection.
|
||||||
|
3. Reload the shell and confirm one `Finding reopened` notification reaches the current assignee, or the current owner if no assignee exists.
|
||||||
|
4. Confirm manual reopen remains silent in v1.
|
||||||
|
|
||||||
|
### 4. Validate due-soon and overdue direct delivery
|
||||||
|
|
||||||
|
1. Prepare two open findings:
|
||||||
|
- one with `due_at` inside the next 24 hours
|
||||||
|
- one with `due_at` already in the past
|
||||||
|
2. Run the existing alert-evaluation command for the target workspace.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan tenantpilot:alerts:dispatch --workspace=<workspace-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Reload the notification drawer.
|
||||||
|
4. Confirm:
|
||||||
|
- due soon goes to the current assignee, else the current owner
|
||||||
|
- overdue goes to the current owner, else the current assignee
|
||||||
|
- if owner and assignee are the same user, only one direct notification is created
|
||||||
|
5. Run the same command again without changing `due_at` or reopening the finding and confirm no duplicate due-soon or overdue direct notification is created for the current cycle.
|
||||||
|
6. Recalculate the due cycle through the existing lifecycle contract, then rerun evaluation and confirm one fresh due notification can emit for the new cycle.
|
||||||
|
|
||||||
|
### 5. Validate optional external copies through existing alert rules
|
||||||
|
|
||||||
|
1. Enable an alert rule for one of the new finding event types with a real destination.
|
||||||
|
2. Trigger the corresponding event again.
|
||||||
|
3. Confirm the direct responsible-user notification still appears.
|
||||||
|
4. Confirm one or more `Alert deliveries` rows are created only when a matching enabled rule exists.
|
||||||
|
5. Confirm the existing tenant-level aggregate `sla_due` alert behavior remains unchanged.
|
||||||
|
|
||||||
|
### 6. Validate entitlement-safe deep links
|
||||||
|
|
||||||
|
1. Create a direct finding notification for an entitled recipient.
|
||||||
|
2. Remove that user’s tenant membership or findings-view capability.
|
||||||
|
3. Open the notification action URL.
|
||||||
|
4. Confirm the existing route behavior remains authoritative:
|
||||||
|
- hidden tenant or record paths stay `404`
|
||||||
|
- in-scope but unauthorized access stays `403`
|
||||||
|
5. Confirm the notification title and body never broaden disclosure beyond the existing finding summary vocabulary.
|
||||||
|
|
||||||
|
## Reviewer Notes
|
||||||
|
|
||||||
|
- The feature is Livewire v4.0+ compatible and stays on existing Filament v5 primitives.
|
||||||
|
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`.
|
||||||
|
- No globally searchable resource behavior changes in this feature.
|
||||||
|
- No new destructive action is introduced, so no new confirmation flow is required.
|
||||||
|
- Asset strategy is unchanged: no new panel or shared assets, and the existing deploy `filament:assets` step remains sufficient.
|
||||||
69
specs/224-findings-notifications-escalation/research.md
Normal file
69
specs/224-findings-notifications-escalation/research.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Research: Findings Notifications & Escalation v1
|
||||||
|
|
||||||
|
## Decision 1: Reuse the existing alert event registry and alert-rule surfaces
|
||||||
|
|
||||||
|
**Decision**: Add the four finding event constants to `AlertRule` and expose them through `AlertRuleResource::eventTypeOptions()` and `AlertRuleResource::eventTypeLabel()`.
|
||||||
|
|
||||||
|
**Rationale**: Workspace alert rules, destinations, deliveries, cooldowns, quiet hours, and delivery-history viewing already exist and are the approved external-copy path. Extending that registry keeps rule configuration and delivery viewing in one place and avoids a second event catalog.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
|
||||||
|
- Create a findings-specific alert configuration surface. Rejected because the spec explicitly requires reuse of the existing alert system and forbids a second preference or notification center.
|
||||||
|
- Pass new event strings ad hoc without updating the central registry. Rejected because alert-rule selectors, delivery filters, and labels would drift immediately.
|
||||||
|
|
||||||
|
## Decision 2: Use existing Filament database notifications for direct personal delivery
|
||||||
|
|
||||||
|
**Decision**: Deliver direct operator notifications through Laravel database notifications with Filament payloads stored in the existing `notifications` table.
|
||||||
|
|
||||||
|
**Rationale**: `AdminPanelProvider` already enables `databaseNotifications()`, and the existing `OperationRunQueued` and `OperationRunCompleted` notification classes show the repository’s established pattern for title, body, and action-link payloads. This satisfies the requirement for in-app personal notifications without creating a new page or asset surface.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
|
||||||
|
- Build a custom Livewire inbox or findings-notification page. Rejected because it introduces a second notification surface and broader UI scope than the spec allows.
|
||||||
|
- Deliver only external copies through email or Teams. Rejected because the spec requires direct in-app notifications first and treats external channels as optional copies controlled by alert rules.
|
||||||
|
|
||||||
|
## Decision 3: Emit assignment and automatic-reopen events from `FindingWorkflowService` after commit
|
||||||
|
|
||||||
|
**Decision**: Hook `findings.assigned` and `findings.reopened` at the workflow-service boundary after `mutateAndAudit(...)` returns a refreshed `Finding` record.
|
||||||
|
|
||||||
|
**Rationale**: `FindingWorkflowService` already owns assignment, reopen, due-date reset, authorization, and audit semantics. Emitting after commit avoids dispatching notification side effects for rolled-back writes and keeps event truth attached to the seam that actually changes responsibility or lifecycle.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
|
||||||
|
- Emit from controllers, Filament actions, or pages. Rejected because some finding changes are system-driven and those surfaces do not own the canonical mutation truth.
|
||||||
|
- Emit inside the transaction. Rejected because direct or external delivery could start before the write commits.
|
||||||
|
- Notify on manual `reopen(...)` in the same pass. Rejected for v1 because the spec is limited to automatic reopen notification and keeping manual reopen silent reduces scope.
|
||||||
|
|
||||||
|
## Decision 4: Keep due-soon and overdue evaluation inside `EvaluateAlertsJob`
|
||||||
|
|
||||||
|
**Decision**: Extend `EvaluateAlertsJob` with finding due-soon and overdue candidate scans and route each candidate through the finding-notification delivery seam.
|
||||||
|
|
||||||
|
**Rationale**: The job already evaluates workspace-scoped alert events on a scheduled cadence, carries evaluation-window semantics, and runs inside the existing `alerts.evaluate` operational path. Reusing it avoids a second scheduler, command, or run family.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
|
||||||
|
- Create a dedicated due-notification job or Artisan command. Rejected because it duplicates scheduling, workspace scoping, and operational observability for no functional gain.
|
||||||
|
- Trigger due reminders from the UI. Rejected because reminder delivery must not depend on an operator visiting a page.
|
||||||
|
|
||||||
|
## Decision 5: Use notification-payload metadata for direct-delivery dedupe
|
||||||
|
|
||||||
|
**Decision**: Store a `fingerprint_key` and event metadata in the existing database notification `data` JSONB payload and query the existing `notifications` table to suppress duplicate direct deliveries.
|
||||||
|
|
||||||
|
**Rationale**: The spec forbids new persistence while still requiring one notification per due cycle and fingerprint-aware delivery semantics. Existing notification rows are already persisted delivery artifacts, so they are the narrowest place to record direct-delivery fingerprints without adding a new table.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
|
||||||
|
- Create a new `FindingNotificationDelivery` table. Rejected because it adds persistence and lifecycle ownership that the spec explicitly avoids.
|
||||||
|
- Skip direct dedupe entirely. Rejected because due-soon and overdue would repeat on every scheduled evaluation pass.
|
||||||
|
|
||||||
|
## Decision 6: Reuse tenant finding detail links and current entitlement checks
|
||||||
|
|
||||||
|
**Decision**: Build notification actions against the existing tenant-panel finding detail route and suppress send-time direct delivery if the selected recipient no longer has current tenant membership plus findings-view capability.
|
||||||
|
|
||||||
|
**Rationale**: The spec requires the notification to explain why the user is seeing it and send them to the existing finding detail route while preserving current `404` and `403` semantics. Rechecking entitlement at send time prevents stale assignments or ownership from generating a misleading in-app notification.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
|
||||||
|
- Link to the My Findings inbox instead of the single finding detail. Rejected because the spec calls for direct follow-up on the existing finding detail and the inbox adds avoidable navigation indirection.
|
||||||
|
- Add a new notification-detail page. Rejected because it duplicates finding detail and creates a second follow-up surface.
|
||||||
|
- Skip send-time entitlement revalidation. Rejected because tenant membership and capability state can change between assignment and delivery.
|
||||||
257
specs/224-findings-notifications-escalation/spec.md
Normal file
257
specs/224-findings-notifications-escalation/spec.md
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
# Feature Specification: Findings Notifications & Escalation v1
|
||||||
|
|
||||||
|
**Feature Branch**: `224-findings-notifications-escalation`
|
||||||
|
**Created**: 2026-04-22
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Findings Notifications & Escalation v1"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Findings now have clear owner-versus-assignee semantics plus personal and shared work surfaces, but assignment, automatic reopen, and aging transitions still remain silent unless operators keep polling findings pages.
|
||||||
|
- **Today's failure**: A finding can be assigned, auto-reopened, become due soon, or become overdue without the responsible operator noticing in time. Due dates become passive metadata instead of a real control loop.
|
||||||
|
- **User-visible improvement**: Responsible operators receive calm, tenant-safe notifications with one clear deep link when work is assigned, reopens automatically, approaches its due date, or becomes overdue. Workspace teams can optionally add existing external alert copies without building a second findings-specific notification system.
|
||||||
|
- **Smallest enterprise-capable version**: Add four finding event types, one bounded recipient-resolution contract over existing owner and assignee truth, direct personal notifications for actionable finding events, and optional workspace-level external copies through the existing Alerts rules and destinations.
|
||||||
|
- **Explicit non-goals**: No multi-stage escalation chains, no notification-preference center, no comments or chat, no external ticket synchronization, no new owner-only queue, and no generic workflow engine.
|
||||||
|
- **Permanent complexity imported**: Four finding event types, one bounded recipient-resolution contract, one direct-notification copy contract, and focused regression coverage for entitlement-safe delivery and dedupe.
|
||||||
|
- **Why now**: Spec 219 clarified responsibility, Spec 221 created the personal assignee queue, and Spec 222 created shared intake. The next missing slice is to close the loop so those surfaces no longer rely on manual polling.
|
||||||
|
- **Why not local**: A badge or queue-only polish fix would still leave assignment, reopen, and aging changes silent across the workspace. The gap is cross-cutting workflow feedback, not one page.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: One bounded new recipient-resolution rule and one new event family. Scope stays acceptable because it reuses existing finding truth, existing alert delivery infrastructure, and existing notification primitives.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace
|
||||||
|
- **Primary Routes**:
|
||||||
|
- Admin UI → Workspace → Monitoring → Alerts → Alert rules
|
||||||
|
- Admin UI → Workspace → Monitoring → Alerts → Alert deliveries
|
||||||
|
- `/admin/t/{tenant}/findings/{finding}` as the primary follow-up destination from finding notifications
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned findings remain the only source of truth for assignment, reopen state, due state, severity, and tenant scope.
|
||||||
|
- Workspace-owned alert rules and alert destinations remain the only source of truth for external copies and shared escalation delivery.
|
||||||
|
- Existing user notification records remain delivery artifacts only and must not become a second findings workflow state store.
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership is required for alert rule and alert delivery surfaces.
|
||||||
|
- `ALERTS_VIEW` gates viewing alert-rule configuration and alert delivery history.
|
||||||
|
- `ALERTS_MANAGE` gates creating, editing, enabling, disabling, or deleting external alert-copy rules.
|
||||||
|
- Direct finding notifications are only delivered to currently entitled operators who may already inspect the target tenant and finding scope.
|
||||||
|
- Non-members or wrong-plane requests remain deny-as-not-found. Members missing capability receive `403` on protected configuration surfaces.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Alert rules resource gains finding notification event types and routing copy | yes | Native Filament resource forms + existing alert configuration primitives | Same workspace monitoring configuration family as existing alert rules | form, event labels, routing helper copy | no | Existing surface extended; no new alert page |
|
||||||
|
| Alert deliveries viewer gains finding-event labels and delivery visibility | yes | Native Filament table + existing alert delivery viewer | Same workspace alert observability family | table, filter labels, delivery summaries | no | Existing viewer extended; still read-only |
|
||||||
|
| Direct finding notifications and deep links | yes | Existing in-app notification primitives + shared link helpers | Same personal workflow entry-point family as other operator notifications | notification payload, deep link, recipient explanation | no | Reuses current notification surface; no new notification center |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Alert rules resource gains finding notification event types and routing copy | Secondary Context Surface | A workspace operator decides which finding events should also create external or shared-team copies | Event type, tenant scope, severity threshold, cooldown, quiet-hours, and destinations | Delivery history, raw event keys, and failure diagnostics | Secondary because it configures workflow feedback rather than hosting the work itself | Keeps findings notification policy inside existing Alerts administration | Avoids inventing a second findings-specific settings area |
|
||||||
|
| Alert deliveries viewer gains finding-event labels and delivery visibility | Tertiary Evidence / Diagnostics Surface | A workspace operator verifies whether rule-based copies were sent, deferred, suppressed, or failed | Event label, tenant, severity, status, and timestamp | Safe diagnostics and historical delivery details | Tertiary because it explains delivery history after the notification decision already happened | Preserves one audit-style surface for external delivery truth | Avoids debugging notification behavior by searching logs or guessing destination behavior |
|
||||||
|
| Direct finding notifications and deep links | Secondary Context Surface | An operator receives a finding event and decides whether to open the finding now | Why the operator was notified, the affected tenant, the finding summary, severity, and one deep link | Full finding detail, evidence, audit trail, and workflow actions after opening the finding | Secondary because the notification points into the real work surface instead of replacing it | Aligns assignment and aging signals with the already-established findings workflow surfaces | Removes repeated polling of My Findings, intake, and tenant-local findings pages |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Alert rules resource gains finding notification event types and routing copy | Config / Settings | Workflow routing configuration | Save or update a rule for finding events | Alert rule | required on the existing list | Existing `More` group on the alert-rules list | Existing destructive actions remain grouped and confirmed on the resource | Workspace Monitoring → Alerts → Alert rules | Existing alert-rule edit surface | Workspace scope, event type, severity, tenant targeting, destinations | Alert rules / Alert rule | Which finding events are routed externally and under which scope rules | none |
|
||||||
|
| Alert deliveries viewer gains finding-event labels and delivery visibility | Utility / System | Read-only log / report surface | Inspect whether a finding-event delivery was sent or suppressed | Alert delivery | allowed on the existing viewer | Existing read-only filter and inspect controls only | none | Workspace Monitoring → Alerts → Alert deliveries | Existing delivery inspect surface | Workspace scope, tenant label, delivery status, event type | Alert deliveries / Alert delivery | Whether a finding-event copy was sent, deferred, suppressed, or failed | none |
|
||||||
|
| Direct finding notifications and deep links | Utility / System | Notification / drill-in entry point | Open the finding that needs follow-up | Finding | forbidden | No competing mutation; one deep link only | none | Existing in-app notification surface | `/admin/t/{tenant}/findings/{finding}` | Tenant name, severity, recipient reason, event label | Findings / Finding | Why this operator was notified and what finding requires attention | Existing notification surface reused; no new standalone page |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Alert rules resource gains finding notification event types and routing copy | Workspace operator with alert-management responsibility | Decide which finding events should create external copies or shared escalation signals | Configuration surface | Which finding workflow events should notify beyond the directly responsible operator? | Event type, minimum severity, tenant scope, destinations, cooldown, quiet hours | Delivery failures, suppressed history, low-level event keys | alert-event type, severity threshold, tenant scope | Workspace configuration only | Create rule, Edit rule, Enable or disable rule | Delete rule |
|
||||||
|
| Alert deliveries viewer gains finding-event labels and delivery visibility | Workspace operator reviewing alert behavior | Verify whether external finding-event copies were delivered as configured | Read-only history surface | Did the configured finding notification copy actually go out? | Event label, tenant, severity, timestamp, delivery status | Safe failure reasons and delivery metadata | delivery status, event type, severity | none | View delivery | none |
|
||||||
|
| Direct finding notifications and deep links | Tenant operator or tenant manager | Decide whether to open a specific finding now because work was assigned, reopened, is due soon, or is overdue | Notification entry point | Why am I being notified, and what do I need to look at? | Tenant, finding summary, severity, event label, recipient reason, deep link | Full evidence, audit trail, lifecycle history, and related context after opening the finding | assignment change, reopen truth, due-state aging | none on the notification itself | Open finding | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: yes — one bounded recipient-resolution contract over existing finding owner and assignee truth plus existing alert destinations
|
||||||
|
- **New enum/state/reason family?**: yes — four finding notification event types and one bounded recipient-reason vocabulary
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: Findings already move through assignment, reopen, and due-state transitions, but the responsible operator still has to rediscover those changes by polling queues and tenant pages.
|
||||||
|
- **Existing structure is insufficient because**: Existing findings queues only show current backlog once an operator opens them. Existing Alerts rules handle shared external delivery, but they do not by themselves define who the directly responsible operator should be for assignment, reopen, and aging signals.
|
||||||
|
- **Narrowest correct implementation**: Reuse the current finding owner and assignee contract, add four event types, resolve one direct recipient per event when possible, and let existing Alerts rules optionally produce external copies.
|
||||||
|
- **Ownership cost**: Ongoing maintenance for one bounded recipient-resolution rule set, event labels, and regression coverage for entitlement-safe delivery and dedupe.
|
||||||
|
- **Alternative intentionally rejected**: A full notification-preference center or a generic workflow-notification engine was rejected because it imports durable product complexity before the smaller control-loop problem is proven.
|
||||||
|
- **Release truth**: Current-release truth. This spec turns existing findings workflow changes into actionable operator feedback now rather than preparing for a distant automation layer.
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||||
|
|
||||||
|
Canonical replacement is preferred over preservation.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: The feature changes visible workflow consequences for findings and workspace alert configuration, but it does not add a new browser-only interaction model or a heavy-governance computation lane. Focused feature coverage proves event production, direct recipient routing, external rule integration, deep-link safety, and no-leak authorization behavior.
|
||||||
|
- **New or expanded test families**: Add focused finding-notification event tests, direct-recipient routing tests, due-cycle dedupe tests, alert-rule event-type option tests, and deep-link authorization tests.
|
||||||
|
- **Fixture / helper cost impact**: Moderate. Tests need findings with explicit owner and assignee combinations, due dates across notification thresholds, workspace alert rules and destinations, and hidden versus visible tenant memberships.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: global-context-shell
|
||||||
|
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit proof that notification deep links honor current tenant entitlement and that hidden tenants do not leak through notification text, event routing, or delivery history.
|
||||||
|
- **Reviewer handoff**: Reviewers must confirm that each event type has a deterministic trigger, that direct recipient precedence matches the spec, that owner-only changes do not emit assignment notifications, and that external rule-based copies remain optional rather than becoming the only notification path.
|
||||||
|
- **Budget / baseline / trend impact**: none
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Receive direct notification when work changes hands or reopens (Priority: P1)
|
||||||
|
|
||||||
|
As a responsible operator, I want assignment and automatic reopen events to notify me directly, so findings do not silently appear in my workload.
|
||||||
|
|
||||||
|
**Why this priority**: This is the smallest workflow-closing slice. If assignment and automatic reopen remain silent, ownership semantics and personal queues still rely on manual polling.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by assigning findings to a user, triggering an automatic reopen, and verifying that the correct recipient receives one tenant-safe notification with a deep link.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an open finding is assigned to a new assignee, **When** the assignment is saved, **Then** the new assignee receives one direct notification explaining that the finding was assigned to them.
|
||||||
|
2. **Given** a previously resolved finding is automatically reopened by system detection and still has an assignee, **When** the reopen is recorded, **Then** the assignee receives one direct reopen notification with a deep link to the finding.
|
||||||
|
3. **Given** only the accountable owner changes while the assignee remains unchanged, **When** the owner update is saved, **Then** no assignment notification is emitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Get due-soon reminders and overdue escalation without spam (Priority: P1)
|
||||||
|
|
||||||
|
As an operator responsible for a finding, I want the system to remind me before a due date and escalate overdue items predictably, so aging work does not disappear until the next manual review.
|
||||||
|
|
||||||
|
**Why this priority**: Due dates without reminder and escalation behavior are not operational controls. This story turns due metadata into an actionable workflow signal.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by placing findings into the due-soon window and overdue state across different owner and assignee combinations, then verifying the correct recipient, one-time due-cycle behavior, and no duplicate notifications when the same person holds both roles.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an open finding enters the due-soon reminder window and has a current assignee, **When** evaluation runs, **Then** the assignee receives one due-soon reminder for that due cycle.
|
||||||
|
2. **Given** an open finding becomes overdue and has an owner distinct from the assignee, **When** evaluation runs, **Then** the owner receives the overdue escalation and the system does not send a second duplicate direct notification if owner and assignee are the same person.
|
||||||
|
3. **Given** a finding has already emitted a due-soon or overdue notification for the current due cycle, **When** later evaluations run without a due-date reset or reopen, **Then** no duplicate direct notification is emitted for that same cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Configure external copies through existing Alerts management (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace operator, I want the existing Alerts rules to support finding assignment, reopen, due-soon, and overdue event types, so shared team destinations can receive the same workflow signals without a second notification product.
|
||||||
|
|
||||||
|
**Why this priority**: The direct user notification closes the personal loop, but enterprise teams still need optional shared or external copies through the current alerting foundation.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by adding the new event types to an alert rule, triggering one matching event, and verifying that an external delivery is created while the direct personal notification behavior remains available.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a workspace operator edits an alert rule, **When** they open the event-type selector, **Then** the four new findings notification event types are available.
|
||||||
|
2. **Given** a matching alert rule exists for an overdue findings event, **When** an open finding becomes overdue, **Then** the existing alert delivery pipeline creates one external copy per matching enabled destination.
|
||||||
|
3. **Given** no matching alert rule exists for an assignment event, **When** a finding is assigned to an entitled operator, **Then** the direct operator notification still occurs while no external delivery is created.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- The same entitled user may be both owner and assignee; the system must send one direct notification, not two copies with different labels.
|
||||||
|
- A finding may lose its assignee or owner entitlement after the workflow event was created but before delivery; direct delivery must re-check current entitlement and suppress the personal notification instead of leaking scope.
|
||||||
|
- A finding may become terminal after entering the due-soon window but before evaluation sends reminders; due-soon and overdue notifications must suppress for terminal findings.
|
||||||
|
- Rapid reassignment or repeated automatic reopen within the same notification fingerprint window must not fan out duplicate direct notifications.
|
||||||
|
- Existing tenant-level `sla_due` summary alerts remain valid and separate; this feature must not redefine or remove that aggregate overdue signal.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature adds no Microsoft Graph calls and no new `OperationRun` type. It reuses the existing alert-evaluation and alert-delivery operations plus existing in-app notification primitives. Scheduled evaluation remains system-run, so no new initiator-only terminal DB notification rule is introduced. Tenant isolation, delivery history, and test coverage remain mandatory because the feature emits new finding event types into existing background alert flows.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** The proof burden is focused feature coverage for finding event production, recipient resolution, direct notification deep-link safety, alert-rule UI options, external delivery integration, and hidden-tenant suppression. No browser or heavy-governance lane is required.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature spans workspace-context alert configuration and tenant-scoped finding follow-up. Non-members, wrong-workspace users, and hidden-tenant requests remain `404`. Workspace members without `ALERTS_VIEW` or `ALERTS_MANAGE` remain `403` on protected alert pages. Notification payloads and deep links must not reveal finding or tenant details to operators who are no longer entitled to inspect the finding at open time.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** Existing Filament `AlertRuleResource` and alert delivery viewer remain the only configuration and delivery-history surfaces. Existing notification primitives and shared link helpers must be reused for direct finding notifications. No findings-specific notification center, badge system, or page-local alert markup may be introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target object is always the `finding`. Primary operator verbs are `assigned`, `reopened`, `due soon`, `overdue`, and `Open finding`. The same finding vocabulary must hold across alert-rule labels, notification titles, delivery history, and deep-link copy. Implementation-first terms such as fingerprint, recipient resolver, or evaluation window remain secondary.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** Direct notifications are entry points, not replacement work surfaces. They must make the first decision obvious in one glance and then defer to the existing finding detail as the durable decision context.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** No new list or detail page is introduced. Existing alert configuration surfaces stay config-first. Direct notifications expose one inspect model only: the finding. They must not compete with page-local mutation affordances or invent a second queue model.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** This feature derives notification truth directly from existing finding lifecycle, ownership, due-date, and tenant-entitlement data. It must not add a second persisted workflow state, a local notification-only status, or a duplicate owner or assignee meaning.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST support four new finding notification event types for the existing alert dispatch pipeline: `findings.assigned`, `findings.reopened`, `findings.due_soon`, and `findings.overdue`.
|
||||||
|
- **FR-002**: `findings.assigned` MUST be produced when an open finding is assigned to a new assignee. Owner-only changes, assignee clears, and no-op saves MUST NOT emit this event.
|
||||||
|
- **FR-003**: `findings.reopened` MUST be produced only for system-driven reopen of an existing finding caused by recurring detection. Manual reopen remains out of scope for v1 notifications.
|
||||||
|
- **FR-004**: `findings.due_soon` MUST be produced once per due cycle when an open finding first enters the due-soon reminder window. The default v1 reminder window is 24 hours before `due_at`.
|
||||||
|
- **FR-005**: `findings.overdue` MUST be produced once per due cycle when an open finding first becomes overdue.
|
||||||
|
- **FR-006**: A finding due cycle resets only when the finding’s `due_at` is recalculated by the existing lifecycle contract, such as after a system reopen that resets due dates. `reopened_at` may explain why the cycle changed, but only a new `due_at` value may produce a new due-soon or overdue event for the same finding.
|
||||||
|
- **FR-007**: The system MUST resolve one direct personal recipient for each event when possible using this precedence:
|
||||||
|
- `findings.assigned` → new assignee
|
||||||
|
- `findings.reopened` → current assignee, else current owner
|
||||||
|
- `findings.due_soon` → current assignee, else current owner
|
||||||
|
- `findings.overdue` → current owner, else current assignee
|
||||||
|
- **FR-008**: Direct personal delivery MUST only occur when the resolved recipient is still currently entitled to inspect the target tenant and finding at send time. If no currently entitled direct recipient exists, the system MUST suppress direct personal delivery rather than broaden disclosure.
|
||||||
|
- **FR-009**: When the resolved owner and assignee are the same currently entitled user, the system MUST send only one direct personal notification for that event.
|
||||||
|
- **FR-010**: Each finding event MUST carry a deterministic fingerprint suitable for dedupe and cooldown. Assignment fingerprints must distinguish the target assignee change, automatic reopen fingerprints must distinguish the reopen occurrence, and due-soon or overdue fingerprints must roll when the due cycle resets.
|
||||||
|
- **FR-011**: Direct personal notifications MUST be available without requiring a matching workspace alert rule. Workspace alert rules remain optional copies for external or shared-team delivery.
|
||||||
|
- **FR-012**: Existing Alerts rules MUST support the four new finding notification event types in the event-type selector. No new alert pages, no new destination type family, and no findings-specific preference center may be introduced in v1.
|
||||||
|
- **FR-013**: Existing Alerts v1 behavior for minimum severity, tenant scoping, cooldown, dedupe, quiet hours, and destination fan-out MUST apply unchanged to external rule-based copies of the new finding events.
|
||||||
|
- **FR-014**: Direct personal notification payloads MUST include the finding summary, tenant context, severity, event label, why the current operator received it, and one deep link to open the finding.
|
||||||
|
- **FR-015**: Notification titles, body copy, delivery history labels, and alert-rule event labels MUST stay domain-first and must not expose raw internal event keys as the primary operator language.
|
||||||
|
- **FR-016**: Direct and external notifications MUST deep-link to the existing tenant finding detail route for the target finding. Opening that link MUST continue to honor current `404` versus `403` entitlement semantics at the time of use.
|
||||||
|
- **FR-017**: Existing tenant-level `sla_due` summary alert behavior from Spec 111 MUST remain in place. The new due-soon and overdue events are finding-level actionable signals and MUST NOT replace or silently redefine the aggregate `sla_due` event.
|
||||||
|
- **FR-018**: Alert delivery history MUST show external finding-event deliveries in the existing workspace alert deliveries viewer with the new event labels. The feature MUST NOT introduce a second workspace-wide history page for direct in-app personal notifications.
|
||||||
|
- **FR-019**: The feature MUST NOT introduce a new findings lifecycle state, a second assignment model, a notification-only queue, or a repeated daily overdue escalation loop by default.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Alert Rules Resource | Workspace Monitoring → Alerts → Alert rules | Existing create action only | Clickable row to existing edit surface | Existing Edit and `More` actions only | none | Existing create CTA only | n/a | Save / Cancel | Yes for existing rule mutations | Surface is extended only with four event types and routing helper copy. Action Surface Contract remains satisfied by the existing resource pattern. |
|
||||||
|
| Alert Deliveries viewer | Workspace Monitoring → Alerts → Alert deliveries | none | Existing inspect behavior only | Existing read-only inspect action only | none | none | n/a | n/a | No new mutation audit because the surface stays read-only | Existing viewer only gains finding-event labels and safe recipient-facing delivery summaries. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Finding notification event**: A derived workflow event produced from an existing finding transition or aging threshold and routed through the existing alerting pipeline.
|
||||||
|
- **Direct finding notification**: A personal in-app notification sent to the single currently responsible operator resolved from existing finding owner and assignee truth.
|
||||||
|
- **Alert rule (extended)**: The existing workspace alert rule model, extended with the four new finding notification event types.
|
||||||
|
- **Recipient-resolution contract**: The bounded precedence rule that selects the directly responsible operator for assignment, automatic reopen, due-soon, and overdue events without creating a second ownership model.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: In acceptance review, an operator can understand why they received an assignment, reopen, due-soon, or overdue notification and open the correct finding in one interaction.
|
||||||
|
- **SC-002**: 100% of covered automated tests route each of the four event types to the correct direct recipient or correctly suppress direct delivery when no currently entitled recipient exists.
|
||||||
|
- **SC-003**: 100% of covered due-cycle tests emit at most one direct due-soon reminder and one direct overdue escalation per finding due cycle unless the due cycle resets.
|
||||||
|
- **SC-004**: 100% of covered alert-rule tests show that the four new event types are available in the existing Alerts UI and create external deliveries only when matching rules exist.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The existing notification-routing foundation can deliver direct in-app notifications to currently entitled operators without introducing a second notification product.
|
||||||
|
- The due-soon horizon is fixed at 24 hours before `due_at` in v1.
|
||||||
|
- Existing finding detail remains the canonical follow-up surface reached from the notification deep link.
|
||||||
|
- Membership and assignment hygiene after a person loses tenant access remains a separate hardening slice and is not solved fully here.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introduce multi-stage escalation chains or automatic reassign logic.
|
||||||
|
- Build a findings-specific notification-preference center or destination management UX.
|
||||||
|
- Replace the existing aggregate `sla_due` alert semantics with item-level reminders.
|
||||||
|
- Add comments, chat, or external ticket handoff.
|
||||||
|
- Create a second findings queue or a notification-only workflow state.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 099, Alerts v1, remains the source of truth for workspace alert rules, destinations, cooldown, quiet hours, and delivery history.
|
||||||
|
- Spec 100, Alert target test actions, remains the source of truth for alert destination testability and delivery-viewer conventions.
|
||||||
|
- Spec 111, Findings Workflow + SLA, remains the source of truth for finding lifecycle, due dates, reopen behavior, and aggregate `sla_due` alerts.
|
||||||
|
- Spec 219, Finding Ownership Semantics Clarification, remains the source of truth for owner-versus-assignee meaning.
|
||||||
|
- Spec 221, Findings Operator Inbox V1, and Spec 222, Findings Intake & Team Queue V1, remain the established work surfaces that benefit from these notifications even though this spec does not create new queue pages.
|
||||||
211
specs/224-findings-notifications-escalation/tasks.md
Normal file
211
specs/224-findings-notifications-escalation/tasks.md
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
# Tasks: Findings Notifications & Escalation v1
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/224-findings-notifications-escalation/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/findings-notifications-escalation.logical.openapi.yaml`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Required. This feature changes runtime behavior in finding workflow mutations, scheduled alert evaluation, Laravel database notifications, and existing Filament alert-management surfaces, so Pest coverage must be added in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`, `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php`, and `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`.
|
||||||
|
**Operations**: No new `OperationRun` is introduced. Scheduled due-soon and overdue evaluation must remain on the existing `alerts.evaluate` cadence via `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php` and the existing `tenantpilot:alerts:dispatch` command.
|
||||||
|
**RBAC**: Workspace alert configuration stays on the admin `/admin` plane and finding follow-up stays on the tenant `/admin/t/{tenant}/findings/{finding}` plane. The implementation must preserve `ALERTS_VIEW`-gated read access to alert rules and alert deliveries, `ALERTS_MANAGE`-gated alert-rule mutation, non-member and hidden-scope `404`, in-scope missing-capability `403`, and send-time entitlement rechecks before direct personal delivery.
|
||||||
|
**UI / Surface Guardrails**: `Alert rules` and `Alert deliveries` stay `standard-native-filament` surfaces. The database notification drawer plus tenant finding detail link is a `global-context-shell` seam and must keep one calm `Open finding` drill-in path only.
|
||||||
|
**Filament UI Action Surfaces**: `AlertRuleResource` and `AlertDeliveryResource` remain existing resource patterns with no new page family, no new destructive action, and no second notification center. Direct finding notifications expose one inspect model only: the finding.
|
||||||
|
**Badges**: Existing finding severity, lifecycle, and alert-delivery status semantics remain authoritative. No page-local badge taxonomy or ad-hoc status mapping is introduced.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3`, because direct responsible-user delivery closes the smallest workflow gap first, due-cycle reminders build on the same delivery seam second, and external-copy management is safest after direct-event truth is established.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
||||||
|
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||||
|
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||||
|
- [x] The declared surface test profile or `standard-native-filament` relief is explicit.
|
||||||
|
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Notification Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Prepare the shared delivery seam and focused regression suites used across all stories.
|
||||||
|
|
||||||
|
- [X] T001 [P] Create the finding notification service scaffold in `apps/platform/app/Services/Findings/FindingNotificationService.php`
|
||||||
|
- [X] T002 [P] Create the finding event database-notification scaffold in `apps/platform/app/Notifications/Findings/FindingEventNotification.php`
|
||||||
|
- [X] T003 [P] Create focused Pest scaffolding in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`, and `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The shared service, notification class, and focused test files exist and are ready for implementation work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Event Vocabulary And Delivery Seams)
|
||||||
|
|
||||||
|
**Purpose**: Establish the canonical finding event keys, direct-delivery baseline, and shared dispatch contract every story depends on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T004 Add the four canonical finding event constants in `apps/platform/app/Models/AlertRule.php`
|
||||||
|
- [X] T005 Implement the shared finding-event envelope builder, recipient-resolution precedence, send-time entitlement recheck, fingerprint helpers, and direct-delivery dedupe baseline in `apps/platform/app/Services/Findings/FindingNotificationService.php`
|
||||||
|
- [X] T006 Implement the base Filament database notification payload, recipient-reason copy, and tenant finding deep link in `apps/platform/app/Notifications/Findings/FindingEventNotification.php`
|
||||||
|
- [X] T007 Add foundational database-notification payload-shape and tenant-safe `404` versus `403` link coverage in `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||||
|
- [X] T008 Wire the shared optional external-copy dispatch baseline from `apps/platform/app/Services/Findings/FindingNotificationService.php` into `apps/platform/app/Services/Alerts/AlertDispatchService.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Shared event vocabulary, recipient resolution, direct personal delivery, and external-copy handoff are available for all stories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Receive direct notification when work changes hands or reopens (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Notify the directly responsible operator when an open finding is assigned to them or a previously terminal finding is reopened by system detection.
|
||||||
|
|
||||||
|
**Independent Test**: Assign an open finding to a new assignee and trigger a system reopen on a terminal finding, then verify one entitled recipient receives one tenant-safe notification with a deep link while owner-only changes remain silent.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T009 [P] [US1] Add assignment and system-reopen event production plus rapid reassignment and repeated automatic-reopen dedupe coverage in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`
|
||||||
|
- [X] T010 [P] [US1] Add recipient precedence, owner-only suppression, assignee-clear suppression, and entitlement-loss coverage for assignment and reopen flows in `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T011 [US1] Emit `findings.assigned` only after committed assignee changes and suppress owner-only, assignee-clear, and no-op saves in `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||||
|
- [X] T012 [US1] Emit `findings.reopened` only from the system-driven reopen path in `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||||
|
- [X] T013 [US1] Align assignment and reopen notification titles, bodies, and recipient-reason vocabulary in `apps/platform/app/Services/Findings/FindingNotificationService.php` and `apps/platform/app/Notifications/Findings/FindingEventNotification.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional and the responsible operator no longer needs to poll findings pages to notice assignment or automatic reopen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Get due-soon reminders and overdue escalation without spam (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Turn finding due dates into one-per-cycle due-soon reminders and overdue escalations without duplicate personal notification noise.
|
||||||
|
|
||||||
|
**Independent Test**: Seed open findings across due-soon and overdue thresholds with different owner and assignee combinations, run alert evaluation, and verify correct recipient precedence, terminal suppression, same-user dedupe, and one notification per due cycle.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T014 [P] [US2] Add due-soon and overdue window, one-per-cycle, and `due_at`-driven due-cycle-reset coverage in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`
|
||||||
|
- [X] T015 [P] [US2] Add due recipient precedence, same-user dedupe, terminal-finding suppression, and no-entitled-recipient suppression coverage in `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [US2] Add workspace-scoped due-soon and overdue candidate collection with a 24-hour reminder window and non-open-finding suppression in `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`
|
||||||
|
- [X] T017 [US2] Route due-soon and overdue candidates through the shared delivery seam and derive per-cycle fingerprints from the recalculated `due_at` value in `apps/platform/app/Services/Findings/FindingNotificationService.php`
|
||||||
|
- [X] T018 [US2] Add due-window and owner-versus-assignee factory states needed for due-cycle reminder coverage in `apps/platform/database/factories/FindingFactory.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional and due dates now produce predictable, non-spammy direct reminders and overdue escalation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Configure external copies through existing Alerts management (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Let workspace operators configure and inspect optional external copies for the same finding events through the existing Alerts management surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Add the new finding event types to an alert rule, trigger a matching event through the shared delivery seam, and verify external deliveries appear in the existing viewer while direct personal delivery still works without a matching rule.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T019 [US3] Add alert-rule event option exposure, alert-rule and alert-delivery non-member or hidden-scope `404` versus in-scope capability `403` coverage, `ALERTS_VIEW` read versus `ALERTS_MANAGE` mutation boundaries, inherited external-copy behavior covering minimum severity, tenant scoping, cooldown, quiet hours, dedupe, delivery-history label and filter coverage, and direct-without-rule behavior in `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`; extend `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php` to prove aggregate `sla_due` behavior remains unchanged
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T020 [P] [US3] Expose the four finding notification event types in the existing selector and label mapping in `apps/platform/app/Filament/Resources/AlertRuleResource.php`
|
||||||
|
- [X] T021 [P] [US3] Render finding-event labels and filter options in the existing viewer in `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`
|
||||||
|
- [X] T022 [US3] Finalize finding-event payload normalization and rule-driven external-copy fan-out behavior in `apps/platform/app/Services/Alerts/AlertDispatchService.php` and `apps/platform/app/Services/Findings/FindingNotificationService.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional and workspace teams can reuse existing Alerts rules and delivery history for finding assignment, reopen, due-soon, and overdue copies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finish guardrail alignment, formatting, and focused verification across the full feature.
|
||||||
|
|
||||||
|
- [X] T023 Review operator-facing finding vocabulary, recipient-reason copy, and guardrail alignment in `apps/platform/app/Services/Findings/FindingNotificationService.php`, `apps/platform/app/Notifications/Findings/FindingEventNotification.php`, `apps/platform/app/Filament/Resources/AlertRuleResource.php`, and `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`
|
||||||
|
- [X] T024 Run formatting for `apps/platform/app/Services/Findings/FindingNotificationService.php`, `apps/platform/app/Notifications/Findings/FindingEventNotification.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, `apps/platform/app/Services/Alerts/AlertDispatchService.php`, `apps/platform/app/Models/AlertRule.php`, `apps/platform/app/Filament/Resources/AlertRuleResource.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`, and `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [X] T025 Run the focused verification workflow from `specs/224-findings-notifications-escalation/quickstart.md` against `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`, `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php`, and `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately and prepares the shared service, notification class, and focused Pest files.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until shared event vocabulary, recipient resolution, and dispatch seams exist.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the recommended MVP cut.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion and extends the same direct-delivery seam with scheduled due evaluation.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion and extends the existing Alerts management surfaces with the same event vocabulary.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: No dependencies beyond Foundational.
|
||||||
|
- **US2**: No hard dependency on US1, but it reuses the same delivery seam and should preserve direct-delivery vocabulary established there.
|
||||||
|
- **US3**: No hard dependency on US1 or US2 after Foundational, but it must stay aligned with the shared event vocabulary and direct-delivery contract they use.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the story tests first and confirm they fail before implementation is considered complete.
|
||||||
|
- Keep shared delivery behavior authoritative in `FindingNotificationService.php` before duplicating logic in `FindingWorkflowService.php` or `EvaluateAlertsJob.php`.
|
||||||
|
- Finish story-level verification before moving to the next priority slice.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001`, `T002`, and `T003` can run in parallel during Setup.
|
||||||
|
- `T009` and `T010` can run in parallel for User Story 1.
|
||||||
|
- `T014` and `T015` can run in parallel for User Story 2.
|
||||||
|
- `T020` and `T021` can run in parallel for User Story 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 1 tests in parallel
|
||||||
|
T009 apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php
|
||||||
|
T010 apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 2 tests in parallel
|
||||||
|
T014 apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php
|
||||||
|
T015 apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 3 surface work in parallel
|
||||||
|
T020 apps/platform/app/Filament/Resources/AlertRuleResource.php
|
||||||
|
T021 apps/platform/app/Filament/Resources/AlertDeliveryResource.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Validate the feature against the focused US1 tests before widening the slice.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Ship US1 to close the direct assignment and automatic-reopen feedback gap.
|
||||||
|
2. Add US2 to turn due dates into actionable reminders and overdue escalation.
|
||||||
|
3. Add US3 to let workspace teams configure optional external copies through the existing Alerts UI.
|
||||||
|
4. Finish with copy review, formatting, and the focused verification pack.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One contributor can scaffold the shared service and notification class while another prepares the focused Pest suites.
|
||||||
|
2. After Foundational work lands, one contributor can wire workflow-event production while another implements due-evaluation logic.
|
||||||
|
3. Alert-rule and delivery-viewer surface work can proceed in parallel with direct-delivery trigger work once the shared event vocabulary is stable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared.
|
||||||
|
- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories.
|
||||||
|
- The suggested MVP scope is Phase 1 through Phase 3 only.
|
||||||
|
- All implementation tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.
|
||||||
Loading…
Reference in New Issue
Block a user