Compare commits
4 Commits
221-findin
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e15d80cca5 | |||
| 712576c447 | |||
| cebd5ee1b0 | |||
| 81bb5f42c7 |
18
.github/agents/copilot-instructions.md
vendored
18
.github/agents/copilot-instructions.md
vendored
@ -218,12 +218,20 @@ ## Active Technologies
|
|||||||
- Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages)
|
- Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` (216-provider-dispatch-gate)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` (216-provider-dispatch-gate)
|
||||||
- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate)
|
- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate)
|
||||||
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure)
|
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (217-homepage-structure)
|
||||||
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure)
|
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (217-homepage-structure)
|
||||||
|
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests (218-homepage-hero)
|
||||||
|
- Static filesystem content and assets under `apps/website/src` and `apps/website/public`; no database (218-homepage-hero)
|
||||||
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics)
|
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics)
|
||||||
- PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned (219-finding-ownership-semantics)
|
- PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned (219-finding-ownership-semantics)
|
||||||
- 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)
|
- 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)
|
||||||
- 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)
|
||||||
|
- 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)
|
||||||
|
- 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)
|
||||||
|
|
||||||
@ -258,9 +266,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 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
|
- 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`
|
||||||
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
- 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`
|
||||||
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
|
- 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`
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
688
apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
Normal file
688
apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
Normal file
@ -0,0 +1,688 @@
|
|||||||
|
<?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\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\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\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class MyFindingsInbox extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $title = 'My Findings';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'findings/my-work';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.findings.my-findings-inbox';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $authorizedTenants = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, Tenant>|null
|
||||||
|
*/
|
||||||
|
private ?array $visibleTenants = null;
|
||||||
|
|
||||||
|
private ?Workspace $workspace = null;
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
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::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.')
|
||||||
|
->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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->authorizePageAccess();
|
||||||
|
|
||||||
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
['overdue', 'reopened', 'high_severity'],
|
||||||
|
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->queueBaseQuery())
|
||||||
|
->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)),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('tenant_id')
|
||||||
|
->label('Tenant')
|
||||||
|
->options(fn (): array => $this->tenantFilterOptions())
|
||||||
|
->searchable(),
|
||||||
|
Filter::make('overdue')
|
||||||
|
->label('Overdue')
|
||||||
|
->query(fn (Builder $query): Builder => $query
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())),
|
||||||
|
Filter::make('reopened')
|
||||||
|
->label('Reopened')
|
||||||
|
->query(fn (Builder $query): Builder => $query->whereNotNull('reopened_at')),
|
||||||
|
Filter::make('high_severity')
|
||||||
|
->label('High severity')
|
||||||
|
->query(fn (Builder $query): Builder => $query->whereIn('severity', Finding::highSeverityValues())),
|
||||||
|
])
|
||||||
|
->actions([])
|
||||||
|
->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();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_scoped' => true,
|
||||||
|
'assignee_scope' => 'current_user_only',
|
||||||
|
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
|
||||||
|
'tenant_label' => $tenant?->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function availableFilters(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'key' => 'assignee_scope',
|
||||||
|
'label' => 'Assigned to me',
|
||||||
|
'fixed' => true,
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'tenant',
|
||||||
|
'label' => 'Tenant',
|
||||||
|
'fixed' => false,
|
||||||
|
'options' => collect($this->visibleTenants())
|
||||||
|
->map(fn (Tenant $tenant): array => [
|
||||||
|
'value' => (string) $tenant->getKey(),
|
||||||
|
'label' => (string) $tenant->name,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'overdue',
|
||||||
|
'label' => 'Overdue',
|
||||||
|
'fixed' => false,
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'reopened',
|
||||||
|
'label' => 'Reopened',
|
||||||
|
'fixed' => false,
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'high_severity',
|
||||||
|
'label' => 'High severity',
|
||||||
|
'fixed' => false,
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{open_assigned: int, overdue_assigned: int}
|
||||||
|
*/
|
||||||
|
public function summaryCounts(): array
|
||||||
|
{
|
||||||
|
$query = $this->filteredQueueQuery();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'open_assigned' => (clone $query)->count(),
|
||||||
|
'overdue_assigned' => (clone $query)
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function emptyState(): array
|
||||||
|
{
|
||||||
|
if ($this->tenantFilterAloneExcludesRows()) {
|
||||||
|
return [
|
||||||
|
'title' => 'No assigned findings match this tenant scope',
|
||||||
|
'body' => 'Your current tenant filter is hiding assigned 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeTenant = $this->activeVisibleTenant();
|
||||||
|
|
||||||
|
if ($activeTenant instanceof Tenant) {
|
||||||
|
return [
|
||||||
|
'title' => 'No visible assigned findings right now',
|
||||||
|
'body' => 'Nothing currently assigned to you needs attention in the visible tenant scope. You can still open tenant findings for broader context.',
|
||||||
|
'icon' => 'heroicon-o-clipboard-document-check',
|
||||||
|
'action_name' => 'open_tenant_findings_empty',
|
||||||
|
'action_label' => 'Open tenant findings',
|
||||||
|
'action_kind' => 'url',
|
||||||
|
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $activeTenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'No visible assigned findings right now',
|
||||||
|
'body' => 'Nothing currently assigned to you needs attention across the visible tenant scope. Choose a tenant to continue working elsewhere in the workspace.',
|
||||||
|
'icon' => 'heroicon-o-clipboard-document-check',
|
||||||
|
'action_name' => 'choose_tenant_empty',
|
||||||
|
'action_label' => 'Choose a tenant',
|
||||||
|
'action_kind' => 'url',
|
||||||
|
'action_url' => route('filament.admin.pages.choose-tenant'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
$tenantIds = array_map(
|
||||||
|
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||||
|
$this->visibleTenants(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $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)
|
||||||
|
->where('assignee_user_id', (int) $user->getKey())
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->orderByRaw(
|
||||||
|
'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc',
|
||||||
|
[now()],
|
||||||
|
)
|
||||||
|
->orderByRaw('case when due_at is null then 1 else 0 end asc')
|
||||||
|
->orderBy('due_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filteredQueueQuery(bool $includeTenantFilter = true): Builder
|
||||||
|
{
|
||||||
|
$query = $this->queueBaseQuery();
|
||||||
|
$filters = $this->currentQueueFiltersState();
|
||||||
|
|
||||||
|
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterIdFromFilters($filters)) !== null) {
|
||||||
|
$query->where('tenant_id', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->filterIsActive($filters, 'overdue')) {
|
||||||
|
$query
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->filterIsActive($filters, 'reopened')) {
|
||||||
|
$query->whereNotNull('reopened_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->filterIsActive($filters, 'high_severity')) {
|
||||||
|
$query->whereIn('severity', Finding::highSeverityValues());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
{
|
||||||
|
return $this->currentTenantFilterIdFromFilters($this->currentQueueFiltersState());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
private function currentTenantFilterIdFromFilters(array $filters): ?int
|
||||||
|
{
|
||||||
|
$tenantFilter = data_get($filters, '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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $filters
|
||||||
|
*/
|
||||||
|
private function filterIsActive(array $filters, string $name): bool
|
||||||
|
{
|
||||||
|
return (bool) data_get($filters, "{$name}.isActive", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$ownerLabel = FindingResource::accountableOwnerDisplayFor($record);
|
||||||
|
$assigneeLabel = $record->assigneeUser?->name;
|
||||||
|
|
||||||
|
if ($record->owner_user_id === null || $ownerLabel === $assigneeLabel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Owner: '.$ownerLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reopenedCue(Finding $record): ?string
|
||||||
|
{
|
||||||
|
if ($record->reopened_at === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Reopened';
|
||||||
|
}
|
||||||
|
|
||||||
|
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.my_inbox',
|
||||||
|
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
||||||
|
tenantId: $this->currentTenantFilterId(),
|
||||||
|
backLinkLabel: 'Back to my findings',
|
||||||
|
backLinkUrl: $this->queueUrl(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queueUrl(): string
|
||||||
|
{
|
||||||
|
$tenant = $this->filteredTenant();
|
||||||
|
|
||||||
|
return static::getUrl(
|
||||||
|
panel: 'admin',
|
||||||
|
parameters: array_filter([
|
||||||
|
'tenant' => $tenant?->external_id,
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -165,9 +165,9 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('finding_due_attention')
|
TextEntry::make('finding_due_attention')
|
||||||
->label('Due state')
|
->label('Due state')
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (Finding $record): ?string => static::dueAttentionLabel($record))
|
->state(fn (Finding $record): ?string => static::dueAttentionLabelFor($record))
|
||||||
->color(fn (Finding $record): string => static::dueAttentionColor($record))
|
->color(fn (Finding $record): string => static::dueAttentionColorFor($record))
|
||||||
->visible(fn (Finding $record): bool => static::dueAttentionLabel($record) !== null),
|
->visible(fn (Finding $record): bool => static::dueAttentionLabelFor($record) !== null),
|
||||||
TextEntry::make('finding_governance_validity_leading')
|
TextEntry::make('finding_governance_validity_leading')
|
||||||
->label('Governance')
|
->label('Governance')
|
||||||
->badge()
|
->badge()
|
||||||
@ -215,10 +215,10 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
|
->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
|
||||||
TextEntry::make('owner_user_id_leading')
|
TextEntry::make('owner_user_id_leading')
|
||||||
->label('Accountable owner')
|
->label('Accountable owner')
|
||||||
->state(fn (Finding $record): string => static::accountableOwnerDisplay($record)),
|
->state(fn (Finding $record): string => static::accountableOwnerDisplayFor($record)),
|
||||||
TextEntry::make('assignee_user_id_leading')
|
TextEntry::make('assignee_user_id_leading')
|
||||||
->label('Active assignee')
|
->label('Active assignee')
|
||||||
->state(fn (Finding $record): string => static::activeAssigneeDisplay($record)),
|
->state(fn (Finding $record): string => static::activeAssigneeDisplayFor($record)),
|
||||||
TextEntry::make('finding_responsibility_summary')
|
TextEntry::make('finding_responsibility_summary')
|
||||||
->label('Current split')
|
->label('Current split')
|
||||||
->state(fn (Finding $record): string => static::responsibilitySummary($record))
|
->state(fn (Finding $record): string => static::responsibilitySummary($record))
|
||||||
@ -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::dueAttentionLabel($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')
|
||||||
@ -2065,12 +2069,12 @@ private static function responsibilityStateColor(Finding $finding): string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function accountableOwnerDisplay(Finding $finding): string
|
public static function accountableOwnerDisplayFor(Finding $finding): string
|
||||||
{
|
{
|
||||||
return $finding->ownerUser?->name ?? 'Unassigned';
|
return $finding->ownerUser?->name ?? 'Unassigned';
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function activeAssigneeDisplay(Finding $finding): string
|
public static function activeAssigneeDisplayFor(Finding $finding): string
|
||||||
{
|
{
|
||||||
return $finding->assigneeUser?->name ?? 'Unassigned';
|
return $finding->assigneeUser?->name ?? 'Unassigned';
|
||||||
}
|
}
|
||||||
@ -2152,7 +2156,7 @@ private static function primaryNextAction(Finding $finding): ?string
|
|||||||
->resolvePrimaryNextAction($finding, static::resolvedFindingException($finding));
|
->resolvePrimaryNextAction($finding, static::resolvedFindingException($finding));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function dueAttentionLabel(Finding $finding): ?string
|
public static function dueAttentionLabelFor(Finding $finding): ?string
|
||||||
{
|
{
|
||||||
if (! $finding->hasOpenStatus() || ! $finding->due_at) {
|
if (! $finding->hasOpenStatus() || ! $finding->due_at) {
|
||||||
return null;
|
return null;
|
||||||
@ -2169,9 +2173,9 @@ private static function dueAttentionLabel(Finding $finding): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function dueAttentionColor(Finding $finding): string
|
public static function dueAttentionColorFor(Finding $finding): string
|
||||||
{
|
{
|
||||||
return match (static::dueAttentionLabel($finding)) {
|
return match (static::dueAttentionLabelFor($finding)) {
|
||||||
'Overdue' => 'danger',
|
'Overdue' => 'danger',
|
||||||
'Due soon' => 'warning',
|
'Due soon' => 'warning',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
|
|||||||
@ -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,8 @@
|
|||||||
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\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
@ -176,6 +178,8 @@ public function panel(Panel $panel): Panel
|
|||||||
InventoryCoverage::class,
|
InventoryCoverage::class,
|
||||||
TenantRequiredPermissions::class,
|
TenantRequiredPermissions::class,
|
||||||
WorkspaceSettings::class,
|
WorkspaceSettings::class,
|
||||||
|
FindingsIntakeQueue::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,6 +76,12 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake'], true)) {
|
||||||
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
if ($path === '/admin/operations/'.$request->route('run')) {
|
if ($path === '/admin/operations/'.$request->route('run')) {
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
@ -113,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'], 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);
|
||||||
|
|
||||||
@ -251,6 +257,14 @@ private function adminPathRequiresTenantSelection(string $path): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, '/admin/findings/my-work')) {
|
||||||
|
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.'),
|
||||||
|
|||||||
@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\AlertDelivery;
|
use App\Models\AlertDelivery;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -40,6 +42,7 @@
|
|||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
final class WorkspaceOverviewBuilder
|
final class WorkspaceOverviewBuilder
|
||||||
{
|
{
|
||||||
@ -130,6 +133,8 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
|
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
|
||||||
|
|
||||||
$zeroTenantState = null;
|
$zeroTenantState = null;
|
||||||
|
|
||||||
if ($accessibleTenants->isEmpty()) {
|
if ($accessibleTenants->isEmpty()) {
|
||||||
@ -168,6 +173,7 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
'workspace_id' => $workspaceId,
|
'workspace_id' => $workspaceId,
|
||||||
'workspace_name' => (string) $workspace->name,
|
'workspace_name' => (string) $workspace->name,
|
||||||
'accessible_tenant_count' => $accessibleTenants->count(),
|
'accessible_tenant_count' => $accessibleTenants->count(),
|
||||||
|
'my_findings_signal' => $myFindingsSignal,
|
||||||
'summary_metrics' => $summaryMetrics,
|
'summary_metrics' => $summaryMetrics,
|
||||||
'triage_review_progress' => $triageReviewProgress['families'],
|
'triage_review_progress' => $triageReviewProgress['families'],
|
||||||
'attention_items' => $attentionItems,
|
'attention_items' => $attentionItems,
|
||||||
@ -198,6 +204,65 @@ private function accessibleTenants(Workspace $workspace, User $user): Collection
|
|||||||
->get(['id', 'name', 'external_id', 'workspace_id']);
|
->get(['id', 'name', 'external_id', 'workspace_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Tenant> $accessibleTenants
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function myFindingsSignal(int $workspaceId, Collection $accessibleTenants, User $user): array
|
||||||
|
{
|
||||||
|
$visibleTenantIds = $accessibleTenants
|
||||||
|
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$assignedCounts = $visibleTenantIds === []
|
||||||
|
? null
|
||||||
|
: $this->scopeToVisibleTenants(
|
||||||
|
Finding::query(),
|
||||||
|
$workspaceId,
|
||||||
|
$visibleTenantIds,
|
||||||
|
)
|
||||||
|
->where('assignee_user_id', (int) $user->getKey())
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->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();
|
||||||
|
|
||||||
|
$openAssignedCount = is_numeric($assignedCounts?->open_assigned_count)
|
||||||
|
? (int) $assignedCounts->open_assigned_count
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$overdueAssignedCount = is_numeric($assignedCounts?->overdue_assigned_count)
|
||||||
|
? (int) $assignedCounts->overdue_assigned_count
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
$isCalm = $openAssignedCount === 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'headline' => $isCalm
|
||||||
|
? 'Assigned work is calm'
|
||||||
|
: sprintf(
|
||||||
|
'%d assigned %s visible in this workspace',
|
||||||
|
$openAssignedCount,
|
||||||
|
Str::plural('finding', $openAssignedCount),
|
||||||
|
),
|
||||||
|
'description' => $isCalm
|
||||||
|
? 'No visible assigned findings currently need attention across your entitled tenants.'
|
||||||
|
: sprintf(
|
||||||
|
'Visible assigned work stays in one queue. %d overdue %s currently need follow-up.',
|
||||||
|
$overdueAssignedCount,
|
||||||
|
Str::plural('finding', $overdueAssignedCount),
|
||||||
|
),
|
||||||
|
'open_assigned_count' => $openAssignedCount,
|
||||||
|
'overdue_assigned_count' => $overdueAssignedCount,
|
||||||
|
'is_calm' => $isCalm,
|
||||||
|
'cta_label' => 'Open my findings',
|
||||||
|
'cta_url' => MyFindingsInbox::getUrl(panel: 'admin'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Tenant> $accessibleTenants
|
* @param Collection<int, Tenant> $accessibleTenants
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
@ -1366,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,92 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
@php($scope = $this->appliedScope())
|
||||||
|
@php($summary = $this->summaryCounts())
|
||||||
|
@php($availableFilters = $this->availableFilters())
|
||||||
|
|
||||||
|
<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-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
|
||||||
|
<x-filament::icon icon="heroicon-o-clipboard-document-check" class="h-3.5 w-3.5" />
|
||||||
|
Assigned to me
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
||||||
|
My Findings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
Review open assigned findings across visible tenants in one queue. Tenant context can narrow the view, but the personal assignment 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">
|
||||||
|
Open assigned
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-3xl font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $summary['open_assigned'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Visible rows after the current filters.
|
||||||
|
</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['overdue_assigned'] }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-danger-800 dark:text-danger-200">
|
||||||
|
Assigned findings 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">
|
||||||
|
Assigned to me only
|
||||||
|
</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 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">
|
||||||
|
Available filters
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
@foreach ($availableFilters as $filter)
|
||||||
|
<span class="inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium {{ ($filter['fixed'] ?? false) ? '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-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300' }}">
|
||||||
|
{{ $filter['label'] }}
|
||||||
|
@if (($filter['fixed'] ?? false) === true)
|
||||||
|
<span class="ml-2 text-[11px] uppercase tracking-[0.12em] opacity-70">Fixed</span>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -2,6 +2,7 @@
|
|||||||
@php
|
@php
|
||||||
$workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
|
$workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
|
||||||
$quickActions = $overview['quick_actions'] ?? [];
|
$quickActions = $overview['quick_actions'] ?? [];
|
||||||
|
$myFindingsSignal = $overview['my_findings_signal'] ?? null;
|
||||||
$zeroTenantState = $overview['zero_tenant_state'] ?? null;
|
$zeroTenantState = $overview['zero_tenant_state'] ?? null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@ -57,6 +58,49 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if (is_array($myFindingsSignal))
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
|
||||||
|
<x-filament::icon icon="heroicon-o-clipboard-document-check" class="h-3.5 w-3.5" />
|
||||||
|
Assigned to me
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
||||||
|
{{ $myFindingsSignal['headline'] }}
|
||||||
|
</h2>
|
||||||
|
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $myFindingsSignal['description'] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs">
|
||||||
|
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-3 py-1 font-medium text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
||||||
|
Open assigned: {{ $myFindingsSignal['open_assigned_count'] }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ ($myFindingsSignal['overdue_assigned_count'] ?? 0) > 0 ? 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200' : 'border-success-200 bg-success-50 text-success-700 dark:border-success-700/50 dark:bg-success-950/30 dark:text-success-200' }}">
|
||||||
|
Overdue: {{ $myFindingsSignal['overdue_assigned_count'] }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ ($myFindingsSignal['is_calm'] ?? false) ? 'border-success-200 bg-success-50 text-success-700 dark:border-success-700/50 dark:bg-success-950/30 dark:text-success-200' : 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200' }}">
|
||||||
|
{{ ($myFindingsSignal['is_calm'] ?? false) ? 'Calm' : 'Needs follow-up' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
color="primary"
|
||||||
|
:href="$myFindingsSignal['cta_url']"
|
||||||
|
icon="heroicon-o-arrow-right"
|
||||||
|
>
|
||||||
|
{{ $myFindingsSignal['cta_label'] }}
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if (is_array($zeroTenantState))
|
@if (is_array($zeroTenantState))
|
||||||
<section class="rounded-2xl border border-warning-200 bg-warning-50 p-5 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
|
<section class="rounded-2xl border border-warning-200 bg-warning-50 p-5 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
|||||||
@ -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,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
|
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\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
use function Pest\Laravel\mock;
|
||||||
|
|
||||||
|
it('redirects inbox 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(MyFindingsInbox::getUrl(panel: 'admin'))
|
||||||
|
->assertRedirect('/admin/choose-workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for users outside the active workspace on the inbox 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(MyFindingsInbox::getUrl(panel: 'admin'))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the inbox accessible while suppressing blocked-tenant rows and summary counts', 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,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||||
|
$mock->shouldReceive('primeMemberships')->once();
|
||||||
|
$mock->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$mock->shouldReceive('can')
|
||||||
|
->andReturnUsing(static function (User $user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
|
||||||
|
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
||||||
|
|
||||||
|
return $capability === Capabilities::TENANT_FINDINGS_VIEW ? false : false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(MyFindingsInbox::class)
|
||||||
|
->assertCanNotSeeTableRecords([$finding])
|
||||||
|
->assertSee('No visible assigned findings right now');
|
||||||
|
|
||||||
|
expect($component->instance()->summaryCounts())->toBe([
|
||||||
|
'open_assigned' => 0,
|
||||||
|
'overdue_assigned' => 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$hiddenFinding = Finding::factory()->for($hiddenTenant)->create([
|
||||||
|
'workspace_id' => (int) $hiddenTenant->workspace_id,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(MyFindingsInbox::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('preserves forbidden detail destinations for workspace members who still lack findings 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,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||||
|
$mock->shouldReceive('primeMemberships')->once();
|
||||||
|
$mock->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$mock->shouldReceive('can')
|
||||||
|
->andReturnUsing(static function (User $user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
|
||||||
|
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
||||||
|
|
||||||
|
return $capability === Capabilities::TENANT_FINDINGS_VIEW ? false : false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(MyFindingsInbox::class)
|
||||||
|
->assertCanNotSeeTableRecords([$finding]);
|
||||||
|
|
||||||
|
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, static function (User $user, ?Tenant $resolvedTenant = null): bool {
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
146
apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php
Normal file
146
apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
use function Pest\Laravel\mock;
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds an assigned-to-me signal from visible assigned findings only', function (): void {
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 21, 9, 0, 0, 'UTC'));
|
||||||
|
|
||||||
|
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Bravo Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$hiddenTenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Hidden Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenantA)->create([
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'due_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenantB)->create([
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->addDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenantB)->create([
|
||||||
|
'workspace_id' => (int) $tenantB->workspace_id,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($hiddenTenant)->create([
|
||||||
|
'workspace_id' => (int) $hiddenTenant->workspace_id,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$signal = app(WorkspaceOverviewBuilder::class)
|
||||||
|
->build($tenantA->workspace()->firstOrFail(), $user)['my_findings_signal'];
|
||||||
|
|
||||||
|
expect($signal['open_assigned_count'])->toBe(2)
|
||||||
|
->and($signal['overdue_assigned_count'])->toBe(1)
|
||||||
|
->and($signal['is_calm'])->toBeFalse()
|
||||||
|
->and($signal['cta_label'])->toBe('Open my findings')
|
||||||
|
->and($signal['cta_url'])->toBe(MyFindingsInbox::getUrl(panel: 'admin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the signal calm when no visible assigned findings remain', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
'resolved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$signal = app(WorkspaceOverviewBuilder::class)
|
||||||
|
->build($tenant->workspace()->firstOrFail(), $user)['my_findings_signal'];
|
||||||
|
|
||||||
|
expect($signal['open_assigned_count'])->toBe(0)
|
||||||
|
->and($signal['overdue_assigned_count'])->toBe(0)
|
||||||
|
->and($signal['is_calm'])->toBeTrue()
|
||||||
|
->and($signal['description'])->toContain('visible assigned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses blocked-tenant findings from the assigned-to-me signal', function (): void {
|
||||||
|
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
|
||||||
|
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$blockedTenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||||
|
'name' => 'Blocked Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant($blockedTenant, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
Finding::factory()->for($visibleTenant)->create([
|
||||||
|
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($blockedTenant)->create([
|
||||||
|
'workspace_id' => (int) $blockedTenant->workspace_id,
|
||||||
|
'assignee_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
mock(CapabilityResolver::class, function ($mock) use ($visibleTenant, $blockedTenant): void {
|
||||||
|
$mock->shouldReceive('primeMemberships')->once();
|
||||||
|
$mock->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$mock->shouldReceive('can')
|
||||||
|
->andReturnUsing(static function (User $user, Tenant $tenant, string $capability) use ($visibleTenant, $blockedTenant): bool {
|
||||||
|
expect([(int) $visibleTenant->getKey(), (int) $blockedTenant->getKey()])
|
||||||
|
->toContain((int) $tenant->getKey());
|
||||||
|
|
||||||
|
return match ($capability) {
|
||||||
|
Capabilities::TENANT_FINDINGS_VIEW => (int) $tenant->getKey() === (int) $visibleTenant->getKey(),
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$signal = app(WorkspaceOverviewBuilder::class)
|
||||||
|
->build($visibleTenant->workspace()->firstOrFail(), $user)['my_findings_signal'];
|
||||||
|
|
||||||
|
expect($signal['open_assigned_count'])->toBe(1)
|
||||||
|
->and($signal['overdue_assigned_count'])->toBe(0)
|
||||||
|
->and($signal['description'])->not->toContain($blockedTenant->name);
|
||||||
|
});
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
@ -13,8 +14,11 @@
|
|||||||
->get('/admin')
|
->get('/admin')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Overview')
|
->assertSee('Overview')
|
||||||
->assertSee('Switch workspace');
|
->assertSee('Switch workspace')
|
||||||
|
->assertSee('Assigned to me')
|
||||||
|
->assertSee('Open my findings');
|
||||||
|
|
||||||
expect(Filament::getPanel('admin')->getHomeUrl())->toBe(route('admin.home'));
|
expect(Filament::getPanel('admin')->getHomeUrl())->toBe(route('admin.home'));
|
||||||
expect((string) $response->getContent())->toContain('href="'.route('admin.home').'"');
|
expect((string) $response->getContent())->toContain('href="'.route('admin.home').'"');
|
||||||
|
expect((string) $response->getContent())->toContain('href="'.MyFindingsInbox::getUrl(panel: 'admin').'"');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
346
apps/platform/tests/Feature/Findings/MyWorkInboxTest.php
Normal file
346
apps/platform/tests/Feature/Findings/MyWorkInboxTest.php
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
|
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 myWorkInboxActingUser(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 myWorkInboxPage(?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(MyFindingsInbox::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAssignedFindingForInbox(Tenant $tenant, User $assignee, array $attributes = []): Finding
|
||||||
|
{
|
||||||
|
return Finding::factory()->for($tenant)->create(array_merge([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'owner_user_id' => (int) $assignee->getKey(),
|
||||||
|
'assignee_user_id' => (int) $assignee->getKey(),
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'subject_external_id' => fake()->uuid(),
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows only visible assigned open findings and exposes the fixed filter contract', function (): void {
|
||||||
|
[$user, $tenantA] = myWorkInboxActingUser();
|
||||||
|
$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');
|
||||||
|
|
||||||
|
$assignedVisible = makeAssignedFindingForInbox($tenantA, $user, [
|
||||||
|
'subject_external_id' => 'visible-a',
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assignedOwnerSplit = makeAssignedFindingForInbox($tenantB, $user, [
|
||||||
|
'subject_external_id' => 'visible-b',
|
||||||
|
'owner_user_id' => (int) $otherOwner->getKey(),
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ownerOnly = makeAssignedFindingForInbox($tenantA, $otherAssignee, [
|
||||||
|
'subject_external_id' => 'owner-only',
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assignedTerminal = makeAssignedFindingForInbox($tenantA, $user, [
|
||||||
|
'subject_external_id' => 'terminal',
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
'resolved_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assignedToOther = makeAssignedFindingForInbox($tenantA, $otherAssignee, [
|
||||||
|
'subject_external_id' => 'other-assignee',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$hiddenAssigned = makeAssignedFindingForInbox($hiddenTenant, $user, [
|
||||||
|
'subject_external_id' => 'hidden',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = myWorkInboxPage($user)
|
||||||
|
->assertCanSeeTableRecords([$assignedVisible, $assignedOwnerSplit])
|
||||||
|
->assertCanNotSeeTableRecords([$ownerOnly, $assignedTerminal, $assignedToOther, $hiddenAssigned])
|
||||||
|
->assertSee('Owner: '.$otherOwner->name)
|
||||||
|
->assertSee('Assigned to me only');
|
||||||
|
|
||||||
|
expect($component->instance()->summaryCounts())->toBe([
|
||||||
|
'open_assigned' => 2,
|
||||||
|
'overdue_assigned' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($component->instance()->availableFilters())->toBe([
|
||||||
|
[
|
||||||
|
'key' => 'assignee_scope',
|
||||||
|
'label' => 'Assigned to me',
|
||||||
|
'fixed' => true,
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'tenant',
|
||||||
|
'label' => 'Tenant',
|
||||||
|
'fixed' => false,
|
||||||
|
'options' => [
|
||||||
|
['value' => (string) $tenantA->getKey(), 'label' => 'Alpha Tenant'],
|
||||||
|
['value' => (string) $tenantB->getKey(), 'label' => $tenantB->name],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'overdue',
|
||||||
|
'label' => 'Overdue',
|
||||||
|
'fixed' => false,
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'reopened',
|
||||||
|
'label' => 'Reopened',
|
||||||
|
'fixed' => false,
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'high_severity',
|
||||||
|
'label' => 'High severity',
|
||||||
|
'fixed' => false,
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the active tenant prefilter and lets the operator clear it without dropping personal scope', function (): void {
|
||||||
|
[$user, $tenantA] = myWorkInboxActingUser();
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Beta Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
$findingA = makeAssignedFindingForInbox($tenantA, $user, [
|
||||||
|
'subject_external_id' => 'tenant-a',
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
$findingB = makeAssignedFindingForInbox($tenantB, $user, [
|
||||||
|
'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 = myWorkInboxPage($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,
|
||||||
|
'assignee_scope' => 'current_user_only',
|
||||||
|
'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,
|
||||||
|
'assignee_scope' => 'current_user_only',
|
||||||
|
'tenant_prefilter_source' => 'none',
|
||||||
|
'tenant_label' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders overdue work before reopened work and keeps deterministic due-date and id tie breaks', function (): void {
|
||||||
|
[$user, $tenant] = myWorkInboxActingUser();
|
||||||
|
|
||||||
|
$overdue = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'overdue',
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reopened = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'reopened',
|
||||||
|
'status' => Finding::STATUS_REOPENED,
|
||||||
|
'reopened_at' => now()->subHours(6),
|
||||||
|
'due_at' => now()->addDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ordinarySooner = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'ordinary-sooner',
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->addDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ordinaryLater = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'ordinary-later',
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'due_at' => now()->addDays(4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ordinaryNoDue = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'ordinary-no-due',
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
myWorkInboxPage($user)
|
||||||
|
->assertCanSeeTableRecords([$overdue, $reopened, $ordinarySooner, $ordinaryLater, $ordinaryNoDue], inOrder: true)
|
||||||
|
->assertSee('Reopened');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies reopened and high-severity filters without widening beyond assigned work', function (): void {
|
||||||
|
[$user, $tenant] = myWorkInboxActingUser();
|
||||||
|
|
||||||
|
$reopenedHigh = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'reopened-high',
|
||||||
|
'status' => Finding::STATUS_REOPENED,
|
||||||
|
'reopened_at' => now()->subHour(),
|
||||||
|
'severity' => Finding::SEVERITY_CRITICAL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$highOnly = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'high-only',
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ordinary = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'ordinary',
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
]);
|
||||||
|
|
||||||
|
myWorkInboxPage($user)
|
||||||
|
->set('tableFilters.reopened.isActive', true)
|
||||||
|
->assertCanSeeTableRecords([$reopenedHigh])
|
||||||
|
->assertCanNotSeeTableRecords([$highOnly, $ordinary])
|
||||||
|
->set('tableFilters.reopened.isActive', false)
|
||||||
|
->set('tableFilters.high_severity.isActive', true)
|
||||||
|
->assertCanSeeTableRecords([$reopenedHigh, $highOnly])
|
||||||
|
->assertCanNotSeeTableRecords([$ordinary]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the tenant-prefilter empty-state branch and offers only a clear-filter recovery action', function (): void {
|
||||||
|
[$user, $tenantA] = myWorkInboxActingUser();
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'workspace_id' => (int) $tenantA->workspace_id,
|
||||||
|
'name' => 'Work Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
makeAssignedFindingForInbox($tenantB, $user, [
|
||||||
|
'subject_external_id' => 'available-elsewhere',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = myWorkInboxPage($user, [
|
||||||
|
'tenant' => (string) $tenantA->external_id,
|
||||||
|
])
|
||||||
|
->assertCanNotSeeTableRecords([])
|
||||||
|
->assertSee('No assigned findings match this tenant scope')
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']);
|
||||||
|
|
||||||
|
expect($component->instance()->summaryCounts())->toBe([
|
||||||
|
'open_assigned' => 0,
|
||||||
|
'overdue_assigned' => 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the calm zero-work branch and points back to tenant selection when no active tenant context exists', function (): void {
|
||||||
|
[$user, $tenant] = myWorkInboxActingUser();
|
||||||
|
|
||||||
|
session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
|
myWorkInboxPage($user)
|
||||||
|
->assertSee('No visible assigned findings right now')
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['choose_tenant_empty']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the active visible tenant for the calm empty-state drillback when tenant context exists', function (): void {
|
||||||
|
[$user, $tenant] = myWorkInboxActingUser();
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||||
|
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = myWorkInboxPage($user)
|
||||||
|
->assertSee('No visible assigned findings right now')
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['open_tenant_findings_empty']);
|
||||||
|
|
||||||
|
expect($component->instance()->emptyState())->toMatchArray([
|
||||||
|
'action_name' => 'open_tenant_findings_empty',
|
||||||
|
'action_label' => 'Open tenant findings',
|
||||||
|
'action_kind' => 'url',
|
||||||
|
'action_url' => FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds tenant detail drilldowns with inbox continuity', function (): void {
|
||||||
|
[$user, $tenant] = myWorkInboxActingUser();
|
||||||
|
|
||||||
|
$finding = makeAssignedFindingForInbox($tenant, $user, [
|
||||||
|
'subject_external_id' => 'continuity',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = myWorkInboxPage($user);
|
||||||
|
$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+my+findings');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get($detailUrl)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Back to my findings');
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import icon from 'astro-icon';
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
const publicSiteUrl = process.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example';
|
const publicSiteUrl = process.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
integrations: [icon()],
|
||||||
output: 'static',
|
output: 'static',
|
||||||
site: publicSiteUrl,
|
site: publicSiteUrl,
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@ -14,7 +14,9 @@
|
|||||||
"test:smoke": "playwright test"
|
"test:smoke": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^6.0.0"
|
"@iconify-json/lucide": "^1.2.102",
|
||||||
|
"astro": "^6.0.0",
|
||||||
|
"astro-icon": "^1.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
|||||||
@ -1,81 +1,70 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500">
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="bg" x1="60" y1="40" x2="740" y2="460" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0%" style="stop-color:#f8fafc;stop-opacity:1" />
|
<stop offset="0" stop-color="#F8FAFC" />
|
||||||
<stop offset="100%" style="stop-color:#e2e8f0;stop-opacity:1" />
|
<stop offset="1" stop-color="#E2E8F0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="header" x1="0%" y1="0%" x2="100%" y2="0%">
|
<linearGradient id="topbar" x1="0" y1="0" x2="800" y2="0" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0%" style="stop-color:#2f6fb7;stop-opacity:1" />
|
<stop offset="0" stop-color="#1D4ED8" />
|
||||||
<stop offset="100%" style="stop-color:#8eaed1;stop-opacity:1" />
|
<stop offset="1" stop-color="#4F46E5" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="800" height="500" rx="16" fill="url(#bg)" stroke="#e2e8f0" stroke-width="2"/>
|
<rect x="8" y="8" width="784" height="484" rx="20" fill="url(#bg)" stroke="#D7E2EE" stroke-width="2" />
|
||||||
<!-- Header bar -->
|
<rect x="8" y="8" width="784" height="52" rx="20" fill="url(#topbar)" />
|
||||||
<rect x="0" y="0" width="800" height="48" rx="16" fill="url(#header)"/>
|
<rect x="8" y="30" width="784" height="30" fill="url(#topbar)" />
|
||||||
<rect x="0" y="16" width="800" height="32" fill="url(#header)"/>
|
<text x="32" y="40" font-family="system-ui" font-size="15" font-weight="700" fill="#FFFFFF">TenantAtlas</text>
|
||||||
<text x="24" y="30" font-family="system-ui" font-size="14" fill="white" font-weight="600">TenantAtlas</text>
|
<circle cx="742" cy="34" r="6" fill="#FFFFFF" fill-opacity="0.7" />
|
||||||
<!-- Sidebar -->
|
<circle cx="762" cy="34" r="6" fill="#FFFFFF" fill-opacity="0.45" />
|
||||||
<rect x="0" y="48" width="180" height="452" fill="#f1f5f9"/>
|
<rect x="24" y="76" width="168" height="396" rx="18" fill="#F3F6FB" stroke="#D7E2EE" />
|
||||||
<rect x="16" y="68" width="148" height="32" rx="8" fill="#2f6fb7" opacity="0.12"/>
|
<text x="44" y="108" font-family="system-ui" font-size="11" font-weight="700" fill="#64748B">WORKSPACE</text>
|
||||||
<text x="28" y="89" font-family="system-ui" font-size="12" fill="#2f6fb7" font-weight="600">Inventory</text>
|
<rect x="36" y="128" width="144" height="34" rx="10" fill="#DBEAFE" />
|
||||||
<rect x="16" y="112" width="148" height="28" rx="8" fill="transparent"/>
|
<text x="52" y="149" font-family="system-ui" font-size="12" font-weight="700" fill="#1D4ED8">Change history</text>
|
||||||
<text x="28" y="130" font-family="system-ui" font-size="12" fill="#64748b">Backup History</text>
|
<text x="52" y="194" font-family="system-ui" font-size="12" fill="#475569">Restore preview</text>
|
||||||
<rect x="16" y="148" width="148" height="28" rx="8" fill="transparent"/>
|
<text x="52" y="230" font-family="system-ui" font-size="12" fill="#475569">Review queue</text>
|
||||||
<text x="28" y="166" font-family="system-ui" font-size="12" fill="#64748b">Restore</text>
|
<text x="52" y="266" font-family="system-ui" font-size="12" fill="#475569">Evidence</text>
|
||||||
<rect x="16" y="184" width="148" height="28" rx="8" fill="transparent"/>
|
<text x="52" y="302" font-family="system-ui" font-size="12" fill="#475569">Assignments</text>
|
||||||
<text x="28" y="202" font-family="system-ui" font-size="12" fill="#64748b">Drift Detection</text>
|
<rect x="36" y="344" width="144" height="96" rx="14" fill="#FFFFFF" stroke="#D7E2EE" />
|
||||||
<rect x="16" y="220" width="148" height="28" rx="8" fill="transparent"/>
|
<text x="52" y="370" font-family="system-ui" font-size="11" font-weight="700" fill="#334155">Current tenant</text>
|
||||||
<text x="28" y="238" font-family="system-ui" font-size="12" fill="#64748b">Governance</text>
|
<text x="52" y="394" font-family="system-ui" font-size="12" fill="#0F172A">Northwind Services</text>
|
||||||
<!-- Main content area -->
|
<text x="52" y="418" font-family="system-ui" font-size="11" fill="#64748B">Inventory linked to reviewable history</text>
|
||||||
<rect x="196" y="64" width="588" height="420" rx="12" fill="white" stroke="#e2e8f0"/>
|
<rect x="212" y="76" width="564" height="396" rx="18" fill="#FFFFFF" stroke="#D7E2EE" />
|
||||||
<!-- Stats row -->
|
<text x="236" y="108" font-family="system-ui" font-size="12" font-weight="700" fill="#334155">Recent tenant changes</text>
|
||||||
<rect x="212" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
<rect x="236" y="124" width="90" height="24" rx="12" fill="#EFF6FF" />
|
||||||
<text x="228" y="104" font-family="system-ui" font-size="22" fill="#1e293b" font-weight="700">247</text>
|
<text x="252" y="140" font-family="system-ui" font-size="10" font-weight="700" fill="#2563EB">Policies</text>
|
||||||
<text x="228" y="130" font-family="system-ui" font-size="11" fill="#64748b">Policies tracked</text>
|
<rect x="336" y="124" width="82" height="24" rx="12" fill="#F8FAFC" stroke="#D7E2EE" />
|
||||||
<rect x="358" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
<text x="356" y="140" font-family="system-ui" font-size="10" font-weight="700" fill="#475569">Drift</text>
|
||||||
<text x="374" y="104" font-family="system-ui" font-size="22" fill="#16a34a" font-weight="700">98.4%</text>
|
<rect x="428" y="124" width="108" height="24" rx="12" fill="#F8FAFC" stroke="#D7E2EE" />
|
||||||
<text x="374" y="130" font-family="system-ui" font-size="11" fill="#64748b">Compliance rate</text>
|
<text x="448" y="140" font-family="system-ui" font-size="10" font-weight="700" fill="#475569">Assignments</text>
|
||||||
<rect x="504" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
<rect x="236" y="164" width="320" height="236" rx="16" fill="#F8FAFC" stroke="#D7E2EE" />
|
||||||
<text x="520" y="104" font-family="system-ui" font-size="22" fill="#2f6fb7" font-weight="700">12</text>
|
<text x="256" y="192" font-family="system-ui" font-size="11" font-weight="700" fill="#64748B">CHANGE RECORD</text>
|
||||||
<text x="520" y="130" font-family="system-ui" font-size="11" fill="#64748b">Versions stored</text>
|
<line x1="256" y1="210" x2="536" y2="210" stroke="#E2E8F0" />
|
||||||
<rect x="650" y="80" width="118" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
<text x="256" y="236" font-family="system-ui" font-size="13" font-weight="700" fill="#0F172A">Windows Compliance Baseline</text>
|
||||||
<text x="666" y="104" font-family="system-ui" font-size="22" fill="#f59e0b" font-weight="700">3</text>
|
<text x="256" y="256" font-family="system-ui" font-size="11" fill="#475569">Version diff prepared for review</text>
|
||||||
<text x="666" y="130" font-family="system-ui" font-size="11" fill="#64748b">Drift alerts</text>
|
<rect x="454" y="222" width="78" height="22" rx="11" fill="#DCFCE7" />
|
||||||
<!-- Table header -->
|
<text x="469" y="237" font-family="system-ui" font-size="10" font-weight="700" fill="#15803D">Ready</text>
|
||||||
<rect x="212" y="160" width="556" height="36" rx="0" fill="#f8fafc"/>
|
<line x1="256" y1="274" x2="536" y2="274" stroke="#E2E8F0" />
|
||||||
<text x="228" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">POLICY NAME</text>
|
<text x="256" y="300" font-family="system-ui" font-size="13" font-weight="700" fill="#0F172A">Conditional Access MFA</text>
|
||||||
<text x="440" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">TYPE</text>
|
<text x="256" y="320" font-family="system-ui" font-size="11" fill="#475569">Assignment drift surfaced before rollout</text>
|
||||||
<text x="560" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">STATUS</text>
|
<rect x="438" y="286" width="94" height="22" rx="11" fill="#FEF3C7" />
|
||||||
<text x="670" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">LAST BACKUP</text>
|
<text x="452" y="301" font-family="system-ui" font-size="10" font-weight="700" fill="#B45309">Needs review</text>
|
||||||
<!-- Table rows -->
|
<line x1="256" y1="338" x2="536" y2="338" stroke="#E2E8F0" />
|
||||||
<line x1="212" y1="196" x2="768" y2="196" stroke="#e2e8f0"/>
|
<text x="256" y="364" font-family="system-ui" font-size="13" font-weight="700" fill="#0F172A">BitLocker policy</text>
|
||||||
<text x="228" y="220" font-family="system-ui" font-size="12" fill="#1e293b">Windows Compliance Baseline</text>
|
<text x="256" y="384" font-family="system-ui" font-size="11" fill="#475569">Restore candidate linked to prior snapshot</text>
|
||||||
<text x="440" y="220" font-family="system-ui" font-size="12" fill="#64748b">Compliance</text>
|
<rect x="420" y="350" width="112" height="22" rx="11" fill="#DBEAFE" />
|
||||||
<rect x="560" y="208" width="56" height="20" rx="10" fill="#dcfce7"/>
|
<text x="436" y="365" font-family="system-ui" font-size="10" font-weight="700" fill="#1D4ED8">Snapshot linked</text>
|
||||||
<text x="572" y="222" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
|
<rect x="576" y="164" width="176" height="112" rx="16" fill="#F8FAFC" stroke="#D7E2EE" />
|
||||||
<text x="670" y="220" font-family="system-ui" font-size="12" fill="#64748b">2 min ago</text>
|
<text x="596" y="192" font-family="system-ui" font-size="11" font-weight="700" fill="#64748B">RESTORE PREVIEW</text>
|
||||||
<line x1="212" y1="236" x2="768" y2="236" stroke="#f1f5f9"/>
|
<text x="596" y="220" font-family="system-ui" font-size="12" fill="#0F172A">Scope validated</text>
|
||||||
<text x="228" y="260" font-family="system-ui" font-size="12" fill="#1e293b">BitLocker Encryption Policy</text>
|
<text x="596" y="242" font-family="system-ui" font-size="12" fill="#0F172A">Assignments included</text>
|
||||||
<text x="440" y="260" font-family="system-ui" font-size="12" fill="#64748b">Config</text>
|
<text x="596" y="264" font-family="system-ui" font-size="12" fill="#0F172A">Confirmation required</text>
|
||||||
<rect x="560" y="248" width="56" height="20" rx="10" fill="#dcfce7"/>
|
<circle cx="580" cy="217" r="4" fill="#16A34A" />
|
||||||
<text x="572" y="262" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
|
<circle cx="580" cy="239" r="4" fill="#16A34A" />
|
||||||
<text x="670" y="260" font-family="system-ui" font-size="12" fill="#64748b">15 min ago</text>
|
<circle cx="580" cy="261" r="4" fill="#F59E0B" />
|
||||||
<line x1="212" y1="276" x2="768" y2="276" stroke="#f1f5f9"/>
|
<rect x="576" y="292" width="176" height="108" rx="16" fill="#F8FAFC" stroke="#D7E2EE" />
|
||||||
<text x="228" y="300" font-family="system-ui" font-size="12" fill="#1e293b">Conditional Access – MFA</text>
|
<text x="596" y="320" font-family="system-ui" font-size="11" font-weight="700" fill="#64748B">REVIEW QUEUE</text>
|
||||||
<text x="440" y="300" font-family="system-ui" font-size="12" fill="#64748b">Access</text>
|
<text x="596" y="346" font-family="system-ui" font-size="12" fill="#0F172A">Conditional Access drift</text>
|
||||||
<rect x="560" y="288" width="48" height="20" rx="10" fill="#fef3c7"/>
|
<text x="596" y="368" font-family="system-ui" font-size="12" fill="#0F172A">Restore plan awaiting approval</text>
|
||||||
<text x="569" y="302" font-family="system-ui" font-size="10" fill="#d97706" font-weight="500">Drift</text>
|
<text x="596" y="390" font-family="system-ui" font-size="12" fill="#0F172A">Evidence attached to change record</text>
|
||||||
<text x="670" y="300" font-family="system-ui" font-size="12" fill="#64748b">1 hr ago</text>
|
<rect x="236" y="420" width="516" height="28" rx="14" fill="#EEF2FF" />
|
||||||
<line x1="212" y1="316" x2="768" y2="316" stroke="#f1f5f9"/>
|
<text x="256" y="438" font-family="system-ui" font-size="11" font-weight="700" fill="#4338CA">Change history, restore preview, and review queue stay connected on one screen.</text>
|
||||||
<text x="228" y="340" font-family="system-ui" font-size="12" fill="#1e293b">Autopilot Deployment Profile</text>
|
|
||||||
<text x="440" y="340" font-family="system-ui" font-size="12" fill="#64748b">Enrollment</text>
|
|
||||||
<rect x="560" y="328" width="56" height="20" rx="10" fill="#dcfce7"/>
|
|
||||||
<text x="572" y="342" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
|
|
||||||
<text x="670" y="340" font-family="system-ui" font-size="12" fill="#64748b">30 min ago</text>
|
|
||||||
<line x1="212" y1="356" x2="768" y2="356" stroke="#f1f5f9"/>
|
|
||||||
<text x="228" y="380" font-family="system-ui" font-size="12" fill="#1e293b">App Protection – iOS Managed</text>
|
|
||||||
<text x="440" y="380" font-family="system-ui" font-size="12" fill="#64748b">Protection</text>
|
|
||||||
<rect x="560" y="368" width="56" height="20" rx="10" fill="#dcfce7"/>
|
|
||||||
<text x="572" y="382" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
|
|
||||||
<text x="670" y="380" font-family="system-ui" font-size="12" fill="#64748b">45 min ago</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.1 KiB |
@ -11,14 +11,17 @@ interface Props {
|
|||||||
|
|
||||||
const { content } = Astro.props;
|
const { content } = Astro.props;
|
||||||
const variant = content.tone === 'accent' ? 'accent' : content.tone === 'subtle' ? 'subtle' : 'default';
|
const variant = content.tone === 'accent' ? 'accent' : content.tone === 'subtle' ? 'subtle' : 'default';
|
||||||
|
const barTone = content.tone === 'accent' ? undefined : content.tone === 'subtle' ? 'trust' : undefined;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Card variant={variant}>
|
<Card variant={variant} hoverable>
|
||||||
{content.eyebrow && <Eyebrow>{content.eyebrow}</Eyebrow>}
|
<div class="callout-bar" data-bar-tone={barTone}>
|
||||||
<Headline as="h3" size="card" class="mt-4">
|
{content.eyebrow && <Eyebrow>{content.eyebrow}</Eyebrow>}
|
||||||
{content.title}
|
<Headline as="h3" size="card" class="mt-4">
|
||||||
</Headline>
|
{content.title}
|
||||||
<Lead class="mt-3" size="body">
|
</Headline>
|
||||||
{content.description}
|
<Lead class="mt-3" size="body">
|
||||||
</Lead>
|
{content.description}
|
||||||
|
</Lead>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
import Card from '@/components/primitives/Card.astro';
|
import Card from '@/components/primitives/Card.astro';
|
||||||
import Eyebrow from '@/components/content/Eyebrow.astro';
|
import Eyebrow from '@/components/content/Eyebrow.astro';
|
||||||
import Headline from '@/components/content/Headline.astro';
|
import Headline from '@/components/content/Headline.astro';
|
||||||
@ -10,24 +11,56 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { item } = Astro.props;
|
const { item } = Astro.props;
|
||||||
|
|
||||||
|
const lucideMap: Record<string, string> = {
|
||||||
|
'shield': 'lucide:shield',
|
||||||
|
'database': 'lucide:database',
|
||||||
|
'refresh': 'lucide:refresh-cw',
|
||||||
|
'eye': 'lucide:eye',
|
||||||
|
'file-check': 'lucide:file-check',
|
||||||
|
'layers': 'lucide:layers',
|
||||||
|
'search': 'lucide:search',
|
||||||
|
'lock': 'lucide:lock',
|
||||||
|
'zap': 'lucide:zap',
|
||||||
|
'clipboard': 'lucide:clipboard-list',
|
||||||
|
'git-branch': 'lucide:git-branch',
|
||||||
|
'bar-chart': 'lucide:bar-chart-3',
|
||||||
|
'activity': 'lucide:activity',
|
||||||
|
'settings': 'lucide:settings',
|
||||||
|
'globe': 'lucide:globe',
|
||||||
|
'users': 'lucide:users',
|
||||||
|
'check-circle': 'lucide:check-circle',
|
||||||
|
'archive': 'lucide:archive',
|
||||||
|
'trending-up': 'lucide:trending-up',
|
||||||
|
'cpu': 'lucide:cpu',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconName = item.icon ? lucideMap[item.icon] : undefined;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Card class="h-full">
|
<Card class="h-full" hoverable>
|
||||||
{item.eyebrow && <Eyebrow>{item.eyebrow}</Eyebrow>}
|
<div class="space-y-3">
|
||||||
<Headline as="h3" size="card" class="mt-4">
|
{iconName && (
|
||||||
{item.title}
|
<div class="feature-icon">
|
||||||
</Headline>
|
<Icon name={iconName} size={20} />
|
||||||
<Lead class="mt-3" size="body">
|
</div>
|
||||||
{item.description}
|
)}
|
||||||
</Lead>
|
{item.eyebrow && <Eyebrow>{item.eyebrow}</Eyebrow>}
|
||||||
{(item.meta || item.href) && (
|
<Headline as="h3" size="card">
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm">
|
{item.title}
|
||||||
{item.meta && <span class="text-[var(--color-brand)]">{item.meta}</span>}
|
</Headline>
|
||||||
{item.href && (
|
<Lead size="body">
|
||||||
<a class="text-link font-semibold" href={item.href}>
|
{item.description}
|
||||||
Learn more
|
</Lead>
|
||||||
</a>
|
{(item.meta || item.href) && (
|
||||||
)}
|
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm">
|
||||||
</div>
|
{item.meta && <span class="text-[var(--color-brand)]">{item.meta}</span>}
|
||||||
)}
|
{item.href && (
|
||||||
|
<a class="text-link font-semibold" href={item.href}>
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -9,14 +9,14 @@ const { as = 'h2', class: className = '', size = 'section' } = Astro.props;
|
|||||||
const Tag = as;
|
const Tag = as;
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
display:
|
display:
|
||||||
'font-[var(--font-display)] text-[length:var(--type-display-size)] leading-[var(--line-display)] tracking-[var(--tracking-display)]',
|
'font-[var(--font-display)] font-bold text-[length:var(--type-display-size)] leading-[var(--line-display)] tracking-[var(--tracking-display)]',
|
||||||
page: 'font-[var(--font-display)] text-[length:var(--type-page-size)] leading-[var(--line-heading)] tracking-[var(--tracking-tight)]',
|
page: 'font-[var(--font-display)] font-semibold text-[length:var(--type-page-size)] leading-[var(--line-heading)] tracking-[var(--tracking-tight)]',
|
||||||
section:
|
section:
|
||||||
'font-[var(--font-display)] text-[length:var(--type-section-size)] leading-[var(--line-heading)] tracking-[var(--tracking-tight)]',
|
'font-[var(--font-display)] font-semibold text-[length:var(--type-section-size)] leading-[var(--line-heading)] tracking-[var(--tracking-tight)]',
|
||||||
card: 'font-semibold text-[length:var(--type-card-size)] leading-[1.12] tracking-[var(--tracking-tight)]',
|
card: 'font-medium text-[length:var(--type-card-size)] leading-[1.18] tracking-[var(--tracking-tight)]',
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Tag class:list={['m-0 text-[var(--color-ink-900)]', sizeClasses[size], className]}>
|
<Tag class:list={['m-0 text-[var(--color-ink-900)] [&>.accent]:text-[var(--color-primary)]', sizeClasses[size], className]}>
|
||||||
<slot />
|
<slot />
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
467
apps/website/src/components/content/HeroDashboard.astro
Normal file
467
apps/website/src/components/content/HeroDashboard.astro
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="hero-dashboard"
|
||||||
|
role="img"
|
||||||
|
aria-label="TenantAtlas — change history, restore preview, and a review queue with baseline drift and evidence links"
|
||||||
|
>
|
||||||
|
<div class="dashboard-chrome">
|
||||||
|
<div class="dashboard-titlebar">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span class="dot dot-red"></span>
|
||||||
|
<span class="dot dot-yellow"></span>
|
||||||
|
<span class="dot dot-green"></span>
|
||||||
|
</div>
|
||||||
|
<span class="titlebar-label">TenantAtlas — Governance Surface</span>
|
||||||
|
</div>
|
||||||
|
<div class="titlebar-url">
|
||||||
|
<span class="url-text">app.tenantatlas.com/admin/governance/change-history</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-body">
|
||||||
|
<div class="dashboard-sidebar">
|
||||||
|
<div class="sidebar-logo">TA</div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="nav-item active">
|
||||||
|
<div class="nav-dot"></div>
|
||||||
|
<span>Change history</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-dot"></div>
|
||||||
|
<span>Restore preview</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-dot"></div>
|
||||||
|
<span>Review queue</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-dot"></div>
|
||||||
|
<span>Evidence</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-dot"></div>
|
||||||
|
<span>Assignments</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-divider"></div>
|
||||||
|
<div class="sidebar-nav">
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-dot muted"></div>
|
||||||
|
<span>Settings</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<div class="nav-dot muted"></div>
|
||||||
|
<span>Audit Log</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-main">
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Baseline drift</span>
|
||||||
|
<span class="stat-value">2 items need review</span>
|
||||||
|
<span class="stat-change neutral">Windows baseline and Conditional Access</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Restore preview</span>
|
||||||
|
<span class="stat-value">Scope and assignments validated</span>
|
||||||
|
<span class="stat-change positive">Confirmation required before execution</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Evidence linked</span>
|
||||||
|
<span class="stat-value">3 change records attached</span>
|
||||||
|
<span class="stat-change neutral">Review context stays with the operator path</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-grid">
|
||||||
|
<div class="activity-section">
|
||||||
|
<div class="activity-header">
|
||||||
|
<span class="activity-title">Change record</span>
|
||||||
|
<span class="activity-badge">Review active</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-table">
|
||||||
|
<div class="table-row">
|
||||||
|
<span class="row-status warning"></span>
|
||||||
|
<span class="row-name">Windows Compliance Baseline</span>
|
||||||
|
<span class="row-type">Baseline drift</span>
|
||||||
|
<span class="row-time">Needs review</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-row">
|
||||||
|
<span class="row-status success"></span>
|
||||||
|
<span class="row-name">BitLocker policy restore</span>
|
||||||
|
<span class="row-type">Restore preview</span>
|
||||||
|
<span class="row-time">Assignments in scope</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-row">
|
||||||
|
<span class="row-status success"></span>
|
||||||
|
<span class="row-name">Conditional Access MFA</span>
|
||||||
|
<span class="row-type">Evidence linked</span>
|
||||||
|
<span class="row-time">Ready for approval</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue-column">
|
||||||
|
<div class="queue-card">
|
||||||
|
<span class="queue-label">Restore preview</span>
|
||||||
|
<p class="queue-title">Scope and assignment edges stay visible before execution.</p>
|
||||||
|
<ul class="queue-list">
|
||||||
|
<li>Scope validated</li>
|
||||||
|
<li>Assignments included</li>
|
||||||
|
<li>Confirmation required</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="queue-card">
|
||||||
|
<span class="queue-label">Review queue</span>
|
||||||
|
<ul class="queue-list">
|
||||||
|
<li>Baseline drift awaiting reviewer</li>
|
||||||
|
<li>Restore plan queued for approval</li>
|
||||||
|
<li>Evidence pack linked to the change record</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evidence-strip">Evidence stays linked to the change record before an operator acts.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero-dashboard {
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(20, 20, 20, 0.08);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 80px rgba(20, 20, 20, 0.1),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.05);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chrome {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid rgba(20, 20, 20, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-red { background: #ff5f57; }
|
||||||
|
.dot-yellow { background: #febc2e; }
|
||||||
|
.dot-green { background: #28c840; }
|
||||||
|
|
||||||
|
.titlebar-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(20, 20, 20, 0.45);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-url {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(20, 20, 20, 0.35);
|
||||||
|
background: rgba(20, 20, 20, 0.04);
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border-right: 1px solid rgba(20, 20, 20, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-ink-900);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(20, 20, 20, 0.06);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(20, 20, 20, 0.5);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(40, 60, 120, 0.06);
|
||||||
|
color: var(--color-ink-900);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot {
|
||||||
|
width: 0.35rem;
|
||||||
|
height: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand-500);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot.muted {
|
||||||
|
background: rgba(20, 20, 20, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .nav-dot {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-main {
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(20, 20, 20, 0.05);
|
||||||
|
background: #fbfbfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgba(20, 20, 20, 0.45);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-ink-900);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-change {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-change.positive {
|
||||||
|
color: var(--color-mint-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-change.neutral {
|
||||||
|
color: rgba(20, 20, 20, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-section {
|
||||||
|
border: 1px solid rgba(20, 20, 20, 0.05);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.28fr) minmax(14rem, 0.92fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid rgba(20, 20, 20, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-title {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-badge {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(40, 60, 120, 0.07);
|
||||||
|
color: var(--color-brand-500);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-column {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-card {
|
||||||
|
border: 1px solid rgba(20, 20, 20, 0.05);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #fbfbfd;
|
||||||
|
padding: 0.65rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-label {
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(20, 20, 20, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-title {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: rgba(20, 20, 20, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-strip {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(40, 60, 120, 0.06);
|
||||||
|
color: var(--color-ink-800);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.5rem 1fr auto auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
border-bottom: 1px solid rgba(20, 20, 20, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status {
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status.success {
|
||||||
|
background: var(--color-mint-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status.warning {
|
||||||
|
background: #febc2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-type {
|
||||||
|
color: rgba(20, 20, 20, 0.4);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-time {
|
||||||
|
color: rgba(20, 20, 20, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.dashboard-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-url {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -11,12 +11,14 @@ interface Props {
|
|||||||
const { item } = Astro.props;
|
const { item } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Card class="h-full">
|
<Card class="h-full" hoverable>
|
||||||
<Headline as="h3" size="card">
|
<div class="callout-bar" data-bar-tone="trust">
|
||||||
{item.title}
|
<Headline as="h3" size="card">
|
||||||
</Headline>
|
{item.title}
|
||||||
<Lead class="mt-3" size="body">
|
</Headline>
|
||||||
{item.description}
|
<Lead class="mt-3" size="body">
|
||||||
</Lead>
|
{item.description}
|
||||||
{item.note && <Lead class="mt-4 text-[var(--color-brand)]" size="small">{item.note}</Lead>}
|
</Lead>
|
||||||
|
{item.note && <Lead class="mt-4 text-[var(--color-brand)]" size="small">{item.note}</Lead>}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -24,19 +24,19 @@ const {
|
|||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const baseClass =
|
const baseClass =
|
||||||
'inline-flex items-center justify-center rounded-[var(--radius-pill)] border font-semibold tracking-[var(--tracking-tight)] transition duration-150';
|
'inline-flex items-center justify-center rounded-[var(--radius-pill)] border font-semibold tracking-[var(--tracking-tight)] transition-all duration-200 cursor-pointer';
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'min-h-10 px-4 text-sm',
|
sm: 'min-h-10 px-5 text-sm',
|
||||||
md: 'min-h-11 px-5 text-sm sm:text-[0.95rem]',
|
md: 'min-h-12 px-6 text-[0.95rem]',
|
||||||
lg: 'min-h-12 px-6 text-[0.97rem]',
|
lg: 'min-h-14 px-8 text-base',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
primary:
|
primary:
|
||||||
'border-transparent bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-[var(--shadow-inline)] hover:bg-[var(--color-brand-700)]',
|
'border-transparent bg-[var(--color-ink-900)] text-white shadow-[0_2px_8px_rgba(0,0,0,0.12)] hover:bg-[var(--color-ink-800)] hover:shadow-[0_4px_16px_rgba(0,0,0,0.18)] active:scale-[0.98]',
|
||||||
secondary:
|
secondary:
|
||||||
'border-[color:var(--color-border)] bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)] hover:border-[var(--color-border-strong)] hover:bg-white',
|
'border-[color:var(--color-border)] bg-white text-[var(--color-secondary-foreground)] hover:border-[var(--color-border-strong)] hover:bg-[var(--surface-muted)] active:scale-[0.98]',
|
||||||
ghost: 'border-transparent bg-transparent text-[var(--color-ink-800)] hover:bg-white/70',
|
ghost: 'border-transparent bg-transparent text-[var(--color-ink-800)] hover:bg-white/70',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,12 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
as?: keyof HTMLElementTagNameMap;
|
as?: keyof HTMLElementTagNameMap;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
hoverable?: boolean;
|
||||||
variant?: 'accent' | 'default' | 'subtle';
|
variant?: 'accent' | 'default' | 'subtle';
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { as = 'article', class: className = '', variant = 'default', ...rest } = Astro.props;
|
const { as = 'article', class: className = '', hoverable = false, variant = 'default', ...rest } = Astro.props;
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
default: 'surface-card',
|
default: 'surface-card',
|
||||||
@ -18,7 +19,7 @@ const Tag = as;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Tag
|
<Tag
|
||||||
class:list={['rounded-[1.65rem] p-6 sm:p-7', variantClasses[variant], className]}
|
class:list={['rounded-[1.65rem] p-6 sm:p-7', variantClasses[variant], hoverable && 'card-hoverable', className]}
|
||||||
data-surface={variant}
|
data-surface={variant}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ interface Props {
|
|||||||
density?: 'base' | 'compact' | 'spacious';
|
density?: 'base' | 'compact' | 'spacious';
|
||||||
id?: string;
|
id?: string;
|
||||||
layer?: '1' | '2' | '3';
|
layer?: '1' | '2' | '3';
|
||||||
tone?: 'default' | 'emphasis' | 'muted';
|
tone?: 'default' | 'emphasis' | 'muted' | 'tinted' | 'warm';
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,6 +28,8 @@ const toneClasses = {
|
|||||||
default: '',
|
default: '',
|
||||||
muted: 'section-shell-muted px-3 sm:px-4',
|
muted: 'section-shell-muted px-3 sm:px-4',
|
||||||
emphasis: 'section-shell-emphasis px-3 sm:px-4',
|
emphasis: 'section-shell-emphasis px-3 sm:px-4',
|
||||||
|
tinted: 'section-tinted px-3 sm:px-4',
|
||||||
|
warm: 'section-warm px-3 sm:px-4',
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ interface Props {
|
|||||||
description?: string;
|
description?: string;
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleHtml?: string;
|
||||||
width?: 'default' | 'measure' | 'wide';
|
width?: 'default' | 'measure' | 'wide';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,6 +19,7 @@ const {
|
|||||||
description,
|
description,
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
|
titleHtml,
|
||||||
width = 'default',
|
width = 'default',
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
const widthClasses = {
|
const widthClasses = {
|
||||||
@ -29,6 +31,6 @@ const widthClasses = {
|
|||||||
|
|
||||||
<div class:list={[widthClasses[width], align === 'center' ? 'mx-auto text-center' : '', className]}>
|
<div class:list={[widthClasses[width], align === 'center' ? 'mx-auto text-center' : '', className]}>
|
||||||
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
||||||
<Headline>{title}</Headline>
|
{titleHtml ? <Headline><Fragment set:html={titleHtml} /></Headline> : <Headline>{title}</Headline>}
|
||||||
{description && <Lead class="mt-4">{description}</Lead>}
|
{description && <Lead class="mt-4">{description}</Lead>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,18 +14,19 @@ interface Props {
|
|||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
items: CapabilityClusterContent[];
|
items: CapabilityClusterContent[];
|
||||||
title: string;
|
title: string;
|
||||||
|
titleHtml?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { description, eyebrow, items, title } = Astro.props;
|
const { description, eyebrow, items, title, titleHtml } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Section layer="2" data-section="capability">
|
<Section layer="2" tone="tinted" data-section="capability">
|
||||||
<Container width="wide">
|
<Container width="wide">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
<SectionHeader eyebrow={eyebrow} title={title} titleHtml={titleHtml} description={description} />
|
||||||
<Grid cols="2" gap="lg">
|
<Grid cols="2" gap="lg">
|
||||||
{items.map((cluster) => (
|
{items.map((cluster) => (
|
||||||
<Card class="h-full">
|
<Card class="h-full" hoverable>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<Headline as="h3" size="card">
|
<Headline as="h3" size="card">
|
||||||
|
|||||||
@ -11,15 +11,17 @@ interface Props {
|
|||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
items: FeatureItemContent[];
|
items: FeatureItemContent[];
|
||||||
title: string;
|
title: string;
|
||||||
|
titleHtml?: string;
|
||||||
|
tone?: 'default' | 'tinted';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { description, eyebrow, items, title } = Astro.props;
|
const { description, eyebrow, items, title, titleHtml, tone = 'default' } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Section layer="2">
|
<Section layer="2" tone={tone === 'tinted' ? 'tinted' : 'default'}>
|
||||||
<Container width="wide">
|
<Container width="wide">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
<SectionHeader eyebrow={eyebrow} title={title} titleHtml={titleHtml} description={description} />
|
||||||
<Grid cols="3">
|
<Grid cols="3">
|
||||||
{items.map((item) => <FeatureItem item={item} />)}
|
{items.map((item) => <FeatureItem item={item} />)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -10,9 +10,10 @@ import type { OutcomeSectionContent } from '@/types/site';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: OutcomeSectionContent;
|
content: OutcomeSectionContent;
|
||||||
|
titleHtml?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content } = Astro.props;
|
const { content, titleHtml } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Section layer="2" data-section="outcome">
|
<Section layer="2" data-section="outcome">
|
||||||
@ -21,11 +22,12 @@ const { content } = Astro.props;
|
|||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow="Why it matters"
|
eyebrow="Why it matters"
|
||||||
title={content.title}
|
title={content.title}
|
||||||
|
titleHtml={titleHtml}
|
||||||
description={content.description}
|
description={content.description}
|
||||||
/>
|
/>
|
||||||
<Grid cols="3">
|
<Grid cols="3">
|
||||||
{content.outcomes.map((outcome) => (
|
{content.outcomes.map((outcome) => (
|
||||||
<Card class="h-full">
|
<Card class="h-full" hoverable>
|
||||||
<Headline as="h3" size="card">
|
<Headline as="h3" size="card">
|
||||||
{outcome.title}
|
{outcome.title}
|
||||||
</Headline>
|
</Headline>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import Card from '@/components/primitives/Card.astro';
|
|||||||
import Cluster from '@/components/primitives/Cluster.astro';
|
import Cluster from '@/components/primitives/Cluster.astro';
|
||||||
import Container from '@/components/primitives/Container.astro';
|
import Container from '@/components/primitives/Container.astro';
|
||||||
import Headline from '@/components/content/Headline.astro';
|
import Headline from '@/components/content/Headline.astro';
|
||||||
|
import HeroDashboard from '@/components/content/HeroDashboard.astro';
|
||||||
import Lead from '@/components/content/Lead.astro';
|
import Lead from '@/components/content/Lead.astro';
|
||||||
import Metric from '@/components/content/Metric.astro';
|
import Metric from '@/components/content/Metric.astro';
|
||||||
import PrimaryCTA from '@/components/content/PrimaryCTA.astro';
|
import PrimaryCTA from '@/components/content/PrimaryCTA.astro';
|
||||||
@ -18,89 +19,221 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props;
|
const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props;
|
||||||
|
const isHomepageHero = Astro.url.pathname === '/';
|
||||||
|
const heroHeadlineSize = isHomepageHero ? 'page' : 'display';
|
||||||
|
const heroLeadSize = isHomepageHero ? 'body' : 'lead';
|
||||||
|
const heroPrimaryAnchor = hero.primaryAnchor ?? 'headline';
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="pt-8 sm:pt-10 lg:pt-14">
|
<section
|
||||||
|
class:list={[
|
||||||
|
isHomepageHero ? 'hero-gradient pt-2 sm:pt-8 lg:pt-14' : 'pt-8 sm:pt-10 lg:pt-14',
|
||||||
|
]}
|
||||||
|
data-hero-root
|
||||||
|
data-hero-surface={isHomepageHero ? 'homepage' : 'page'}
|
||||||
|
data-homepage-hero={isHomepageHero ? 'true' : undefined}
|
||||||
|
data-hero-primary-anchor={isHomepageHero && heroPrimaryAnchor === 'composition' ? 'composition' : undefined}
|
||||||
|
data-section={isHomepageHero ? 'hero' : undefined}
|
||||||
|
>
|
||||||
<Container width="wide">
|
<Container width="wide">
|
||||||
<div class="grid gap-6 lg:grid-cols-[1.35fr,0.85fr]" data-disclosure-layer="1">
|
{isHomepageHero ? (
|
||||||
<Card class="motion-rise overflow-hidden">
|
<div class="space-y-6 sm:space-y-8 lg:space-y-10" data-disclosure-layer="1" data-hero-layout>
|
||||||
<div class="space-y-6">
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,0.82fr)_minmax(22rem,1.18fr)] lg:items-start lg:gap-10">
|
||||||
<Badge>{hero.eyebrow}</Badge>
|
<div class="motion-rise flex flex-col gap-5 sm:gap-6 lg:max-w-[40rem]" data-hero-panel="text">
|
||||||
<div class="space-y-4">
|
<div class="space-y-5" data-hero-anchor-group>
|
||||||
<Headline as="h1" size="display" class="max-w-4xl">
|
<div data-hero-text-core>
|
||||||
{hero.title}
|
<div data-hero-eyebrow data-hero-segment="eyebrow">
|
||||||
</Headline>
|
<Badge>{hero.eyebrow}</Badge>
|
||||||
<Lead class="max-w-3xl" size="lead">
|
</div>
|
||||||
{hero.description}
|
<div class="mt-3 space-y-4 sm:mt-5 sm:space-y-5">
|
||||||
</Lead>
|
<div
|
||||||
</div>
|
data-hero-heading
|
||||||
{(hero.primaryCta || hero.secondaryCta) && (
|
data-hero-primary-anchor={heroPrimaryAnchor === 'headline' ? 'headline' : undefined}
|
||||||
<Cluster data-cta-cluster gap="md">
|
data-hero-segment="headline"
|
||||||
<PrimaryCTA cta={hero.primaryCta} />
|
>
|
||||||
{hero.secondaryCta && (
|
<Headline
|
||||||
<SecondaryCTA cta={hero.secondaryCta} />
|
as="h1"
|
||||||
|
size="page"
|
||||||
|
class="max-w-[13ch] text-balance text-[length:clamp(2.7rem,4.6vw,4.8rem)] leading-[0.94] tracking-[-0.045em]"
|
||||||
|
>
|
||||||
|
{hero.titleHtml ? <Fragment set:html={hero.titleHtml} /> : hero.title}
|
||||||
|
</Headline>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-hero-copy-role="supporting"
|
||||||
|
data-hero-supporting-copy
|
||||||
|
data-hero-segment="supporting-copy"
|
||||||
|
>
|
||||||
|
<Lead class="max-w-[36rem] text-[1.02rem] leading-8 text-[var(--color-copy)] sm:text-[1.08rem]">
|
||||||
|
{hero.description}
|
||||||
|
</Lead>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(hero.primaryCta || hero.secondaryCta) && (
|
||||||
|
<div data-hero-cta-pair data-hero-segment="cta-pair">
|
||||||
|
<Cluster data-cta-cluster gap="sm" class="items-center sm:gap-[var(--space-cluster)]">
|
||||||
|
<PrimaryCTA cta={hero.primaryCta} size="lg" />
|
||||||
|
{hero.secondaryCta && (
|
||||||
|
<SecondaryCTA cta={hero.secondaryCta} />
|
||||||
|
)}
|
||||||
|
</Cluster>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Cluster>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{hero.trustSubclaims && hero.trustSubclaims.length > 0 && (
|
<div
|
||||||
<ul class="grid gap-3 p-0 sm:grid-cols-3">
|
class="motion-rise lg:pt-1"
|
||||||
{
|
style="animation-delay: 120ms;"
|
||||||
hero.trustSubclaims.map((claim) => (
|
data-hero-panel="dashboard"
|
||||||
<li class="list-none rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/70 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
|
data-hero-primary-anchor={heroPrimaryAnchor === 'product-visual' ? 'product-visual' : undefined}
|
||||||
{claim}
|
data-hero-visual
|
||||||
</li>
|
data-hero-visual-style="governance-surface"
|
||||||
))
|
data-hero-segment="product-near-visual"
|
||||||
}
|
>
|
||||||
</ul>
|
<div class="overflow-hidden rounded-[2rem] border border-[color:var(--color-border)] bg-[linear-gradient(180deg,rgba(255,255,255,0.9),rgba(246,248,252,0.94))] p-3 shadow-[var(--shadow-panel-strong)] sm:p-4">
|
||||||
)}
|
{hero.visualFocus && (
|
||||||
{hero.highlights && hero.highlights.length > 0 && !hero.trustSubclaims?.length && (
|
<div class="mb-3 rounded-[1.45rem] border border-[color:var(--color-border-subtle)] bg-white/88 px-4 py-4 sm:px-5">
|
||||||
<ul class="grid gap-3 p-0 sm:grid-cols-3">
|
<p class="m-0 text-[0.72rem] font-semibold uppercase tracking-[var(--tracking-eyebrow)] text-[var(--color-brand-500)]">
|
||||||
{
|
{hero.visualFocus.eyebrow}
|
||||||
hero.highlights.map((highlight) => (
|
</p>
|
||||||
<li class="list-none rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/70 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
|
<p class="mt-2 max-w-[42rem] text-sm font-semibold leading-6 text-[var(--color-ink-900)] sm:text-[0.98rem]">
|
||||||
{highlight}
|
{hero.visualFocus.title}
|
||||||
</li>
|
</p>
|
||||||
))
|
<ul class="mt-3 grid gap-2 p-0 sm:grid-cols-3">
|
||||||
}
|
{hero.visualFocus.points.map((point) => (
|
||||||
</ul>
|
<li class="list-none rounded-[1rem] border border-[color:var(--color-border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-sm font-medium leading-5 text-[var(--color-ink-800)]">
|
||||||
)}
|
{point}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<HeroDashboard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
{hero.trustSubclaims && hero.trustSubclaims.length > 0 && (
|
||||||
|
<div class="motion-rise space-y-2" data-hero-segment="trust-subclaims" data-hero-trust-signals>
|
||||||
<div class="grid gap-5">
|
<ul class="flex flex-wrap gap-3 p-0">
|
||||||
{hero.productVisual && (
|
{hero.trustSubclaims.map((claim) => (
|
||||||
<Card variant="accent" class="motion-rise overflow-hidden" data-hero-visual>
|
<li class="list-none rounded-full border border-[color:var(--color-border)] bg-white/80 px-4 py-1.5 text-sm font-medium text-[var(--color-ink-800)]">
|
||||||
<img
|
{claim}
|
||||||
src={hero.productVisual.src}
|
</li>
|
||||||
alt={hero.productVisual.alt}
|
))}
|
||||||
class="w-full rounded-[var(--radius-lg)] object-cover"
|
</ul>
|
||||||
loading="eager"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hero.productVisual && (calloutTitle || calloutDescription) && (
|
|
||||||
<Card variant="accent" class="motion-rise">
|
|
||||||
<p class="m-0 text-sm font-semibold uppercase tracking-[0.15em] text-[var(--color-brand)]">
|
|
||||||
Trust-first launch surface
|
|
||||||
</p>
|
|
||||||
{calloutTitle && (
|
|
||||||
<h2 class="mt-4 font-[var(--font-display)] text-3xl leading-tight text-[var(--color-ink-900)]">
|
|
||||||
{calloutTitle}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
{calloutDescription && (
|
|
||||||
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{calloutDescription}</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{metrics.length > 0 && (
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
|
||||||
{metrics.map((metric) => <Metric item={metric} />)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<!-- Subpage hero: card-based 2-col layout -->
|
||||||
|
<div
|
||||||
|
class="grid gap-5 sm:gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(20rem,0.92fr)] lg:items-start"
|
||||||
|
data-disclosure-layer="1"
|
||||||
|
data-hero-layout
|
||||||
|
>
|
||||||
|
<Card class="motion-rise overflow-hidden" data-hero-panel="text">
|
||||||
|
<div class="space-y-4 sm:space-y-6">
|
||||||
|
<div data-hero-text-core>
|
||||||
|
<div data-hero-eyebrow data-hero-segment="eyebrow">
|
||||||
|
<Badge>{hero.eyebrow}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 space-y-4 sm:mt-4 sm:space-y-5">
|
||||||
|
<div data-hero-heading data-hero-segment="headline">
|
||||||
|
<Headline
|
||||||
|
as="h1"
|
||||||
|
size={heroHeadlineSize}
|
||||||
|
class="max-w-3xl text-balance"
|
||||||
|
>
|
||||||
|
{hero.titleHtml ? <Fragment set:html={hero.titleHtml} /> : hero.title}
|
||||||
|
</Headline>
|
||||||
|
</div>
|
||||||
|
<div data-hero-copy-role="supporting" data-hero-supporting-copy data-hero-segment="supporting-copy">
|
||||||
|
<Lead class="max-w-2xl" size={heroLeadSize}>
|
||||||
|
{hero.description}
|
||||||
|
</Lead>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(hero.primaryCta || hero.secondaryCta) && (
|
||||||
|
<div data-hero-cta-pair data-hero-segment="cta-pair">
|
||||||
|
<Cluster data-cta-cluster gap="sm" class="sm:gap-[var(--space-cluster)]">
|
||||||
|
<PrimaryCTA cta={hero.primaryCta} />
|
||||||
|
{hero.secondaryCta && (
|
||||||
|
<SecondaryCTA cta={hero.secondaryCta} />
|
||||||
|
)}
|
||||||
|
</Cluster>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hero.highlights && hero.highlights.length > 0 && !hero.trustSubclaims?.length && (
|
||||||
|
<ul class="grid gap-3 p-0 sm:grid-cols-3">
|
||||||
|
{hero.highlights.map((highlight) => (
|
||||||
|
<li class="list-none rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/70 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
|
||||||
|
{highlight}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:gap-5" data-hero-panel="supporting">
|
||||||
|
{hero.productVisual && (
|
||||||
|
<Card
|
||||||
|
variant="accent"
|
||||||
|
class="motion-rise overflow-hidden"
|
||||||
|
data-hero-segment="product-near-visual"
|
||||||
|
data-hero-visual
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={hero.productVisual.src}
|
||||||
|
alt={hero.productVisual.alt}
|
||||||
|
class="max-h-[22rem] w-full rounded-[var(--radius-lg)] object-cover object-top"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hero.trustSubclaims && hero.trustSubclaims.length > 0 && (
|
||||||
|
<Card variant="subtle" class="motion-rise" data-hero-segment="trust-subclaims">
|
||||||
|
<div class="space-y-3" data-hero-trust-signals>
|
||||||
|
<p class="m-0 text-[0.72rem] font-semibold uppercase tracking-[var(--tracking-eyebrow)] text-[var(--color-copy)]">
|
||||||
|
Early trust
|
||||||
|
</p>
|
||||||
|
<ul class="grid gap-2.5 p-0">
|
||||||
|
{hero.trustSubclaims.map((claim) => (
|
||||||
|
<li class="list-none rounded-[1rem] border border-[color:var(--color-line)] bg-white/82 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
|
||||||
|
{claim}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hero.productVisual && (calloutTitle || calloutDescription) && (
|
||||||
|
<Card variant="accent" class="motion-rise">
|
||||||
|
<p class="m-0 text-sm font-semibold uppercase tracking-[0.15em] text-[var(--color-brand)]">
|
||||||
|
Trust-first launch surface
|
||||||
|
</p>
|
||||||
|
{calloutTitle && (
|
||||||
|
<h2 class="mt-4 font-[var(--font-display)] text-3xl font-bold leading-tight text-[var(--color-ink-900)]">
|
||||||
|
{calloutTitle}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{calloutDescription && (
|
||||||
|
<p class="mt-3 text-base leading-7 text-[var(--color-copy)]">{calloutDescription}</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metrics.length > 0 && (
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||||
|
{metrics.map((metric) => <Metric item={metric} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -11,15 +11,16 @@ interface Props {
|
|||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
items: TrustPrincipleContent[];
|
items: TrustPrincipleContent[];
|
||||||
title: string;
|
title: string;
|
||||||
|
titleHtml?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { description, eyebrow, items, title } = Astro.props;
|
const { description, eyebrow, items, title, titleHtml } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Section layer="2">
|
<Section layer="2" tone="warm">
|
||||||
<Container width="wide">
|
<Container width="wide">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
<SectionHeader eyebrow={eyebrow} title={title} titleHtml={titleHtml} description={description} />
|
||||||
<Grid cols="3">
|
<Grid cols="3">
|
||||||
{items.map((item) => <TrustPrincipleCard item={item} />)}
|
{items.map((item) => <TrustPrincipleCard item={item} />)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -17,10 +17,10 @@ export const homeSeo: PageSeo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const homeHero: HeroContent = {
|
export const homeHero: HeroContent = {
|
||||||
eyebrow: 'Governance of record',
|
eyebrow: 'Microsoft tenant governance',
|
||||||
title: 'TenantAtlas keeps Microsoft tenant change history, restore posture, and review context inside one operating record.',
|
title: 'TenantAtlas gives Microsoft tenant teams one operating record for change history, drift review, and restore planning.',
|
||||||
description:
|
description:
|
||||||
'MSP and enterprise teams use TenantAtlas to understand what changed, what drifted, what can be restored, and what needs review — without turning governance into disconnected screens.',
|
'Security, endpoint, and platform teams use TenantAtlas to see what changed, preview restores, and move reviews forward without stitching governance across exports and memory.',
|
||||||
primaryCta: {
|
primaryCta: {
|
||||||
href: '/contact',
|
href: '/contact',
|
||||||
label: 'Request a working session',
|
label: 'Request a working session',
|
||||||
@ -32,12 +32,12 @@ export const homeHero: HeroContent = {
|
|||||||
},
|
},
|
||||||
productVisual: {
|
productVisual: {
|
||||||
src: '/images/hero-product-visual.svg',
|
src: '/images/hero-product-visual.svg',
|
||||||
alt: 'TenantAtlas governance dashboard showing tenant change history and restore posture',
|
alt: 'TenantAtlas screen showing change history, restore preview, and a review queue for Microsoft tenant policies',
|
||||||
},
|
},
|
||||||
trustSubclaims: [
|
trustSubclaims: [
|
||||||
'Tenant-isolated by design',
|
'Tenant-scoped boundaries',
|
||||||
'Immutable change history',
|
'Reviewable change history',
|
||||||
'Bounded public claims',
|
'Preview before restore',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -50,36 +50,42 @@ export const productMetrics: MetricItem[] = [
|
|||||||
export const productModelBlocks: FeatureItemContent[] = [
|
export const productModelBlocks: FeatureItemContent[] = [
|
||||||
{
|
{
|
||||||
eyebrow: 'Inventory and drift',
|
eyebrow: 'Inventory and drift',
|
||||||
|
icon: 'search',
|
||||||
title: 'Current-state inventory and drift visibility establish what the tenant actually looks like now.',
|
title: 'Current-state inventory and drift visibility establish what the tenant actually looks like now.',
|
||||||
description:
|
description:
|
||||||
'The product starts with last-observed tenant state so teams can compare real configuration truth instead of relying on partial memory or exported spreadsheets.',
|
'The product starts with last-observed tenant state so teams can compare real configuration truth instead of relying on partial memory or exported spreadsheets.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eyebrow: 'Backup and versioning',
|
eyebrow: 'Backup and versioning',
|
||||||
|
icon: 'database',
|
||||||
title: 'Snapshots and versions preserve immutable history without replacing present-tense truth.',
|
title: 'Snapshots and versions preserve immutable history without replacing present-tense truth.',
|
||||||
description:
|
description:
|
||||||
'Backups and versions are explicit artifacts tied to tenant context, operators, and timing so the history remains reproducible and queryable.',
|
'Backups and versions are explicit artifacts tied to tenant context, operators, and timing so the history remains reproducible and queryable.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eyebrow: 'Restore safety',
|
eyebrow: 'Restore safety',
|
||||||
|
icon: 'refresh',
|
||||||
title: 'Restore is handled as a governed operation, not as a blind push.',
|
title: 'Restore is handled as a governed operation, not as a blind push.',
|
||||||
description:
|
description:
|
||||||
'Preview, validation, selective scope, and confirmation reduce the risk of turning a recovery step into a new incident.',
|
'Preview, validation, selective scope, and confirmation reduce the risk of turning a recovery step into a new incident.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eyebrow: 'Audit and review',
|
eyebrow: 'Audit and review',
|
||||||
|
icon: 'eye',
|
||||||
title: 'Differences become reviewable signals instead of noisy raw deltas.',
|
title: 'Differences become reviewable signals instead of noisy raw deltas.',
|
||||||
description:
|
description:
|
||||||
'Human-readable summaries and structured differences help operators and reviewers decide what changed, who needs to know, and what deserves follow-up.',
|
'Human-readable summaries and structured differences help operators and reviewers decide what changed, who needs to know, and what deserves follow-up.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eyebrow: 'Findings and evidence',
|
eyebrow: 'Findings and evidence',
|
||||||
|
icon: 'file-check',
|
||||||
title: 'Findings, exceptions, and evidence stay anchored to operational truth.',
|
title: 'Findings, exceptions, and evidence stay anchored to operational truth.',
|
||||||
description:
|
description:
|
||||||
'Governance discussions stay attached to the real object, version, and review context instead of drifting into separate manual trackers.',
|
'Governance discussions stay attached to the real object, version, and review context instead of drifting into separate manual trackers.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eyebrow: 'Baselines and governance',
|
eyebrow: 'Baselines and governance',
|
||||||
|
icon: 'shield',
|
||||||
title: 'Baselines, reviews, and operator safety belong to the same workflow.',
|
title: 'Baselines, reviews, and operator safety belong to the same workflow.',
|
||||||
description:
|
description:
|
||||||
'The product is built so teams can explain actions afterward, not just execute them quickly in the moment.',
|
'The product is built so teams can explain actions afterward, not just execute them quickly in the moment.',
|
||||||
|
|||||||
@ -41,6 +41,12 @@ const openGraphDescription = Astro.props.openGraphDescription ?? description;
|
|||||||
<meta name="twitter:description" content={openGraphDescription} />
|
<meta name="twitter:description" content={openGraphDescription} />
|
||||||
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -38,11 +38,15 @@ const progressContent = {
|
|||||||
items={homeEcosystem}
|
items={homeEcosystem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OutcomeSection content={homeOutcome} />
|
<OutcomeSection
|
||||||
|
content={homeOutcome}
|
||||||
|
titleHtml='Teams that <span class="accent">understand their tenant</span> make better decisions.'
|
||||||
|
/>
|
||||||
|
|
||||||
<CapabilityGrid
|
<CapabilityGrid
|
||||||
eyebrow="What TenantAtlas covers"
|
eyebrow="What TenantAtlas covers"
|
||||||
title="A connected product model, not a feature wall."
|
title="A connected product model, not a feature wall."
|
||||||
|
titleHtml='A <span class="accent">connected product model</span>, not a feature wall.'
|
||||||
description="TenantAtlas groups backup, restore, inventory, drift, and governance into connected clusters instead of listing isolated features."
|
description="TenantAtlas groups backup, restore, inventory, drift, and governance into connected clusters instead of listing isolated features."
|
||||||
items={homeCapabilities}
|
items={homeCapabilities}
|
||||||
/>
|
/>
|
||||||
@ -51,6 +55,7 @@ const progressContent = {
|
|||||||
<TrustGrid
|
<TrustGrid
|
||||||
eyebrow="Trust posture"
|
eyebrow="Trust posture"
|
||||||
title={homeTrustSignals.title}
|
title={homeTrustSignals.title}
|
||||||
|
titleHtml='Trust is a <span class="accent">first-read concern</span>, not a footnote.'
|
||||||
description={homeTrustSignals.description}
|
description={homeTrustSignals.description}
|
||||||
items={homeTrustSignals.signals}
|
items={homeTrustSignals.signals}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -28,8 +28,10 @@ import {
|
|||||||
<FeatureGrid
|
<FeatureGrid
|
||||||
eyebrow="Connected governance model"
|
eyebrow="Connected governance model"
|
||||||
title="Explain what the product does before asking for buyer trust."
|
title="Explain what the product does before asking for buyer trust."
|
||||||
|
titleHtml='Explain what the product does before asking for <span class="accent">buyer trust</span>.'
|
||||||
description="This page should explain how the pieces fit together so visitors do not mistake the product for a loose collection of backup, reporting, and restore features."
|
description="This page should explain how the pieces fit together so visitors do not mistake the product for a loose collection of backup, reporting, and restore features."
|
||||||
items={productModelBlocks}
|
items={productModelBlocks}
|
||||||
|
tone="tinted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Section tone="muted" density="base" layer="3">
|
<Section tone="muted" density="base" layer="3">
|
||||||
|
|||||||
@ -12,9 +12,11 @@ body {
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 30%),
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.88), transparent 30%),
|
||||||
linear-gradient(180deg, var(--color-background) 0%, var(--color-background-elevated) 48%, var(--color-muted) 100%);
|
linear-gradient(180deg, var(--color-background) 0%, var(--color-background-elevated) 48%, var(--color-muted) 100%);
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@ -23,7 +25,7 @@ body {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a:not([data-button-variant]) {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,12 +39,12 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: rgba(47, 111, 183, 0.18);
|
background: rgba(40, 60, 120, 0.12);
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(a, button, input, textarea, summary):focus-visible {
|
:where(a, button, input, textarea, summary):focus-visible {
|
||||||
outline: 3px solid rgba(47, 111, 183, 0.32);
|
outline: 3px solid rgba(40, 60, 120, 0.22);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,16 +71,16 @@ .foundation-page::before {
|
|||||||
.foundation-page[data-shell-tone='brand']::before {
|
.foundation-page[data-shell-tone='brand']::before {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 8% 0%, rgba(255, 255, 255, 0.82), transparent 30%),
|
radial-gradient(circle at 8% 0%, rgba(255, 255, 255, 0.82), transparent 30%),
|
||||||
radial-gradient(circle at 86% 0%, rgba(47, 111, 183, 0.18), transparent 28%),
|
radial-gradient(circle at 86% 0%, rgba(40, 60, 120, 0.04), transparent 28%),
|
||||||
radial-gradient(circle at 72% 22%, rgba(59, 139, 120, 0.12), transparent 26%),
|
radial-gradient(circle at 72% 22%, rgba(40, 60, 120, 0.03), transparent 26%),
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.62), transparent 18%);
|
linear-gradient(180deg, rgba(255, 255, 255, 0.62), transparent 18%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.foundation-page[data-shell-tone='trust']::before {
|
.foundation-page[data-shell-tone='trust']::before {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 10% 0%, rgba(255, 255, 255, 0.8), transparent 28%),
|
radial-gradient(circle at 10% 0%, rgba(255, 255, 255, 0.8), transparent 28%),
|
||||||
radial-gradient(circle at 82% 8%, rgba(59, 139, 120, 0.16), transparent 30%),
|
radial-gradient(circle at 82% 8%, rgba(40, 60, 120, 0.04), transparent 30%),
|
||||||
radial-gradient(circle at 74% 30%, rgba(175, 109, 67, 0.08), transparent 26%),
|
radial-gradient(circle at 74% 30%, rgba(100, 80, 140, 0.03), transparent 26%),
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.62), transparent 18%);
|
linear-gradient(180deg, rgba(255, 255, 255, 0.62), transparent 18%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,7 +172,7 @@ .section-shell-muted {
|
|||||||
|
|
||||||
.section-shell-emphasis {
|
.section-shell-emphasis {
|
||||||
border: 1px solid var(--color-border-strong);
|
border: 1px solid var(--color-border-strong);
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(47, 111, 183, 0.05));
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(40, 60, 120, 0.03));
|
||||||
border-radius: calc(var(--radius-lg) + 0.15rem);
|
border-radius: calc(var(--radius-lg) + 0.15rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,7 +188,7 @@ .text-link {
|
|||||||
|
|
||||||
.text-link:hover {
|
.text-link:hover {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
text-decoration-color: rgba(47, 111, 183, 0.35);
|
text-decoration-color: rgba(40, 60, 120, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legal-prose p {
|
.legal-prose p {
|
||||||
@ -240,3 +242,84 @@ @keyframes rise-in {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Section tint bands ── */
|
||||||
|
.section-tinted {
|
||||||
|
background: var(--surface-section-tinted);
|
||||||
|
border-radius: calc(var(--radius-lg) + 0.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-warm {
|
||||||
|
background: var(--surface-section-warm);
|
||||||
|
border-radius: calc(var(--radius-lg) + 0.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero gradient band ── */
|
||||||
|
.hero-gradient {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 20% 40%, rgba(40, 60, 120, 0.02), transparent),
|
||||||
|
radial-gradient(ellipse 60% 50% at 80% 20%, rgba(40, 60, 120, 0.015), transparent);
|
||||||
|
padding-bottom: var(--space-section);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card hover lift ── */
|
||||||
|
.surface-card,
|
||||||
|
.surface-card-muted,
|
||||||
|
.surface-card-accent {
|
||||||
|
transition:
|
||||||
|
box-shadow 220ms ease,
|
||||||
|
transform 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hoverable:hover {
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Callout accent bar ── */
|
||||||
|
.callout-bar {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-bar::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-bar[data-bar-tone="trust"]::before {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-bar[data-bar-tone="warm"]::before {
|
||||||
|
background: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Accent text highlight ── */
|
||||||
|
.text-accent-word {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feature icon circle ── */
|
||||||
|
.feature-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--surface-accent), var(--surface-accent-strong));
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,23 +1,28 @@
|
|||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
|
--font-sans: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||||
--font-display: "Iowan Old Style", "Palatino Linotype", serif;
|
--font-display: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
|
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
|
||||||
--color-stone-50: oklch(0.986 0.008 86);
|
/* Cool slate neutrals — shadcn / Apex direction */
|
||||||
--color-stone-100: oklch(0.974 0.012 84);
|
--color-stone-50: oklch(0.985 0.002 250);
|
||||||
--color-stone-150: oklch(0.958 0.016 82);
|
--color-stone-100: oklch(0.975 0.003 250);
|
||||||
--color-stone-200: oklch(0.936 0.018 79);
|
--color-stone-150: oklch(0.962 0.004 248);
|
||||||
--color-stone-300: oklch(0.896 0.025 76);
|
--color-stone-200: oklch(0.94 0.006 246);
|
||||||
--color-ink-700: oklch(0.4 0.04 244);
|
--color-stone-300: oklch(0.90 0.008 244);
|
||||||
--color-ink-800: oklch(0.31 0.04 248);
|
/* Ink: dark slate, very low chroma — near-black with cool undertone */
|
||||||
--color-ink-900: oklch(0.23 0.038 252);
|
--color-ink-700: oklch(0.44 0.01 250);
|
||||||
--color-brand-300: oklch(0.84 0.05 228);
|
--color-ink-800: oklch(0.30 0.008 250);
|
||||||
--color-brand-400: oklch(0.76 0.09 214);
|
--color-ink-900: oklch(0.14 0.005 250);
|
||||||
--color-brand-500: oklch(0.67 0.12 226);
|
/* Brand: dry slate-blue — enterprise, not flashy */
|
||||||
--color-brand-700: oklch(0.48 0.1 232);
|
--color-brand-300: oklch(0.78 0.06 245);
|
||||||
--color-mint-300: oklch(0.85 0.05 182);
|
--color-brand-400: oklch(0.64 0.09 245);
|
||||||
--color-mint-500: oklch(0.72 0.07 186);
|
--color-brand-500: oklch(0.50 0.10 245);
|
||||||
--color-mint-700: oklch(0.54 0.07 184);
|
--color-brand-700: oklch(0.38 0.09 248);
|
||||||
|
/* Mint → Steel: functional accent for success states */
|
||||||
|
--color-mint-300: oklch(0.84 0.05 175);
|
||||||
|
--color-mint-500: oklch(0.66 0.08 172);
|
||||||
|
--color-mint-700: oklch(0.48 0.08 170);
|
||||||
|
/* Amber: stays functional (warning states) */
|
||||||
--color-amber-300: oklch(0.88 0.045 73);
|
--color-amber-300: oklch(0.88 0.045 73);
|
||||||
--color-amber-500: oklch(0.75 0.085 62);
|
--color-amber-500: oklch(0.75 0.085 62);
|
||||||
--color-amber-700: oklch(0.56 0.09 54);
|
--color-amber-700: oklch(0.56 0.09 54);
|
||||||
@ -32,18 +37,18 @@ :root {
|
|||||||
--color-foreground: var(--color-ink-900);
|
--color-foreground: var(--color-ink-900);
|
||||||
--color-muted: var(--color-stone-150);
|
--color-muted: var(--color-stone-150);
|
||||||
--color-muted-foreground: var(--color-ink-700);
|
--color-muted-foreground: var(--color-ink-700);
|
||||||
--color-card: rgba(255, 255, 255, 0.9);
|
--color-card: rgba(255, 255, 255, 0.92);
|
||||||
--color-card-foreground: var(--color-ink-900);
|
--color-card-foreground: var(--color-ink-900);
|
||||||
--color-border: rgba(17, 36, 58, 0.12);
|
--color-border: rgba(20, 20, 20, 0.08);
|
||||||
--color-border-strong: rgba(47, 111, 183, 0.22);
|
--color-border-strong: rgba(20, 20, 20, 0.15);
|
||||||
--color-border-subtle: rgba(17, 36, 58, 0.07);
|
--color-border-subtle: rgba(20, 20, 20, 0.05);
|
||||||
--color-frame: rgba(17, 36, 58, 0.06);
|
--color-frame: rgba(20, 20, 20, 0.04);
|
||||||
--color-input: rgba(255, 255, 255, 0.94);
|
--color-input: rgba(255, 255, 255, 0.94);
|
||||||
--color-primary: var(--color-brand-500);
|
--color-primary: var(--color-brand-500);
|
||||||
--color-primary-foreground: #f9fbff;
|
--color-primary-foreground: #f8f9fb;
|
||||||
--color-secondary: rgba(255, 255, 255, 0.82);
|
--color-secondary: rgba(255, 255, 255, 0.82);
|
||||||
--color-secondary-foreground: var(--color-ink-900);
|
--color-secondary-foreground: var(--color-ink-900);
|
||||||
--color-accent: rgba(47, 111, 183, 0.1);
|
--color-accent: rgba(40, 60, 120, 0.06);
|
||||||
--color-accent-foreground: var(--color-brand-700);
|
--color-accent-foreground: var(--color-brand-700);
|
||||||
--color-success: var(--color-mint-700);
|
--color-success: var(--color-mint-700);
|
||||||
--color-warning: var(--color-amber-700);
|
--color-warning: var(--color-amber-700);
|
||||||
@ -53,12 +58,14 @@ :root {
|
|||||||
--surface-page: rgba(255, 255, 255, 0.34);
|
--surface-page: rgba(255, 255, 255, 0.34);
|
||||||
--surface-shell: rgba(255, 255, 255, 0.78);
|
--surface-shell: rgba(255, 255, 255, 0.78);
|
||||||
--surface-shell-strong: rgba(255, 255, 255, 0.94);
|
--surface-shell-strong: rgba(255, 255, 255, 0.94);
|
||||||
--surface-card-soft: rgba(246, 248, 251, 0.82);
|
--surface-card-soft: rgba(248, 249, 252, 0.82);
|
||||||
--surface-muted: rgba(243, 247, 251, 0.88);
|
--surface-muted: rgba(246, 248, 252, 0.88);
|
||||||
--surface-muted-strong: rgba(247, 249, 252, 0.94);
|
--surface-muted-strong: rgba(250, 251, 254, 0.94);
|
||||||
--surface-accent: rgba(47, 111, 183, 0.1);
|
--surface-accent: rgba(40, 60, 120, 0.04);
|
||||||
--surface-accent-strong: rgba(241, 246, 253, 0.98);
|
--surface-accent-strong: rgba(246, 248, 254, 0.98);
|
||||||
--surface-trust: rgba(59, 139, 120, 0.09);
|
--surface-trust: rgba(40, 60, 120, 0.03);
|
||||||
|
--surface-section-tinted: rgba(246, 248, 252, 0.62);
|
||||||
|
--surface-section-warm: rgba(248, 249, 252, 0.52);
|
||||||
|
|
||||||
--radius-sm: 1rem;
|
--radius-sm: 1rem;
|
||||||
--radius-md: 1.35rem;
|
--radius-md: 1.35rem;
|
||||||
@ -68,14 +75,15 @@ :root {
|
|||||||
|
|
||||||
--shadow-panel-strong: 0 28px 90px rgba(17, 36, 58, 0.14);
|
--shadow-panel-strong: 0 28px 90px rgba(17, 36, 58, 0.14);
|
||||||
--shadow-card: 0 20px 56px rgba(17, 36, 58, 0.1);
|
--shadow-card: 0 20px 56px rgba(17, 36, 58, 0.1);
|
||||||
|
--shadow-card-hover: 0 24px 64px rgba(17, 36, 58, 0.15);
|
||||||
--shadow-soft: 0 12px 36px rgba(17, 36, 58, 0.08);
|
--shadow-soft: 0 12px 36px rgba(17, 36, 58, 0.08);
|
||||||
--shadow-inline: 0 10px 22px rgba(17, 36, 58, 0.08);
|
--shadow-inline: 0 10px 22px rgba(17, 36, 58, 0.08);
|
||||||
|
|
||||||
--space-page-x: clamp(1.25rem, 2vw, 2.5rem);
|
--space-page-x: clamp(1.25rem, 2vw, 2.5rem);
|
||||||
--space-page-y: clamp(4rem, 6vw, 6rem);
|
--space-page-y: clamp(5rem, 8vw, 8rem);
|
||||||
--space-section-compact: clamp(3rem, 4vw, 4.25rem);
|
--space-section-compact: clamp(3.5rem, 5vw, 5rem);
|
||||||
--space-section: clamp(4rem, 6vw, 5.75rem);
|
--space-section: clamp(5rem, 8vw, 7rem);
|
||||||
--space-section-spacious: clamp(5rem, 7vw, 7rem);
|
--space-section-spacious: clamp(6rem, 9vw, 9rem);
|
||||||
--space-cluster-sm: 0.75rem;
|
--space-cluster-sm: 0.75rem;
|
||||||
--space-cluster: 1rem;
|
--space-cluster: 1rem;
|
||||||
--space-cluster-lg: 1.5rem;
|
--space-cluster-lg: 1.5rem;
|
||||||
@ -89,19 +97,19 @@ :root {
|
|||||||
--wide-max-width: 84rem;
|
--wide-max-width: 84rem;
|
||||||
--reading-max-width: 68rem;
|
--reading-max-width: 68rem;
|
||||||
|
|
||||||
--type-display-size: clamp(3.3rem, 6vw, 5rem);
|
--type-display-size: clamp(2.75rem, 5vw, 4.25rem);
|
||||||
--type-page-size: clamp(2.75rem, 4.2vw, 3.75rem);
|
--type-page-size: clamp(2.25rem, 3.8vw, 3.25rem);
|
||||||
--type-section-size: clamp(2rem, 3.5vw, 3rem);
|
--type-section-size: clamp(1.75rem, 2.8vw, 2.5rem);
|
||||||
--type-card-size: clamp(1.35rem, 2vw, 1.85rem);
|
--type-card-size: clamp(1.25rem, 1.8vw, 1.6rem);
|
||||||
--type-body-size: 1.02rem;
|
--type-body-size: 1.05rem;
|
||||||
--type-small-size: 0.94rem;
|
--type-small-size: 0.94rem;
|
||||||
--type-eyebrow-size: 0.74rem;
|
--type-eyebrow-size: 0.74rem;
|
||||||
--type-helper-size: 0.82rem;
|
--type-helper-size: 0.82rem;
|
||||||
--tracking-display: -0.055em;
|
--tracking-display: -0.04em;
|
||||||
--tracking-tight: -0.03em;
|
--tracking-tight: -0.025em;
|
||||||
--tracking-eyebrow: 0.18em;
|
--tracking-eyebrow: 0.18em;
|
||||||
--line-display: 0.95;
|
--line-display: 0.96;
|
||||||
--line-heading: 1.02;
|
--line-heading: 1.04;
|
||||||
--line-body: 1.75;
|
--line-body: 1.75;
|
||||||
--line-tight: 1.45;
|
--line-tight: 1.45;
|
||||||
|
|
||||||
|
|||||||
@ -98,11 +98,14 @@ export interface HeroContent {
|
|||||||
description: string;
|
description: string;
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
highlights?: string[];
|
highlights?: string[];
|
||||||
|
primaryAnchor?: HeroPrimaryAnchor;
|
||||||
primaryCta: CtaLink;
|
primaryCta: CtaLink;
|
||||||
productVisual?: HeroVisualContent;
|
productVisual?: HeroVisualContent;
|
||||||
secondaryCta?: CtaLink;
|
secondaryCta?: CtaLink;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleHtml?: string;
|
||||||
trustSubclaims?: string[];
|
trustSubclaims?: string[];
|
||||||
|
visualFocus?: HeroVisualFocusContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetricItem {
|
export interface MetricItem {
|
||||||
@ -115,6 +118,7 @@ export interface FeatureItemContent {
|
|||||||
description: string;
|
description: string;
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
icon?: string;
|
||||||
meta?: string;
|
meta?: string;
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
@ -163,6 +167,14 @@ export interface HeroVisualContent {
|
|||||||
src: string;
|
src: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HeroPrimaryAnchor = 'headline' | 'product-visual' | 'composition';
|
||||||
|
|
||||||
|
export interface HeroVisualFocusContent {
|
||||||
|
eyebrow: string;
|
||||||
|
points: string[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutcomeItemContent {
|
export interface OutcomeItemContent {
|
||||||
description: string;
|
description: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@ -4,6 +4,12 @@ import {
|
|||||||
expectCtaHierarchy,
|
expectCtaHierarchy,
|
||||||
expectDisclosureLayer,
|
expectDisclosureLayer,
|
||||||
expectFooterLinks,
|
expectFooterLinks,
|
||||||
|
expectHomepageHeroCtaPair,
|
||||||
|
expectHomepageHeroOrder,
|
||||||
|
expectHomepageHeroRouteTargets,
|
||||||
|
expectHomepageHeroStructure,
|
||||||
|
expectHomepageHeroTrustSignals,
|
||||||
|
expectHomepageHeroVisibleOnMobile,
|
||||||
expectHomepageSectionOrder,
|
expectHomepageSectionOrder,
|
||||||
expectMobileReadability,
|
expectMobileReadability,
|
||||||
expectNavigationVsCtaDifferentiation,
|
expectNavigationVsCtaDifferentiation,
|
||||||
@ -72,6 +78,41 @@ test('homepage shows explicit trust signals before the final CTA', async ({
|
|||||||
await expectOnwardRouteReachable(page, ['/trust']);
|
await expectOnwardRouteReachable(page, ['/trust']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('homepage hero makes the product category, text core, and one CTA pair explicit on first read', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expectHomepageHeroStructure(page);
|
||||||
|
await expect(page.locator('[data-homepage-hero="true"] [data-hero-eyebrow]')).toContainText(/microsoft tenant governance/i);
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-homepage-hero="true"] [data-hero-heading]').getByRole('heading', {
|
||||||
|
level: 1,
|
||||||
|
name: /one operating record for change history, drift review, and restore planning/i,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('[data-homepage-hero="true"] [data-hero-supporting-copy]')).toContainText(
|
||||||
|
/security, endpoint, and platform teams use TenantAtlas to see what changed, preview restores, and move reviews forward/i,
|
||||||
|
);
|
||||||
|
await expectHomepageHeroCtaPair(page, /working session/i, /product model/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homepage hero keeps product-near proof and bounded trust cues inside the hero itself', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expectProductNearVisual(page, /change history, restore preview, and a review queue/i);
|
||||||
|
await expectHomepageHeroTrustSignals(page);
|
||||||
|
await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText(
|
||||||
|
/tenant-scoped boundaries/i,
|
||||||
|
);
|
||||||
|
await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText(
|
||||||
|
/reviewable change history/i,
|
||||||
|
);
|
||||||
|
await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText(
|
||||||
|
/preview before restore/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('homepage shows dated progress signals before the final CTA', async ({
|
test('homepage shows dated progress signals before the final CTA', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@ -97,6 +138,20 @@ test.describe('homepage mobile', () => {
|
|||||||
await expect(page.locator('[data-section="capability"]')).toBeVisible();
|
await expect(page.locator('[data-section="capability"]')).toBeVisible();
|
||||||
await expect(page.locator('[data-section="trust"]')).toBeVisible();
|
await expect(page.locator('[data-section="trust"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('homepage hero preserves meaning order and hero route intent on narrow screens', async ({ page }) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expectHomepageHeroOrder(page, [
|
||||||
|
'eyebrow',
|
||||||
|
'headline',
|
||||||
|
'supporting-copy',
|
||||||
|
'cta-pair',
|
||||||
|
'product-near-visual',
|
||||||
|
'trust-subclaims',
|
||||||
|
]);
|
||||||
|
await expectHomepageHeroVisibleOnMobile(page);
|
||||||
|
await expectHomepageHeroRouteTargets(page, ['/contact', '/product']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('product keeps the connected operating model readable without collapsing into a feature list', async ({
|
test('product keeps the connected operating model readable without collapsing into a feature list', async ({
|
||||||
|
|||||||
@ -87,6 +87,180 @@ export async function expectCtaHierarchy(
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroStructure(page: Page): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
|
||||||
|
await expect(hero).toBeVisible();
|
||||||
|
await expect(hero.locator('[data-hero-text-core]').first()).toBeVisible();
|
||||||
|
await expect(hero.locator('[data-hero-eyebrow]').first()).toBeVisible();
|
||||||
|
await expect(hero.locator('[data-hero-heading]').getByRole('heading', { level: 1 })).toBeVisible();
|
||||||
|
await expect(hero.locator('[data-hero-supporting-copy]').first()).toBeVisible();
|
||||||
|
await expect(hero.locator('[data-hero-cta-pair]').first()).toBeVisible();
|
||||||
|
await expect(hero.locator('[data-cta-slot="primary"]')).toHaveCount(1);
|
||||||
|
await expect(hero.locator('[data-cta-slot="secondary"]')).toHaveCount(1);
|
||||||
|
await expect(hero.locator('[data-hero-visual]').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroCtaPair(
|
||||||
|
page: Page,
|
||||||
|
primaryLabel: string | RegExp,
|
||||||
|
secondaryLabel: string | RegExp,
|
||||||
|
): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
|
||||||
|
await expect(hero.locator('[data-cta-weight="primary"]').filter({ hasText: primaryLabel }).first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
hero.locator('[data-cta-weight="secondary"]').filter({ hasText: secondaryLabel }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroPrimaryAnchor(
|
||||||
|
page: Page,
|
||||||
|
anchor: 'headline' | 'product-visual' | 'composition',
|
||||||
|
): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
|
||||||
|
await expect(hero.locator(`[data-hero-primary-anchor="${anchor}"]`).first()).toBeVisible();
|
||||||
|
await expect(hero.locator('[data-hero-primary-anchor]')).toHaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroSupportingCopySubordination(page: Page): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
const heading = hero.locator('[data-hero-heading]').getByRole('heading', { level: 1 }).first();
|
||||||
|
const supportingCopy = hero.locator('[data-hero-supporting-copy] p').first();
|
||||||
|
|
||||||
|
await expect(hero.locator('[data-hero-supporting-copy]').first()).toHaveAttribute('data-hero-copy-role', 'supporting');
|
||||||
|
|
||||||
|
const [headingFontSize, supportingCopyFontSize] = await Promise.all([
|
||||||
|
heading.evaluate((element) => Number.parseFloat(window.getComputedStyle(element).fontSize)),
|
||||||
|
supportingCopy.evaluate((element) => Number.parseFloat(window.getComputedStyle(element).fontSize)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(headingFontSize, 'Hero heading should remain larger than supporting copy').toBeGreaterThan(
|
||||||
|
supportingCopyFontSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroAnchorCtaAlignment(page: Page): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
const anchorGroup = hero.locator('[data-hero-anchor-group]').first();
|
||||||
|
|
||||||
|
await expect(anchorGroup).toBeVisible();
|
||||||
|
await expect(anchorGroup.locator('[data-hero-heading]').first()).toBeVisible();
|
||||||
|
await expect(anchorGroup.locator('[data-hero-cta-pair]').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroOrder(
|
||||||
|
page: Page,
|
||||||
|
segments: Array<'eyebrow' | 'headline' | 'supporting-copy' | 'cta-pair' | 'product-near-visual' | 'trust-subclaims'>,
|
||||||
|
): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
const actual = await hero.locator('[data-hero-segment]').evaluateAll((elements) =>
|
||||||
|
elements
|
||||||
|
.map((element) => element.getAttribute('data-hero-segment'))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
expect(actual.indexOf(segments[i]), `Hero segment "${segments[i]}" should exist`).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
expect(
|
||||||
|
actual.indexOf(segments[i]),
|
||||||
|
`Hero segment "${segments[i]}" should appear after "${segments[i - 1]}"`,
|
||||||
|
).toBeGreaterThan(actual.indexOf(segments[i - 1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroTrustSignals(page: Page): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
const trustSignals = hero.locator('[data-hero-trust-signals] li');
|
||||||
|
const count = await trustSignals.count();
|
||||||
|
|
||||||
|
await expect(hero.locator('[data-hero-trust-signals]').first()).toBeVisible();
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
expect(count).toBeLessThanOrEqual(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroVisualSemantics(
|
||||||
|
page: Page,
|
||||||
|
terms: Array<string | RegExp>,
|
||||||
|
): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
const visual = hero.locator('[data-hero-visual]').first();
|
||||||
|
|
||||||
|
await expect(visual).toBeVisible();
|
||||||
|
await expect(visual).toHaveAttribute('data-hero-visual-style', 'governance-surface');
|
||||||
|
|
||||||
|
for (const term of terms) {
|
||||||
|
await expect(visual).toContainText(term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroSplitLayout(page: Page): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
const textPanel = hero.locator('[data-hero-panel="text"]').first();
|
||||||
|
const visualPanel = hero.locator('[data-hero-panel="dashboard"]').first();
|
||||||
|
|
||||||
|
await expect(textPanel).toBeVisible();
|
||||||
|
await expect(visualPanel).toBeVisible();
|
||||||
|
|
||||||
|
const [textBox, visualBox] = await Promise.all([textPanel.boundingBox(), visualPanel.boundingBox()]);
|
||||||
|
|
||||||
|
expect(textBox, 'Hero text panel should have a bounding box').not.toBeNull();
|
||||||
|
expect(visualBox, 'Hero visual panel should have a bounding box').not.toBeNull();
|
||||||
|
|
||||||
|
if (!textBox || !visualBox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
textBox.x + textBox.width,
|
||||||
|
'Desktop hero text should end before the visual surface begins so both read as one split composition',
|
||||||
|
).toBeLessThanOrEqual(visualBox.x + 48);
|
||||||
|
expect(
|
||||||
|
Math.abs(textBox.y - visualBox.y),
|
||||||
|
'Desktop hero text and visual should share a horizontal composition instead of stacking far apart',
|
||||||
|
).toBeLessThan(140);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectLocatorInInitialViewport(page: Page, selector: string, label: string): Promise<void> {
|
||||||
|
const locator = page.locator(selector).first();
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
|
||||||
|
await expect(locator).toBeVisible();
|
||||||
|
|
||||||
|
expect(box, `${label} should have a bounding box`).not.toBeNull();
|
||||||
|
expect(viewport, 'Viewport should be available').not.toBeNull();
|
||||||
|
|
||||||
|
if (!box || !viewport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(box.y, `${label} should start within the initial viewport`).toBeLessThan(viewport.height);
|
||||||
|
expect(box.y + Math.min(box.height, 32), `${label} should remain on screen at first paint`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroVisibleOnMobile(page: Page): Promise<void> {
|
||||||
|
await expectLocatorInInitialViewport(
|
||||||
|
page,
|
||||||
|
'[data-homepage-hero="true"] [data-hero-primary-anchor]',
|
||||||
|
'Hero primary anchor',
|
||||||
|
);
|
||||||
|
await expectLocatorInInitialViewport(page, '[data-homepage-hero="true"] [data-hero-cta-pair]', 'Hero CTA pair');
|
||||||
|
await expect(page.locator('[data-homepage-hero="true"] [data-hero-visual]').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageHeroRouteTargets(page: Page, routes: string[]): Promise<void> {
|
||||||
|
const hero = page.locator('[data-homepage-hero="true"]').first();
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
await expect(hero.locator(`a[href="${route}"]`).first(), `Route "${route}" should be reachable from hero`).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function expectNavigationVsCtaDifferentiation(page: Page): Promise<void> {
|
export async function expectNavigationVsCtaDifferentiation(page: Page): Promise<void> {
|
||||||
const header = page.getByRole('banner');
|
const header = page.getByRole('banner');
|
||||||
|
|
||||||
@ -120,10 +294,15 @@ export async function expectHomepageSectionOrder(page: Page, sections: string[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectProductNearVisual(page: Page): Promise<void> {
|
export async function expectProductNearVisual(page: Page, alt?: string | RegExp): Promise<void> {
|
||||||
const main = page.getByRole('main');
|
const main = page.getByRole('main');
|
||||||
|
const visual = main.locator('[data-hero-visual] img, [data-hero-visual]').first();
|
||||||
|
|
||||||
await expect(main.locator('[data-hero-visual] img, [data-hero-visual]').first()).toBeVisible();
|
await expect(visual).toBeVisible();
|
||||||
|
|
||||||
|
if (alt) {
|
||||||
|
await expect(main.getByRole('img', { name: alt }).first()).toBeVisible();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectMobileReadability(page: Page): Promise<void> {
|
export async function expectMobileReadability(page: Page): Promise<void> {
|
||||||
|
|||||||
@ -13,11 +13,11 @@ test('representative pages route CTA, badge, surface, and input semantics throug
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await visitPage(page, '/');
|
await visitPage(page, '/');
|
||||||
await expectShell(page, /TenantAtlas/);
|
await expectShell(page, /control surface/i);
|
||||||
await expectPageFamily(page, 'landing');
|
await expectPageFamily(page, 'landing');
|
||||||
await expectPrimaryNavigation(page);
|
await expectPrimaryNavigation(page);
|
||||||
await expectNavigationVsCtaDifferentiation(page);
|
await expectNavigationVsCtaDifferentiation(page);
|
||||||
await expectCtaHierarchy(page, 'Request a working session', 'See the product model');
|
await expectCtaHierarchy(page, 'Request a working session', /product model/i);
|
||||||
await expect(page.locator('[data-interaction="button"]').filter({ hasText: 'Request a working session' }).first()).toBeVisible();
|
await expect(page.locator('[data-interaction="button"]').filter({ hasText: 'Request a working session' }).first()).toBeVisible();
|
||||||
await expect(page.locator('[data-badge-tone]').first()).toBeVisible();
|
await expect(page.locator('[data-badge-tone]').first()).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@ -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-20 (reconciled promoted candidates with current specs)
|
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224 and aligned the list)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -42,7 +42,11 @@ ## Promoted to Spec
|
|||||||
- Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`)
|
- Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`)
|
||||||
- Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`)
|
- Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`)
|
||||||
- Governance Operator Outcome Compression → Spec 214 (`governance-outcome-compression`)
|
- Governance Operator Outcome Compression → Spec 214 (`governance-outcome-compression`)
|
||||||
|
- 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 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`)
|
||||||
@ -358,54 +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.
|
||||||
|
|
||||||
### Finding Ownership Semantics Clarification
|
|
||||||
- **Type**: domain semantics / workflow hardening
|
|
||||||
- **Source**: findings execution layer candidate pack 2026-04-17; current Finding owner/assignee overlap analysis
|
|
||||||
- **Problem**: Finding already models `owner` and `assignee`, but the semantic split is not crisp enough to support inboxes, escalation, stale-work detection, or consistent audit language. Accountability and active execution responsibility can currently blur together in UI copy, policies, and workflow rules.
|
|
||||||
- **Why it matters**: Without a shared contract, every downstream workflow slice will invent its own meaning for owner versus assignee. That produces ambiguous queues, brittle escalation rules, and inconsistent governance language.
|
|
||||||
- **Proposed direction**: Define canonical semantics for accountability owner versus active assignee; align labels, policies, audit/event vocabulary, and permission expectations around that split; encode expected lifecycle states for unassigned, assigned, reassigned, and orphaned work without introducing team-queue abstractions yet.
|
|
||||||
- **Explicit non-goals**: Team-/queue-based ownership, ticketing, comments, and notification delivery.
|
|
||||||
- **Dependencies**: Existing Findings model and workflow state machine, Findings UI surfaces, audit vocabulary.
|
|
||||||
- **Roadmap fit**: Findings Workflow v2 hardening lane.
|
|
||||||
- **Strategic sequencing**: First. The rest of the findings execution layer consumes this decision.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Findings Operator Inbox v1
|
|
||||||
- **Type**: operator surface / workflow execution
|
|
||||||
- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment auditability and day-to-day operator flow
|
|
||||||
- **Problem**: Findings can be assigned, but TenantPilot lacks a personal work surface that turns assignment into a real operator queue. Assigned work is still discovered indirectly through broader tenant or findings lists.
|
|
||||||
- **Why it matters**: Without a dedicated personal queue, assignment remains metadata instead of operational flow. A "My Work" surface is the simplest bridge from governance data to daily execution.
|
|
||||||
- **Proposed direction**: Add a workspace-level or otherwise permission-safe "My Findings / My Work" surface for the current user; emphasize open, due, overdue, high-severity, and reopened findings; provide fast drilldown into the finding record; add a small "assigned to me" dashboard signal; reuse existing RBAC and finding visibility rules instead of inventing a second permission system.
|
|
||||||
- **Explicit non-goals**: Team queues, complex routing rules, external ticketing, and multi-step approval chains.
|
|
||||||
- **Dependencies**: Ownership semantics, `assignee_user_id`, `due_at`, finding status logic, RBAC on finding read/open.
|
|
||||||
- **Roadmap fit**: Findings Workflow v2; feeds later governance inbox work.
|
|
||||||
- **Strategic sequencing**: Second, ideally immediately after ownership semantics.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### 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
|
||||||
@ -622,6 +578,106 @@ ### Surface Taxonomy & Workflow-First IA Classification
|
|||||||
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
|
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Personal Work IA / My Work
|
||||||
|
- **Type**: IA / workflow foundation
|
||||||
|
- **Source**: admin workspace IA discussion 2026-04-21; personal work architecture candidate pack
|
||||||
|
- **Problem**: TenantPilot now has a real assignee-facing work surface in Spec 221 (`findings-operator-inbox`), but future personal work would otherwise fragment across findings, approvals, reviews, alerts, and exception-renewal surfaces without one stable "what is my work today?" entry point.
|
||||||
|
- **Why it matters**: This is not just a navigation tweak. As TenantPilot becomes more workflow- and decision-oriented, personally addressed actionable work needs its own IA layer. Without that layer, discoverability, counts, and operator mental models drift by domain.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Add a top-level `My Work` group in the admin panel as the personal lens on domain work, not as a second monitoring tree or favorites bucket
|
||||||
|
- Allow only surfaces that are explicitly assigned to the current user or awaiting that user's concrete decision
|
||||||
|
- Keep global domain navigation canonical for browsing, reporting, and non-personal work
|
||||||
|
- Treat the dashboard as a signal and entry surface, not the durable home of personal queues
|
||||||
|
- Start with the IA contract and admission rules; do not require every future child surface to ship together
|
||||||
|
- **Admission rules**:
|
||||||
|
- Personal addressability: explicit assignee, approver, or decision owner; generic responsibility is insufficient
|
||||||
|
- Concrete next action: triage, approve, renew, close, escalate, or equivalent; reports and diagnostics alone are out
|
||||||
|
- Workspace-safe scope: rows, counts, and badges stay limited to visible, authorized workspace and tenant scope
|
||||||
|
- Personal value-add: the surface does more than deep-link to a global list by adding personal filtering, prioritization, or decision support
|
||||||
|
- No replacement of domain navigation: domain collections remain canonical outside the personal lens
|
||||||
|
- **Vehicle note**: `My Work — Assigned Findings` is already materially represented by Spec 221 (`findings-operator-inbox`) and should be treated as the first concrete child surface rather than a second open candidate.
|
||||||
|
- **Activation rule**: Introduce `My Work` as actual top-level navigation only once at least two real personal work surfaces exist or are committed near-term. Before that, the IA contract may exist without forcing a single-link top-level group.
|
||||||
|
- **Explicit non-goals**: Not a generic "My Area", not profile/settings relocation, not favorites/bookmarks, not a universal task engine, not a dashboard replacement, and not a notification center.
|
||||||
|
- **Boundary with Spec 221 (Findings Operator Inbox)**: Spec 221 defines the first concrete personal findings queue. This candidate defines the durable admin-IA rule that decides when that queue graduates into a broader personal-work group and how future personal surfaces should join it.
|
||||||
|
- **Boundary with Human-in-the-Loop Autonomous Governance / Governance Inbox**: Governance Inbox is the long-horizon cross-workflow decision cockpit with structured recommendations and controlled execution. `My Work` is the nearer-term IA layer for personally addressed queues in the existing admin workspace. It should not absorb the full governance-inbox ambition.
|
||||||
|
- **Dependencies**: Surface Taxonomy & Workflow-First IA Classification, Spec 221 (`findings-operator-inbox`), workspace/tenant scope enforcement, future assignment and approval routing semantics
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
> `My Work` candidate family: keep child surfaces and cross-cutting semantics split so prioritization can land the IA contract, the next concrete personal queues, and the routing/count foundations independently instead of turning personal work into one oversized umbrella spec.
|
||||||
|
|
||||||
|
### My Work — Pending Approvals
|
||||||
|
- **Type**: workflow execution / approvals
|
||||||
|
- **Source**: personal work architecture candidate pack 2026-04-21; future approval-bearing workflows
|
||||||
|
- **Problem**: Approval work would otherwise be scattered across risk acceptance, drift governance, restore, or rollout surfaces without one trustworthy personal decision queue.
|
||||||
|
- **Why it matters**: Approval is the cleanest form of personally addressed work. If it remains buried in domain pages, operators lose the "awaiting my decision" contract.
|
||||||
|
- **Proposed direction**: Add a personal approvals queue for decisions that explicitly await the current user's approval or rejection; show decision summary, urgency, scope, and safe drilldown; keep FYI notifications and passive review signals out.
|
||||||
|
- **Explicit non-goals**: Notification center, knowledge-only acknowledgements, general automation orchestration, or inventing a full approval engine before approval-producing domains exist.
|
||||||
|
- **Dependencies**: Risk acceptance lifecycle (Spec 154), drift/change approval direction, restore or rollout approval producers, routing semantics
|
||||||
|
- **Strategic sequencing**: Strong candidate for the second real `My Work` child surface because it naturally satisfies the admission rules.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### My Work — Assigned Reviews
|
||||||
|
- **Type**: workflow execution / review work
|
||||||
|
- **Source**: personal work architecture candidate pack 2026-04-21; governance/review responsibility gap
|
||||||
|
- **Problem**: Review work can easily remain hidden in tenant review, evidence, or governance surfaces even when a specific reviewer is responsible.
|
||||||
|
- **Why it matters**: Reviews are person-bound work, but not all reviews are findings or approvals. A dedicated personal review queue keeps governance responsibility visible without flattening everything into one findings model.
|
||||||
|
- **Proposed direction**: Add a review queue for review packs, evidence bundles, or governance review steps explicitly assigned to the current user; emphasize due state, review scope, and next action.
|
||||||
|
- **Explicit non-goals**: Generic reporting hub, passive read receipts, or turning `My Work` into a full collaboration suite.
|
||||||
|
- **Dependencies**: Review-layer maturity, evidence surfaces, assignment semantics, due-date conventions
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### My Work — Risk Acceptance Renewals
|
||||||
|
- **Type**: workflow execution / time-bound governance
|
||||||
|
- **Source**: personal work architecture candidate pack 2026-04-21; exception-renewal follow-up
|
||||||
|
- **Problem**: Expiring risk acceptances or exceptions create person-addressed renewal work, but that work is neither standard findings triage nor generic monitoring.
|
||||||
|
- **Why it matters**: Renewal work is deadline-driven and materially important, so it needs a calm but trustworthy personal queue instead of disappearing inside exception detail pages.
|
||||||
|
- **Proposed direction**: Add a renewal queue for expiring or expired risk acceptances where the current user is owner or required approver; support renew, close, or escalate next steps.
|
||||||
|
- **Explicit non-goals**: Full exception lifecycle redesign or generic reminder infrastructure for every dated object in the product.
|
||||||
|
- **Dependencies**: Spec 154 (`finding-risk-acceptance`), due/expiry semantics, routing semantics
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### My Work — Actionable Alerts
|
||||||
|
- **Type**: alerts / workflow execution
|
||||||
|
- **Source**: personal work architecture candidate pack 2026-04-21; action-vs-notification boundary review
|
||||||
|
- **Problem**: Some alerts represent concrete assigned follow-up, while others are only awareness signals. Without a boundary, `My Work` either becomes noisy or misses genuine action-bearing alerts.
|
||||||
|
- **Why it matters**: `My Work` must stay quiet and trustworthy. Admitting every notification would destroy the queue's meaning; admitting none would keep action-bearing alerts disconnected from work.
|
||||||
|
- **Proposed direction**: Route only alerts with explicit ownership and one clear next action into `My Work`; keep generic notifications, telemetry, and passive monitoring signals outside the group.
|
||||||
|
- **Explicit non-goals**: General notification center, chat/activity feed, or bulk alert triage system.
|
||||||
|
- **Dependencies**: Alert infrastructure, ownership semantics, escalation rules, personal count semantics
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### My Work — Approval & Escalation Routing
|
||||||
|
- **Type**: foundation / routing semantics
|
||||||
|
- **Source**: personal work architecture candidate pack 2026-04-21; ownership and fallback analysis
|
||||||
|
- **Problem**: Personal queues become inconsistent when owner, assignee, approver, escalation target, and fallback role mean different things in each domain.
|
||||||
|
- **Why it matters**: `My Work` cannot be trustworthy without a shared answer to "why did this item land on me?" and "who gets it if no person is assigned?".
|
||||||
|
- **Proposed direction**: Define shared routing semantics for assignee versus owner versus approver, fallback-to-role behavior, no-assignee escalation, and future delegation boundaries; keep this as a governance contract, not a UI-only heuristic.
|
||||||
|
- **Explicit non-goals**: Full org-chart modeling, absence management, or automatic load balancing.
|
||||||
|
- **Dependencies**: Ownership semantics (Spec 219), findings workflow, approval-producing domains, RBAC/capability model, alerting
|
||||||
|
- **Strategic sequencing**: Foundational before `My Work` expands beyond findings into approvals, reviews, or renewals.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### My Work — Personal Counts & Priority Semantics
|
||||||
|
- **Type**: foundation / queue semantics
|
||||||
|
- **Source**: personal work architecture candidate pack 2026-04-21; count-trust and priority-shaping analysis
|
||||||
|
- **Problem**: Once more than one personal queue exists, badges and ordering can drift, double-count, or leak hidden scope unless the inclusion and weighting rules are explicit.
|
||||||
|
- **Why it matters**: Personal counts are operator trust surfaces. If badges are noisy, inconsistent, or scope-leaky, the IA layer becomes less usable than the domain pages it was meant to simplify.
|
||||||
|
- **Proposed direction**: Define group-badge inclusion, visible-scope count rules, urgency weighting for overdue versus pending approval versus reopened work, and the relationship between workspace-wide truth and active-tenant context.
|
||||||
|
- **Explicit non-goals**: Complex cross-domain scoring engine, productivity gamification, or predictive prioritization.
|
||||||
|
- **Dependencies**: `My Work` IA, routing semantics, alerting/approval/review producers, RBAC scope enforcement
|
||||||
|
- **Strategic sequencing**: Must exist before a multi-surface `My Work` badge ships.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### My Work — Dashboard Signals & Personal Entry Points
|
||||||
|
- **Type**: IA / entry-point semantics
|
||||||
|
- **Source**: personal work architecture candidate pack 2026-04-21; dashboard-versus-nav continuity analysis
|
||||||
|
- **Problem**: Dashboard summary cards, CTA strips, and future personal queues can easily duplicate or contradict each other unless their roles are defined together.
|
||||||
|
- **Why it matters**: The workspace dashboard should signal personal work, not become a second queue. Operators need consistent drill-in and return behavior between the dashboard and `My Work`.
|
||||||
|
- **Proposed direction**: Define which personal signals belong on `/admin`, when a CTA is enough versus when a nav point is required, and how context/filter carry-over works between dashboard signals and personal queues.
|
||||||
|
- **Explicit non-goals**: Full dashboard redesign or a second summary layer that mirrors every `My Work` list.
|
||||||
|
- **Dependencies**: Spec 221 workspace signal, `My Work` IA, dashboard surface conventions, personal count semantics
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting
|
### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, 0800-future-features brainstorming (pillar #1 — MSP Portfolio & Operations), product positioning for MSP portfolio owners
|
- **Source**: roadmap-to-spec coverage audit 2026-03-18, 0800-future-features brainstorming (pillar #1 — MSP Portfolio & Operations), product positioning for MSP portfolio owners
|
||||||
|
|||||||
423
pnpm-lock.yaml
423
pnpm-lock.yaml
@ -43,9 +43,15 @@ importers:
|
|||||||
|
|
||||||
apps/website:
|
apps/website:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@iconify-json/lucide':
|
||||||
|
specifier: ^1.2.102
|
||||||
|
version: 1.2.102
|
||||||
astro:
|
astro:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.1.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)
|
version: 6.1.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)
|
||||||
|
astro-icon:
|
||||||
|
specifier: ^1.1.5
|
||||||
|
version: 1.1.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.59.1
|
specifier: ^1.59.1
|
||||||
@ -65,6 +71,12 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@antfu/install-pkg@1.1.0':
|
||||||
|
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||||
|
|
||||||
|
'@antfu/utils@8.1.1':
|
||||||
|
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
|
||||||
|
|
||||||
'@astrojs/compiler@3.0.1':
|
'@astrojs/compiler@3.0.1':
|
||||||
resolution: {integrity: sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==}
|
resolution: {integrity: sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==}
|
||||||
|
|
||||||
@ -567,6 +579,18 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@iconify-json/lucide@1.2.102':
|
||||||
|
resolution: {integrity: sha512-Dm3EEqu5NrmzyDMB2U1+8yroEj2/dB9V4KlH0m/szwwF/ofSf0cPaGTZqkd1aExXjCor+vU53ttRMCGuXf+/cg==}
|
||||||
|
|
||||||
|
'@iconify/tools@4.2.0':
|
||||||
|
resolution: {integrity: sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA==}
|
||||||
|
|
||||||
|
'@iconify/types@2.0.0':
|
||||||
|
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||||
|
|
||||||
|
'@iconify/utils@2.3.0':
|
||||||
|
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||||
|
|
||||||
'@img/colour@1.1.0':
|
'@img/colour@1.1.0':
|
||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -720,6 +744,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
|
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@ -1040,9 +1068,17 @@ packages:
|
|||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
|
'@types/yauzl@2.10.3':
|
||||||
|
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
|
acorn@8.16.0:
|
||||||
|
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
ansi-regex@5.0.1:
|
ansi-regex@5.0.1:
|
||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1065,6 +1101,9 @@ packages:
|
|||||||
array-iterate@2.0.1:
|
array-iterate@2.0.1:
|
||||||
resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==}
|
resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==}
|
||||||
|
|
||||||
|
astro-icon@1.1.5:
|
||||||
|
resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==}
|
||||||
|
|
||||||
astro@6.1.4:
|
astro@6.1.4:
|
||||||
resolution: {integrity: sha512-SRy1bONuCHkGWhI5JiWCQKVDVbeaXOikjAVZs/Nz+lvUvubtdLoZfnacmuZHQ9RL2IOkU54M8/qZYm9ypJDKrg==}
|
resolution: {integrity: sha512-SRy1bONuCHkGWhI5JiWCQKVDVbeaXOikjAVZs/Nz+lvUvubtdLoZfnacmuZHQ9RL2IOkU54M8/qZYm9ypJDKrg==}
|
||||||
engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'}
|
engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'}
|
||||||
@ -1086,6 +1125,9 @@ packages:
|
|||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
|
buffer-crc32@0.2.13:
|
||||||
|
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@ -1109,10 +1151,21 @@ packages:
|
|||||||
character-entities@2.0.2:
|
character-entities@2.0.2:
|
||||||
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
||||||
|
|
||||||
|
cheerio-select@2.1.0:
|
||||||
|
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
|
||||||
|
|
||||||
|
cheerio@1.2.0:
|
||||||
|
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
chokidar@5.0.0:
|
chokidar@5.0.0:
|
||||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||||
engines: {node: '>= 20.19.0'}
|
engines: {node: '>= 20.19.0'}
|
||||||
|
|
||||||
|
chownr@3.0.0:
|
||||||
|
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
ci-info@4.4.0:
|
ci-info@4.4.0:
|
||||||
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1143,6 +1196,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
commander@7.2.0:
|
||||||
|
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
common-ancestor-path@2.0.0:
|
common-ancestor-path@2.0.0:
|
||||||
resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==}
|
resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@ -1152,6 +1209,12 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
confbox@0.1.8:
|
||||||
|
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||||
|
|
||||||
|
confbox@0.2.4:
|
||||||
|
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
|
||||||
|
|
||||||
cookie-es@1.2.3:
|
cookie-es@1.2.3:
|
||||||
resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}
|
resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}
|
||||||
|
|
||||||
@ -1169,6 +1232,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
|
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
|
||||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||||
|
|
||||||
|
css-tree@2.3.1:
|
||||||
|
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
|
||||||
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||||
|
|
||||||
css-tree@3.2.1:
|
css-tree@3.2.1:
|
||||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||||
@ -1344,6 +1411,12 @@ packages:
|
|||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
|
encoding-sniffer@0.2.1:
|
||||||
|
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
|
||||||
|
|
||||||
|
end-of-stream@1.4.5:
|
||||||
|
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||||
|
|
||||||
enhanced-resolve@5.20.1:
|
enhanced-resolve@5.20.1:
|
||||||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@ -1356,6 +1429,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
es-define-property@1.0.1:
|
es-define-property@1.0.1:
|
||||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -1404,9 +1481,17 @@ packages:
|
|||||||
eventemitter3@5.0.4:
|
eventemitter3@5.0.4:
|
||||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||||
|
|
||||||
|
exsolve@1.0.8:
|
||||||
|
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||||
|
|
||||||
extend@3.0.2:
|
extend@3.0.2:
|
||||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||||
|
|
||||||
|
extract-zip@2.0.1:
|
||||||
|
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
|
||||||
|
engines: {node: '>= 10.17.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
fast-string-truncated-width@1.2.1:
|
fast-string-truncated-width@1.2.1:
|
||||||
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
|
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
|
||||||
|
|
||||||
@ -1416,6 +1501,9 @@ packages:
|
|||||||
fast-wrap-ansi@0.1.6:
|
fast-wrap-ansi@0.1.6:
|
||||||
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
|
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
|
||||||
|
|
||||||
|
fd-slicer@1.1.0:
|
||||||
|
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@ -1474,12 +1562,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
get-stream@5.2.0:
|
||||||
|
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
get-tsconfig@4.13.7:
|
get-tsconfig@4.13.7:
|
||||||
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
|
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
|
||||||
|
|
||||||
github-slugger@2.0.0:
|
github-slugger@2.0.0:
|
||||||
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
||||||
|
|
||||||
|
globals@15.15.0:
|
||||||
|
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
gopd@1.2.0:
|
gopd@1.2.0:
|
||||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -1542,9 +1638,16 @@ packages:
|
|||||||
html-void-elements@3.0.0:
|
html-void-elements@3.0.0:
|
||||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||||
|
|
||||||
|
htmlparser2@10.1.0:
|
||||||
|
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
|
||||||
|
|
||||||
http-cache-semantics@4.2.0:
|
http-cache-semantics@4.2.0:
|
||||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||||
|
|
||||||
|
iconv-lite@0.6.3:
|
||||||
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
iron-webcrypto@1.2.1:
|
iron-webcrypto@1.2.1:
|
||||||
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
|
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
|
||||||
|
|
||||||
@ -1578,6 +1681,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
kolorist@1.8.0:
|
||||||
|
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||||
|
|
||||||
laravel-vite-plugin@2.1.0:
|
laravel-vite-plugin@2.1.0:
|
||||||
resolution: {integrity: sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==}
|
resolution: {integrity: sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@ -1659,6 +1765,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
local-pkg@1.1.2:
|
||||||
|
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
longest-streak@3.1.0:
|
longest-streak@3.1.0:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||||
|
|
||||||
@ -1721,6 +1831,9 @@ packages:
|
|||||||
mdn-data@2.0.28:
|
mdn-data@2.0.28:
|
||||||
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
|
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
|
||||||
|
|
||||||
|
mdn-data@2.0.30:
|
||||||
|
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
|
||||||
|
|
||||||
mdn-data@2.27.1:
|
mdn-data@2.27.1:
|
||||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||||
|
|
||||||
@ -1816,6 +1929,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
minipass@7.1.3:
|
||||||
|
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||||
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
|
|
||||||
|
minizlib@3.1.0:
|
||||||
|
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
mlly@1.8.2:
|
||||||
|
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||||
|
|
||||||
mrmime@2.0.1:
|
mrmime@2.0.1:
|
||||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -1857,6 +1981,9 @@ packages:
|
|||||||
ohash@2.0.11:
|
ohash@2.0.11:
|
||||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||||
|
|
||||||
|
once@1.4.0:
|
||||||
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
oniguruma-parser@0.12.1:
|
oniguruma-parser@0.12.1:
|
||||||
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
||||||
|
|
||||||
@ -1881,9 +2008,21 @@ packages:
|
|||||||
parse-latin@7.0.0:
|
parse-latin@7.0.0:
|
||||||
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
|
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
|
||||||
|
|
||||||
|
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||||
|
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
|
||||||
|
|
||||||
|
parse5-parser-stream@7.1.2:
|
||||||
|
resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
|
||||||
|
|
||||||
parse5@7.3.0:
|
parse5@7.3.0:
|
||||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pend@1.2.0:
|
||||||
|
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||||
|
|
||||||
pg-cloudflare@1.3.0:
|
pg-cloudflare@1.3.0:
|
||||||
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||||
|
|
||||||
@ -1932,6 +2071,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pkg-types@1.3.1:
|
||||||
|
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||||
|
|
||||||
|
pkg-types@2.3.0:
|
||||||
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
playwright-core@1.59.1:
|
playwright-core@1.59.1:
|
||||||
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -1973,6 +2118,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
pump@3.0.4:
|
||||||
|
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||||
|
|
||||||
|
quansync@0.2.11:
|
||||||
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
|
|
||||||
radix3@1.1.2:
|
radix3@1.1.2:
|
||||||
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
|
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
|
||||||
|
|
||||||
@ -2044,6 +2195,9 @@ packages:
|
|||||||
rxjs@7.8.2:
|
rxjs@7.8.2:
|
||||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||||
|
|
||||||
|
safer-buffer@2.1.2:
|
||||||
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
sax@1.6.0:
|
sax@1.6.0:
|
||||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
||||||
engines: {node: '>=11.0.0'}
|
engines: {node: '>=11.0.0'}
|
||||||
@ -2109,6 +2263,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
svgo@3.3.3:
|
||||||
|
resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
svgo@4.0.1:
|
svgo@4.0.1:
|
||||||
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@ -2121,6 +2280,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
|
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tar@7.5.13:
|
||||||
|
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tiny-inflate@1.0.3:
|
tiny-inflate@1.0.3:
|
||||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||||
|
|
||||||
@ -2181,6 +2344,10 @@ packages:
|
|||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
|
undici@7.25.0:
|
||||||
|
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
unified@11.0.5:
|
unified@11.0.5:
|
||||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||||
|
|
||||||
@ -2339,6 +2506,15 @@ packages:
|
|||||||
web-namespaces@2.0.1:
|
web-namespaces@2.0.1:
|
||||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||||
|
|
||||||
|
whatwg-encoding@3.1.1:
|
||||||
|
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||||
|
|
||||||
|
whatwg-mimetype@4.0.0:
|
||||||
|
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
which-pm-runs@1.1.0:
|
which-pm-runs@1.1.0:
|
||||||
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
|
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -2347,6 +2523,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
wrappy@1.0.2:
|
||||||
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@ -2358,6 +2537,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
yallist@5.0.0:
|
||||||
|
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -2370,6 +2553,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yauzl@2.10.0:
|
||||||
|
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||||
|
|
||||||
yocto-queue@1.2.2:
|
yocto-queue@1.2.2:
|
||||||
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
|
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
|
||||||
engines: {node: '>=12.20'}
|
engines: {node: '>=12.20'}
|
||||||
@ -2382,6 +2568,13 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@antfu/install-pkg@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
package-manager-detector: 1.6.0
|
||||||
|
tinyexec: 1.1.1
|
||||||
|
|
||||||
|
'@antfu/utils@8.1.1': {}
|
||||||
|
|
||||||
'@astrojs/compiler@3.0.1': {}
|
'@astrojs/compiler@3.0.1': {}
|
||||||
|
|
||||||
'@astrojs/internal-helpers@0.8.0':
|
'@astrojs/internal-helpers@0.8.0':
|
||||||
@ -2698,6 +2891,39 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.27.7':
|
'@esbuild/win32-x64@0.27.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@iconify-json/lucide@1.2.102':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
|
'@iconify/tools@4.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
'@iconify/utils': 2.3.0
|
||||||
|
cheerio: 1.2.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
extract-zip: 2.0.1
|
||||||
|
local-pkg: 1.1.2
|
||||||
|
pathe: 2.0.3
|
||||||
|
svgo: 3.3.3
|
||||||
|
tar: 7.5.13
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@iconify/types@2.0.0': {}
|
||||||
|
|
||||||
|
'@iconify/utils@2.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@antfu/install-pkg': 1.1.0
|
||||||
|
'@antfu/utils': 8.1.1
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
debug: 4.4.3
|
||||||
|
globals: 15.15.0
|
||||||
|
kolorist: 1.8.0
|
||||||
|
local-pkg: 1.1.2
|
||||||
|
mlly: 1.8.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@img/colour@1.1.0':
|
'@img/colour@1.1.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -2795,6 +3021,10 @@ snapshots:
|
|||||||
'@img/sharp-win32-x64@0.34.5':
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
|
dependencies:
|
||||||
|
minipass: 7.1.3
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@ -3037,8 +3267,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
|
'@types/yauzl@2.10.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.12.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
ansi-styles@4.3.0:
|
||||||
@ -3056,6 +3293,14 @@ snapshots:
|
|||||||
|
|
||||||
array-iterate@2.0.1: {}
|
array-iterate@2.0.1: {}
|
||||||
|
|
||||||
|
astro-icon@1.1.5:
|
||||||
|
dependencies:
|
||||||
|
'@iconify/tools': 4.2.0
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
'@iconify/utils': 2.3.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
astro@6.1.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3):
|
astro@6.1.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@astrojs/compiler': 3.0.1
|
'@astrojs/compiler': 3.0.1
|
||||||
@ -3166,6 +3411,8 @@ snapshots:
|
|||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
|
buffer-crc32@0.2.13: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
call-bind-apply-helpers@1.0.2:
|
call-bind-apply-helpers@1.0.2:
|
||||||
@ -3186,10 +3433,35 @@ snapshots:
|
|||||||
|
|
||||||
character-entities@2.0.2: {}
|
character-entities@2.0.2: {}
|
||||||
|
|
||||||
|
cheerio-select@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
boolbase: 1.0.0
|
||||||
|
css-select: 5.2.2
|
||||||
|
css-what: 6.2.2
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
domutils: 3.2.2
|
||||||
|
|
||||||
|
cheerio@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
cheerio-select: 2.1.0
|
||||||
|
dom-serializer: 2.0.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
domutils: 3.2.2
|
||||||
|
encoding-sniffer: 0.2.1
|
||||||
|
htmlparser2: 10.1.0
|
||||||
|
parse5: 7.3.0
|
||||||
|
parse5-htmlparser2-tree-adapter: 7.1.0
|
||||||
|
parse5-parser-stream: 7.1.2
|
||||||
|
undici: 7.25.0
|
||||||
|
whatwg-mimetype: 4.0.0
|
||||||
|
|
||||||
chokidar@5.0.0:
|
chokidar@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 5.0.0
|
readdirp: 5.0.0
|
||||||
|
|
||||||
|
chownr@3.0.0: {}
|
||||||
|
|
||||||
ci-info@4.4.0: {}
|
ci-info@4.4.0: {}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
@ -3214,6 +3486,8 @@ snapshots:
|
|||||||
|
|
||||||
commander@11.1.0: {}
|
commander@11.1.0: {}
|
||||||
|
|
||||||
|
commander@7.2.0: {}
|
||||||
|
|
||||||
common-ancestor-path@2.0.0: {}
|
common-ancestor-path@2.0.0: {}
|
||||||
|
|
||||||
concurrently@9.2.1:
|
concurrently@9.2.1:
|
||||||
@ -3225,6 +3499,10 @@ snapshots:
|
|||||||
tree-kill: 1.2.2
|
tree-kill: 1.2.2
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
|
|
||||||
|
confbox@0.1.8: {}
|
||||||
|
|
||||||
|
confbox@0.2.4: {}
|
||||||
|
|
||||||
cookie-es@1.2.3: {}
|
cookie-es@1.2.3: {}
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
@ -3246,6 +3524,11 @@ snapshots:
|
|||||||
mdn-data: 2.0.28
|
mdn-data: 2.0.28
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
css-tree@2.3.1:
|
||||||
|
dependencies:
|
||||||
|
mdn-data: 2.0.30
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
css-tree@3.2.1:
|
css-tree@3.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mdn-data: 2.27.1
|
mdn-data: 2.27.1
|
||||||
@ -3324,6 +3607,15 @@ snapshots:
|
|||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
|
encoding-sniffer@0.2.1:
|
||||||
|
dependencies:
|
||||||
|
iconv-lite: 0.6.3
|
||||||
|
whatwg-encoding: 3.1.1
|
||||||
|
|
||||||
|
end-of-stream@1.4.5:
|
||||||
|
dependencies:
|
||||||
|
once: 1.4.0
|
||||||
|
|
||||||
enhanced-resolve@5.20.1:
|
enhanced-resolve@5.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@ -3333,6 +3625,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
es-define-property@1.0.1: {}
|
es-define-property@1.0.1: {}
|
||||||
|
|
||||||
es-errors@1.3.0: {}
|
es-errors@1.3.0: {}
|
||||||
@ -3441,8 +3735,20 @@ snapshots:
|
|||||||
|
|
||||||
eventemitter3@5.0.4: {}
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
|
exsolve@1.0.8: {}
|
||||||
|
|
||||||
extend@3.0.2: {}
|
extend@3.0.2: {}
|
||||||
|
|
||||||
|
extract-zip@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
get-stream: 5.2.0
|
||||||
|
yauzl: 2.10.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/yauzl': 2.10.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
fast-string-truncated-width@1.2.1: {}
|
fast-string-truncated-width@1.2.1: {}
|
||||||
|
|
||||||
fast-string-width@1.1.0:
|
fast-string-width@1.1.0:
|
||||||
@ -3453,6 +3759,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fast-string-width: 1.1.0
|
fast-string-width: 1.1.0
|
||||||
|
|
||||||
|
fd-slicer@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
pend: 1.2.0
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@ -3505,12 +3815,18 @@ snapshots:
|
|||||||
dunder-proto: 1.0.1
|
dunder-proto: 1.0.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
get-stream@5.2.0:
|
||||||
|
dependencies:
|
||||||
|
pump: 3.0.4
|
||||||
|
|
||||||
get-tsconfig@4.13.7:
|
get-tsconfig@4.13.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
github-slugger@2.0.0: {}
|
github-slugger@2.0.0: {}
|
||||||
|
|
||||||
|
globals@15.15.0: {}
|
||||||
|
|
||||||
gopd@1.2.0: {}
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
@ -3630,8 +3946,19 @@ snapshots:
|
|||||||
|
|
||||||
html-void-elements@3.0.0: {}
|
html-void-elements@3.0.0: {}
|
||||||
|
|
||||||
|
htmlparser2@10.1.0:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
domutils: 3.2.2
|
||||||
|
entities: 7.0.1
|
||||||
|
|
||||||
http-cache-semantics@4.2.0: {}
|
http-cache-semantics@4.2.0: {}
|
||||||
|
|
||||||
|
iconv-lite@0.6.3:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
iron-webcrypto@1.2.1: {}
|
iron-webcrypto@1.2.1: {}
|
||||||
|
|
||||||
is-docker@3.0.0: {}
|
is-docker@3.0.0: {}
|
||||||
@ -3654,6 +3981,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
|
|
||||||
|
kolorist@1.8.0: {}
|
||||||
|
|
||||||
laravel-vite-plugin@2.1.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)):
|
laravel-vite-plugin@2.1.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
@ -3709,6 +4038,12 @@ snapshots:
|
|||||||
lightningcss-win32-arm64-msvc: 1.32.0
|
lightningcss-win32-arm64-msvc: 1.32.0
|
||||||
lightningcss-win32-x64-msvc: 1.32.0
|
lightningcss-win32-x64-msvc: 1.32.0
|
||||||
|
|
||||||
|
local-pkg@1.1.2:
|
||||||
|
dependencies:
|
||||||
|
mlly: 1.8.2
|
||||||
|
pkg-types: 2.3.0
|
||||||
|
quansync: 0.2.11
|
||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
|
|
||||||
lru-cache@11.3.2: {}
|
lru-cache@11.3.2: {}
|
||||||
@ -3849,6 +4184,8 @@ snapshots:
|
|||||||
|
|
||||||
mdn-data@2.0.28: {}
|
mdn-data@2.0.28: {}
|
||||||
|
|
||||||
|
mdn-data@2.0.30: {}
|
||||||
|
|
||||||
mdn-data@2.27.1: {}
|
mdn-data@2.27.1: {}
|
||||||
|
|
||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
@ -4048,6 +4385,19 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.52.0
|
mime-db: 1.52.0
|
||||||
|
|
||||||
|
minipass@7.1.3: {}
|
||||||
|
|
||||||
|
minizlib@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
minipass: 7.1.3
|
||||||
|
|
||||||
|
mlly@1.8.2:
|
||||||
|
dependencies:
|
||||||
|
acorn: 8.16.0
|
||||||
|
pathe: 2.0.3
|
||||||
|
pkg-types: 1.3.1
|
||||||
|
ufo: 1.6.3
|
||||||
|
|
||||||
mrmime@2.0.1: {}
|
mrmime@2.0.1: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
@ -4080,6 +4430,10 @@ snapshots:
|
|||||||
|
|
||||||
ohash@2.0.11: {}
|
ohash@2.0.11: {}
|
||||||
|
|
||||||
|
once@1.4.0:
|
||||||
|
dependencies:
|
||||||
|
wrappy: 1.0.2
|
||||||
|
|
||||||
oniguruma-parser@0.12.1: {}
|
oniguruma-parser@0.12.1: {}
|
||||||
|
|
||||||
oniguruma-to-es@4.3.5:
|
oniguruma-to-es@4.3.5:
|
||||||
@ -4110,10 +4464,23 @@ snapshots:
|
|||||||
unist-util-visit-children: 3.0.0
|
unist-util-visit-children: 3.0.0
|
||||||
vfile: 6.0.3
|
vfile: 6.0.3
|
||||||
|
|
||||||
|
parse5-htmlparser2-tree-adapter@7.1.0:
|
||||||
|
dependencies:
|
||||||
|
domhandler: 5.0.3
|
||||||
|
parse5: 7.3.0
|
||||||
|
|
||||||
|
parse5-parser-stream@7.1.2:
|
||||||
|
dependencies:
|
||||||
|
parse5: 7.3.0
|
||||||
|
|
||||||
parse5@7.3.0:
|
parse5@7.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 6.0.1
|
entities: 6.0.1
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
pend@1.2.0: {}
|
||||||
|
|
||||||
pg-cloudflare@1.3.0:
|
pg-cloudflare@1.3.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -4157,6 +4524,18 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
|
|
||||||
|
pkg-types@1.3.1:
|
||||||
|
dependencies:
|
||||||
|
confbox: 0.1.8
|
||||||
|
mlly: 1.8.2
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
pkg-types@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
confbox: 0.2.4
|
||||||
|
exsolve: 1.0.8
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
playwright-core@1.59.1: {}
|
playwright-core@1.59.1: {}
|
||||||
|
|
||||||
playwright@1.59.1:
|
playwright@1.59.1:
|
||||||
@ -4187,6 +4566,13 @@ snapshots:
|
|||||||
|
|
||||||
proxy-from-env@2.1.0: {}
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
|
pump@3.0.4:
|
||||||
|
dependencies:
|
||||||
|
end-of-stream: 1.4.5
|
||||||
|
once: 1.4.0
|
||||||
|
|
||||||
|
quansync@0.2.11: {}
|
||||||
|
|
||||||
radix3@1.1.2: {}
|
radix3@1.1.2: {}
|
||||||
|
|
||||||
readdirp@5.0.0: {}
|
readdirp@5.0.0: {}
|
||||||
@ -4331,6 +4717,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
sax@1.6.0: {}
|
sax@1.6.0: {}
|
||||||
|
|
||||||
semver@7.7.4: {}
|
semver@7.7.4: {}
|
||||||
@ -4420,6 +4808,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
svgo@3.3.3:
|
||||||
|
dependencies:
|
||||||
|
commander: 7.2.0
|
||||||
|
css-select: 5.2.2
|
||||||
|
css-tree: 2.3.1
|
||||||
|
css-what: 6.2.2
|
||||||
|
csso: 5.0.5
|
||||||
|
picocolors: 1.1.1
|
||||||
|
sax: 1.6.0
|
||||||
|
|
||||||
svgo@4.0.1:
|
svgo@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 11.1.0
|
commander: 11.1.0
|
||||||
@ -4434,6 +4832,14 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.2: {}
|
tapable@2.3.2: {}
|
||||||
|
|
||||||
|
tar@7.5.13:
|
||||||
|
dependencies:
|
||||||
|
'@isaacs/fs-minipass': 4.0.1
|
||||||
|
chownr: 3.0.0
|
||||||
|
minipass: 7.1.3
|
||||||
|
minizlib: 3.1.0
|
||||||
|
yallist: 5.0.0
|
||||||
|
|
||||||
tiny-inflate@1.0.3: {}
|
tiny-inflate@1.0.3: {}
|
||||||
|
|
||||||
tinyclip@0.1.12: {}
|
tinyclip@0.1.12: {}
|
||||||
@ -4474,6 +4880,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
|
undici@7.25.0: {}
|
||||||
|
|
||||||
unified@11.0.5:
|
unified@11.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@ -4584,6 +4992,12 @@ snapshots:
|
|||||||
|
|
||||||
web-namespaces@2.0.1: {}
|
web-namespaces@2.0.1: {}
|
||||||
|
|
||||||
|
whatwg-encoding@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
iconv-lite: 0.6.3
|
||||||
|
|
||||||
|
whatwg-mimetype@4.0.0: {}
|
||||||
|
|
||||||
which-pm-runs@1.1.0: {}
|
which-pm-runs@1.1.0: {}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
@ -4592,12 +5006,16 @@ snapshots:
|
|||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
xxhash-wasm@1.1.0: {}
|
xxhash-wasm@1.1.0: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
|
yallist@5.0.0: {}
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
yargs-parser@21.1.1: {}
|
||||||
|
|
||||||
yargs-parser@22.0.0: {}
|
yargs-parser@22.0.0: {}
|
||||||
@ -4612,6 +5030,11 @@ snapshots:
|
|||||||
y18n: 5.0.8
|
y18n: 5.0.8
|
||||||
yargs-parser: 21.1.1
|
yargs-parser: 21.1.1
|
||||||
|
|
||||||
|
yauzl@2.10.0:
|
||||||
|
dependencies:
|
||||||
|
buffer-crc32: 0.2.13
|
||||||
|
fd-slicer: 1.1.0
|
||||||
|
|
||||||
yocto-queue@1.2.2: {}
|
yocto-queue@1.2.2: {}
|
||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -193,11 +193,12 @@ ### Measurable Outcomes
|
|||||||
|
|
||||||
## Planned Follow-on Specs
|
## Planned Follow-on Specs
|
||||||
|
|
||||||
- Spec 216 - Homepage Structure and Section Model
|
- Spec 217 - Homepage Structure and Section Model
|
||||||
- Spec 217 - Product Page Structure
|
- Spec 218 - Homepage Hero Contract
|
||||||
- Spec 218 - Trust Surface
|
- Spec 219 - Product Page Structure
|
||||||
- Spec 219 - Contact / Demo Flow
|
- Spec 220 - Trust Surface
|
||||||
- Spec 220 - Changelog Surface
|
- Spec 221 - Contact / Demo Flow
|
||||||
- Spec 221 - Blog / Resources Surface, if activated
|
- Spec 222 - Changelog Surface
|
||||||
- Spec 222 - Solutions / Use-Case Surfaces, if activated later
|
- Spec 223 - Blog / Resources Surface, if activated
|
||||||
- Spec 223 - Pricing Surface, if activated later
|
- Spec 224 - Solutions / Use-Case Surfaces, if activated later
|
||||||
|
- Spec 225 - Pricing Surface, if activated later
|
||||||
@ -1,66 +0,0 @@
|
|||||||
# Quickstart: Website Homepage Structure & Section Model
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Verify that the homepage in `apps/website` follows the Spec 216 section contract and routes visitors clearly into Product, Trust, Changelog, and Contact.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Node.js 20+
|
|
||||||
- Corepack enabled
|
|
||||||
- Repo dependencies installed with `corepack pnpm install`
|
|
||||||
|
|
||||||
## Run the website locally
|
|
||||||
|
|
||||||
From the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm dev:website
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternative, inside the website app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/website
|
|
||||||
corepack pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Default local URL: `http://127.0.0.1:4321/`
|
|
||||||
|
|
||||||
## What to verify on the homepage
|
|
||||||
|
|
||||||
Check the homepage in this order:
|
|
||||||
|
|
||||||
1. Header and global navigation expose Product, Trust, Changelog, and Contact, with no prominent links to unsubstantial optional routes.
|
|
||||||
2. Hero shows one dominant primary CTA, one secondary deepening CTA, and a product-near visual.
|
|
||||||
3. Outcome framing explains why the product matters in buyer language rather than route or feature-admin language.
|
|
||||||
4. Capability section groups the product model instead of listing a flat feature wall.
|
|
||||||
5. Trust block appears before the final CTA and routes to `/trust`.
|
|
||||||
6. Progress block shows visible dated product movement and routes to `/changelog`.
|
|
||||||
7. Final CTA offers one clear next step, currently `/contact`.
|
|
||||||
8. Footer keeps Product, Trust, Changelog, Contact, Privacy, and Imprint reachable.
|
|
||||||
|
|
||||||
## Build proof
|
|
||||||
|
|
||||||
From the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
corepack pnpm build:website
|
|
||||||
```
|
|
||||||
|
|
||||||
## Browser smoke proof
|
|
||||||
|
|
||||||
Run the website smoke suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/website
|
|
||||||
corepack pnpm exec playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Expected proof points
|
|
||||||
|
|
||||||
- Homepage required blocks are visible in the intended order.
|
|
||||||
- The hero CTA hierarchy remains clear and non-competing.
|
|
||||||
- `/product`, `/trust`, `/changelog`, and `/contact` are reachable from the homepage.
|
|
||||||
- Optional unpublished routes are not surfaced prominently.
|
|
||||||
- The homepage remains readable on desktop and mobile widths.
|
|
||||||
@ -2,10 +2,10 @@ openapi: 3.1.0
|
|||||||
info:
|
info:
|
||||||
title: TenantAtlas Homepage Surface Contract
|
title: TenantAtlas Homepage Surface Contract
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
summary: Structural contract for the `apps/website` homepage in Spec 216.
|
summary: Structural contract for the `apps/website` homepage in Spec 217.
|
||||||
description: >-
|
description: >-
|
||||||
This contract defines the public HTML routes that participate in the
|
This contract defines the public HTML routes that participate in the
|
||||||
homepage journey for Spec 216. The homepage remains a static Astro surface
|
homepage journey for Spec 217. The homepage remains a static Astro surface
|
||||||
and must route visitors into Product, Trust, Changelog, and Contact while
|
and must route visitors into Product, Trust, Changelog, and Contact while
|
||||||
satisfying the required homepage section model.
|
satisfying the required homepage section model.
|
||||||
servers:
|
servers:
|
||||||
@ -1,16 +1,23 @@
|
|||||||
# Implementation Plan: Website Homepage Structure & Section Model
|
# Implementation Plan: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
**Branch**: `216-homepage-structure` | **Date**: 2026-04-19 | **Spec**: `specs/216-homepage-structure/spec.md`
|
**Branch**: `217-homepage-structure` | **Date**: 2026-04-19 | **Spec**: `specs/217-homepage-structure/spec.md`
|
||||||
**Input**: Feature specification from `specs/216-homepage-structure/spec.md`
|
**Input**: Feature specification from `specs/217-homepage-structure/spec.md`
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- Rework the `apps/website` homepage into the explicit section flow required by Spec 216: hero, outcome framing, capability model, trust, progress, CTA, while preserving existing header and footer shells.
|
- Rework the `apps/website` homepage into the explicit section flow required by Spec 217: hero, outcome framing, capability model, trust, progress, CTA, while preserving existing header and footer shells.
|
||||||
- Implement the change by extending the current Astro content-driven homepage model (`src/content/pages/home.ts`) and existing section primitives instead of adding a new section registry or CMS-like composition layer.
|
- Implement the change by extending the current Astro content-driven homepage model (`src/content/pages/home.ts`) and existing section primitives instead of adding a new section registry or CMS-like composition layer.
|
||||||
- Reuse existing Trust and Changelog truth for homepage proof signals, and validate the result with the current website build proof plus focused Playwright smoke coverage.
|
- Reuse existing Trust and Changelog truth for homepage proof signals, and validate the result with the current website build proof plus focused Playwright smoke coverage.
|
||||||
|
|
||||||
|
## Addendum Status
|
||||||
|
|
||||||
|
- A post-implementation refinement now extends Spec 217 with hero art-direction guardrails aimed at avoiding generic neutral drift and generic shadcn-style marketing output.
|
||||||
|
- The original structural homepage work remains completed through T019.
|
||||||
|
- Phase 7 is now completed through T023, with an explicit headline primary anchor, stronger hero content contracts, a split desktop composition, and a governance-specific visual surface on the homepage.
|
||||||
|
- The hero-direction guardrails are now enforced by automated smoke coverage for primary anchor presence, supporting-copy subordination, CTA-anchor reinforcement, governance-specific visual semantics, and desktop/mobile hierarchy.
|
||||||
|
|
||||||
## Technical Context
|
## Technical Context
|
||||||
|
|
||||||
**Language/Version**: Astro 6.0.0 templates + TypeScript 5.9.x
|
**Language/Version**: Astro 6.0.0 templates + TypeScript 5.9.x
|
||||||
@ -74,7 +81,7 @@ ## Project Structure
|
|||||||
### Documentation (this feature)
|
### Documentation (this feature)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
specs/216-homepage-structure/
|
specs/217-homepage-structure/
|
||||||
├── plan.md
|
├── plan.md
|
||||||
├── research.md
|
├── research.md
|
||||||
├── data-model.md
|
├── data-model.md
|
||||||
@ -125,7 +132,7 @@ ### Source Code (repository root)
|
|||||||
└── smoke-helpers.ts
|
└── smoke-helpers.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Structure Decision**: Keep the feature completely inside `apps/website`, using the existing Astro page/content/component split. Extend `src/content/pages/home.ts`, reuse current section components where possible, and add only the smallest missing homepage composition pieces required by Spec 216.
|
**Structure Decision**: Keep the feature completely inside `apps/website`, using the existing Astro page/content/component split. Extend `src/content/pages/home.ts`, reuse current section components where possible, and add only the smallest missing homepage composition pieces required by Spec 217.
|
||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
@ -142,11 +149,11 @@ ## Proportionality Review
|
|||||||
|
|
||||||
## Phase 0 — Outline & Research (complete)
|
## Phase 0 — Outline & Research (complete)
|
||||||
|
|
||||||
- Output: `specs/216-homepage-structure/research.md`
|
- Output: `specs/217-homepage-structure/research.md`
|
||||||
- Key decisions captured:
|
- Key decisions captured:
|
||||||
- Keep the homepage local to the static Astro website and preserve runtime separation from `apps/platform`.
|
- Keep the homepage local to the static Astro website and preserve runtime separation from `apps/platform`.
|
||||||
- Extend the current content-driven homepage model instead of adding a new section framework.
|
- Extend the current content-driven homepage model instead of adding a new section framework.
|
||||||
- Reorder the homepage into the explicit Spec 216 narrative flow while preserving optional supporting context only where it helps clarity.
|
- Reorder the homepage into the explicit Spec 217 narrative flow while preserving optional supporting context only where it helps clarity.
|
||||||
- Reuse existing Trust page truth and changelog collection data for homepage proof signals.
|
- Reuse existing Trust page truth and changelog collection data for homepage proof signals.
|
||||||
- Validate via the current website build proof plus focused Playwright smoke coverage.
|
- Validate via the current website build proof plus focused Playwright smoke coverage.
|
||||||
|
|
||||||
@ -154,17 +161,17 @@ ## Phase 1 — Design & Contracts (complete)
|
|||||||
|
|
||||||
### Data model
|
### Data model
|
||||||
|
|
||||||
- Output: `specs/216-homepage-structure/data-model.md`
|
- Output: `specs/217-homepage-structure/data-model.md`
|
||||||
- Model remains file- and route-based. No database schema changes are required.
|
- Model remains file- and route-based. No database schema changes are required.
|
||||||
|
|
||||||
### Public homepage contract
|
### Public homepage contract
|
||||||
|
|
||||||
- Output: `specs/216-homepage-structure/contracts/homepage-surface.openapi.yaml`
|
- Output: `specs/217-homepage-structure/contracts/homepage-surface.openapi.yaml`
|
||||||
- Contract captures the homepage route plus the required onward routes (`/product`, `/trust`, `/changelog`, `/contact`) and the structural rules the homepage must satisfy.
|
- Contract captures the homepage route plus the required onward routes (`/product`, `/trust`, `/changelog`, `/contact`) and the structural rules the homepage must satisfy.
|
||||||
|
|
||||||
### Quickstart
|
### Quickstart
|
||||||
|
|
||||||
- Output: `specs/216-homepage-structure/quickstart.md`
|
- Output: `specs/217-homepage-structure/quickstart.md`
|
||||||
- Quickstart covers local development, homepage verification points, build proof, and smoke-test execution.
|
- Quickstart covers local development, homepage verification points, build proof, and smoke-test execution.
|
||||||
|
|
||||||
### Agent context update
|
### Agent context update
|
||||||
@ -216,10 +223,20 @@ ## Close-Out Notes
|
|||||||
|
|
||||||
**Implementation completed**: All 19 tasks (T001–T019) across 6 phases.
|
**Implementation completed**: All 19 tasks (T001–T019) across 6 phases.
|
||||||
|
|
||||||
|
**Post-close-out refinement**: Spec 217 now also includes a completed Phase 7 hero-direction addendum that sharpens the homepage hero without widening the rest of the homepage contract.
|
||||||
|
|
||||||
### Build & Test Proof
|
### Build & Test Proof
|
||||||
|
|
||||||
- `corepack pnpm build:website`: ✅ 12 pages built, 0 errors
|
- `corepack pnpm build:website`: ✅ 12 pages built, 0 errors
|
||||||
- `cd apps/website && corepack pnpm exec playwright test`: ✅ 20/20 tests pass
|
- `cd apps/website && corepack pnpm exec playwright test`: ✅ 20/20 tests pass
|
||||||
|
- Phase 7 validation on 2026-04-20: `corepack pnpm build:website` ✅ and `cd apps/website && corepack pnpm exec playwright test` ✅ 26/26 tests pass
|
||||||
|
|
||||||
|
### Phase 7 Hero Refinement
|
||||||
|
|
||||||
|
- The homepage hero now uses an explicit `headline` primary anchor with stronger typographic tension and subordinate supporting copy.
|
||||||
|
- The desktop hero now reads as one split composition, with the copy block and governance surface sharing the same first-read layer instead of stacking like separate sections.
|
||||||
|
- The hero visual now emphasizes baseline drift, restore preview, evidence linking, and review queue semantics instead of generic KPI-oriented dashboard language.
|
||||||
|
- Phase 7 browser coverage now proves the addendum requirements directly in `apps/website/tests/smoke/home-product.spec.ts` and `apps/website/tests/smoke/smoke-helpers.ts`.
|
||||||
|
|
||||||
### Summary of Changes
|
### Summary of Changes
|
||||||
|
|
||||||
@ -231,17 +248,17 @@ ### Summary of Changes
|
|||||||
- `apps/website/public/images/hero-product-visual.svg` — product-near hero visual
|
- `apps/website/public/images/hero-product-visual.svg` — product-near hero visual
|
||||||
|
|
||||||
**Modified files**:
|
**Modified files**:
|
||||||
- `apps/website/src/types/site.ts` — 7 new interfaces for Spec 216 section model
|
- `apps/website/src/types/site.ts` — 7 new interfaces for Spec 217 section model
|
||||||
- `apps/website/src/content/pages/home.ts` — full rewrite with Spec 216 content
|
- `apps/website/src/content/pages/home.ts` — full rewrite with Spec 217 content
|
||||||
- `apps/website/src/pages/index.astro` — full rewrite with new section flow
|
- `apps/website/src/pages/index.astro` — full rewrite with new section flow
|
||||||
- `apps/website/src/components/sections/PageHero.astro` — added productVisual + trustSubclaims support
|
- `apps/website/src/components/sections/PageHero.astro` — added productVisual + trustSubclaims support
|
||||||
- `apps/website/src/components/primitives/Section.astro` — forward rest attributes (data-section)
|
- `apps/website/src/components/primitives/Section.astro` — forward rest attributes (data-section)
|
||||||
- `apps/website/src/components/primitives/Card.astro` — forward rest attributes (data-hero-visual)
|
- `apps/website/src/components/primitives/Card.astro` — forward rest attributes (data-hero-visual)
|
||||||
- `apps/website/tests/smoke/home-product.spec.ts` — rewritten for Spec 216 homepage structure
|
- `apps/website/tests/smoke/home-product.spec.ts` — rewritten for Spec 217 homepage structure
|
||||||
- `apps/website/tests/smoke/smoke-helpers.ts` — 4 new assertion helpers
|
- `apps/website/tests/smoke/smoke-helpers.ts` — 4 new assertion helpers
|
||||||
- `apps/website/tests/smoke/visual-foundation-guardrails.spec.ts` — updated CTA labels
|
- `apps/website/tests/smoke/visual-foundation-guardrails.spec.ts` — updated CTA labels
|
||||||
|
|
||||||
### Homepage Section Flow (Spec 216)
|
### Homepage Section Flow (Spec 217)
|
||||||
|
|
||||||
1. **PageHero** — eyebrow, headline, description, primary/secondary CTA, product visual, trust subclaims
|
1. **PageHero** — eyebrow, headline, description, primary/secondary CTA, product visual, trust subclaims
|
||||||
2. **LogoStrip** — ecosystem fit (Microsoft Graph, Entra ID, Intune, Review workflows)
|
2. **LogoStrip** — ecosystem fit (Microsoft Graph, Entra ID, Intune, Review workflows)
|
||||||
80
specs/217-homepage-structure/quickstart.md
Normal file
80
specs/217-homepage-structure/quickstart.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# Quickstart: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Verify that the homepage in `apps/website` follows the Spec 217 section contract and routes visitors clearly into Product, Trust, Changelog, and Contact.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- Corepack enabled
|
||||||
|
- Repo dependencies installed with `corepack pnpm install`
|
||||||
|
|
||||||
|
## Run the website locally
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm dev:website
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative, inside the website app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/website
|
||||||
|
corepack pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Default local URL: `http://127.0.0.1:4321/`
|
||||||
|
|
||||||
|
## What to verify on the homepage
|
||||||
|
|
||||||
|
Check the homepage in this order:
|
||||||
|
|
||||||
|
1. Header and global navigation expose Product, Trust, Changelog, and Contact, with no prominent links to unsubstantial optional routes.
|
||||||
|
2. Hero shows one dominant primary anchor, one dominant primary CTA, one secondary deepening CTA, and a product-near visual.
|
||||||
|
3. Hero typography, spacing, and surface contrast feel deliberate rather than generic or washed out.
|
||||||
|
4. Hero visual reads as governance-, review-, restore-, drift-, or evidence-oriented product truth rather than generic dashboard wallpaper.
|
||||||
|
5. Supporting copy and secondary CTA clearly serve the primary anchor instead of competing with it.
|
||||||
|
6. Accent use supports hierarchy and product truth rather than behaving like decorative garnish.
|
||||||
|
7. Outcome framing explains why the product matters in buyer language rather than route or feature-admin language.
|
||||||
|
8. Capability section groups the product model instead of listing a flat feature wall.
|
||||||
|
9. Trust block appears before the final CTA and routes to `/trust`.
|
||||||
|
10. Progress block shows visible dated product movement and routes to `/changelog`.
|
||||||
|
11. Final CTA offers one clear next step, currently `/contact`.
|
||||||
|
12. Footer keeps Product, Trust, Changelog, Contact, Privacy, and Imprint reachable.
|
||||||
|
|
||||||
|
## Addendum review note
|
||||||
|
|
||||||
|
The hero-direction checks above remain the manual review rubric for overall quality, but they are no longer manual-only. Phase 7 now adds automated smoke proof for the explicit primary anchor, supporting-copy subordination, CTA-anchor reinforcement, governance-specific visual semantics, and desktop/mobile hierarchy.
|
||||||
|
|
||||||
|
## Build proof
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm build:website
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser smoke proof
|
||||||
|
|
||||||
|
Run the website smoke suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/website
|
||||||
|
corepack pnpm exec playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected proof points
|
||||||
|
|
||||||
|
- Homepage required blocks are visible in the intended order.
|
||||||
|
- The hero CTA hierarchy remains clear and non-competing.
|
||||||
|
- The hero exposes one explicit primary anchor and keeps supporting copy visually subordinate to it.
|
||||||
|
- The hero has one obvious focal point and does not flatten into neutral mush.
|
||||||
|
- The hero visual conveys TenantAtlas-specific governance truth rather than generic admin or analytics UI.
|
||||||
|
- The hero desktop layout keeps copy and product surface in one split composition instead of stacking them into unrelated blocks.
|
||||||
|
- Supporting copy and the secondary CTA reinforce the focal point instead of competing with it.
|
||||||
|
- Hero hierarchy remains legible on both desktop and mobile widths.
|
||||||
|
- `/product`, `/trust`, `/changelog`, and `/contact` are reachable from the homepage.
|
||||||
|
- Optional unpublished routes are not surfaced prominently.
|
||||||
|
- The homepage remains readable on desktop and mobile widths.
|
||||||
@ -3,7 +3,7 @@ # Research: Website Homepage Structure & Section Model
|
|||||||
## Decision 1: Keep the homepage implementation local to the static Astro website
|
## Decision 1: Keep the homepage implementation local to the static Astro website
|
||||||
|
|
||||||
- **Decision**: Continue treating the homepage as a static `apps/website` route composed from Astro content modules and section components, with no runtime dependency on `apps/platform`.
|
- **Decision**: Continue treating the homepage as a static `apps/website` route composed from Astro content modules and section components, with no runtime dependency on `apps/platform`.
|
||||||
- **Rationale**: Spec 216 is explicitly website-only. The current website already runs as a standalone Astro app, and the required homepage improvements concern structure, sequencing, and public route discoverability rather than dynamic runtime behavior.
|
- **Rationale**: Spec 217 is explicitly website-only. The current website already runs as a standalone Astro app, and the required homepage improvements concern structure, sequencing, and public route discoverability rather than dynamic runtime behavior.
|
||||||
- **Alternatives considered**:
|
- **Alternatives considered**:
|
||||||
- Couple homepage composition to `apps/platform` or a shared API: rejected because the spec forbids platform obligations and the homepage needs no dynamic platform data.
|
- Couple homepage composition to `apps/platform` or a shared API: rejected because the spec forbids platform obligations and the homepage needs no dynamic platform data.
|
||||||
- Introduce a CMS or page-builder layer first: rejected because a single homepage route does not justify that operational overhead.
|
- Introduce a CMS or page-builder layer first: rejected because a single homepage route does not justify that operational overhead.
|
||||||
@ -21,7 +21,7 @@ ## Decision 3: Recompose the homepage into an explicit narrative flow
|
|||||||
- **Decision**: Implement the homepage in the following functional order: header, hero, outcome framing, capability model, trust, progress, CTA, footer. Optional supporting context stays secondary and may only appear if it reinforces clarity.
|
- **Decision**: Implement the homepage in the following functional order: header, hero, outcome framing, capability model, trust, progress, CTA, footer. Optional supporting context stays secondary and may only appear if it reinforces clarity.
|
||||||
- **Rationale**: Exploration of the current homepage showed that the site already has hero, optional ecosystem context, and CTA pieces, but the middle narrative is misaligned: the current feature grid explains route jobs instead of product outcomes or capabilities, trust is too implicit, and progress is only a CTA target.
|
- **Rationale**: Exploration of the current homepage showed that the site already has hero, optional ecosystem context, and CTA pieces, but the middle narrative is misaligned: the current feature grid explains route jobs instead of product outcomes or capabilities, trust is too implicit, and progress is only a CTA target.
|
||||||
- **Alternatives considered**:
|
- **Alternatives considered**:
|
||||||
- Keep the current hero → ecosystem → route-jobs → proof → CTA sequence: rejected because it does not satisfy Spec 216’s required block responsibilities.
|
- Keep the current hero → ecosystem → route-jobs → proof → CTA sequence: rejected because it does not satisfy Spec 217’s required block responsibilities.
|
||||||
- Collapse trust or progress into the CTA block: rejected because the spec requires both to appear explicitly before the final CTA.
|
- Collapse trust or progress into the CTA block: rejected because the spec requires both to appear explicitly before the final CTA.
|
||||||
|
|
||||||
## Decision 4: Reuse existing Trust and Changelog truth for homepage proof blocks
|
## Decision 4: Reuse existing Trust and Changelog truth for homepage proof blocks
|
||||||
@ -34,8 +34,16 @@ ## Decision 4: Reuse existing Trust and Changelog truth for homepage proof block
|
|||||||
|
|
||||||
## Decision 5: Validate through the existing website smoke harness
|
## Decision 5: Validate through the existing website smoke harness
|
||||||
|
|
||||||
- **Decision**: Prove Spec 216 with the existing website build command and focused Playwright smoke updates for homepage section order, CTA hierarchy, and onward route reachability.
|
- **Decision**: Prove Spec 217 with the existing website build command and focused Playwright smoke updates for homepage section order, CTA hierarchy, and onward route reachability.
|
||||||
- **Rationale**: The homepage contract is about public rendering, navigational clarity, and responsive visibility. Browser smoke coverage is the narrowest proving layer that can validate those concerns.
|
- **Rationale**: The homepage contract is about public rendering, navigational clarity, and responsive visibility. Browser smoke coverage is the narrowest proving layer that can validate those concerns.
|
||||||
- **Alternatives considered**:
|
- **Alternatives considered**:
|
||||||
- Build-only proof alone: rejected because static output generation does not prove section order, CTA hierarchy, or visible route reachability.
|
- Build-only proof alone: rejected because static output generation does not prove section order, CTA hierarchy, or visible route reachability.
|
||||||
- Add visual regression or heavier browser matrices immediately: rejected because the feature scope does not require that extra cost.
|
- Add visual regression or heavier browser matrices immediately: rejected because the feature scope does not require that extra cost.
|
||||||
|
|
||||||
|
## Decision 6: The hero must favor distinctive restraint over generic neutral minimalism
|
||||||
|
|
||||||
|
- **Decision**: Treat the homepage hero as a brand and product identity surface with one clear anchor, governance-specific product truth, and enough contrast to avoid neutral drift.
|
||||||
|
- **Rationale**: Structural correctness alone still allows a failure mode where the hero feels like a clean but anonymous shadcn or Tailwind marketing shell. TenantAtlas needs a hero that remains calm while still being memorable, product-near, and clearly governance-oriented.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Keep iterating only on structure and copy while leaving art direction implicit: rejected because that path still permits correct-but-forgettable output.
|
||||||
|
- Solve distinctiveness mainly through stronger color or decoration: rejected because the desired signal is precise, enterprise, and trust-first rather than flashy.
|
||||||
@ -1,9 +1,9 @@
|
|||||||
# Feature Specification: Website Homepage Structure & Section Model
|
# Feature Specification: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
**Feature Branch**: `216-homepage-structure`
|
**Feature Branch**: `217-homepage-structure`
|
||||||
**Created**: 2026-04-19
|
**Created**: 2026-04-19
|
||||||
**Status**: Draft
|
**Status**: Draft
|
||||||
**Input**: User description: "Define Spec 216 as the website-only homepage structure and section model for `apps/website`, covering required sections, ordering, CTA logic, trust signal placement, product visuals, and onward routing."
|
**Input**: User description: "Define Spec 217 as the website-only homepage structure and section model for `apps/website`, covering required sections, ordering, CTA logic, trust signal placement, product visuals, and onward routing."
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
@ -113,6 +113,33 @@ ### Edge Cases
|
|||||||
- How does the homepage behave on narrow screens? The same meaning order must survive mobile compression, and trust, progress, product-near context, and the primary CTA must remain visible without horizontal scrolling.
|
- How does the homepage behave on narrow screens? The same meaning order must survive mobile compression, and trust, progress, product-near context, and the primary CTA must remain visible without horizontal scrolling.
|
||||||
- What happens when the changelog surface has only a small amount of published history? The progress signal may stay concise, but it must still indicate real dated movement and link into the actual changelog route.
|
- What happens when the changelog surface has only a small amount of published history? The progress signal may stay concise, but it must still indicate real dated movement and link into the actual changelog route.
|
||||||
|
|
||||||
|
## Hero Direction Addendum *(Homepage Hero Refinement / Anti-Generic Direction)*
|
||||||
|
|
||||||
|
This addendum sharpens Spec 217 without widening scope beyond `apps/website`. The base homepage contract already fixes structure, routing, trust sequencing, and CTA discipline. This refinement adds the missing art-direction guardrail: the hero must not be merely correct and calm; it must also be distinct, product-specific, and immediately recognizable as TenantAtlas.
|
||||||
|
|
||||||
|
### Additional Problem Definition
|
||||||
|
|
||||||
|
- A hero can satisfy the structural contract and still fail if it has no clear visual stance, feels like a neat shadcn or Tailwind midpoint, spreads attention evenly, or uses so many similar neutral surfaces that nothing leads.
|
||||||
|
- TenantAtlas needs a hero that reads as a serious governance surface, not as a generic friendly SaaS shell with better spacing.
|
||||||
|
- The failure mode to avoid is not visual loudness but visual anonymity: a clean hero that nobody remembers after ten seconds is still a failed hero.
|
||||||
|
|
||||||
|
### Direction Principles
|
||||||
|
|
||||||
|
- **Distinctive restraint**: The hero SHOULD remain calm, but calmness MUST still feel intentional and ownable rather than default-neutral.
|
||||||
|
- **One dominant idea**: The hero MUST have one clearly dominant focal point, whether that is the headline, the product visual, or a deliberately weighted composition of both.
|
||||||
|
- **Product-first art direction**: The hero SHOULD feel like an entry into the product, not a generic marketing scaffold decorated with UI.
|
||||||
|
- **Controlled brand signal**: Brand signal MUST come primarily from typography, contrast, composition, product truth, and accent discipline rather than from decorative effects.
|
||||||
|
- **No neutral drift**: Neutral-first styling MAY remain the baseline, but the hero MUST NOT flatten into washed-out sameness, accidental softness, or brandless enterprise blandness.
|
||||||
|
|
||||||
|
### Hero Anti-Patterns
|
||||||
|
|
||||||
|
- **Correct but forgettable**: Everything is structurally right, but nothing leaves a mark.
|
||||||
|
- **Neutral mush**: Too many similar light surfaces and too little hierarchy or focus.
|
||||||
|
- **Dashboard wallpaper**: The product visual exists, but it behaves as decorative scenery rather than as a meaning-carrying surface.
|
||||||
|
- **Generic shadcn marketing**: Good spacing and clean cards, but no product-specific identity or visual stance.
|
||||||
|
- **Over-disciplined minimalism**: The hero becomes so restrained that it stops leading.
|
||||||
|
- **Brandless enterprise**: The page looks professional, but not like TenantAtlas.
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
This feature changes only the public homepage in `apps/website`. It introduces no Microsoft Graph calls, no platform authorization changes, no Filament surfaces, no queued work, and no runtime coupling to `apps/platform`.
|
This feature changes only the public homepage in `apps/website`. It introduces no Microsoft Graph calls, no platform authorization changes, no Filament surfaces, no queued work, and no runtime coupling to `apps/platform`.
|
||||||
@ -140,6 +167,17 @@ ### Functional Requirements
|
|||||||
- **FR-019**: The homepage MUST stay understandable and structurally equivalent on mobile. Hero, outcome, capability, trust, progress, and CTA blocks MUST remain recognizable on narrow screens, and mobile compression MUST NOT effectively hide trust or product-near context.
|
- **FR-019**: The homepage MUST stay understandable and structurally equivalent on mobile. Hero, outcome, capability, trust, progress, and CTA blocks MUST remain recognizable on narrow screens, and mobile compression MUST NOT effectively hide trust or product-near context.
|
||||||
- **FR-020**: The homepage MUST avoid the following disallowed patterns: template-first SaaS framing, abstract-only storytelling, unstructured feature walls, hidden trust, demo-only pressure, fake social proof, and enterprise-theater claims.
|
- **FR-020**: The homepage MUST avoid the following disallowed patterns: template-first SaaS framing, abstract-only storytelling, unstructured feature walls, hidden trust, demo-only pressure, fake social proof, and enterprise-theater claims.
|
||||||
- **FR-021**: The homepage MUST remain strictly local to `apps/website` and MUST NOT create implementation or contract requirements for `apps/platform`.
|
- **FR-021**: The homepage MUST remain strictly local to `apps/website` and MUST NOT create implementation or contract requirements for `apps/platform`.
|
||||||
|
- **FR-022**: The homepage hero MUST be distinct and memorable enough to signal TenantAtlas as a serious governance surface; structural correctness and visual cleanliness alone are not sufficient.
|
||||||
|
- **FR-023**: The hero MUST establish one clear primary anchor through the headline, the product visual, or a deliberately weighted composition of both. Text, CTA, badge, and product visual MUST NOT all compete as equally weak elements.
|
||||||
|
- **FR-024**: The hero MUST create internal contrast through scale, whitespace, typography, surface hierarchy, accent placement, or a combination of these. Near-identical surface values across the text block, supporting elements, and visual block are not sufficient.
|
||||||
|
- **FR-025**: Hero typography SHOULD create more tension than standard UI copy through a clear size hierarchy, controlled line breaks, display treatment, or similarly intentional framing. The headline MUST remain scannable and formally deliberate rather than blocky or purely utilitarian.
|
||||||
|
- **FR-026**: Supporting copy in the hero MUST serve the headline focus and MUST NOT claim equal visual priority.
|
||||||
|
- **FR-027**: The hero product visual MUST depict governance-, audit-, drift-, review-, restore-, evidence-, or bounded-access-oriented product truth and MUST NOT read as a generic analytics dashboard, vague KPI slab, or decorative admin table.
|
||||||
|
- **FR-028**: The hero product visual SHOULD feel compositionally integrated with the hero surface and MUST NOT read as a generic screenshot box inserted beside unrelated marketing copy.
|
||||||
|
- **FR-029**: Neutral-first color usage MAY remain the baseline, but the hero MUST still provide a clear hierarchy and MUST NOT collapse into washed-out sameness or accidental softness.
|
||||||
|
- **FR-030**: Brand signal in the hero MUST come primarily from typography, contrast, composition, product specificity, and disciplined accent usage rather than from decorative effects or scattered color.
|
||||||
|
- **FR-031**: Hero CTA composition MUST reinforce the primary anchor. The primary CTA MUST remain clearly dominant, and the secondary CTA SHOULD remain legible without reading as a generic outline fallback detached from the rest of the hero.
|
||||||
|
- **FR-032**: The hero MUST avoid the following additional anti-patterns: correct but forgettable, neutral mush, dashboard wallpaper, generic shadcn marketing, over-disciplined minimalism, and brandless enterprise.
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
@ -148,6 +186,9 @@ ### Key Entities *(include if feature involves data)*
|
|||||||
- **Trust Claim**: A bounded public assertion about hosting, residency, seriousness, isolation, governance posture, or similar credibility signals that must be supportable by the Trust surface.
|
- **Trust Claim**: A bounded public assertion about hosting, residency, seriousness, isolation, governance posture, or similar credibility signals that must be supportable by the Trust surface.
|
||||||
- **Progress Signal**: A homepage block or teaser that shows visible dated product movement and routes to the changelog.
|
- **Progress Signal**: A homepage block or teaser that shows visible dated product movement and routes to the changelog.
|
||||||
- **CTA Target**: A next-question route reached from the homepage, especially Product, Trust, Changelog, or Contact.
|
- **CTA Target**: A next-question route reached from the homepage, especially Product, Trust, Changelog, or Contact.
|
||||||
|
- **Hero Primary Anchor**: The single dominant focal point in the hero, formed by the headline, the product visual, or an intentionally weighted composition of both.
|
||||||
|
- **Governance-Specific Product Visual**: A hero visual that communicates change history, baselines, drift, findings, review workflows, restore planning, evidence, or scoped actions rather than generic KPI output.
|
||||||
|
- **Hero Anti-Pattern**: A hero failure mode such as neutral mush or dashboard wallpaper that can satisfy structural correctness while still failing memorability or product specificity.
|
||||||
|
|
||||||
## Assumptions & Dependencies
|
## Assumptions & Dependencies
|
||||||
|
|
||||||
@ -167,3 +208,8 @@ ### Measurable Outcomes
|
|||||||
- **SC-004**: Trust and progress signals appear before the final CTA and remain discoverable without leaving the homepage, while deeper substantiation stays reachable in one click to `/trust` and `/changelog`.
|
- **SC-004**: Trust and progress signals appear before the final CTA and remain discoverable without leaving the homepage, while deeper substantiation stays reachable in one click to `/trust` and `/changelog`.
|
||||||
- **SC-005**: No released homepage version contains unsupported trust claims, fake logos or badges, placeholder routes, or more than one equally dominant primary conversion action.
|
- **SC-005**: No released homepage version contains unsupported trust claims, fake logos or badges, placeholder routes, or more than one equally dominant primary conversion action.
|
||||||
- **SC-006**: On mobile widths, visitors can still identify the hero, outcome framing, capability model, trust block, progress block, and CTA transition without horizontal scrolling or hidden primary navigation.
|
- **SC-006**: On mobile widths, visitors can still identify the hero, outcome framing, capability model, trust block, progress block, and CTA transition without horizontal scrolling or hidden primary navigation.
|
||||||
|
- **SC-007**: In a homepage review, a reviewer can identify the hero's primary anchor within 10 seconds and can distinguish headline, product visual, and CTA hierarchy without ambiguity.
|
||||||
|
- **SC-008**: The hero visual communicates at least one TenantAtlas-specific governance concept such as drift, review, restore, evidence, or bounded access rather than generic dashboard activity.
|
||||||
|
- **SC-009**: Reviewers can identify at least one typographic or compositional cue and one product-truth cue that distinguish the hero from a generic shadcn or Tailwind marketing layout.
|
||||||
|
- **SC-010**: On desktop and mobile, the hero retains clear contrast between the dominant element, supporting copy, product visual, and CTA instead of flattening into visually equal neutral surfaces.
|
||||||
|
- **SC-011**: The released homepage hero avoids the anti-patterns of neutral mush and correct-but-forgettable minimalism while remaining calm and trust-oriented.
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Tasks: Website Homepage Structure & Section Model
|
# Tasks: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/216-homepage-structure/`
|
**Input**: Design documents from `/specs/217-homepage-structure/`
|
||||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/homepage-surface.openapi.yaml`
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/homepage-surface.openapi.yaml`
|
||||||
|
|
||||||
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
||||||
@ -49,7 +49,7 @@ ### Tests for User Story 1
|
|||||||
|
|
||||||
### Implementation for User Story 1
|
### Implementation for User Story 1
|
||||||
|
|
||||||
- [X] T006 [P] [US1] Add Spec 216 hero content, product-near visual data, optional bounded trust subclaims, and outcome blocks in `apps/website/src/content/pages/home.ts`
|
- [X] T006 [P] [US1] Add Spec 217 hero content, product-near visual data, optional bounded trust subclaims, and outcome blocks in `apps/website/src/content/pages/home.ts`
|
||||||
- [X] T007 [US1] Implement the hero-to-outcome homepage flow and hero visual rendering in `apps/website/src/pages/index.astro` and `apps/website/src/components/sections/PageHero.astro`
|
- [X] T007 [US1] Implement the hero-to-outcome homepage flow and hero visual rendering in `apps/website/src/pages/index.astro` and `apps/website/src/components/sections/PageHero.astro`
|
||||||
|
|
||||||
**Checkpoint**: The homepage delivers the MVP story of product clarity, buyer relevance, and one clear next step.
|
**Checkpoint**: The homepage delivers the MVP story of product clarity, buyer relevance, and one clear next step.
|
||||||
@ -105,9 +105,20 @@ ## Phase 6: Polish & Cross-Cutting Concerns
|
|||||||
**Purpose**: Validate proof commands, tighten claim wording, and capture close-out notes.
|
**Purpose**: Validate proof commands, tighten claim wording, and capture close-out notes.
|
||||||
|
|
||||||
- [X] T016 [P] Review homepage proof and trust wording against bounded-claim rules in `apps/website/src/content/pages/home.ts`, `apps/website/src/content/pages/trust.ts`, and `apps/website/src/content/pages/changelog.ts`
|
- [X] T016 [P] Review homepage proof and trust wording against bounded-claim rules in `apps/website/src/content/pages/home.ts`, `apps/website/src/content/pages/trust.ts`, and `apps/website/src/content/pages/changelog.ts`
|
||||||
- [X] T017 [P] Run `corepack pnpm build:website` from `package.json` and confirm homepage proof expectations in `specs/216-homepage-structure/quickstart.md`
|
- [X] T017 [P] Run `corepack pnpm build:website` from `package.json` and confirm homepage proof expectations in `specs/217-homepage-structure/quickstart.md`
|
||||||
- [X] T018 [P] Run `cd apps/website && corepack pnpm exec playwright test` against `apps/website/tests/smoke/home-product.spec.ts`, `apps/website/tests/smoke/changelog-core-ia.spec.ts`, and `apps/website/tests/smoke/contact-legal.spec.ts`
|
- [X] T018 [P] Run `cd apps/website && corepack pnpm exec playwright test` against `apps/website/tests/smoke/home-product.spec.ts`, `apps/website/tests/smoke/changelog-core-ia.spec.ts`, and `apps/website/tests/smoke/contact-legal.spec.ts`
|
||||||
- [X] T019 Record the homepage smoke-coverage close-out and verification notes in `specs/216-homepage-structure/plan.md` and `specs/216-homepage-structure/quickstart.md`
|
- [X] T019 Record the homepage smoke-coverage close-out and verification notes in `specs/217-homepage-structure/plan.md` and `specs/217-homepage-structure/quickstart.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Hero Direction Addendum Follow-Up
|
||||||
|
|
||||||
|
**Purpose**: Sharpen the homepage hero so Spec 217 remains product-specific, memorable, and resistant to generic neutral drift.
|
||||||
|
|
||||||
|
- [X] T020 [P] Add homepage proof coverage for a clear hero primary anchor, supporting-copy subordination, CTA-anchor reinforcement, governance-specific product visual semantics, and desktop/mobile anti-generic hierarchy in `apps/website/tests/smoke/home-product.spec.ts` and `apps/website/tests/smoke/smoke-helpers.ts`
|
||||||
|
- [X] T021 [P] Extend homepage content and hero type contracts for stronger typographic tension, a dominant anchor, and governance-specific visual truth in `apps/website/src/content/pages/home.ts` and `apps/website/src/types/site.ts`
|
||||||
|
- [X] T022 Rework hero composition, accent discipline, and product-visual integration to avoid dashboard-wallpaper, neutral-mush, and detached-outline-CTA outcomes in `apps/website/src/components/sections/PageHero.astro`, `apps/website/src/components/content/HeroDashboard.astro`, and `apps/website/src/pages/index.astro`
|
||||||
|
- [X] T023 [P] Re-run `corepack pnpm build:website` plus `cd apps/website && corepack pnpm exec playwright test`, then refresh Phase 7 proof notes in `specs/217-homepage-structure/quickstart.md` and `specs/217-homepage-structure/plan.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -119,6 +130,7 @@ ### Phase Dependencies
|
|||||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories.
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories.
|
||||||
- **User Stories (Phases 3-5)**: Depend on Foundational. They remain independently testable, but shared homepage assembly in `apps/website/src/pages/index.astro` should land sequentially in story order.
|
- **User Stories (Phases 3-5)**: Depend on Foundational. They remain independently testable, but shared homepage assembly in `apps/website/src/pages/index.astro` should land sequentially in story order.
|
||||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
- **Hero Direction Addendum (Phase 7)**: Depends on the structural homepage work in Phases 1-6 and sharpens the hero without expanding the rest of the homepage contract.
|
||||||
|
|
||||||
### User Story Dependencies
|
### User Story Dependencies
|
||||||
|
|
||||||
@ -150,7 +162,7 @@ ## Parallel Example: User Story 1
|
|||||||
```bash
|
```bash
|
||||||
# After the foundations are complete, split the first slice into test + content work:
|
# After the foundations are complete, split the first slice into test + content work:
|
||||||
Task: "T005 [US1] Write failing homepage smoke assertions for hero clarity, outcome framing, and one dominant CTA hierarchy"
|
Task: "T005 [US1] Write failing homepage smoke assertions for hero clarity, outcome framing, and one dominant CTA hierarchy"
|
||||||
Task: "T006 [US1] Add Spec 216 hero and outcome content blocks"
|
Task: "T006 [US1] Add Spec 217 hero and outcome content blocks"
|
||||||
|
|
||||||
# Then assemble the homepage route:
|
# Then assemble the homepage route:
|
||||||
Task: "T007 [US1] Implement the hero-to-outcome homepage flow"
|
Task: "T007 [US1] Implement the hero-to-outcome homepage flow"
|
||||||
@ -198,6 +210,7 @@ ### Incremental Delivery
|
|||||||
3. US2 upgrades the homepage middle narrative into grouped product model, trust, and progress proof.
|
3. US2 upgrades the homepage middle narrative into grouped product model, trust, and progress proof.
|
||||||
4. US3 sharpens onward routing and discoverability for Product, Trust, Changelog, Contact, and legal follow-through.
|
4. US3 sharpens onward routing and discoverability for Product, Trust, Changelog, Contact, and legal follow-through.
|
||||||
5. Polish runs both proof commands, validates wording, and records close-out notes before merge.
|
5. Polish runs both proof commands, validates wording, and records close-out notes before merge.
|
||||||
|
6. Phase 7 refines the hero art direction so the homepage avoids generic neutral drift while preserving the original structure contract.
|
||||||
|
|
||||||
### Suggested MVP Scope
|
### Suggested MVP Scope
|
||||||
|
|
||||||
35
specs/218-homepage-hero/checklists/requirements.md
Normal file
35
specs/218-homepage-hero/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Website Homepage Hero
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-19
|
||||||
|
**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 pass 1 completed successfully.
|
||||||
|
- No clarification questions were required because the hero role, required elements, CTA structure, visual constraints, and website-only scope boundaries were explicit in the input.
|
||||||
148
specs/218-homepage-hero/contracts/homepage-hero.openapi.yaml
Normal file
148
specs/218-homepage-hero/contracts/homepage-hero.openapi.yaml
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: TenantAtlas Homepage Hero Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Semantic contract for the `apps/website` homepage hero in Spec 218.
|
||||||
|
description: >-
|
||||||
|
This contract defines the public HTML routes that participate in the
|
||||||
|
homepage hero journey for Spec 218. The homepage remains a static Astro
|
||||||
|
surface and must present a clear category context, one headline, one
|
||||||
|
supporting-copy block, one CTA pair, a product-near visual, and optional
|
||||||
|
bounded trust cues while routing visitors into Product, Trust, Changelog,
|
||||||
|
and Contact.
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:{port}
|
||||||
|
description: Local Astro development or preview server
|
||||||
|
variables:
|
||||||
|
port:
|
||||||
|
default: '4321'
|
||||||
|
tags:
|
||||||
|
- name: Homepage Hero Journey
|
||||||
|
description: Public HTML routes used by the homepage hero contract
|
||||||
|
paths:
|
||||||
|
/:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Hero Journey]
|
||||||
|
operationId: getHomepageHero
|
||||||
|
summary: Homepage hero
|
||||||
|
description: >-
|
||||||
|
Product-near homepage hero that positions the product, explains the
|
||||||
|
problem space, offers one clear CTA pair, and establishes bounded
|
||||||
|
first-read credibility without replacing the deeper Product or Trust
|
||||||
|
surfaces.
|
||||||
|
x-tenantatlas-homepage-hero:
|
||||||
|
requiredElements:
|
||||||
|
- category-context
|
||||||
|
- headline
|
||||||
|
- supporting-copy
|
||||||
|
- primary-cta
|
||||||
|
- secondary-cta
|
||||||
|
- product-near-visual
|
||||||
|
optionalElements:
|
||||||
|
- trust-subclaims
|
||||||
|
contentPriority:
|
||||||
|
- product-and-problem-understanding
|
||||||
|
- clear-next-step
|
||||||
|
- product-reality
|
||||||
|
- early-trust
|
||||||
|
- stylistic-finish
|
||||||
|
primaryCtaTargets:
|
||||||
|
- /contact
|
||||||
|
- /demo
|
||||||
|
secondaryCtaTargets:
|
||||||
|
- /product
|
||||||
|
- /trust
|
||||||
|
- /changelog
|
||||||
|
onwardRoutes:
|
||||||
|
- /product
|
||||||
|
- /trust
|
||||||
|
- /changelog
|
||||||
|
- /contact
|
||||||
|
mobileMeaningOrder:
|
||||||
|
- headline
|
||||||
|
- supporting-copy
|
||||||
|
- cta-pair
|
||||||
|
- product-near-visual
|
||||||
|
- trust-subclaims
|
||||||
|
productVisualRules:
|
||||||
|
- derived-from-real-product-structure
|
||||||
|
- no-generic-dashboard-wallpaper
|
||||||
|
- no-fake-metrics
|
||||||
|
- alt-text-must-be-product-specific
|
||||||
|
trustSubclaimRules:
|
||||||
|
- factually-supportable
|
||||||
|
- concise
|
||||||
|
- supportable-by-trust-surface
|
||||||
|
- no-legal-or-compliance-guarantees
|
||||||
|
- no-badge-wall
|
||||||
|
forbiddenPatterns:
|
||||||
|
- generic-startup-hero
|
||||||
|
- abstract-only-hero
|
||||||
|
- dashboard-wallpaper-hero
|
||||||
|
- badge-overload-hero
|
||||||
|
- sales-pressure-hero
|
||||||
|
- compliance-theater-hero
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Homepage HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
/product:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Hero Journey]
|
||||||
|
operationId: getHomepageHeroProductTarget
|
||||||
|
summary: Product target route
|
||||||
|
description: Deeper product-model route linked from the homepage hero.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Product page HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
/trust:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Hero Journey]
|
||||||
|
operationId: getHomepageHeroTrustTarget
|
||||||
|
summary: Trust target route
|
||||||
|
description: Bounded trust route that supports any public trust-adjacent hero language.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Trust page HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
/changelog:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Hero Journey]
|
||||||
|
operationId: getHomepageHeroChangelogTarget
|
||||||
|
summary: Changelog target route
|
||||||
|
description: Dated progress route that may be used as a secondary deepening destination from the homepage journey.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Changelog page HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
/contact:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Hero Journey]
|
||||||
|
operationId: getHomepageHeroContactTarget
|
||||||
|
summary: Contact target route
|
||||||
|
description: Primary next-step route used by the homepage hero until a distinct public `/demo` route exists.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Contact page HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
HtmlDocument:
|
||||||
|
type: string
|
||||||
|
description: Server-rendered static HTML document
|
||||||
100
specs/218-homepage-hero/data-model.md
Normal file
100
specs/218-homepage-hero/data-model.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# Data Model: Website Homepage Hero
|
||||||
|
|
||||||
|
This feature introduces no database schema and no platform-side persistence. The model is file- and route-based inside `apps/website` and defines how the homepage hero expresses product truth, CTA hierarchy, early trust cues, and the next-step path.
|
||||||
|
|
||||||
|
## 1. Homepage Hero Source Object
|
||||||
|
|
||||||
|
Represents the hero content exported from `apps/website/src/content/pages/home.ts`.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `eyebrow` | string | Short positioning cue or category context | Required; must anchor the hero in a believable category or problem space |
|
||||||
|
| `title` | string | Primary positioning headline | Required; must frame product, problem, or outcome without hype language |
|
||||||
|
| `description` | string | Supporting copy | Required; must sharpen the headline in plain product language |
|
||||||
|
| `primaryCta` | `CtaLink` | Dominant next-step action | Required; exactly one dominant primary CTA |
|
||||||
|
| `secondaryCta` | `CtaLink` | Secondary deepening action | Required for Spec 218 hero contract; must remain lower-emphasis than the primary CTA |
|
||||||
|
| `productVisual` | `HeroVisualContent` | Product-near screenshot or stylized product shot | Required by the hero contract unless a temporary explicit exemption is documented |
|
||||||
|
| `trustSubclaims` | string[] | Optional early trust cues | Optional; must remain short, factual, and bounded |
|
||||||
|
| `highlights` | string[] | Optional lightweight support points | Optional; should not compete with trust subclaims when both exist |
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- Rendered by `apps/website/src/components/sections/PageHero.astro`
|
||||||
|
- Consumed by `apps/website/src/pages/index.astro`
|
||||||
|
- Secondary truth for trust-facing claims must remain compatible with `apps/website/src/content/pages/trust.ts`
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Exactly one primary CTA and one secondary CTA must remain visible as the hero action pair
|
||||||
|
- Headline and description must not collapse into generic SaaS phrasing or stacked claims
|
||||||
|
- Trust subclaims must never become a badge wall or imply legal or compliance guarantees
|
||||||
|
|
||||||
|
## 2. Hero CTA Pair
|
||||||
|
|
||||||
|
Represents the two hero actions as one intentional routing pair.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `primary` | `CtaLink` | Primary next-step action | Must lead to `/contact`, `/demo`, or an equivalent explicitly approved next step |
|
||||||
|
| `secondary` | `CtaLink` | Lower-emphasis exploration route | Must lead to a maintained informational surface such as `/product`, `/trust`, or `/changelog` |
|
||||||
|
| `dominance` | derived UI property | Relative emphasis between the two CTAs | Primary must remain visually dominant |
|
||||||
|
| `reachability` | derived route truth | Whether the linked routes are real and maintained | Hero must not point to placeholder or immature surfaces |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- The pair must communicate one clear next step and one clear deepening path
|
||||||
|
- Multiple equally dominant primary sales actions are forbidden
|
||||||
|
|
||||||
|
## 3. Hero Visual Asset
|
||||||
|
|
||||||
|
Represents the product-near media used in the hero.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src` | string | Public asset path | Must resolve to a maintained hero asset in `apps/website/public` or equivalent |
|
||||||
|
| `alt` | string | Accessibility text for the visual | Must describe product-relevant UI truth, not generic marketing scenery |
|
||||||
|
| `truthBasis` | derived review rule | Why the visual is considered product-near | Must be traceable to real product structure or a truthful simplification |
|
||||||
|
| `mobilePersistence` | derived render rule | Whether the visual remains visible on small screens | Must stay visible when it is a key credibility signal |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- No fantasy metrics, fake dashboards, or unrelated analytics wallpaper
|
||||||
|
- Cropping or stylization is allowed only when product structure remains clear
|
||||||
|
|
||||||
|
## 4. Trust Subclaim Set
|
||||||
|
|
||||||
|
Represents the optional early-trust layer inside the hero.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `claims` | string[] | Short factual trust cues | Optional; should remain few and concise |
|
||||||
|
| `supportRoute` | route or derived surface | Where deeper trust context lives | Normally `/trust` |
|
||||||
|
| `visibility` | derived render rule | When trust cues are shown | Show only when claims are supportable and do not compete with the hero core |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Claims must be factual and publicly supportable
|
||||||
|
- Claims must not imply absolute compliance, security, or hosting guarantees
|
||||||
|
- Trust cues must support the hero, not overrun it
|
||||||
|
|
||||||
|
## 5. Hero Render Contract
|
||||||
|
|
||||||
|
Represents the semantic structure that `PageHero.astro` must preserve.
|
||||||
|
|
||||||
|
| Element | Role | Requirement |
|
||||||
|
|---|---|---|
|
||||||
|
| Category context | Early positioning cue | Must appear before or adjacent to the headline |
|
||||||
|
| Headline | Primary positioning statement | Must remain the dominant text signal |
|
||||||
|
| Supporting copy | Headline clarification | Must remain directly associated with the headline |
|
||||||
|
| CTA pair | Action transition | Must keep one dominant primary and one lower-emphasis secondary action |
|
||||||
|
| Product-near visual | Product truth signal | Must remain part of the hero composition |
|
||||||
|
| Optional trust subclaims | Early credibility cues | Must stay secondary to product and CTA understanding |
|
||||||
|
|
||||||
|
**Mobile meaning order**:
|
||||||
|
- headline and supporting copy
|
||||||
|
- CTA pair
|
||||||
|
- product-near visual
|
||||||
|
- optional trust signals
|
||||||
|
|
||||||
|
## Derived State and Availability
|
||||||
|
|
||||||
|
- No independent state machine is added by this feature.
|
||||||
|
- Hero route availability remains derived from the homepage route at `/`.
|
||||||
|
- Hero CTA reachability remains derived from canonical public routes in `apps/website`.
|
||||||
|
- Trust-subclaim legitimacy remains derived from public website truth, especially the Trust surface.
|
||||||
|
- Product-visual readiness remains asset-based; if the current visual stops being truthful enough, it must be replaced rather than papered over with abstraction.
|
||||||
204
specs/218-homepage-hero/plan.md
Normal file
204
specs/218-homepage-hero/plan.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# Implementation Plan: Website Homepage Hero
|
||||||
|
|
||||||
|
**Branch**: `218-homepage-hero` | **Date**: 2026-04-19 | **Spec**: `specs/218-homepage-hero/spec.md`
|
||||||
|
**Input**: Feature specification from `specs/218-homepage-hero/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Tighten the `apps/website` homepage hero so the first screen satisfies Spec 218: category context, precise headline and supporting copy, one clear CTA pair, a product-near visual, and bounded early trust signals.
|
||||||
|
- Implement the change by reusing the current `homeHero` content object and `PageHero.astro`, adding only the smallest missing semantic hooks, ordering guarantees, and asset or copy refinements instead of introducing a new hero framework.
|
||||||
|
- Validate the result with `corepack pnpm build:website` plus focused Playwright smoke coverage for hero composition, CTA hierarchy, visual truthfulness, and mobile meaning order.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Astro 6.0.0 templates + TypeScript 5.9.x
|
||||||
|
**Primary Dependencies**: Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests
|
||||||
|
**Storage**: Static filesystem content and assets under `apps/website/src` and `apps/website/public`; no database
|
||||||
|
**Testing**: `corepack pnpm build:website` plus Playwright smoke coverage in `apps/website/tests/smoke`
|
||||||
|
**Validation Lanes**: fast-feedback
|
||||||
|
**Target Platform**: Static public website for modern desktop and mobile browsers
|
||||||
|
**Project Type**: Web application in a monorepo (`apps/platform` plus `apps/website`)
|
||||||
|
**Performance Goals**: Keep the hero server-rendered and readable without required client-side hydration, preserve fast first-read clarity on desktop and mobile, and keep the product visual and CTA visible on narrow screens
|
||||||
|
**Constraints**: Stay strictly inside `apps/website`; preserve canonical core routes (`/`, `/product`, `/trust`, `/changelog`, `/contact`); keep one dominant primary CTA and one lower-emphasis secondary CTA; avoid unsupported trust claims, generic dashboard visuals, and any runtime coupling to `apps/platform`
|
||||||
|
**Scale/Scope**: One homepage route, one shared hero component, one hero content object, one product-near asset, and a focused extension of the existing homepage smoke coverage
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: no operator-facing surface change
|
||||||
|
- **Native vs custom classification summary**: N/A - public Astro website surface only
|
||||||
|
- **Shared-family relevance**: none
|
||||||
|
- **State layers in scope**: page
|
||||||
|
- **Handling modes by drift class or surface**: N/A
|
||||||
|
- **Repository-signal treatment**: report-only
|
||||||
|
- **Special surface test profiles**: N/A
|
||||||
|
- **Required tests or manual smoke**: manual-smoke plus homepage-focused browser smoke coverage
|
||||||
|
- **Exception path and spread control**: none
|
||||||
|
- **Active feature PR close-out entry**: Smoke Coverage
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first / snapshots / Graph contract / deterministic capabilities / RBAC-UX / Filament guardrails: N/A for this feature because all work stays on the public Astro website and changes no `/admin`, `/admin/t/{tenant}/...`, or `/system` runtime surfaces.
|
||||||
|
- Read/write separation: Pass. The homepage hero remains a static public read surface. No writes, remote calls, queued work, or contact submission backend are introduced in this feature.
|
||||||
|
- Workspace and tenant isolation: Pass. The hero stays runtime-independent from `apps/platform`, with no shared auth, session, tenant data, or scoped route behavior.
|
||||||
|
- Data minimization: Pass. The feature only refines public copy, CTA paths, visual assets, and render semantics already owned by `apps/website`.
|
||||||
|
- Test governance: Pass. Proof remains in `fast-feedback` through static build output and focused browser smoke coverage, with no database, membership, provider, or heavy-suite defaults.
|
||||||
|
- Proportionality / no premature abstraction: Pass. The plan reuses `home.ts`, `PageHero.astro`, current CTA primitives, and the existing smoke harness instead of introducing a hero registry, CMS layer, or presentation framework.
|
||||||
|
- Persisted truth / new state: Pass. No database artifacts, queues, or independent state machines are added. Hero trust signals and the product-near visual remain file-based and derived from public website truth.
|
||||||
|
- UI semantics / few layers: Pass. The hero contract maps directly from `homeHero` content into `PageHero.astro`, with only thin render hooks or test markers if needed.
|
||||||
|
|
||||||
|
Status: ✅ No constitution violations identified before research.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Browser
|
||||||
|
- **Affected validation lanes**: fast-feedback
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The feature changes only public hero rendering, CTA emphasis, visual truthfulness, and responsive visibility. Browser smoke coverage is the narrowest layer that can prove those concerns without introducing backend or heavy browser-matrix cost.
|
||||||
|
- **Narrowest proving command(s)**: `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: none; public routes do not require database, auth, provider, workspace, or tenant setup
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; any helper additions stay inside the existing `apps/website/tests/smoke` harness and remain homepage-focused
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: N/A
|
||||||
|
- **Closing validation and reviewer handoff**: Re-run the website build and the focused homepage smoke file after hero changes. If shared smoke helpers change materially, reviewers may also run the full website smoke suite. Reviewers should verify required hero elements, one dominant CTA pair, visible product-near media, bounded trust cues, and mobile visibility order.
|
||||||
|
- **Budget / baseline / trend follow-up**: none beyond a small increase in homepage smoke assertions
|
||||||
|
- **Review-stop questions**: Does proof stay homepage-focused and browser-only? Did the change accidentally introduce a new abstraction or shared helper burden? Does the mobile layout keep CTA and product-near visual visible? Are trust claims still bounded and supportable?
|
||||||
|
- **Escalation path**: document-in-feature
|
||||||
|
- **Active feature PR close-out entry**: Smoke Coverage
|
||||||
|
- **Why no dedicated follow-up spec is needed**: Validation remains feature-local to the homepage hero and the existing website smoke harness. A separate follow-up spec is only needed if screenshot governance, visual-regression tooling, or multi-page hero conventions become shared structural concerns.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/218-homepage-hero/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── homepage-hero.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/website/
|
||||||
|
├── package.json
|
||||||
|
├── public/
|
||||||
|
│ └── images/
|
||||||
|
│ └── hero-product-visual.svg
|
||||||
|
├── src/
|
||||||
|
│ ├── content/
|
||||||
|
│ │ └── pages/
|
||||||
|
│ │ ├── home.ts
|
||||||
|
│ │ ├── product.ts
|
||||||
|
│ │ └── trust.ts
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── content/
|
||||||
|
│ │ ├── primitives/
|
||||||
|
│ │ └── sections/
|
||||||
|
│ │ └── PageHero.astro
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ └── index.astro
|
||||||
|
│ └── types/
|
||||||
|
│ └── site.ts
|
||||||
|
└── tests/
|
||||||
|
└── smoke/
|
||||||
|
├── home-product.spec.ts
|
||||||
|
└── smoke-helpers.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the feature completely inside `apps/website`, reusing the existing `HeroContent` data shape, `PageHero.astro`, and homepage smoke suite. Prefer small edits to `home.ts`, `PageHero.astro`, and homepage smoke assertions over new hero components, registries, or cross-page content frameworks.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: The homepage hero is the highest-risk public screen for drift into generic marketing or weak product truth, so small regressions there have disproportionate impact on credibility and next-step clarity.
|
||||||
|
- **Existing structure is insufficient because**: Spec 217 establishes homepage section flow broadly, but the current implementation still needs a hero-specific contract to lock down required semantics, CTA hierarchy, product-near truth, and mobile meaning order.
|
||||||
|
- **Narrowest correct implementation**: Reuse the existing `homeHero` content object and `PageHero.astro`, tightening content, render semantics, and smoke coverage only where Spec 218 requires stronger guarantees.
|
||||||
|
- **Ownership cost created**: Ongoing maintenance of explicit hero content rules, a product-near visual asset, and a slightly richer homepage smoke suite.
|
||||||
|
- **Alternative intentionally rejected**: A generic hero framework or a separate CMS-like hero configuration layer was rejected because only one homepage hero needs this contract now and the current Astro content model already fits the problem.
|
||||||
|
- **Release truth**: Current-release truth
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research (complete)
|
||||||
|
|
||||||
|
- Output: `specs/218-homepage-hero/research.md`
|
||||||
|
- Key decisions captured:
|
||||||
|
- Keep the hero local to the static Astro website and preserve runtime separation from `apps/platform`.
|
||||||
|
- Reuse the existing `homeHero` content object and `PageHero.astro` instead of adding a new hero abstraction.
|
||||||
|
- Treat hero semantics as explicit render responsibilities that can be tested directly.
|
||||||
|
- Keep the product visual truthful and derived from real product structure rather than decorative SaaS wallpaper.
|
||||||
|
- Validate through focused Playwright homepage smoke coverage plus build proof.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (complete)
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
- Output: `specs/218-homepage-hero/data-model.md`
|
||||||
|
- Model remains file- and route-based. No database schema changes are required.
|
||||||
|
|
||||||
|
### Public hero contract
|
||||||
|
|
||||||
|
- Output: `specs/218-homepage-hero/contracts/homepage-hero.openapi.yaml`
|
||||||
|
- Contract captures the homepage route plus the hero-only semantic requirements and downstream route expectations for Product, Trust, Changelog, and Contact.
|
||||||
|
|
||||||
|
### Quickstart
|
||||||
|
|
||||||
|
- Output: `specs/218-homepage-hero/quickstart.md`
|
||||||
|
- Quickstart covers local development, hero-specific verification points, build proof, and focused smoke-test execution.
|
||||||
|
|
||||||
|
### Agent context update
|
||||||
|
|
||||||
|
- Completed via `.specify/scripts/bash/update-agent-context.sh copilot` after plan artifacts were generated.
|
||||||
|
|
||||||
|
### Constitution re-check (post-design)
|
||||||
|
|
||||||
|
- ✅ The plan remains website-only and static, with no platform-runtime coupling.
|
||||||
|
- ✅ No new persistence, state machines, background operations, or auth flows are introduced.
|
||||||
|
- ✅ The chosen shape reuses existing Astro content modules, CTA primitives, and section components instead of adding speculative abstraction.
|
||||||
|
- ✅ Validation remains cheap, local, and aligned with the current website smoke harness.
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Plan (next)
|
||||||
|
|
||||||
|
### Story 1 (P1): Understand the product from the first screen
|
||||||
|
|
||||||
|
- Review and tighten `apps/website/src/content/pages/home.ts` so the hero eyebrow, headline, supporting copy, CTA pair, and visual all align with Spec 218 language and positioning rules without hype or stacked claims.
|
||||||
|
- Keep hero composition inside `apps/website/src/components/sections/PageHero.astro`; add only the smallest missing semantic hooks and DOM structure needed to expose category context, headline, supporting copy, CTA pair, product-near visual, and optional trust subclaims as explicit hero elements.
|
||||||
|
- Preserve one dominant primary CTA to `/contact` and one secondary deepening CTA to `/product`, unless a stronger already-supported secondary destination is chosen during copy review.
|
||||||
|
- Tests / validation:
|
||||||
|
- Extend `apps/website/tests/smoke/home-product.spec.ts` and `apps/website/tests/smoke/smoke-helpers.ts` only as needed to assert the required hero elements and one clear CTA pair.
|
||||||
|
- Re-run `corepack pnpm build:website`.
|
||||||
|
|
||||||
|
### Story 2 (P2): Establish credibility without overclaiming
|
||||||
|
|
||||||
|
- Review `homeHero.trustSubclaims` against the current `/trust` public truth so any hero subclaim remains bounded, factual, concise, and supportable.
|
||||||
|
- Confirm `apps/website/public/images/hero-product-visual.svg` and its alt text remain product-near and truthfully derived from real UI structure; replace or tighten only if the asset reads like a generic dashboard or decorative placeholder.
|
||||||
|
- Ensure the hero stays product-near even if the visual remains stylized, and avoid introducing a separate trust badge, compliance matrix, or homepage-only proof system.
|
||||||
|
- Tests / validation:
|
||||||
|
- Add smoke assertions for visible product-near media, concise trust signals, and continued homepage reachability to `/trust` and other downstream routes.
|
||||||
|
- Keep assertions inside homepage smoke coverage; do not add a new visual-regression matrix.
|
||||||
|
|
||||||
|
### Story 3 (P3): Preserve correct next-step routing on desktop and mobile
|
||||||
|
|
||||||
|
- Verify the hero meaning order stays stable across desktop and narrow screens: category context and headline, supporting copy, CTA pair, product-near visual, and optional trust signals.
|
||||||
|
- Tighten responsive composition inside `PageHero.astro` only as needed to keep CTA and product visual visible without reordering semantics or hiding credibility cues on mobile.
|
||||||
|
- Finalize targeted smoke coverage for mobile visibility and hero-first route reachability into `/product`, `/contact`, and supporting public surfaces.
|
||||||
|
- Tests / validation:
|
||||||
|
- Extend `apps/website/tests/smoke/home-product.spec.ts` with narrow-screen hero checks or extracted helper assertions in `smoke-helpers.ts`.
|
||||||
|
- Run `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`.
|
||||||
|
|
||||||
|
## Implementation Close-out
|
||||||
|
|
||||||
|
- Homepage hero delivery stayed local to `apps/website` and reused the existing `homeHero` content object plus `PageHero.astro` with no new hero framework or platform coupling.
|
||||||
|
- The shipped hero now exposes explicit semantic hooks for category context, headline, supporting copy, CTA pair, product-near visual, and trust cues, while keeping one primary CTA to `/contact` and one secondary deepening CTA to `/product`.
|
||||||
|
- The product visual was replaced with a product-near operating-record illustration that avoids fake KPI cards or generic analytics-dashboard theater.
|
||||||
|
- Shared smoke-helper growth stayed homepage-focused in `apps/website/tests/smoke/smoke-helpers.ts`; no auth, backend, provider, or database fixtures were introduced.
|
||||||
|
- Validation completed on 2026-04-19 with `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`.
|
||||||
91
specs/218-homepage-hero/quickstart.md
Normal file
91
specs/218-homepage-hero/quickstart.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# Quickstart: Website Homepage Hero
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Verify that the homepage hero in `apps/website` follows the Spec 218 contract: clear category context, precise copy, one CTA pair, product-near visual truth, bounded trust cues, and stable mobile meaning order.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- Corepack enabled
|
||||||
|
- Repo dependencies installed with `corepack pnpm install`
|
||||||
|
|
||||||
|
## Run the website locally
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm dev:website
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative, inside the website app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/website
|
||||||
|
corepack pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Default local URL: `http://127.0.0.1:4321/`
|
||||||
|
|
||||||
|
## What to verify in the homepage hero
|
||||||
|
|
||||||
|
Check the hero in this order:
|
||||||
|
|
||||||
|
1. A clear category context or positioning cue appears above or adjacent to the main headline.
|
||||||
|
2. The main headline explains the product category, problem space, or intended outcome without generic startup language.
|
||||||
|
3. Supporting copy makes the headline easier to understand instead of repeating it.
|
||||||
|
4. Exactly one dominant primary CTA is visible, plus one lower-emphasis secondary CTA.
|
||||||
|
5. The hero includes a product-near visual that looks derived from real product structure rather than a generic dashboard placeholder.
|
||||||
|
6. Any trust subclaims remain concise, factual, and secondary to the product explanation.
|
||||||
|
7. The hero still routes cleanly into maintained downstream pages such as `/product`, `/trust`, `/changelog`, and `/contact`.
|
||||||
|
|
||||||
|
## Mobile verification
|
||||||
|
|
||||||
|
On a narrow viewport, verify:
|
||||||
|
|
||||||
|
1. Headline and supporting copy remain first in the reading flow.
|
||||||
|
2. CTA pair remains clearly visible without being pushed below decorative content.
|
||||||
|
3. Product-near visual still appears when it is a key credibility signal.
|
||||||
|
4. Optional trust cues stay visible only if they do not bury the main message.
|
||||||
|
|
||||||
|
## Build proof
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm build:website
|
||||||
|
```
|
||||||
|
|
||||||
|
## Focused browser smoke proof
|
||||||
|
|
||||||
|
Run the homepage-focused smoke file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/website
|
||||||
|
corepack pnpm exec playwright test tests/smoke/home-product.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional broader browser proof
|
||||||
|
|
||||||
|
If shared smoke helpers or route behavior changed more broadly, run the full website smoke suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/website
|
||||||
|
corepack pnpm exec playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected proof points
|
||||||
|
|
||||||
|
- Hero required elements are all visible.
|
||||||
|
- One dominant primary CTA and one secondary CTA remain clearly differentiated.
|
||||||
|
- Product-near visual remains visible and believable.
|
||||||
|
- Trust subclaims stay bounded and concise.
|
||||||
|
- Homepage still routes cleanly into deeper informational and action surfaces.
|
||||||
|
- Mobile layout preserves the intended meaning order without hiding CTA or product reality.
|
||||||
|
|
||||||
|
## Feature Close-out
|
||||||
|
|
||||||
|
- Validated on 2026-04-19 with `corepack pnpm build:website` from the repository root.
|
||||||
|
- Validated on 2026-04-19 with `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`.
|
||||||
|
- Homepage smoke coverage now checks the hero semantic structure, CTA pair, product-near visual alt text, bounded trust cues, and narrow-screen meaning order.
|
||||||
|
- Mobile proof keeps the CTA pair above the fold and verifies the product visual remains present within the hero in the correct sequence.
|
||||||
41
specs/218-homepage-hero/research.md
Normal file
41
specs/218-homepage-hero/research.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Research: Website Homepage Hero
|
||||||
|
|
||||||
|
## Decision 1: Keep the hero implementation local to the static Astro website
|
||||||
|
|
||||||
|
- **Decision**: Continue treating the homepage hero as a static `apps/website` concern composed from the current Astro content module and section component, with no runtime dependency on `apps/platform`.
|
||||||
|
- **Rationale**: Spec 218 is explicitly website-only. The current website already runs as a standalone Astro app, and the hero changes concern semantics, copy discipline, asset truthfulness, and responsive ordering rather than dynamic runtime behavior.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Couple hero content to `apps/platform` or a shared API: rejected because the spec forbids platform obligations and the hero needs no dynamic platform data.
|
||||||
|
- Introduce a CMS or hero-builder layer first: rejected because one homepage hero does not justify that operational overhead.
|
||||||
|
|
||||||
|
## Decision 2: Reuse the existing `homeHero` content object and `PageHero.astro`
|
||||||
|
|
||||||
|
- **Decision**: Preserve `apps/website/src/content/pages/home.ts` as the canonical source for hero content and refine `apps/website/src/components/sections/PageHero.astro` as the render surface instead of creating a new hero abstraction.
|
||||||
|
- **Rationale**: The current site already uses typed content exports and a shared `PageHero` component. This is the narrowest correct place to express hero-specific semantics without adding a second content shape, a render registry, or a page-builder concept.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Build a homepage-hero-specific component tree parallel to `PageHero.astro`: rejected because the existing component already supports eyebrow, CTA pair, product visual, and trust subclaims.
|
||||||
|
- Hardcode all hero content directly in `index.astro`: rejected because it would bypass the current typed content pattern and make later hero iteration less disciplined.
|
||||||
|
|
||||||
|
## Decision 3: Make hero semantics explicit through direct render structure, not through a new framework
|
||||||
|
|
||||||
|
- **Decision**: If stronger hero guarantees are needed, add small render markers and explicit DOM ordering to `PageHero.astro` rather than inventing a new semantic layer.
|
||||||
|
- **Rationale**: Spec 218 requires stable verification of category context, headline, supporting copy, CTA hierarchy, product-near visual, and optional trust cues. Those guarantees can be expressed directly in the hero render surface and smoke tests.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Add a separate hero registry or semantic presenter layer: rejected as premature abstraction for one public surface.
|
||||||
|
- Rely on text-only selectors in browser tests: rejected because stable hero markers make long-term regression checks clearer and less brittle.
|
||||||
|
|
||||||
|
## Decision 4: Treat the hero visual as a curated product-truth asset
|
||||||
|
|
||||||
|
- **Decision**: Keep the hero visual tied to a product-near asset such as the current `hero-product-visual.svg`, and only replace it with another asset that remains truthfully derived from real product structure.
|
||||||
|
- **Rationale**: Spec 218 explicitly rejects generic dashboard wallpaper and fantasy metrics. The visual must support credibility by showing product-adjacent structure, even if it is stylized for the website.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Use abstract shapes or decorative illustration only: rejected because the spec requires product-near credibility.
|
||||||
|
- Pull in a theme-provided analytics dashboard placeholder: rejected because it would weaken product truth and create a generic SaaS feel.
|
||||||
|
|
||||||
|
## Decision 5: Validate through focused homepage smoke coverage
|
||||||
|
|
||||||
|
- **Decision**: Prove Spec 218 with the current website build command and focused Playwright updates in `apps/website/tests/smoke/home-product.spec.ts`, adding shared helpers only if they improve clarity without broadening scope.
|
||||||
|
- **Rationale**: The hero contract is about visible public rendering, CTA hierarchy, mobile meaning order, and route reachability. Browser smoke coverage is the narrowest proving layer that can validate those concerns.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Build-only proof alone: rejected because static output generation does not prove CTA hierarchy, product-visual presence, or mobile ordering.
|
||||||
|
- Add full visual regression or multi-browser matrices immediately: rejected because the feature scope does not require that extra cost.
|
||||||
189
specs/218-homepage-hero/spec.md
Normal file
189
specs/218-homepage-hero/spec.md
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
# Feature Specification: Website Homepage Hero
|
||||||
|
|
||||||
|
**Feature Branch**: `218-homepage-hero`
|
||||||
|
**Created**: 2026-04-19
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Define Spec 218 as the website-only homepage hero contract for `apps/website`, covering hero role, required elements, copy and CTA rules, product-near visual constraints, trust subclaims, layout logic, responsive behavior, and explicit anti-patterns."
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Without a clear homepage hero contract, the first screen of `apps/website` can drift into generic SaaS language, abstract visuals, weak product truth, or premature CTA pressure that makes the product feel less credible than it is.
|
||||||
|
- **Today's failure**: A first-time visitor can see a polished hero yet still fail to understand what the product is, why it matters, why it should be trusted, or which next route is the right one.
|
||||||
|
- **User-visible improvement**: Visitors can classify the product, understand the problem space, see a product-near signal, and choose a sensible next step from the hero alone.
|
||||||
|
- **Smallest enterprise-capable version**: One homepage-hero-only contract that defines the hero's role, mandatory elements, content priorities, CTA structure, product-visual rules, trust-subclaim rules, and excluded anti-patterns, without prescribing final copy or detailed visual implementation.
|
||||||
|
- **Explicit non-goals**: No full homepage structure beyond hero responsibilities, no final visual design, no final production copy, no complete Product or Trust page spec, no Pricing or Docs surface, no platform UI work, no Filament theming, no motion-spec detail, and no implementation detail for Astro or Tailwind.
|
||||||
|
- **Permanent complexity imported**: A stable website-local hero vocabulary covering category context, headline, supporting copy, CTA roles, product-near visual rules, trust-subclaim rules, information-density guardrails, and hero review criteria.
|
||||||
|
- **Why now**: The homepage structure is already defined in Spec 217, and the hero needs a tighter contract before screenshot strategy, final copy work, and Stitch exploration harden around weak defaults.
|
||||||
|
- **Why not local**: A one-off hero iteration would not reliably prevent generic marketing patterns, screenshot drift, overclaiming, or multi-CTA pressure as future design and copy passes land.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: New website-local hero taxonomy; risk of drifting into design detail; risk of unsupported trust or compliance language. The scope remains justified because it is narrow, homepage-only, and directly improves product clarity and credibility.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace
|
||||||
|
- **Primary Routes**: `/` with onward routing from the hero into `/product`, `/trust`, `/changelog`, and `/contact`
|
||||||
|
- **Data Ownership**: Website-owned homepage-hero semantics, CTA targets, product-visual expectations, trust-subclaim boundaries, and responsive ordering inside `apps/website`; no tenant-owned records, platform runtime data, or shared persistence.
|
||||||
|
- **RBAC**: None. This feature applies to a public website surface and introduces no authorization model.
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: yes
|
||||||
|
- **New enum/state/reason family?**: no
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: yes, but only within `apps/website`
|
||||||
|
- **Current operator problem**: Website contributors and reviewers do not yet have a bounded semantic contract for the most important public screen, so hero work can regress into generic marketing patterns or conflicting hero decisions.
|
||||||
|
- **Existing structure is insufficient because**: Spec 217 defines the homepage section model broadly, but it does not define the hero's specific semantic responsibilities, content priorities, screenshot truth rules, or CTA boundaries tightly enough for repeatable hero work.
|
||||||
|
- **Narrowest correct implementation**: One hero-only specification that locks the homepage hero into required semantic parts, bounded trust language, product-near visual expectations, and responsive meaning order without expanding into full page design or implementation detail.
|
||||||
|
- **Ownership cost**: Future homepage hero reviews must enforce category clarity, CTA discipline, visual truthfulness, and anti-pattern avoidance instead of allowing ad hoc exceptions to accumulate.
|
||||||
|
- **Alternative intentionally rejected**: Designing the hero directly in a theme or in Stitch without a semantic contract was rejected because it would let aesthetics re-decide product truth, CTA weight, and screenshot honesty each time.
|
||||||
|
- **Release truth**: Current-release truth
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Browser
|
||||||
|
- **Validation lane(s)**: fast-feedback
|
||||||
|
- **Why this classification and these lanes are sufficient**: This specification governs public hero rendering, claim placement, CTA hierarchy, and responsive information order on a static website surface. Browser smoke coverage and website build proof are the narrowest honest validation; no auth, tenant, database, or platform-runtime setup is required.
|
||||||
|
- **New or expanded test families**: Focused website smoke coverage for homepage hero presence, CTA reachability, visible product-near media, and mobile meaning-order checks.
|
||||||
|
- **Fixture / helper cost impact**: low; no workspace, tenant, auth, provider, or database fixtures are required.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: N/A
|
||||||
|
- **Standard-native relief or required special coverage**: Homepage-specific browser assertions are sufficient; no platform or operator-surface coverage is required.
|
||||||
|
- **Reviewer handoff**: Reviewers should confirm that the released hero includes the required semantic elements, maintains one dominant primary CTA, routes to real downstream pages, avoids unsupported trust language, and keeps product-near visibility on desktop and mobile.
|
||||||
|
- **Budget / baseline / trend impact**: none beyond small website smoke-suite growth.
|
||||||
|
- **Escalation needed**: document-in-feature
|
||||||
|
- **Active feature PR close-out entry**: Smoke Coverage
|
||||||
|
- **Planned validation commands**: `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Understand the product from the first screen (Priority: P1)
|
||||||
|
|
||||||
|
A first-time buyer or technical evaluator lands on the homepage and can understand what the product roughly is, why it matters, and what to do next before leaving the hero.
|
||||||
|
|
||||||
|
**Why this priority**: If the hero fails to create immediate product clarity, every deeper page loses value.
|
||||||
|
|
||||||
|
**Independent Test**: This can be tested by visiting the homepage only and confirming that the hero provides category context, product/problem framing, and a clear primary next step without requiring deeper page visits.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a first-time visitor opens the homepage, **When** they read the hero, **Then** they can describe what kind of product this is and why it is relevant.
|
||||||
|
2. **Given** a visitor wants to know what to do next, **When** they inspect the hero CTAs, **Then** they can distinguish the primary next step from the secondary deepening route.
|
||||||
|
3. **Given** a visitor is unsure whether the product is real or just marketing, **When** they inspect the hero visual, **Then** they see a product-near signal rather than pure abstraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Assess credibility before committing (Priority: P2)
|
||||||
|
|
||||||
|
A serious evaluator can leave the hero with a preliminary sense that the product is real, technically serious, and careful about trust claims without the hero trying to carry the full Trust page.
|
||||||
|
|
||||||
|
**Why this priority**: The hero must establish enough credibility to earn deeper exploration, but not by overclaiming.
|
||||||
|
|
||||||
|
**Independent Test**: This can be tested by reviewing the hero only and confirming that trust language remains bounded, product-near evidence is present, and deeper trust routes are available.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the hero includes trust subclaims, **When** a reviewer inspects them, **Then** each claim is concise, supportable, and not presented as a legal or compliance guarantee.
|
||||||
|
2. **Given** a technical stakeholder scans the hero, **When** they read the copy and view the product-near visual, **Then** they see signs of governance, audit, recovery, or drift seriousness without being overwhelmed by detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Choose the right deeper route (Priority: P3)
|
||||||
|
|
||||||
|
A qualified visitor can use the hero to move into the Product, Trust, Changelog, or Contact flow instead of being forced into immediate sales contact or a dead-end route.
|
||||||
|
|
||||||
|
**Why this priority**: The hero should route intentionally, not absorb the whole website or pressure every visitor into the same action.
|
||||||
|
|
||||||
|
**Independent Test**: This can be tested by starting on the homepage hero and verifying that the visible CTA pair leads into real, maintained downstream routes with clear intent.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a visitor wants deeper product detail, **When** they use the secondary hero CTA, **Then** they reach a real downstream informational surface such as `/product` or `/trust`.
|
||||||
|
2. **Given** a visitor is ready to engage, **When** they use the primary hero CTA, **Then** they reach the intended primary next-step route without competing primary actions.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when a publishable real screenshot is not yet ready? The hero may use a curated or stylized product-near visual, but it must still reflect real product structure and must not fall back to generic dashboard wallpaper.
|
||||||
|
- How does the hero handle trust or compliance language that cannot be substantiated publicly? The claim must be softened or removed instead of implied as a guarantee.
|
||||||
|
- What happens when mobile space compresses the layout? The meaning order must remain headline, supporting copy, CTA, product-near visual, and optional trust signals, without hiding the CTA or product reality.
|
||||||
|
- What happens when too many chips, badges, or CTA ideas compete for space? The hero must reduce visible signals rather than turning into several mini-sections at once.
|
||||||
|
- What happens when a secondary CTA would lead to a weak or placeholder route? The hero must route to a stronger maintained surface or suppress that CTA until the downstream page is real.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature changes only the public homepage hero in `apps/website`. It introduces no Microsoft Graph calls, no queueing, no long-running operations, no authorization changes, and no Filament or operator-facing surface changes. Its contract is explicitly local to `apps/website` and must not create obligations for `apps/platform`.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / BLOAT-001):** This feature intentionally introduces a website-local hero semantic layer because ad hoc hero styling and copy decisions are insufficient to preserve product truth, CTA hierarchy, and trust boundaries. The layer remains narrow, homepage-only, and must not expand into a shared website-platform design contract.
|
||||||
|
|
||||||
|
**Implementation boundary:** Any implementation under this specification MUST preserve the existing website working contract by keeping `@tenantatlas/website`, `WEBSITE_PORT`, and the root `dev:website` / `build:website` workflows intact, and it MUST NOT introduce runtime or package coupling to `apps/platform`.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The homepage hero MUST act as a positioning, product-near, and trust-capable entry point for `apps/website`, not as a decorative splash screen.
|
||||||
|
- **FR-002**: Within the hero alone, a first-time visitor MUST be able to answer what the product roughly is, why it matters, who it is plausibly for, why it is worth exploring further, and what the next sensible step is.
|
||||||
|
- **FR-003**: The hero MUST functionally include category context, a primary headline, supporting copy, one dominant primary CTA, one secondary CTA, and a product-near visual. Trust subclaims MAY be included when supportable.
|
||||||
|
- **FR-004**: The hero MUST prioritize product and problem understanding first, a clear next step second, product reality third, early trust signals fourth, and stylistic finish fifth.
|
||||||
|
- **FR-005**: The hero MUST include a positioning eyebrow, category context, or equivalent descriptor that anchors the product category or problem space, and it MUST NOT be pure marketing filler or the only place where category context exists.
|
||||||
|
- **FR-006**: The primary headline MUST frame the product category, problem space, or intended outcome clearly enough for quick orientation, and it MUST NOT rely on buzzwords, stacked messages, or unsupported superlatives.
|
||||||
|
- **FR-007**: Supporting copy MUST translate the headline into clearer product and problem language, MUST sharpen relevance, and MUST NOT become a miniature product page or a simple paraphrase of the headline.
|
||||||
|
- **FR-008**: The hero MUST present exactly one dominant primary CTA aligned to the current maturity of the website and product story. It MUST NOT present several equally loud primary sales actions in parallel.
|
||||||
|
- **FR-009**: The hero MUST present one secondary CTA that deepens understanding through a real maintained downstream route. It MUST remain lower-emphasis than the primary CTA and MUST NOT point to an immature or empty surface.
|
||||||
|
- **FR-010**: The hero MUST include a product-near visual. That visual MUST be credible, relevant to the product, compositionally integrated into the hero, and stronger than pure abstraction as a proof-of-product signal.
|
||||||
|
- **FR-011**: If the hero uses a screenshot, crop, or stylized product shot, it SHOULD be derived from real product reality and truthful simplification. It MUST NOT invent fantasy product UI, fake metrics, or generic analytics-template dashboards.
|
||||||
|
- **FR-012**: Optional trust subclaims or trust chips MAY appear only when they are factually correct, concise, and supportable by deeper public context such as `/trust`. They MUST NOT turn into a badge wall or imply legal, compliance, or security guarantees that the website cannot responsibly substantiate.
|
||||||
|
- **FR-013**: The hero MUST keep information density controlled: one main headline, one supporting-copy block, one dominant primary CTA, one secondary CTA, one visual focus, and only a small number of trust signals.
|
||||||
|
- **FR-014**: The hero MUST include a clear text core and a clear visual focus. It MAY use left-right or top-bottom composition, but text clarity and CTA visibility MUST remain primary.
|
||||||
|
- **FR-015**: The product-near visual SHOULD feel like part of the same hero composition rather than a detached block. The hero MUST NOT become image-only, text-only where product-near material is available, or readability-damaging decorative layering.
|
||||||
|
- **FR-016**: Any design or Stitch exploration based on this specification MUST preserve the semantic hero structure of category context, headline, supporting copy, primary CTA, secondary CTA, product-near visual, and optional trust chips. Exploration MUST NOT re-decide homepage IA or product positioning from scratch.
|
||||||
|
- **FR-017**: On mobile, the hero MUST preserve the meaning order of headline, supporting copy, CTA, product-near visual, and optional trust signals.
|
||||||
|
- **FR-018**: Mobile simplification MAY crop, reduce, or reorder the visual, but it MUST NOT remove a product-near visual entirely when that visual is a key credibility signal, and it MUST NOT bury the CTA.
|
||||||
|
- **FR-019**: The hero MUST avoid the following disallowed patterns: generic startup hero, abstract-only hero, dashboard-wallpaper hero, badge-overload hero, sales-pressure hero, and compliance-theater hero.
|
||||||
|
- **FR-020**: The hero MUST support both buyer-oriented clarity and first-pass technical plausibility by signaling that governance, audit, recovery, drift, or similar operational concerns are real parts of the product without trying to explain the full product in one screen.
|
||||||
|
- **FR-021**: This specification MUST remain strictly local to `apps/website` and MUST NOT create implementation, design, routing, or runtime obligations for `apps/platform`.
|
||||||
|
- **FR-022**: This specification MUST define, at minimum, the hero's functional role, mandatory elements, copy rules, CTA rules, product-visual rules, trust-subclaim rules, information-density rules, prohibited anti-patterns, and the hero semantic structure required for downstream design exploration.
|
||||||
|
|
||||||
|
#### Out of Scope
|
||||||
|
|
||||||
|
- Full homepage composition beyond the hero
|
||||||
|
- Final pixel-level visual design
|
||||||
|
- Final production copy
|
||||||
|
- Full Product page, Trust page, Pricing page, Docs surface, or Contact flow specification
|
||||||
|
- Platform UI, Filament theming, or `apps/platform` behavior
|
||||||
|
- Astro, Tailwind, animation, or implementation-detail prescriptions
|
||||||
|
- A full screenshot strategy beyond hero truthfulness and credibility boundaries
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Category Context**: The short eyebrow, descriptor, or context cue that anchors the hero in a believable product category or problem space.
|
||||||
|
- **Product-Near Visual**: A screenshot, crop, or truthful stylized product shot that signals real product existence and supports positioning.
|
||||||
|
- **Hero CTA Pair**: The primary action and lower-emphasis secondary deepening route that move visitors into the correct next step.
|
||||||
|
- **Trust Subclaim**: A concise early trust signal that is factual, bounded, and supportable by a deeper public trust surface.
|
||||||
|
- **Hero Semantic Structure**: The ordered set of content roles that design exploration must preserve even as visual execution evolves.
|
||||||
|
|
||||||
|
## Assumptions & Dependencies
|
||||||
|
|
||||||
|
- This specification builds on [Spec 214](../214-website-visual-foundation/spec.md), [Spec 215](../215-website-core-pages/spec.md), and [Spec 217](../217-homepage-structure/spec.md).
|
||||||
|
- `/product`, `/trust`, `/changelog`, and `/contact` remain the canonical downstream routes surfaced from the homepage hero.
|
||||||
|
- A publishable product-near screenshot or truthful visual approximation can be prepared without inventing product functionality.
|
||||||
|
- Trust, hosting, residency, governance, and compliance-adjacent language will stay limited to claims the team can support publicly.
|
||||||
|
- Later hero copy exploration, screenshot strategy, and Stitch design work must treat this specification as a semantic boundary rather than as optional inspiration.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: A first-time visitor can state what the product roughly is, why it matters, and the next sensible action within 60 seconds of reading the homepage hero.
|
||||||
|
- **SC-002**: The released hero includes all required functional roles: category context, primary headline, supporting copy, one dominant primary CTA, one secondary CTA, and a product-near visual on both desktop and mobile presentations.
|
||||||
|
- **SC-003**: Zero released hero variants contain unsupported compliance or security-guarantee language, fake trust badges, or fabricated product metrics.
|
||||||
|
- **SC-004**: From the hero alone, a visitor can reach at least one deeper informational surface and one primary next-step surface in one click, with no competing equally dominant primary actions.
|
||||||
|
- **SC-005**: On representative desktop and mobile widths, the primary CTA and the product-near visual remain visible without horizontal scrolling or loss of headline-first reading order.
|
||||||
|
- **SC-006**: Reviewers can map the shipped hero to the allowed semantic structure and confirm that it does not match any prohibited anti-pattern family defined by this specification.
|
||||||
|
|
||||||
|
## Planned Follow-on Work
|
||||||
|
|
||||||
|
- Product-visual and screenshot strategy
|
||||||
|
- Final hero copy exploration
|
||||||
|
- Stitch-based hero design exploration
|
||||||
|
- Downstream homepage-section detail work that assumes this hero contract rather than redefining it
|
||||||
202
specs/218-homepage-hero/tasks.md
Normal file
202
specs/218-homepage-hero/tasks.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# Tasks: Website Homepage Hero
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/218-homepage-hero/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/homepage-hero.openapi.yaml`
|
||||||
|
|
||||||
|
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [X] Lane assignment stays `Browser` in `fast-feedback`, which is the narrowest sufficient proof for this homepage-hero-only change.
|
||||||
|
- [X] New or changed tests stay in the existing website smoke suite instead of widening into a heavier family.
|
||||||
|
- [X] Shared helpers stay cheap by default; no backend, auth, database, or provider fixtures are introduced.
|
||||||
|
- [X] Planned validation commands remain the feature-local website build proof and focused Playwright smoke coverage.
|
||||||
|
- [X] No additional budget, baseline, or escalation path is required beyond `document-in-feature` for this slice.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Establish stable hero-specific render hooks before story work begins.
|
||||||
|
|
||||||
|
- [X] T001 Add stable homepage-hero element markers and an explicit hero root hook in `apps/website/src/components/sections/PageHero.astro`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Add the shared hero contract and smoke helpers that every story slice depends on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T002 [P] Extend reusable hero smoke assertions for semantic structure, CTA hierarchy, product-near visual presence, and mobile ordering in `apps/website/tests/smoke/smoke-helpers.ts`
|
||||||
|
- [X] T003 [P] Tighten the shared hero content object for CTA pair, product visual metadata, and trust-subclaim availability in `apps/website/src/content/pages/home.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Homepage-hero foundations are ready. User-story work can proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Understand the Product from the First Screen (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make the homepage hero explain what TenantAtlas is, why it matters, and what the next step is in the first reading pass.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/` and confirm the hero shows category context, a precise headline, supporting copy, and exactly one dominant primary CTA plus one lower-emphasis secondary CTA without opening any other route.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
> **NOTE**: Write this test first and confirm it fails before implementing the story.
|
||||||
|
|
||||||
|
- [X] T004 [P] [US1] Write failing hero-clarity smoke assertions for category context, headline and supporting-copy separation, and one CTA pair in `apps/website/tests/smoke/home-product.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T005 [P] [US1] Refine the hero eyebrow, headline, supporting copy, and CTA labels to meet Spec 218 positioning rules in `apps/website/src/content/pages/home.ts`
|
||||||
|
- [X] T006 [US1] Render the explicit hero text core and CTA cluster in `apps/website/src/components/sections/PageHero.astro`
|
||||||
|
|
||||||
|
**Checkpoint**: The homepage hero delivers the MVP story of product clarity, buyer relevance, and one clear next step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Establish Credibility Without Overclaiming (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Show product reality and bounded early trust inside the hero without collapsing into badge theater or generic SaaS visuals.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/` and confirm the hero visual reads as product-near, trust cues stay concise and supportable, and the hero feels credible without requiring the full Trust page.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
> **NOTE**: Write this test first and confirm it fails before implementing the story.
|
||||||
|
|
||||||
|
- [X] T007 [P] [US2] Write failing hero smoke assertions for product-near visual truth and bounded trust subclaims in `apps/website/tests/smoke/home-product.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T008 [P] [US2] Tighten the hero visual asset, alt text, and bounded trust-subclaim copy in `apps/website/public/images/hero-product-visual.svg` and `apps/website/src/content/pages/home.ts`
|
||||||
|
- [X] T009 [US2] Refine hero visual and trust-subclaim presentation so credibility cues stay secondary and supportable in `apps/website/src/components/sections/PageHero.astro`
|
||||||
|
|
||||||
|
**Checkpoint**: The hero shows believable product reality and early trust without fake proof systems or inflated claims.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Preserve Correct Next-Step Routing on Desktop and Mobile (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Keep the hero meaning order, CTA visibility, and route intent intact across narrow and wide screens.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/` on a narrow viewport and confirm the hero preserves headline and copy first, keeps the CTA pair visible, keeps the product-near visual visible, and routes into the intended hero destinations.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
> **NOTE**: Write this test first and confirm it fails before implementing the story.
|
||||||
|
|
||||||
|
- [X] T010 [P] [US3] Write failing hero smoke assertions for narrow-screen meaning order and hero route reachability in `apps/website/tests/smoke/home-product.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T011 [P] [US3] Tighten the hero responsive composition so the CTA pair and product-near visual remain visible on narrow screens in `apps/website/src/components/sections/PageHero.astro`
|
||||||
|
- [X] T012 [US3] Finalize the hero CTA routing and secondary deepening intent in `apps/website/src/content/pages/home.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: The hero preserves correct mobile meaning order and intentional next-step routing without hiding the CTA or product reality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Validate proof commands, tighten anti-pattern compliance, and capture close-out notes.
|
||||||
|
|
||||||
|
- [X] T013 [P] Review hero copy, trust wording, and anti-pattern compliance in `apps/website/src/content/pages/home.ts`, `apps/website/src/content/pages/trust.ts`, and `apps/website/public/images/hero-product-visual.svg`
|
||||||
|
- [X] T014 [P] Run `corepack pnpm build:website` from `package.json` and verify hero proof steps in `specs/218-homepage-hero/quickstart.md`
|
||||||
|
- [X] T015 [P] Run `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts` against the hero smoke coverage in `apps/website/tests/smoke/home-product.spec.ts`
|
||||||
|
- [X] T016 Record the focused hero smoke-coverage close-out and any helper-growth notes in `specs/218-homepage-hero/plan.md` and `specs/218-homepage-hero/quickstart.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories.
|
||||||
|
- **User Stories (Phases 3-5)**: Depend on Foundational. They remain independently testable, but shared edits to `apps/website/src/components/sections/PageHero.astro`, `apps/website/src/content/pages/home.ts`, and `apps/website/tests/smoke/home-product.spec.ts` should land sequentially in story order.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Starts after Foundational and is the MVP slice.
|
||||||
|
- **User Story 2 (P2)**: Starts after Foundational for test and content work, and remains independently valuable because it upgrades credibility and product-near truth inside the hero. Shared `PageHero.astro` work should follow US1.
|
||||||
|
- **User Story 3 (P3)**: Starts after Foundational for responsive and routing work, and remains independently valuable because it hardens mobile meaning order and hero route intent. Shared `PageHero.astro` and `home.ts` work should follow US2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the story-specific browser smoke assertions first and confirm they fail.
|
||||||
|
- Update content and supporting truth sources before final hero render adjustments in `apps/website/src/components/sections/PageHero.astro`.
|
||||||
|
- Treat `apps/website/src/components/sections/PageHero.astro`, `apps/website/src/content/pages/home.ts`, and `apps/website/tests/smoke/home-product.spec.ts` as shared assembly points: helper or asset work may branch, but shared file edits should land sequentially.
|
||||||
|
- Finish the hero render or content wiring before running the story proof commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
- `T002` and `T003` can run in parallel after `T001`.
|
||||||
|
- In US1, `T004` and `T005` can run in parallel before `T006`.
|
||||||
|
- In US2, `T007` and `T008` can run in parallel before `T009`.
|
||||||
|
- In US3, `T010` and `T011` can run in parallel before `T012`.
|
||||||
|
- In polish, `T013`, `T014`, and `T015` can run in parallel before `T016`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After the hero foundations are complete, split the first slice into test + content work:
|
||||||
|
Task: "T004 [US1] Write failing hero-clarity smoke assertions for category context, headline and supporting-copy separation, and one CTA pair"
|
||||||
|
Task: "T005 [US1] Refine the hero eyebrow, headline, supporting copy, and CTA labels"
|
||||||
|
|
||||||
|
# Then finish the shared hero render:
|
||||||
|
Task: "T006 [US1] Render the explicit hero text core and CTA cluster"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Safe split inside US2 is limited to assertions plus content and asset prep before shared hero render work:
|
||||||
|
Task: "T007 [US2] Write failing hero smoke assertions for product-near visual truth and bounded trust subclaims"
|
||||||
|
Task: "T008 [US2] Tighten the hero visual asset, alt text, and bounded trust-subclaim copy"
|
||||||
|
|
||||||
|
# Then finish the shared hero presentation work:
|
||||||
|
Task: "T009 [US2] Refine hero visual and trust-subclaim presentation"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Split mobile assertions from responsive render work first:
|
||||||
|
Task: "T010 [US3] Write failing hero smoke assertions for narrow-screen meaning order and hero route reachability"
|
||||||
|
Task: "T011 [US3] Tighten the hero responsive composition so the CTA pair and product-near visual remain visible on narrow screens"
|
||||||
|
|
||||||
|
# Then finalize route intent in the shared hero content:
|
||||||
|
Task: "T012 [US3] Finalize the hero CTA routing and secondary deepening intent"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Run the website build proof and the homepage hero smoke coverage.
|
||||||
|
5. Demo the homepage hero MVP with clear product explanation and one dominant next step.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Foundations establish stable render hooks, the shared hero data contract, and smoke helpers.
|
||||||
|
2. US1 locks in immediate product clarity and CTA discipline.
|
||||||
|
3. US2 upgrades hero credibility through truthful product-near media and bounded trust cues.
|
||||||
|
4. US3 hardens mobile meaning order and route intent without changing the broader homepage section model.
|
||||||
|
5. Polish runs the proof commands, validates anti-pattern compliance, and records close-out notes before merge.
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- Deliver through **User Story 1** for the smallest independently valuable slice.
|
||||||
|
- Add **User Story 2** next for stronger visual truth and bounded credibility cues.
|
||||||
|
- Finish with **User Story 3** for deliberate narrow-screen behavior and route integrity.
|
||||||
36
specs/221-findings-operator-inbox/checklists/requirements.md
Normal file
36
specs/221-findings-operator-inbox/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Findings Operator Inbox V1
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-20
|
||||||
|
**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 after initial spec drafting on 2026-04-20.
|
||||||
|
- No clarification markers remain.
|
||||||
|
- Candidate ledger was cleaned for the two findings candidates that are now specced as Spec 219 and Spec 221.
|
||||||
@ -0,0 +1,399 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Findings Operator Inbox Surface Contract
|
||||||
|
version: 1.0.0
|
||||||
|
description: >-
|
||||||
|
Internal reference contract for the canonical My Findings inbox and the
|
||||||
|
workspace overview Assigned to me signal. The application continues to
|
||||||
|
return rendered HTML through Filament and Livewire. The vendor media types
|
||||||
|
below document the structured page models that must be derivable before
|
||||||
|
rendering. This is not a public API commitment.
|
||||||
|
paths:
|
||||||
|
/admin/findings/my-work:
|
||||||
|
get:
|
||||||
|
summary: Canonical personal findings inbox
|
||||||
|
description: >-
|
||||||
|
Returns the rendered admin-plane inbox for the current user's visible
|
||||||
|
assigned open findings. Personal assignment scope is fixed. Tenant
|
||||||
|
prefiltering may be derived from the active admin tenant context.
|
||||||
|
responses:
|
||||||
|
'302':
|
||||||
|
description: Redirects into the existing workspace or tenant chooser flow when membership exists but workspace context is not yet established
|
||||||
|
'200':
|
||||||
|
description: Rendered My Findings inbox page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.my-findings-inbox+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MyFindingsInboxPage'
|
||||||
|
'404':
|
||||||
|
description: Workspace scope is not visible because membership is missing or out of scope
|
||||||
|
/admin:
|
||||||
|
get:
|
||||||
|
summary: Workspace overview with assigned-to-me signal
|
||||||
|
description: >-
|
||||||
|
Returns the rendered workspace overview. The vendor media type documents
|
||||||
|
the embedded personal findings signal used to decide whether assigned
|
||||||
|
findings work exists before opening the inbox.
|
||||||
|
responses:
|
||||||
|
'302':
|
||||||
|
description: Redirects into the existing workspace or tenant chooser flow when membership exists but workspace context is not yet established
|
||||||
|
'200':
|
||||||
|
description: Rendered workspace overview page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.workspace-overview-my-findings+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WorkspaceOverviewMyFindingsSignalSurface'
|
||||||
|
'404':
|
||||||
|
description: Workspace scope is not visible because membership is missing or out of scope
|
||||||
|
/admin/t/{tenant}/findings/{finding}:
|
||||||
|
get:
|
||||||
|
summary: Tenant finding detail with inbox 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 the My Findings inbox.
|
||||||
|
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-continuation+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:
|
||||||
|
MyFindingsInboxPage:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- header
|
||||||
|
- appliedScope
|
||||||
|
- availableFilters
|
||||||
|
- summaryCounts
|
||||||
|
- rows
|
||||||
|
- emptyState
|
||||||
|
properties:
|
||||||
|
header:
|
||||||
|
$ref: '#/components/schemas/InboxHeader'
|
||||||
|
appliedScope:
|
||||||
|
$ref: '#/components/schemas/InboxAppliedScope'
|
||||||
|
availableFilters:
|
||||||
|
description: Includes the fixed assignee-scope filter plus tenant, overdue, reopened, and high-severity filters. Tenant filter options are derived only from visible capability-eligible tenants.
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/InboxFilterDefinition'
|
||||||
|
summaryCounts:
|
||||||
|
$ref: '#/components/schemas/MyFindingsSummaryCounts'
|
||||||
|
rows:
|
||||||
|
description: Rows are ordered overdue first, reopened non-overdue second, then remaining findings. 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/AssignedFindingInboxRow'
|
||||||
|
emptyState:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/InboxEmptyState'
|
||||||
|
- type: 'null'
|
||||||
|
InboxHeader:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- My Findings
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
clearTenantFilterAction:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/ActionLink'
|
||||||
|
- type: 'null'
|
||||||
|
InboxAppliedScope:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- workspaceScoped
|
||||||
|
- assigneeScope
|
||||||
|
- tenantPrefilterSource
|
||||||
|
properties:
|
||||||
|
workspaceScoped:
|
||||||
|
type: boolean
|
||||||
|
assigneeScope:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- current_user_only
|
||||||
|
tenantPrefilterSource:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- active_tenant_context
|
||||||
|
- explicit_filter
|
||||||
|
- none
|
||||||
|
tenantLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
InboxFilterDefinition:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- key
|
||||||
|
- label
|
||||||
|
- fixed
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- assignee_scope
|
||||||
|
- tenant
|
||||||
|
- overdue
|
||||||
|
- reopened
|
||||||
|
- high_severity
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
fixed:
|
||||||
|
type: boolean
|
||||||
|
options:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/FilterOption'
|
||||||
|
FilterOption:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- value
|
||||||
|
- label
|
||||||
|
properties:
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
MyFindingsSummaryCounts:
|
||||||
|
type: object
|
||||||
|
description: Counts derived from the currently visible inbox queue after the fixed assignee scope and any active tenant, overdue, reopened, or high-severity filters are applied.
|
||||||
|
required:
|
||||||
|
- openAssigned
|
||||||
|
- overdueAssigned
|
||||||
|
properties:
|
||||||
|
openAssigned:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
overdueAssigned:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
AssignedFindingInboxRow:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- findingId
|
||||||
|
- tenantId
|
||||||
|
- tenantLabel
|
||||||
|
- summary
|
||||||
|
- severity
|
||||||
|
- status
|
||||||
|
- 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'
|
||||||
|
reopened:
|
||||||
|
type: boolean
|
||||||
|
ownerLabel:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
detailUrl:
|
||||||
|
type: string
|
||||||
|
navigationContext:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/CanonicalNavigationContext'
|
||||||
|
- type: 'null'
|
||||||
|
DueState:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- tone
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
tone:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- calm
|
||||||
|
- warning
|
||||||
|
- danger
|
||||||
|
InboxEmptyState:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- body
|
||||||
|
- action
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- no_visible_assigned_work
|
||||||
|
- active_tenant_prefilter_excludes_rows
|
||||||
|
action:
|
||||||
|
description: For `active_tenant_prefilter_excludes_rows`, clears the tenant prefilter and returns the queue to all visible tenants. For `no_visible_assigned_work`, opens the active tenant's findings list when tenant context exists; otherwise opens `/admin/choose-tenant` so the operator can establish tenant context before entering tenant findings.
|
||||||
|
$ref: '#/components/schemas/ActionLink'
|
||||||
|
WorkspaceOverviewMyFindingsSignalSurface:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- workspaceId
|
||||||
|
- myFindingsSignal
|
||||||
|
properties:
|
||||||
|
workspaceId:
|
||||||
|
type: integer
|
||||||
|
myFindingsSignal:
|
||||||
|
$ref: '#/components/schemas/MyFindingsSignal'
|
||||||
|
MyFindingsSignal:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- openAssignedCount
|
||||||
|
- overdueAssignedCount
|
||||||
|
- isCalm
|
||||||
|
- headline
|
||||||
|
- cta
|
||||||
|
properties:
|
||||||
|
openAssignedCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
overdueAssignedCount:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
isCalm:
|
||||||
|
type: boolean
|
||||||
|
headline:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Assigned to me
|
||||||
|
description:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
cta:
|
||||||
|
$ref: '#/components/schemas/OpenMyFindingsActionLink'
|
||||||
|
FindingDetailContinuation:
|
||||||
|
type: object
|
||||||
|
description: Continuity payload for tenant finding detail when it is opened from the My Findings inbox. The backLink is present whenever canonical inbox navigation context is provided and may be null only for direct entry without inbox continuity context.
|
||||||
|
required:
|
||||||
|
- findingId
|
||||||
|
- tenantId
|
||||||
|
properties:
|
||||||
|
findingId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
backLink:
|
||||||
|
description: Present when the detail page is reached from the My Findings inbox with canonical navigation context; null only for direct navigation that did not originate from the inbox.
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/BackToMyFindingsActionLink'
|
||||||
|
- 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
|
||||||
|
BackToMyFindingsActionLink:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ActionLink'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- Back to my findings
|
||||||
|
Badge:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
color:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
169
specs/221-findings-operator-inbox/data-model.md
Normal file
169
specs/221-findings-operator-inbox/data-model.md
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
# Data Model: Findings Operator Inbox V1
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not add or modify persisted entities. It introduces two derived read models:
|
||||||
|
|
||||||
|
- the canonical admin-plane `My Findings` inbox at `/admin/findings/my-work`
|
||||||
|
- one compact `Assigned to me` signal on `/admin`
|
||||||
|
|
||||||
|
Both remain projections over existing finding, tenant membership, and workspace context truth.
|
||||||
|
|
||||||
|
## Existing Persistent Inputs
|
||||||
|
|
||||||
|
### 1. Finding
|
||||||
|
|
||||||
|
- Purpose: Tenant-owned workflow record representing current governance or execution 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`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- `tenant()`
|
||||||
|
- `ownerUser()`
|
||||||
|
- `assigneeUser()`
|
||||||
|
|
||||||
|
Relevant existing semantics:
|
||||||
|
|
||||||
|
- `Finding::openStatusesForQuery()` defines inbox inclusion for open work.
|
||||||
|
- `Finding::openStatuses()` and terminal statuses remain unchanged.
|
||||||
|
- Spec 219 defines assignee inclusion and owner-only exclusion.
|
||||||
|
|
||||||
|
### 2. Tenant
|
||||||
|
|
||||||
|
- Purpose: Tenant boundary for findings ownership and tenant-plane detail navigation.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `name`
|
||||||
|
- `external_id`
|
||||||
|
- `status`
|
||||||
|
|
||||||
|
### 3. TenantMembership
|
||||||
|
|
||||||
|
- Purpose: Per-tenant entitlement boundary for visibility.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `tenant_id`
|
||||||
|
- `user_id`
|
||||||
|
- `role`
|
||||||
|
|
||||||
|
The inbox and overview signal must only materialize findings from tenants where the current user still has membership and findings-view entitlement.
|
||||||
|
|
||||||
|
### 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 inbox
|
||||||
|
- constrains visible tenants to the current workspace
|
||||||
|
- feeds the workspace overview signal
|
||||||
|
|
||||||
|
## Derived Presentation Entities
|
||||||
|
|
||||||
|
### 1. AssignedFindingInboxRow
|
||||||
|
|
||||||
|
Logical row model for `/admin/findings/my-work`.
|
||||||
|
|
||||||
|
| 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` | Workflow/lifecycle badge value | `Finding.status` |
|
||||||
|
| `dueAt` | Due date if present | `Finding.due_at` |
|
||||||
|
| `dueState` | Derived urgency label such as overdue or due soon | existing finding due-state logic |
|
||||||
|
| `reopened` | Whether reopened context should be emphasized | `Finding.status === reopened` or existing lifecycle cues |
|
||||||
|
| `ownerLabel` | Accountable owner when different from assignee | `ownerUser.name` |
|
||||||
|
| `detailUrl` | Tenant finding detail route | derived from tenant finding view route |
|
||||||
|
| `navigationContext` | Return-path payload back to the inbox | derived from `CanonicalNavigationContext` |
|
||||||
|
|
||||||
|
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::openStatusesForQuery()`
|
||||||
|
- `assignee_user_id` equals the current user ID
|
||||||
|
- Owner-only findings are excluded.
|
||||||
|
- Hidden-tenant findings produce no row, no count, and no filter option.
|
||||||
|
|
||||||
|
### 2. MyFindingsInboxState
|
||||||
|
|
||||||
|
Logical state model for the inbox page.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `workspaceId` | Current admin workspace scope |
|
||||||
|
| `assigneeUserId` | Fixed personal-work scope |
|
||||||
|
| `tenantFilter` | Optional active-tenant prefilter, defaulted from canonical admin tenant context |
|
||||||
|
| `overdueOnly` | Optional urgency narrowing |
|
||||||
|
| `reopenedOnly` | Optional reopened-work narrowing |
|
||||||
|
| `highSeverityOnly` | Optional severity narrowing |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `assigneeUserId` is fixed and cannot be cleared in v1.
|
||||||
|
- `tenantFilter` is clearable.
|
||||||
|
- `tenantFilter` values may only reference entitled tenants.
|
||||||
|
- Invalid or stale tenant filter state is discarded rather than widening visibility.
|
||||||
|
- Inbox summary counts reflect the currently visible queue after the fixed assignee scope and any active tenant, overdue, reopened, or high-severity filters are applied.
|
||||||
|
|
||||||
|
### 3. MyFindingsSignal
|
||||||
|
|
||||||
|
Logical summary model for the workspace overview.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `openAssignedCount` | Count of visible open assigned findings | derived `Finding` count |
|
||||||
|
| `overdueAssignedCount` | Count of visible overdue assigned findings | derived `Finding` count with `due_at < now()` |
|
||||||
|
| `isCalm` | Whether the signal should render calm wording | derived from the two counts |
|
||||||
|
| `headline` | Operator-facing signal summary | derived presentation copy |
|
||||||
|
| `description` | Supporting copy clarifying visible-scope truth | derived presentation copy |
|
||||||
|
| `ctaLabel` | Explicit drill-in label | fixed vocabulary `Open my findings` |
|
||||||
|
| `ctaUrl` | Canonical inbox route | derived from the new admin page URL |
|
||||||
|
|
||||||
|
Validation rules:
|
||||||
|
|
||||||
|
- Counts are workspace-scoped and tenant-entitlement scoped.
|
||||||
|
- The signal never implies hidden or inaccessible work.
|
||||||
|
- Calm wording is only allowed when both visible counts are zero.
|
||||||
|
|
||||||
|
## State And Ordering Rules
|
||||||
|
|
||||||
|
### Inbox inclusion order
|
||||||
|
|
||||||
|
1. Restrict to the current workspace.
|
||||||
|
2. Restrict to visible tenant IDs.
|
||||||
|
3. Restrict to `assignee_user_id = current user`.
|
||||||
|
4. Restrict to `Finding::openStatusesForQuery()`.
|
||||||
|
5. Apply optional tenant/overdue/reopened/high-severity filters.
|
||||||
|
6. Sort overdue work first, reopened non-overdue work next, then remaining work.
|
||||||
|
7. Within each urgency 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 work is the highest-priority urgency bucket.
|
||||||
|
- Reopened non-overdue work is the next urgency bucket.
|
||||||
|
- High-severity remains a filter and emphasis cue rather than a separate mandatory sort bucket.
|
||||||
|
- Terminal findings are never part of the inbox or signal.
|
||||||
|
|
||||||
|
### Empty-state semantics
|
||||||
|
|
||||||
|
- If no visible assigned open findings exist anywhere in scope, the inbox shows a calm empty state.
|
||||||
|
- If the active tenant prefilter causes the empty state while other visible tenants still have work, the empty state must explain the tenant boundary and provide a clear fallback CTA.
|
||||||
|
|
||||||
|
## Authorization-sensitive Output
|
||||||
|
|
||||||
|
- Tenant labels, filter values, rows, and counts are only derived from entitled tenants.
|
||||||
|
- The inbox itself is workspace-context dependent.
|
||||||
|
- Detail navigation remains tenant-scoped and must preserve existing `404`/`403` semantics on the destination.
|
||||||
|
- The derived signal and row model remain useful without ever revealing hidden tenant names or quantities.
|
||||||
245
specs/221-findings-operator-inbox/plan.md
Normal file
245
specs/221-findings-operator-inbox/plan.md
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
# Implementation Plan: Findings Operator Inbox V1
|
||||||
|
|
||||||
|
**Branch**: `221-findings-operator-inbox` | **Date**: 2026-04-20 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan keeps the work inside the existing admin workspace shell, tenant-owned `Finding` truth, and current tenant finding detail surface. The intended implementation is one new canonical `/admin` page plus one small workspace overview signal. It does not add persistence, capabilities, workflow states, queue automation, or a second mutation lane.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add one canonical admin-plane inbox at `/admin/findings/my-work` that shows the current user's open assigned findings across visible, capability-eligible tenants, keeps urgency first with a deterministic overdue-then-reopened ordering, and drills into the existing tenant finding detail with a preserved return path. Reuse existing `Finding` lifecycle and responsibility semantics, `CanonicalAdminTenantFilterState` for active-tenant prefiltering and applied-scope metadata, `CanonicalNavigationContext` for queue-to-detail continuity, and extend `WorkspaceOverviewBuilder` plus the workspace overview Blade view with one compact `Assigned to me` signal that exposes open and overdue counts with a single CTA into the inbox.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
|
||||||
|
**Primary Dependencies**: Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext`
|
||||||
|
**Storage**: PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned
|
||||||
|
**Testing**: Pest v4 feature tests with Livewire/Filament page assertions
|
||||||
|
**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 inbox and overview rendering DB-only, eager-load tenant/owner/assignee display context, avoid N+1 queries, and keep the first operator scan within the 10-second acceptance target
|
||||||
|
**Constraints**: No Graph calls, no new `OperationRun`, no new workflow states or owner semantics, no new capabilities, no new queue mutations, no hidden-tenant leakage, no duplicate personal-work truth between inbox and overview, and no new assets
|
||||||
|
**Scale/Scope**: One admin page, one workspace overview signal, one derived cross-tenant personal-assignment query, three focused feature suites, and one focused extension to existing route-alignment coverage
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament page, table, empty-state, badge, and Blade summary primitives only
|
||||||
|
- **Shared-family relevance**: findings workflow family and workspace overview summary family
|
||||||
|
- **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 inbox remains a standard read-first admin page and the overview signal stays an embedded summary, not a second queue
|
||||||
|
- **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 feature reads live `Finding` assignment and lifecycle truth only; no new snapshot or backup semantics are introduced |
|
||||||
|
| Read/write separation | PASS | PASS | The inbox and overview signal are read-only; existing finding lifecycle mutations remain on tenant finding detail |
|
||||||
|
| Graph contract path | PASS | PASS | No Graph client, contract-registry, or external API work is added |
|
||||||
|
| Deterministic capabilities / RBAC-UX | PASS | PASS | Workspace membership gates the admin page, missing workspace context follows the existing chooser or resume flow, tenant entitlement plus existing findings capability gate row visibility, filter values, summary counts, overview counts, and detail drilldown, and non-members remain `404` |
|
||||||
|
| Workspace / tenant isolation | PASS | PASS | Admin-plane inbox is workspace-scoped while 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 notification or progress-surface changes are introduced |
|
||||||
|
| Proportionality / no premature abstraction | PASS | PASS | The plan keeps the query logic inside the new page and existing workspace builder seams; no new shared registry or durable abstraction is required |
|
||||||
|
| Persisted truth / few layers | PASS | PASS | Inbox rows and overview counts remain direct derivations of existing `Finding`, tenant membership, and workspace context data |
|
||||||
|
| Badge semantics (BADGE-001) | PASS | PASS | Severity, lifecycle, and urgency cues reuse existing findings badge semantics rather than introducing local status language |
|
||||||
|
| Filament-native UI (UI-FIL-001) | PASS | PASS | Implementation stays within Filament page, table, header action, empty-state, and existing Blade summary composition |
|
||||||
|
| Action surface / inspect model | PASS | PASS | The inbox keeps one inspect model, full-row open to finding detail, no redundant `View`, and no dangerous queue actions |
|
||||||
|
| Decision-first / OPSURF | PASS | PASS | The inbox is the primary decision surface and the overview signal stays secondary with one CTA |
|
||||||
|
| Test governance (TEST-GOV-001) | PASS | PASS | Proof remains in three focused feature suites plus one focused extension to existing route-alignment coverage, with no browser or heavy-governance promotion |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design uses existing Filament v5 page 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 extends `AdminPanelProvider` only, `FindingResource` global search behavior is unchanged and already has a View page, and no new assets are required |
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Feature` for the admin inbox page, authorization boundary behavior, and workspace overview signal
|
||||||
|
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The feature is proven by visible operator behavior, capability-safe tenant filtering, continuity into existing tenant detail, empty-state fallback behavior, and overview-to-inbox truth alignment. Focused feature tests cover that without adding unit seams, browser automation, or heavy-governance breadth.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/MyFindingsSignalTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: Moderate. The tests need one workspace, multiple visible and hidden tenants, owner-versus-assignee combinations, open and terminal findings, and explicit workspace plus active-tenant session context.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; the new suites should reuse existing `createUserWithTenant(...)` and `Finding::factory()` flows and keep any inbox-specific fixture 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 inbox depends on workspace context, active tenant continuity, and admin-to-tenant drilldown behavior
|
||||||
|
- **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, filter options, or counts, owner-only findings stay out of the queue, the active tenant prefilter is clearable without removing personal scope, the empty state offers the correct fallback CTA, and the detail page exposes a working return path to the inbox.
|
||||||
|
- **Budget / baseline / trend follow-up**: none
|
||||||
|
- **Review-stop questions**: Did the implementation stay read-only? Did the overview signal remain a signal instead of a second queue? Did any shared query abstraction appear without clear reuse pressure beyond this feature? Did row drilldown preserve tenant-safe continuity?
|
||||||
|
- **Escalation path**: document-in-feature unless a second cross-tenant findings surface or a new shared query framework 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**: The feature remains bounded to one queue surface and one overview signal, with no new persistence, workflow family, or reusable platform abstraction
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/221-findings-operator-inbox/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── findings-operator-inbox.logical.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── Pages/
|
||||||
|
│ │ │ └── Findings/
|
||||||
|
│ │ │ └── MyFindingsInbox.php
|
||||||
|
│ │ └── Resources/
|
||||||
|
│ │ └── FindingResource.php
|
||||||
|
│ ├── Providers/
|
||||||
|
│ │ └── Filament/
|
||||||
|
│ │ └── AdminPanelProvider.php
|
||||||
|
│ └── Support/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ └── CanonicalAdminTenantFilterState.php
|
||||||
|
│ ├── Navigation/
|
||||||
|
│ │ └── CanonicalNavigationContext.php
|
||||||
|
│ └── Workspaces/
|
||||||
|
│ └── WorkspaceOverviewBuilder.php
|
||||||
|
├── resources/
|
||||||
|
│ └── views/
|
||||||
|
│ └── filament/
|
||||||
|
│ └── pages/
|
||||||
|
│ ├── findings/
|
||||||
|
│ │ └── my-findings-inbox.blade.php
|
||||||
|
│ └── workspace-overview.blade.php
|
||||||
|
└── tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Authorization/
|
||||||
|
│ └── MyWorkInboxAuthorizationTest.php
|
||||||
|
├── Dashboard/
|
||||||
|
│ └── MyFindingsSignalTest.php
|
||||||
|
├── Filament/
|
||||||
|
│ └── WorkspaceOverviewNavigationTest.php
|
||||||
|
└── Findings/
|
||||||
|
└── MyWorkInboxTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Standard Laravel monolith. The feature stays inside the existing admin panel provider, finding domain model, workspace overview builder, and focused Pest feature suites. No new base directory, package, or persistent model is required.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| none | — | — |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Assignee-based finding work exists in the data model but not as a trustworthy cross-tenant start-of-day surface.
|
||||||
|
- **Existing structure is insufficient because**: The tenant-local findings list can filter to `My assigned work`, but it still forces users to guess the correct tenant first and does not create one canonical admin-plane queue.
|
||||||
|
- **Narrowest correct implementation**: Add one admin page and one small workspace overview signal, both derived directly from existing finding assignment, lifecycle, due-date, severity, and entitlement truth.
|
||||||
|
- **Ownership cost created**: One new page/view pair, one small overview payload addition, three focused feature suites, and one focused extension to existing route-alignment coverage.
|
||||||
|
- **Alternative intentionally rejected**: A broader cross-tenant findings register, owner-plus-assignee mixed queue, or new shared findings query service. Those would increase semantics and maintenance cost beyond the current release need.
|
||||||
|
- **Release truth**: Current-release truth. The work operationalizes the already-shipped assignee concept now rather than preparing future queue or notification systems.
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Implement the inbox as an admin panel page with slug `findings/my-work` under the existing `AdminPanelProvider`, not as a tenant resource variant and not as a standalone controller route.
|
||||||
|
- Keep the cross-tenant personal queue fully derived from `Finding` plus existing workspace and tenant entitlements, using `Finding::openStatusesForQuery()` and eager-loaded tenant/owner/assignee relationships.
|
||||||
|
- Reuse `CanonicalAdminTenantFilterState` to apply the active tenant as the default prefilter, expose the applied-scope state, and preserve the clear-filter behavior without inventing a second context mechanism.
|
||||||
|
- Use `CanonicalNavigationContext` when building row drilldowns so tenant finding detail can expose `Back to my findings` with tenant-safe continuity.
|
||||||
|
- Add the workspace signal as a dedicated overview payload rendered in the existing `/admin` Blade page rather than overloading generic summary metrics, because the signal must show open count, overdue count, and one explicit CTA.
|
||||||
|
- Prove the feature with three focused Pest feature suites plus one focused extension to existing route-alignment coverage.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/221-findings-operator-inbox/`:
|
||||||
|
|
||||||
|
- `research.md`: routing, query, continuity, and workspace-signal decisions
|
||||||
|
- `data-model.md`: existing entities plus derived inbox row and workspace signal projections
|
||||||
|
- `contracts/findings-operator-inbox.logical.openapi.yaml`: internal logical contract for the inbox, overview signal, and detail continuity inputs
|
||||||
|
- `quickstart.md`: focused validation workflow for implementation and review
|
||||||
|
|
||||||
|
Design decisions:
|
||||||
|
|
||||||
|
- No schema migration is required; the inbox and overview signal remain fully derived.
|
||||||
|
- The canonical implementation seam is one new admin page plus one existing workspace overview builder/view extension, not a new shared findings-query subsystem.
|
||||||
|
- Active tenant context stays canonical through `CanonicalAdminTenantFilterState`, while detail continuity stays canonical through `CanonicalNavigationContext`.
|
||||||
|
- Existing tenant finding detail remains the only mutation surface for assignment and workflow actions.
|
||||||
|
|
||||||
|
## Phase 1 Agent Context Update
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Constitution Check — Post-Design Re-evaluation
|
||||||
|
|
||||||
|
- PASS — the design remains read-surface only, adds no writes, no Graph calls, 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`, and no global-search or destructive-action behavior changes are introduced.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Add The Canonical Admin Inbox Surface
|
||||||
|
|
||||||
|
**Goal**: Create one workspace-scoped personal findings queue under `/admin/findings/my-work`.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Add a new Filament admin page with slug `findings/my-work`, `HasTable`, workspace membership access checks, and fixed assignee/open-status scope |
|
||||||
|
| A.2 | `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php` | Render the page shell, page description, native Filament table, and branch-specific calm empty-state CTAs: clear the tenant prefilter when it alone excludes rows, otherwise open tenant findings for the active tenant or `/admin/choose-tenant` when no tenant context exists |
|
||||||
|
| 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 Queue Truth From Existing Findings Semantics
|
||||||
|
|
||||||
|
**Goal**: Keep inclusion, urgency, and owner-versus-assignee behavior aligned with Specs 111 and 219.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Build the table query and derived inbox counts from `Finding`, restricted to the current workspace, visible capability-eligible tenants, `assignee_user_id = auth()->id()`, and `Finding::openStatusesForQuery()` |
|
||||||
|
| B.2 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/app/Filament/Resources/FindingResource.php` | Reuse or mirror existing severity, lifecycle, overdue, reopened, and owner context presentation helpers without inventing new badge semantics |
|
||||||
|
| B.3 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Add the available-filters contract for fixed assignee scope, tenant, overdue, reopened, and high-severity filters, capability-safe tenant filter options, applied-scope metadata, summary counts, and deterministic urgency sorting with due-date and finding-ID tie-breakers while keeping personal assignment scope fixed and non-removable |
|
||||||
|
|
||||||
|
### Phase C — Honor Active Tenant Context And Queue-to-Detail Continuity
|
||||||
|
|
||||||
|
**Goal**: Make the inbox feel canonical inside the existing admin shell instead of a detached register.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Reuse `CanonicalAdminTenantFilterState` so the active tenant becomes the default prefilter and can be cleared via one header action |
|
||||||
|
| C.2 | `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` | Build row URLs to tenant finding detail with `CanonicalNavigationContext` carrying `Back to my findings` continuity |
|
||||||
|
| C.3 | `apps/platform/app/Filament/Resources/FindingResource.php` or existing detail page seam | Ensure the tenant detail page consumes the navigation context and exposes the inbox return link without changing mutation semantics |
|
||||||
|
|
||||||
|
### Phase D — Add The Workspace Overview Signal Without Creating A Second Queue
|
||||||
|
|
||||||
|
**Goal**: Make `/admin` show whether personal findings work exists and link into the inbox in one click.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add a small `my_findings_signal` payload with visible capability-safe open assigned count, overdue count, calm state, and CTA URL |
|
||||||
|
| D.2 | `apps/platform/resources/views/filament/pages/workspace-overview.blade.php` | Render one compact assigned-to-me summary block ahead of the broader workspace metrics |
|
||||||
|
| D.3 | Existing workspace navigation helpers | Keep the CTA pointed at the canonical inbox route and avoid duplicating queue logic or urgency semantics in the overview itself |
|
||||||
|
|
||||||
|
### Phase E — Protect The Feature With Focused Regression Coverage
|
||||||
|
|
||||||
|
**Goal**: Lock down visibility, prioritization, tenant safety, and overview-to-inbox truth alignment.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| E.1 | `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php` | Cover capability-safe visible rows only, owner-context rendering when owner differs from assignee, the full available-filters contract truth for fixed assignee scope plus tenant, overdue, reopened, and high-severity filters, tenant filter option truth, owner-only exclusion, deterministic urgency ordering with due-date and finding-ID tie-breakers, clearable tenant prefilter, applied-scope and summary-count truth, zero-visible-work and tenant-prefilter empty-state branches, and drilldown continuity |
|
||||||
|
| E.2 | `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php` | Cover admin-plane workspace recovery behavior for missing workspace context, member-without-capability disclosure boundaries, protected-destination `403`, and deny-as-not-found boundaries for non-members |
|
||||||
|
| E.3 | `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php` | Cover overview signal counts, calm state, capability-safe suppression, and CTA alignment with inbox truth |
|
||||||
|
| 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 |
|
||||||
147
specs/221-findings-operator-inbox/quickstart.md
Normal file
147
specs/221-findings-operator-inbox/quickstart.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# Quickstart: Findings Operator Inbox V1
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that `/admin/findings/my-work` gives the current user one trustworthy assigned-findings queue across visible tenants, and that `/admin` exposes a matching `Assigned to me` signal with one CTA into the inbox.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
- assigned open finding in tenant A
|
||||||
|
- assigned overdue finding in tenant B
|
||||||
|
- assigned reopened finding in tenant A
|
||||||
|
- assigned ordinary finding without a due date
|
||||||
|
- owner-only open finding where the user is not assignee
|
||||||
|
- assigned terminal finding
|
||||||
|
- assigned finding in a tenant the user is no longer entitled to inspect
|
||||||
|
- assigned finding in a tenant where the user remains a member but no longer has findings visibility capability
|
||||||
|
4. Ensure the workspace overview at `/admin` is reachable for the acting 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/MyWorkInboxTest.php \
|
||||||
|
tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php \
|
||||||
|
tests/Feature/Dashboard/MyFindingsSignalTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the required route-alignment regression for the workspace shell CTA:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Feature/Filament/WorkspaceOverviewNavigationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Validation Pass
|
||||||
|
|
||||||
|
### 1. Workspace overview signal
|
||||||
|
|
||||||
|
Open `/admin`.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the page shows an `Assigned to me` signal,
|
||||||
|
- the signal exposes visible open count and overdue count,
|
||||||
|
- the wording stays calm only when both counts are zero,
|
||||||
|
- and the CTA is labeled `Open my findings`.
|
||||||
|
|
||||||
|
### 2. Canonical inbox route
|
||||||
|
|
||||||
|
Open `/admin/findings/my-work`.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the page title and copy use finding-first vocabulary,
|
||||||
|
- rows show tenant, finding summary, severity, lifecycle, and due urgency,
|
||||||
|
- owner context is shown when owner differs from assignee,
|
||||||
|
- the page reflects fixed assigned-to-me scope and summary counts that match the visible rows,
|
||||||
|
- and no queue-level mutation actions appear.
|
||||||
|
|
||||||
|
### 3. Assignee versus owner boundary
|
||||||
|
|
||||||
|
With fixtures where the acting user is owner-only on one finding and assignee on another:
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the assignee row is visible,
|
||||||
|
- the owner-only row is absent,
|
||||||
|
- a finding where the user is both owner and assignee remains visible,
|
||||||
|
- and owner context is shown only when it differs from the assignee.
|
||||||
|
|
||||||
|
### 4. Active tenant prefilter
|
||||||
|
|
||||||
|
Set an active tenant context before opening the inbox.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the queue defaults to that tenant,
|
||||||
|
- the personal assignment scope remains fixed,
|
||||||
|
- the applied scope reflects that tenant-prefilter source correctly,
|
||||||
|
- a `Clear tenant filter` affordance is available,
|
||||||
|
- summary counts continue to match the visible rows,
|
||||||
|
- and clearing the tenant filter returns the queue to all visible tenants.
|
||||||
|
|
||||||
|
### 5. Hidden-tenant and capability suppression
|
||||||
|
|
||||||
|
Remove the acting user's entitlement from one tenant that still contains assigned findings, and remove findings visibility capability from another tenant where membership still remains.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- those findings disappear from the inbox,
|
||||||
|
- they do not contribute to the workspace signal,
|
||||||
|
- neither tenant appears as a filter value or empty-state hint,
|
||||||
|
- and protected destinations still reject in-scope users without findings visibility.
|
||||||
|
|
||||||
|
### 6. Urgency ordering
|
||||||
|
|
||||||
|
With ordinary, reopened, overdue, and undated assigned findings:
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- overdue work appears ahead of reopened and ordinary work,
|
||||||
|
- reopened non-overdue work appears ahead of ordinary work,
|
||||||
|
- within the same urgency bucket, due-dated rows appear ahead of undated rows,
|
||||||
|
- reopened context is visible,
|
||||||
|
- and terminal findings do not appear.
|
||||||
|
|
||||||
|
### 7. Detail continuity
|
||||||
|
|
||||||
|
Open a row from the inbox.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the destination is the existing tenant finding detail route,
|
||||||
|
- the tenant scope is correct,
|
||||||
|
- and the page offers a `Back to my findings` return path.
|
||||||
|
|
||||||
|
### 8. Empty-state behavior
|
||||||
|
|
||||||
|
Validate two empty states:
|
||||||
|
|
||||||
|
- no visible assigned work anywhere
|
||||||
|
- no rows only because the active tenant prefilter narrows the queue
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the zero-visible-work branch stays calm and offers a clear fallback CTA,
|
||||||
|
- the first state's CTA opens tenant findings when active tenant context exists and `/admin/choose-tenant` when it does not,
|
||||||
|
- the second state explains the tenant boundary instead of claiming there is no work anywhere,
|
||||||
|
- the second state's CTA clears the tenant prefilter back to all visible tenants,
|
||||||
|
- and neither state leaks hidden tenant information.
|
||||||
|
|
||||||
|
## Final Verification Notes
|
||||||
|
|
||||||
|
- The inbox is read-first only. Assignment and workflow mutations stay on tenant finding detail.
|
||||||
|
- The workspace overview signal must remain a signal, not a second queue.
|
||||||
|
- If a reviewer can infer hidden tenant work from counts, filter options, or empty-state copy, treat that as a release blocker.
|
||||||
49
specs/221-findings-operator-inbox/research.md
Normal file
49
specs/221-findings-operator-inbox/research.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Research: Findings Operator Inbox V1
|
||||||
|
|
||||||
|
## Decision 1: Implement the inbox as an admin panel page with slug `findings/my-work`
|
||||||
|
|
||||||
|
- **Decision**: Add a new Filament admin page under the existing `AdminPanelProvider` for the canonical inbox route `/admin/findings/my-work`.
|
||||||
|
- **Rationale**: The feature is explicitly an admin-plane, workspace-scoped surface. The admin panel already owns comparable custom pages such as `FindingExceptionsQueue`, and page registration gives the correct Filament middleware, route shape, and Livewire page lifecycle without creating a second routing model.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Reuse the tenant-local `FindingResource` list as the canonical inbox. Rejected because it keeps the operator trapped in tenant-first navigation and does not answer the cross-tenant personal-work question.
|
||||||
|
- Add a standalone controller route in `routes/web.php`. Rejected because this is a normal admin panel surface, not a one-off shell route like `/admin` home.
|
||||||
|
|
||||||
|
## Decision 2: Keep queue truth as a direct `Finding` query, not a new shared query subsystem
|
||||||
|
|
||||||
|
- **Decision**: Build the inbox from `Finding` records scoped by current workspace, visible capability-eligible tenant IDs, `assignee_user_id = current user`, and `Finding::openStatusesForQuery()`, with eager-loaded `tenant`, `ownerUser`, and `assigneeUser` relationships.
|
||||||
|
- **Rationale**: The feature has two concrete consumers, but both are narrow and local: one queue page and one workspace signal. A direct query keeps the logic readable, honors Spec 111 and Spec 219 as-is, and avoids importing a new reusable abstraction before it is clearly needed.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Introduce a new shared findings-query service immediately. Rejected because the scope is still small and the repo guidance prefers direct implementation until a second real abstraction pressure appears.
|
||||||
|
- Mix owner and assignee semantics into one queue query. Rejected because Spec 219 explicitly separates assignee work from owner accountability.
|
||||||
|
|
||||||
|
## Decision 3: Reuse `CanonicalAdminTenantFilterState` for the default active-tenant prefilter
|
||||||
|
|
||||||
|
- **Decision**: Let the inbox synchronize its tenant filter through `CanonicalAdminTenantFilterState`, so the active tenant becomes the default prefilter and can be cleared without removing personal assignment scope.
|
||||||
|
- **Rationale**: The repo already uses this helper on admin-panel lists and monitoring pages to keep active tenant context honest and clearable. Reusing it keeps the inbox aligned with existing admin context behavior and avoids inventing a page-specific prefilter mechanism.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Drive tenant prefiltering only through explicit query parameters. Rejected because the feature requirement is about active tenant context, not just shareable URLs.
|
||||||
|
- Hard-lock the queue to the active tenant whenever tenant context exists. Rejected because the spec requires a clear path back to all visible tenants.
|
||||||
|
|
||||||
|
## Decision 4: Add the workspace signal as a dedicated overview payload and Blade block
|
||||||
|
|
||||||
|
- **Decision**: Extend `WorkspaceOverviewBuilder` with a compact `my_findings_signal` payload and render it directly in the existing workspace overview Blade view with one explicit CTA.
|
||||||
|
- **Rationale**: The signal must show open count, overdue count, calm state, and one named CTA. The existing generic `summary_metrics` lane is optimized for one metric value plus description and does not cleanly express the contract without distorting the overview metric family.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Encode the signal as another generic summary metric. Rejected because the metric card does not naturally expose both counts and an explicit `Open my findings` CTA.
|
||||||
|
- Add a second standalone dashboard or queue widget. Rejected because the overview only needs a small drill-in signal, not another work surface.
|
||||||
|
|
||||||
|
## Decision 5: Preserve queue-to-detail continuity through `CanonicalNavigationContext`
|
||||||
|
|
||||||
|
- **Decision**: Append a `CanonicalNavigationContext` payload when an inbox row opens `/admin/t/{tenant}/findings/{finding}` so the detail page can render `Back to my findings`.
|
||||||
|
- **Rationale**: The repo already uses `CanonicalNavigationContext` for cross-surface return links on admin and tenant detail pages. Reusing it preserves a single continuity model and keeps the return path explicit instead of relying on fragile browser history.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Depend on the browser referer only. Rejected because it is brittle across reloads, tabs, and copied links.
|
||||||
|
- Add a new inbox-specific controller just to set session return state. Rejected because the existing navigation context already solves this problem cleanly.
|
||||||
|
|
||||||
|
## Decision 6: Prove the feature with three focused feature suites plus one route-alignment extension
|
||||||
|
|
||||||
|
- **Decision**: Add `MyWorkInboxTest`, `MyWorkInboxAuthorizationTest`, and `MyFindingsSignalTest` as the three new focused suites, and extend `WorkspaceOverviewNavigationTest` for route-alignment proof.
|
||||||
|
- **Rationale**: The user-visible risk is visibility, prioritization, tenant safety, and overview-to-inbox truth alignment. Those are best proven through focused feature coverage using the existing workspace and tenant helpers, while the inbox CTA alignment belongs in the existing route-alignment regression instead of a fourth new suite.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Add browser coverage. Rejected because the surface is simple and already well represented by Filament/Livewire feature assertions.
|
||||||
|
- Add a unit-only seam around queue counting. Rejected because the important risk is integrated scope behavior, not isolated arithmetic.
|
||||||
236
specs/221-findings-operator-inbox/spec.md
Normal file
236
specs/221-findings-operator-inbox/spec.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# Feature Specification: Findings Operator Inbox V1
|
||||||
|
|
||||||
|
**Feature Branch**: `221-findings-operator-inbox`
|
||||||
|
**Created**: 2026-04-20
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Findings Operator Inbox v1"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Findings can already be assigned, but the current assignee still has to reconstruct personal work from tenant-local findings lists and ad hoc filters.
|
||||||
|
- **Today's failure**: Assignment remains metadata instead of day-to-day workflow. Operators cannot answer "what is mine right now?" from one trustworthy surface, so they tenant-hop or miss overdue assigned work.
|
||||||
|
- **User-visible improvement**: One personal inbox shows the current user's open assigned findings across visible tenants, highlights urgency, and gives a direct path into the correct finding record.
|
||||||
|
- **Smallest enterprise-capable version**: A canonical read-first inbox for the current user's assigned open findings, urgency filters, tenant-safe drilldown into the existing finding detail, and one small workspace-overview signal that links into the inbox.
|
||||||
|
- **Explicit non-goals**: No owner-only accountability queue, no unassigned intake queue, no notifications or escalation, no comments or external ticketing, no team-routing logic, no bulk queue actions, and no new permission system.
|
||||||
|
- **Permanent complexity imported**: One canonical inbox page, one small workspace overview summary signal, one derived personal-assignment query contract, and focused regression coverage for visibility, context handoff, and empty-state behavior.
|
||||||
|
- **Why now**: Spec 219 made owner versus assignee semantics explicit. The next smallest findings execution slice is to make assignee-based work actually discoverable before intake, escalation, or hygiene hardening land.
|
||||||
|
- **Why not local**: A tenant-local `My assigned` filter still forces operators to guess which tenant to open first and does not create one trustworthy start-of-day queue across the workspace-visible tenant set.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: One mild `Many surfaces` risk because the slice touches the inbox and a workspace overview signal. The scope remains acceptable because both surfaces express the same personal-work truth and do not introduce a new meta-layer.
|
||||||
|
- **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**: canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/findings/my-work` as the new canonical personal findings inbox
|
||||||
|
- `/admin` as the workspace overview where the assigned-to-me signal links into the inbox
|
||||||
|
- `/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 inbox and workspace overview signal are derived views over existing finding assignment, lifecycle, severity, due-date, and tenant-entitlement truth.
|
||||||
|
- **RBAC**: Workspace membership is required to reach the canonical inbox in the admin plane. Every visible row and count additionally requires tenant entitlement plus the existing findings view capability for the referenced tenant. Non-members or out-of-scope users remain deny-as-not-found. In-scope users without the required capability remain forbidden on protected destinations.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: The inbox always applies `assignee = current user` and open-status scope. When an active tenant context exists, the page additionally prefilters to that tenant by default while allowing the operator to clear only the tenant prefilter, not the personal assignment scope.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Counts, rows, tenant filter values, and drilldown links materialize only from tenants the current user may already inspect. Hidden tenants contribute nothing to counts, labels, 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 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| My Findings inbox | yes | Native Filament page + existing table, filter, and empty-state primitives | Same findings workflow family as tenant findings list and finding detail | table, filter state, urgency emphasis, return path | no | Read-first queue; no new mutation family on the inbox itself |
|
||||||
|
| Workspace overview assigned-to-me signal | yes | Native Filament widget or summary primitives | Same workspace-overview summary family as other `/admin` attention signals | embedded summary, drill-in CTA | no | Small entry signal only; not a second queue |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| My Findings inbox | Primary Decision Surface | The operator starts the work session or returns to outstanding assigned findings | Tenant, severity, lifecycle status, due or overdue state, and reopened cues for the operator's assigned work | Full finding detail, evidence, audit trail, and exception context after opening the finding | Primary because this is the first dedicated queue for assignee-based execution work | Aligns daily execution around assigned findings instead of tenant hopping | Removes repeated search across tenant dashboards and tenant findings lists |
|
||||||
|
| Workspace overview assigned-to-me signal | Secondary Context Surface | The operator lands on `/admin` and needs to know whether personal follow-up exists before choosing a domain | Open assigned count, overdue count, and one CTA into the inbox | Full inbox and finding detail after drill-in | Secondary because it points to work rather than hosting it | Keeps workspace home aligned with the assignee queue | Removes opening multiple pages just to discover personal work |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| My Findings inbox | List / Table / Bulk | CRUD / List-first Resource | Open the most urgent assigned finding | Finding | required | Utility filters and fallback navigation stay outside row action noise | None on the inbox; dangerous lifecycle actions stay on finding detail | /admin/findings/my-work | /admin/t/{tenant}/findings/{finding} | Active workspace, optional active-tenant prefilter, tenant column, assigned-to-me scope | Findings / Finding | What is assigned to the current user, what is overdue, and which tenant it belongs to | Operationally this is a personal worklist, but the interaction model is list-first because row open is the only primary action and all mutation remains on the finding detail surface. |
|
||||||
|
| Workspace overview assigned-to-me signal | Utility / System | Read-only Registry / Report Surface | Open the inbox | Explicit summary CTA | forbidden | Summary strip only | none | /admin | /admin/findings/my-work | Active workspace and visible-scope counts | My findings | Whether the current user has assigned open or overdue work | Embedded summary drill-in that stays read-only and points into the canonical inbox rather than becoming its own queue surface. |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| My Findings inbox | Tenant operator or tenant manager | Decide which assigned finding to open and work next | List-first personal work surface | What is assigned to me right now across my visible tenants, and what needs attention first? | Tenant, finding summary, severity, lifecycle status, due date or overdue state, reopened cue, and owner when different from assignee | Raw evidence, run context, exception history, and full audit trail | lifecycle, due urgency, severity, responsibility role | none on the inbox itself; existing tenant detail surfaces keep their current mutation scopes | Open finding, apply filters, clear tenant prefilter | none |
|
||||||
|
| Workspace overview assigned-to-me signal | Workspace member with findings visibility | Decide whether personal findings work exists and drill into it | Summary drill-in | Do I have assigned findings work that needs attention right now? | Open assigned count, overdue count, and one CTA | none | queue presence, overdue urgency | none | Open my findings | 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**: Assigned findings are already present in the product, but there is no single trustworthy place where the assignee can start work across visible tenants.
|
||||||
|
- **Existing structure is insufficient because**: Tenant-local findings pages and one quick filter do not answer the cross-tenant personal-work question. They force the operator to search for assigned work instead of receiving a real queue.
|
||||||
|
- **Narrowest correct implementation**: One derived inbox page and one small workspace summary signal, both powered by existing assignee, severity, due-date, lifecycle, tenant, and entitlement truth.
|
||||||
|
- **Ownership cost**: One cross-tenant query shape, one context-prefilter rule, one return-path convention, and focused regression tests for visibility and empty-state behavior.
|
||||||
|
- **Alternative intentionally rejected**: A full cross-tenant findings register, an owner-plus-assignee mixed queue, or a team workboard was rejected because those shapes are broader than the current operator problem.
|
||||||
|
- **Release truth**: Current-release truth. This slice makes the already-shipped assignment concept operational now.
|
||||||
|
|
||||||
|
### 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 page and one workspace summary surface. Focused feature coverage is sufficient to prove personal-queue visibility, tenant-safe filtering, context handoff, and empty-state behavior without introducing heavy-governance or browser cost.
|
||||||
|
- **New or expanded test families**: Add focused coverage for the canonical inbox page, the workspace overview signal, positive and negative authorization, owner-only versus assignee-only visibility, and active-tenant prefilter behavior.
|
||||||
|
- **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 active tenant context prefilters the inbox safely, that the tenant-prefilter empty-state CTA clears back to all visible tenants, and that row drilldown preserves a return path to the queue.
|
||||||
|
- **Reviewer handoff**: Reviewers must confirm that hidden-tenant findings never leak into rows or counts, owner-only findings are excluded from the personal queue, the queue remains read-first, and the workspace overview CTA lands on the same assigned-work truth shown by the inbox.
|
||||||
|
- **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/MyWorkInboxTest.php tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Dashboard/MyFindingsSignalTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - See my assigned findings in one queue (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want one personal findings queue across my visible tenants so I can start work without searching tenant by tenant.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core value. If the assignee still has to reconstruct personal work from multiple tenant pages, assignment remains metadata instead of workflow.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding multiple visible tenants with findings where the current user is assignee, owner-only, or unrelated, then verifying that the inbox shows only open assigned work from entitled tenants.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the current user is assigned open findings across multiple visible tenants, **When** the user opens the inbox, **Then** the page shows only those open assigned findings with tenant and urgency context.
|
||||||
|
2. **Given** an active tenant context exists, **When** the user opens the inbox, **Then** the queue is prefiltered to that tenant while keeping the personal assignment scope intact.
|
||||||
|
3. **Given** the current user is owner but not assignee on an open finding, **When** the user opens the inbox, **Then** that finding does not appear in the personal queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Prioritize urgent work and drill into the right finding (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want the queue to surface overdue or otherwise urgent assigned work first and take me straight into the correct finding detail, so I can act without reconstructing tenant context.
|
||||||
|
|
||||||
|
**Why this priority**: A queue that is complete but not prioritizable still slows operators down. The first work surface must make the next click obvious.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding overdue, reopened, high-severity, and ordinary assigned findings, opening the inbox, and verifying both urgency ordering and drilldown behavior.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the current user has overdue, reopened, and ordinary assigned findings, **When** the inbox renders, **Then** overdue findings are presented first, reopened non-overdue findings are presented ahead of ordinary assigned work, and high severity remains a filter and emphasis cue rather than a separate mandatory sort bucket in v1.
|
||||||
|
2. **Given** the operator opens a finding from the inbox, **When** the destination page loads, **Then** it opens the existing tenant finding detail for the correct tenant and preserves a return path to the inbox.
|
||||||
|
3. **Given** no visible assigned findings exist because the active tenant prefilter excludes them, **When** the operator opens the inbox, **Then** the empty state explains that the current tenant filter is narrowing the queue and offers one clear fallback CTA that clears the tenant prefilter back to all visible tenants.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Discover my assigned work from the workspace overview (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace member, I want a small assigned-to-me signal on the workspace overview so I can tell immediately whether personal findings work exists before I choose a tenant or open another domain.
|
||||||
|
|
||||||
|
**Why this priority**: This is the smallest entry-point improvement that makes the new queue discoverable without turning `/admin` into a second findings page.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding assigned and unassigned work, opening the workspace overview, and verifying that the signal matches the inbox truth and drills into the queue in one click.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the current user has visible assigned open findings, **When** the user opens the workspace overview, **Then** the assigned-to-me signal shows the open count, overdue count, and one CTA into the inbox.
|
||||||
|
2. **Given** the current user has no visible assigned open findings, **When** the user opens the workspace overview, **Then** the signal remains calm and does not imply missing or hidden work.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A finding may still reference the current user as assignee in a tenant the user is no longer entitled to; the inbox and overview counts must not show it.
|
||||||
|
- An active tenant prefilter may produce an empty queue while other visible tenants still contain assigned work; the empty state must explain the filter boundary instead of claiming no work exists anywhere.
|
||||||
|
- A finding can move to a terminal state in another browser tab while the inbox is open; refresh behavior must remove or de-emphasize it without implying it is still active assigned work.
|
||||||
|
- A finding may have the current user as both owner and assignee; it remains visible because assignee governs inbox inclusion.
|
||||||
|
- A finding may have the current user as owner only; it remains out of scope for this personal queue until a later accountability-focused slice explicitly defines that workload.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no new long-running work, and no new `OperationRun`. It introduces a derived read surface and a summary signal only. Existing tenant findings mutations, audit logging, and workflow confirmations remain governed by their current specs and surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature operates in the admin `/admin` plane for the canonical inbox and workspace overview, with tenant entitlement enforced per referenced finding before disclosure and before drilldown to `/admin/t/{tenant}/findings/{finding}`. Non-members or out-of-scope users continue to receive `404`. When workspace membership exists but workspace context has not yet been established, the feature follows the existing chooser or resume flow instead of returning `404`. In-scope users lacking the existing findings view capability continue to receive `403` on protected destinations. No raw capability strings, role checks, or second permission system may be introduced. Global search behavior is unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The inbox and overview signal must use native Filament page, table, filter, stat, badge, and empty-state primitives or existing shared UI helpers. No local status language, ad hoc color system, or custom badge markup may be introduced for urgency or queue state.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The canonical operator-facing vocabulary is `My Findings`, `Assigned to me`, `Open my findings`, and `Open finding`. The page is about finding work, not a generic task engine. Terms such as `inbox item`, `work unit`, or `queue record` must not replace the finding domain language in primary labels.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** The inbox is a primary decision surface because it answers the assignee's first daily workflow question in one place. The workspace signal is secondary because it points into that work rather than replacing it. Default-visible content must be enough to choose the next finding without reconstructing tenant context elsewhere.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** The inbox has exactly one primary inspect model: the finding. Row click is required. There is no redundant `View` action. Utility controls such as tenant filter and clear-filter affordances stay outside the row action lane. Dangerous lifecycle actions remain on the existing finding detail instead of being promoted into the queue. The workspace overview signal remains a summary drill-in surface with one explicit CTA.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible content on the inbox must stay operator-first: finding summary, tenant, severity, lifecycle state, and due urgency before diagnostics. The inbox itself is read-first and does not introduce a new mutation lane. Workspace and tenant scope must remain explicit through the page title, tenant column, active-tenant prefilter state, and drilldown routing.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct reuse of the tenant-local `My assigned` filter is insufficient because it does not create one cross-tenant personal-work surface. This feature still avoids new semantic infrastructure by deriving the inbox directly from existing assignee, lifecycle, severity, due-date, and entitlement truth. Tests must prove the business consequences: visibility, prioritization, tenant safety, and drilldown continuity.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST provide a canonical personal findings inbox at `/admin/findings/my-work` for the current user.
|
||||||
|
- **FR-002**: The inbox MUST include only findings that are in an open workflow status, are currently assigned to the current user, and belong to tenants the current user is entitled to inspect.
|
||||||
|
- **FR-003**: Findings where the current user is owner but not assignee MUST NOT appear in the inbox.
|
||||||
|
- **FR-004**: The inbox MUST show at minimum the tenant, finding summary, severity, lifecycle status, due date or overdue state, and reopened cue for each visible row.
|
||||||
|
- **FR-005**: If the finding owner differs from the assignee, the inbox MUST show that owner context without turning owner into the inclusion rule for the queue.
|
||||||
|
- **FR-006**: The inbox MUST prioritize urgent work ahead of ordinary assigned work using a deterministic rule: overdue findings first, reopened non-overdue findings next, then remaining assigned work. 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. High severity is a supported filter and emphasis cue in v1, but it does not create a separate mandatory sort bucket.
|
||||||
|
- **FR-007**: The inbox MUST expose available filters for fixed personal assignment scope, tenant, overdue state, reopened state, and high-severity work. The personal assignment scope is fixed and cannot be removed in v1, and tenant filter options MUST be limited to visible capability-eligible tenants.
|
||||||
|
- **FR-008**: When an active tenant context exists, the inbox MUST apply that tenant as a default prefilter and allow the operator to clear that tenant prefilter to return to all visible tenants. If the tenant prefilter alone causes the queue to become empty while other visible tenants still contain assigned work, the empty-state CTA for that branch MUST clear the tenant prefilter.
|
||||||
|
- **FR-009**: Opening a row from the inbox MUST navigate to the existing tenant finding detail for the correct tenant and preserve a return path back to the inbox.
|
||||||
|
- **FR-010**: The workspace overview at `/admin` MUST expose a small assigned-to-me signal that shows the current user's visible assigned open count and overdue count and links into the inbox.
|
||||||
|
- **FR-011**: Inbox rows, overview counts, tenant filter values, and inbox summary counts MUST be derived only from findings the current user is entitled and currently authorized through the existing findings-view capability to inspect and MUST NOT leak hidden or capability-blocked tenants through counts, labels, filter options, or empty-state hints. Inbox summary counts MUST reflect the currently visible queue after active filters are applied.
|
||||||
|
- **FR-012**: When the current user has no visible assigned open findings, the inbox MUST render a calm empty state that explains there is no assigned work and offers one clear CTA. When active tenant context exists, the CTA opens that tenant's findings list. When no active tenant context exists, the CTA opens `/admin/choose-tenant` so the operator can establish tenant context before opening tenant findings.
|
||||||
|
- **FR-013**: The feature MUST reuse the existing finding lifecycle semantics from Spec 111 and the owner-versus-assignee semantics from Spec 219 and MUST NOT introduce new workflow states, new owner semantics, or a second permission system.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| My Findings inbox | `/admin/findings/my-work` | `Clear tenant filter` only when an active tenant prefilter is applied | Full-row open to `/admin/t/{tenant}/findings/{finding}` | none | none | `Clear tenant filter` when the tenant prefilter excludes rows; otherwise `Open tenant findings` with active tenant context or `Choose a tenant` | n/a | n/a | no direct audit because the surface is read-first | Action Surface Contract satisfied. One inspect model only, no redundant `View`, no dangerous queue actions, and no empty groups. |
|
||||||
|
| Workspace overview assigned-to-me signal | `/admin` workspace overview | none | Explicit summary CTA `Open my findings` | none | none | none | n/a | n/a | no | Summary drill-in only; not a second work surface |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Assigned finding**: An open tenant-owned finding where the current user is the assignee and is entitled to inspect the tenant that owns the finding.
|
||||||
|
- **My Findings inbox**: A derived canonical queue over assigned findings that emphasizes urgency and tenant-safe drilldown.
|
||||||
|
- **Assigned-to-me signal**: A derived workspace overview summary that counts visible assigned open findings and overdue assigned findings for the current user.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: In acceptance review, an operator can determine within 10 seconds from `/admin` whether they have overdue assigned findings and open the inbox in one click.
|
||||||
|
- **SC-002**: 100% of covered automated tests show only current-user assigned open findings from visible tenants in the inbox and overview counts.
|
||||||
|
- **SC-003**: 100% of covered automated tests exclude owner-only, terminal, or hidden-tenant findings from the personal queue and assigned-to-me signal.
|
||||||
|
- **SC-004**: From the inbox, an operator can reach the target tenant finding detail in one interaction while preserving a clear return path to the queue.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 219 is the authoritative contract for owner versus assignee semantics.
|
||||||
|
- The existing tenant finding detail remains the canonical mutation surface for finding lifecycle and assignment actions.
|
||||||
|
- The workspace overview at `/admin` can host one small additional summary signal without introducing a new landing-page architecture.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introduce a general cross-tenant findings register for all findings
|
||||||
|
- Introduce an owner-based accountability queue
|
||||||
|
- Add unassigned intake, claim flow, or team workboard behavior
|
||||||
|
- Add notification, escalation, or stale-work automation
|
||||||
|
- Add new bulk lifecycle actions or a second mutation lane on the inbox
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 111, Findings Workflow + SLA, remains the source of truth for open finding lifecycle, due-date behavior, and tenant findings workflow.
|
||||||
|
- Spec 219, Finding Ownership Semantics Clarification, remains the source of truth for assignee-based work versus owner-based accountability.
|
||||||
209
specs/221-findings-operator-inbox/tasks.md
Normal file
209
specs/221-findings-operator-inbox/tasks.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
# Tasks: Findings Operator Inbox V1
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/221-findings-operator-inbox/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/findings-operator-inbox.logical.openapi.yaml`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Required. This feature changes runtime behavior on new and existing Filament/Livewire surfaces, so Pest coverage must be added in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, and `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`, with route-alignment coverage extended in `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`.
|
||||||
|
**Operations**: No new `OperationRun`, audit flow, or long-running work is introduced. The inbox and overview signal remain DB-only, read-first surfaces.
|
||||||
|
**RBAC**: The inbox lives on the admin `/admin` plane and must preserve existing chooser or resume behavior when workspace context is missing, workspace-membership `404` semantics for out-of-scope users, capability-filtered tenant-safe row, filter, and count disclosure inside the queue and overview signal, and existing `404`/`403` behavior on drilldown to `/admin/t/{tenant}/findings/{finding}`.
|
||||||
|
**UI / Surface Guardrails**: `My Findings` remains the primary decision surface. The `/admin` assigned-to-me signal remains secondary and must not become a second queue.
|
||||||
|
**Filament UI Action Surfaces**: `MyFindingsInbox` gets one primary inspect model, one conditional `Clear tenant filter` header action, no row actions, no bulk actions, and one empty-state CTA. The workspace overview signal exposes only one CTA: `Open my findings`.
|
||||||
|
**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 same inbox surface and US3 links to the inbox truth.
|
||||||
|
|
||||||
|
## 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 (Inbox Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Prepare the new admin inbox files and focused regression suites used across all stories.
|
||||||
|
|
||||||
|
- [x] T001 [P] Create the new inbox page scaffold in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
- [x] T002 [P] Create the new inbox page view scaffold in `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`
|
||||||
|
- [x] T003 [P] Create focused Pest scaffolding in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, and `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The new 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/tenant scoping every story depends on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [x] T004 Register `MyFindingsInbox` 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/MyFindingsInbox.php`
|
||||||
|
- [x] T005 Implement workspace-membership gating, visible-tenant resolution, capability-filtered row and count disclosure, and eager-loaded base queue scoping in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
- [x] T006 Add foundational admin-plane page-entry coverage for missing workspace chooser or resume behavior and non-member `404` behavior in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The canonical inbox route exists, the page is workspace-scoped, and tenant-safe base access rules are covered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - See My Assigned Findings In One Queue (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Give the current user one trustworthy cross-tenant queue for visible assigned open findings.
|
||||||
|
|
||||||
|
**Independent Test**: Seed multiple visible and hidden tenants with assigned, owner-only, unrelated, and terminal findings, then verify `/admin/findings/my-work` shows only open assigned work from entitled tenants.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T007 [P] [US1] Add assigned-versus-owner, owner-context rendering, open-versus-terminal, entitled-versus-capability-eligible queue visibility, full available-filters contract coverage for fixed assignee scope plus tenant, overdue, reopened, and high-severity filters, applied-scope and summary-count truth, and zero-visible-work empty-state coverage in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`
|
||||||
|
- [x] T008 [P] [US1] Add member-without-capability disclosure boundaries, hidden-tenant suppression, and protected-destination coverage in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T009 [US1] Implement the fixed `assignee = current user` and open-status queue query in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
- [x] T010 [US1] Implement tenant, finding summary, severity, lifecycle status, due-state, reopened cue, owner context, and calm empty-state rendering with branch-specific CTAs that clear the tenant prefilter when it alone excludes rows or otherwise open active-tenant findings or `/admin/choose-tenant` when no tenant context exists in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php` and `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`
|
||||||
|
- [x] T011 [US1] Implement the available-filters contract with fixed assignee scope, capability-safe tenant filter options, active-tenant default prefilter sync, applied-scope metadata, inbox summary counts, and the conditional `Clear tenant filter` header action in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional and the inbox answers “what is assigned to me right now?” without tenant hopping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Prioritize Urgent Work And Drill Into The Right Finding (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Make the queue surface urgent work first and preserve a clear return path after opening the tenant finding detail.
|
||||||
|
|
||||||
|
**Independent Test**: Seed overdue, reopened, high-severity, and ordinary assigned findings, open the inbox, verify urgency ordering and filters, then open a row and confirm tenant finding detail plus inbox return continuity.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T012 [P] [US2] Add deterministic overdue-then-reopened ordering with due-date and finding-ID tie-break coverage, reopened/high-severity filters, and tenant-prefilter empty-state CTA coverage in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`
|
||||||
|
- [x] T013 [P] [US2] Add queue-to-detail continuity and protected-destination coverage in `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T014 [US2] Implement tenant, overdue, reopened, and high-severity filters plus deterministic urgency sorting of overdue first, reopened next, then ordinary findings, with due-date ascending, undated rows last, and finding-ID tie-breaks in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
- [x] T015 [US2] Implement inbox row drilldown URLs with `CanonicalNavigationContext` in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
- [x] T016 [US2] Preserve `Back to my findings` continuity on tenant finding detail in `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional and the queue now highlights urgent work and lands on the correct finding detail with continuity preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Discover My Assigned Work From The Workspace Overview (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make `/admin` show whether visible assigned findings work exists and link into the canonical inbox in one click.
|
||||||
|
|
||||||
|
**Independent Test**: Seed assigned and unassigned work across visible and hidden tenants, open `/admin`, and verify the assigned-to-me signal matches inbox truth, stays calm when appropriate, and drills into `/admin/findings/my-work`.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T017 [P] [US3] Add open-count, overdue-count, calm-state, hidden-tenant suppression, and capability-safe count suppression coverage in `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`
|
||||||
|
- [x] T018 [P] [US3] Extend inbox CTA and route-alignment coverage in `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T019 [US3] Add the `my_findings_signal` payload with visible capability-safe open and overdue counts to `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
|
||||||
|
- [x] T020 [US3] Render the `Assigned to me` summary block and `Open my findings` CTA in `apps/platform/resources/views/filament/pages/workspace-overview.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional and the workspace overview exposes the same personal-work truth as the inbox.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finish guardrail alignment, formatting, and focused validation across the full feature.
|
||||||
|
|
||||||
|
- [x] T021 Review operator-facing copy, action-surface discipline, and “signal not second queue” guardrails in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, and `apps/platform/resources/views/filament/pages/workspace-overview.blade.php`
|
||||||
|
- [x] T022 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [x] T023 Run the focused verification workflow from `specs/221-findings-operator-inbox/quickstart.md` against `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php`, `apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.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 is the recommended MVP cut.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same inbox query, filter, and drilldown surface.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on User Story 1 because the workspace overview signal must link to established inbox truth.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: No dependencies beyond Foundational.
|
||||||
|
- **US2**: Builds directly on the inbox surface introduced in US1.
|
||||||
|
- **US3**: Depends on the canonical inbox route and queue truth from US1, but not on the urgency and detail work from 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 `MyFindingsInbox.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.
|
||||||
|
- `T012` and `T013` can run in parallel for User Story 2.
|
||||||
|
- `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/MyWorkInboxTest.php
|
||||||
|
T008 apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 2 tests in parallel
|
||||||
|
T012 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php
|
||||||
|
T013 apps/platform/tests/Feature/Authorization/MyWorkInboxAuthorizationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 3 tests in parallel
|
||||||
|
T017 apps/platform/tests/Feature/Dashboard/MyFindingsSignalTest.php
|
||||||
|
T018 apps/platform/tests/Feature/Filament/WorkspaceOverviewNavigationTest.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 inbox against the focused US1 tests before widening the slice.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Ship US1 to establish the canonical personal queue.
|
||||||
|
2. Add US2 to prioritize work and preserve detail continuity.
|
||||||
|
3. Add US3 to make the queue discoverable from `/admin`.
|
||||||
|
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 inbox visibility and another can harden authorization boundaries.
|
||||||
|
3. Once US1 is stable, overview signal work can proceed separately from urgency-ordering refinements if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@ -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.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user