feat: converge findings notification presentation (#265)
Some checks failed
Main Confidence / confidence (push) Failing after 51s
Some checks failed
Main Confidence / confidence (push) Failing after 51s
## Summary - converge finding, queued, and completed database notifications on one shared `OperationUxPresenter` presentation contract - preserve existing finding and operation deep-link authorities while standardizing title, body, status/icon treatment, and single primary action - add focused notification, findings, and guard coverage plus the full feature 230 spec artifacts ## Validation - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` ## Filament / Platform Notes - Livewire v4.0+ compliance preserved on Filament v5 primitives - provider registration remains unchanged in `apps/platform/bootstrap/providers.php` - no globally searchable resource behavior changed in this feature - no destructive actions were introduced - asset strategy is unchanged; the existing `cd apps/platform && php artisan filament:assets` deploy step remains sufficient Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #265
This commit is contained in:
parent
12fb5ebb30
commit
742d65f0d9
@ -4,11 +4,9 @@
|
||||
|
||||
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 App\Support\OpsUx\OperationUxPresenter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@ -38,20 +36,11 @@ public function via(object $notifiable): array
|
||||
*/
|
||||
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 = OperationUxPresenter::findingDatabaseNotificationMessage(
|
||||
$this->finding,
|
||||
$this->tenant,
|
||||
$this->event,
|
||||
);
|
||||
|
||||
$message['finding_event'] = [
|
||||
'event_type' => (string) ($this->event['event_type'] ?? ''),
|
||||
@ -65,29 +54,4 @@ public function toDatabase(object $notifiable): array
|
||||
|
||||
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 => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,12 +3,8 @@
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@ -27,25 +23,7 @@ public function via(object $notifiable): array
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$tenant = $this->run->tenant;
|
||||
$runUrl = match (true) {
|
||||
$notifiable instanceof PlatformUser => SystemOperationRunLinks::view($this->run),
|
||||
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||
default => OperationRunLinks::tenantlessView($this->run),
|
||||
};
|
||||
|
||||
$notification = OperationUxPresenter::terminalDatabaseNotification(
|
||||
run: $this->run,
|
||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||
);
|
||||
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
]);
|
||||
|
||||
$message = $notification->getDatabaseMessage();
|
||||
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
|
||||
@ -3,10 +3,7 @@
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@ -31,31 +28,6 @@ public function via(object $notifiable): array
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$wizard = $context['wizard'] ?? null;
|
||||
|
||||
$isManagedTenantOnboardingWizardRun = is_array($wizard)
|
||||
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
||||
|
||||
$operationLabel = OperationCatalog::label((string) $this->run->type);
|
||||
|
||||
$runUrl = match (true) {
|
||||
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
|
||||
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||
default => null,
|
||||
};
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Queued for execution. Open the operation for progress and next steps.')
|
||||
->info()
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->getDatabaseMessage();
|
||||
return OperationUxPresenter::queuedDatabaseNotificationMessage($this->run, $notifiable);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,11 @@
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -12,11 +16,13 @@
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
final class OperationUxPresenter
|
||||
@ -81,6 +87,48 @@ public static function scopeBusyToast(
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function findingDatabaseNotificationMessage(Finding $finding, Tenant $tenant, array $event): array
|
||||
{
|
||||
return self::databaseNotificationMessage(
|
||||
title: self::findingNotificationTitle($event),
|
||||
body: self::findingNotificationBody($event),
|
||||
status: self::findingNotificationStatus($event),
|
||||
actionName: 'open_finding',
|
||||
actionLabel: 'Open finding',
|
||||
actionUrl: FindingResource::getUrl(
|
||||
'view',
|
||||
['record' => $finding],
|
||||
panel: 'tenant',
|
||||
tenant: $tenant,
|
||||
),
|
||||
actionTarget: 'finding_detail',
|
||||
supportingLines: self::findingNotificationSupportingLines($event),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function queuedDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
|
||||
{
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
|
||||
|
||||
return self::databaseNotificationMessage(
|
||||
title: "{$operationLabel} queued",
|
||||
body: 'Queued for execution. Open the operation for progress and next steps.',
|
||||
status: 'info',
|
||||
actionName: 'view_run',
|
||||
actionLabel: $primaryAction['label'],
|
||||
actionUrl: $primaryAction['url'],
|
||||
actionTarget: $primaryAction['target'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal DB notification payload.
|
||||
*
|
||||
@ -89,44 +137,40 @@ public static function scopeBusyToast(
|
||||
*/
|
||||
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
||||
{
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
$presentation = self::terminalPresentation($run);
|
||||
$bodyLines = [$presentation['body']];
|
||||
$payload = self::terminalNotificationPayload($run);
|
||||
$actionUrl = $tenant instanceof Tenant
|
||||
? OperationRunUrl::view($run, $tenant)
|
||||
: OperationRunLinks::tenantlessView($run);
|
||||
|
||||
$failureMessage = self::surfaceFailureDetail($run);
|
||||
if ($failureMessage !== null) {
|
||||
$bodyLines[] = $failureMessage;
|
||||
}
|
||||
return self::makeDatabaseNotification(
|
||||
title: $payload['title'],
|
||||
body: $payload['body'],
|
||||
status: $payload['status'],
|
||||
actionName: 'view_run',
|
||||
actionLabel: OperationRunLinks::openLabel(),
|
||||
actionUrl: $actionUrl,
|
||||
supportingLines: $payload['supportingLines'],
|
||||
);
|
||||
}
|
||||
|
||||
$guidance = self::surfaceGuidance($run);
|
||||
if ($guidance !== null) {
|
||||
$bodyLines[] = $guidance;
|
||||
}
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function terminalDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
|
||||
{
|
||||
$payload = self::terminalNotificationPayload($run);
|
||||
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
|
||||
|
||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
if ($summary !== null) {
|
||||
$bodyLines[] = $summary;
|
||||
}
|
||||
|
||||
$integritySummary = RedactionIntegrity::noteForRun($run);
|
||||
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
||||
$bodyLines[] = trim($integritySummary);
|
||||
}
|
||||
|
||||
$notification = FilamentNotification::make()
|
||||
->title("{$operationLabel} {$presentation['titleSuffix']}")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->status($presentation['status']);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url(OperationRunUrl::view($run, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
return $notification;
|
||||
return self::databaseNotificationMessage(
|
||||
title: $payload['title'],
|
||||
body: $payload['body'],
|
||||
status: $payload['status'],
|
||||
actionName: 'view_run',
|
||||
actionLabel: $primaryAction['label'],
|
||||
actionUrl: $primaryAction['url'],
|
||||
actionTarget: $primaryAction['target'],
|
||||
supportingLines: $payload['supportingLines'],
|
||||
);
|
||||
}
|
||||
|
||||
public static function surfaceGuidance(OperationRun $run): ?string
|
||||
@ -345,6 +389,59 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function findingNotificationSupportingLines(array $event): array
|
||||
{
|
||||
$recipientReason = self::findingRecipientReasonCopy((string) data_get($event, 'metadata.recipient_reason', ''));
|
||||
|
||||
return $recipientReason !== '' ? [$recipientReason] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
private static function findingNotificationTitle(array $event): string
|
||||
{
|
||||
$title = trim((string) ($event['title'] ?? 'Finding update'));
|
||||
|
||||
return $title !== '' ? $title : 'Finding update';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
private static function findingNotificationBody(array $event): string
|
||||
{
|
||||
$body = trim((string) ($event['body'] ?? 'A finding needs follow-up.'));
|
||||
|
||||
return $body !== '' ? $body : 'A finding needs follow-up.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
private static function findingNotificationStatus(array $event): string
|
||||
{
|
||||
return match ((string) ($event['event_type'] ?? '')) {
|
||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'warning',
|
||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'danger',
|
||||
default => 'info',
|
||||
};
|
||||
}
|
||||
|
||||
private static function findingRecipientReasonCopy(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 => '',
|
||||
};
|
||||
}
|
||||
|
||||
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
||||
{
|
||||
return self::resolveGovernanceOperatorExplanation($run);
|
||||
@ -377,7 +474,7 @@ private static function terminalPresentation(OperationRun $run): array
|
||||
if ($freshnessState->isReconciledFailed()) {
|
||||
return [
|
||||
'titleSuffix' => 'was automatically reconciled',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
|
||||
'body' => 'Automatically reconciled after infrastructure failure.',
|
||||
'status' => 'danger',
|
||||
];
|
||||
}
|
||||
@ -395,17 +492,198 @@ private static function terminalPresentation(OperationRun $run): array
|
||||
],
|
||||
'blocked' => [
|
||||
'titleSuffix' => 'blocked by prerequisite',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
||||
'body' => 'Blocked by prerequisite.',
|
||||
'status' => 'warning',
|
||||
],
|
||||
default => [
|
||||
'titleSuffix' => 'execution failed',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
|
||||
'body' => 'Execution failed.',
|
||||
'status' => 'danger',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* title:string,
|
||||
* body:string,
|
||||
* status:string,
|
||||
* supportingLines:list<string>
|
||||
* }
|
||||
*/
|
||||
private static function terminalNotificationPayload(OperationRun $run): array
|
||||
{
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
$presentation = self::terminalPresentation($run);
|
||||
|
||||
return [
|
||||
'title' => "{$operationLabel} {$presentation['titleSuffix']}",
|
||||
'body' => $presentation['body'],
|
||||
'status' => $presentation['status'],
|
||||
'supportingLines' => self::terminalSupportingLines($run),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function terminalSupportingLines(OperationRun $run): array
|
||||
{
|
||||
$lines = [];
|
||||
$reasonLabel = trim((string) (self::reasonEnvelope($run)?->operatorLabel ?? ''));
|
||||
|
||||
if ($reasonLabel !== '') {
|
||||
$lines[] = $reasonLabel;
|
||||
}
|
||||
|
||||
$failureMessage = self::surfaceFailureDetail($run);
|
||||
|
||||
if ($failureMessage !== null) {
|
||||
$lines[] = $failureMessage;
|
||||
}
|
||||
|
||||
$guidance = self::surfaceGuidance($run);
|
||||
if ($guidance !== null) {
|
||||
$lines[] = $guidance;
|
||||
}
|
||||
|
||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
if ($summary !== null) {
|
||||
$lines[] = $summary;
|
||||
}
|
||||
|
||||
$integritySummary = RedactionIntegrity::noteForRun($run);
|
||||
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
||||
$lines[] = trim($integritySummary);
|
||||
}
|
||||
|
||||
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label:string, url:?string, target:string}
|
||||
*/
|
||||
private static function operationRunPrimaryAction(OperationRun $run, object $notifiable): array
|
||||
{
|
||||
if ($notifiable instanceof PlatformUser) {
|
||||
return [
|
||||
'label' => OperationRunLinks::openLabel(),
|
||||
'url' => SystemOperationRunLinks::view($run),
|
||||
'target' => 'system_operation_run',
|
||||
];
|
||||
}
|
||||
|
||||
if (self::isManagedTenantOnboardingWizardRun($run)) {
|
||||
return [
|
||||
'label' => OperationRunLinks::openLabel(),
|
||||
'url' => OperationRunLinks::tenantlessView($run),
|
||||
'target' => 'tenantless_operation_run',
|
||||
];
|
||||
}
|
||||
|
||||
if ($run->tenant instanceof Tenant) {
|
||||
return [
|
||||
'label' => OperationRunLinks::openLabel(),
|
||||
'url' => OperationRunLinks::view($run, $run->tenant),
|
||||
'target' => 'admin_operation_run',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => OperationRunLinks::openLabel(),
|
||||
'url' => OperationRunLinks::tenantlessView($run),
|
||||
'target' => 'tenantless_operation_run',
|
||||
];
|
||||
}
|
||||
|
||||
private static function isManagedTenantOnboardingWizardRun(OperationRun $run): bool
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$wizard = $context['wizard'] ?? null;
|
||||
|
||||
return is_array($wizard)
|
||||
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $supportingLines
|
||||
*/
|
||||
private static function makeDatabaseNotification(
|
||||
string $title,
|
||||
string $body,
|
||||
string $status,
|
||||
string $actionName,
|
||||
string $actionLabel,
|
||||
?string $actionUrl,
|
||||
array $supportingLines = [],
|
||||
): FilamentNotification {
|
||||
return FilamentNotification::make()
|
||||
->title($title)
|
||||
->body(self::composeDatabaseNotificationBody($body, $supportingLines))
|
||||
->status($status)
|
||||
->actions([
|
||||
Action::make($actionName)
|
||||
->label($actionLabel)
|
||||
->url($actionUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $supportingLines
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function databaseNotificationMessage(
|
||||
string $title,
|
||||
string $body,
|
||||
string $status,
|
||||
string $actionName,
|
||||
string $actionLabel,
|
||||
?string $actionUrl,
|
||||
string $actionTarget,
|
||||
array $supportingLines = [],
|
||||
): array {
|
||||
$message = self::makeDatabaseNotification(
|
||||
title: $title,
|
||||
body: $body,
|
||||
status: $status,
|
||||
actionName: $actionName,
|
||||
actionLabel: $actionLabel,
|
||||
actionUrl: $actionUrl,
|
||||
supportingLines: $supportingLines,
|
||||
)->getDatabaseMessage();
|
||||
|
||||
$message['supporting_lines'] = array_values(array_filter(
|
||||
$supportingLines,
|
||||
static fn (string $line): bool => trim($line) !== '',
|
||||
));
|
||||
|
||||
if (is_array($message['actions'][0] ?? null)) {
|
||||
$message['actions'][0]['target'] = $actionTarget;
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $supportingLines
|
||||
*/
|
||||
private static function composeDatabaseNotificationBody(string $body, array $supportingLines): string
|
||||
{
|
||||
$lines = [trim($body)];
|
||||
|
||||
foreach ($supportingLines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
return implode("\n", array_filter($lines, static fn (string $line): bool => $line !== ''));
|
||||
}
|
||||
|
||||
private static function requiresFollowUp(OperationRun $run): bool
|
||||
{
|
||||
if (self::firstNextStepLabel($run) !== null) {
|
||||
|
||||
@ -13,11 +13,23 @@
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function latestFindingNotificationFor(User $user): ?\Illuminate\Notifications\DatabaseNotification
|
||||
{
|
||||
return $user->notifications()
|
||||
@ -79,6 +91,10 @@ function runEvaluateAlertsForWorkspace(int $workspaceId): void
|
||||
|
||||
expect($firstNotification)->not->toBeNull()
|
||||
->and(data_get($firstNotification?->data, 'title'))->toBe('Finding assigned')
|
||||
->and(data_get($firstNotification?->data, 'status'))->toBe('info')
|
||||
->and(data_get($firstNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'))
|
||||
->and(data_get($firstNotification?->data, 'actions.0.label'))->toBe('Open finding')
|
||||
->and(data_get($firstNotification?->data, 'actions.0.target'))->toBe('finding_detail')
|
||||
->and(data_get($firstNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||
|
||||
$workflow->assign(
|
||||
@ -181,6 +197,24 @@ function runEvaluateAlertsForWorkspace(int $workspaceId): void
|
||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(1)
|
||||
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
|
||||
|
||||
$dueSoonNotification = $assignee->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->get()
|
||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_DUE_SOON);
|
||||
$overdueNotification = $owner->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->get()
|
||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||
|
||||
expect($dueSoonNotification)->not->toBeNull()
|
||||
->and(data_get($dueSoonNotification?->data, 'status'))->toBe('warning')
|
||||
->and(data_get($dueSoonNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('warning'))
|
||||
->and(data_get($dueSoonNotification?->data, 'actions.0.label'))->toBe('Open finding');
|
||||
expect($overdueNotification)->not->toBeNull()
|
||||
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
|
||||
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
|
||||
->and(data_get($overdueNotification?->data, 'actions.0.label'))->toBe('Open finding');
|
||||
|
||||
expect($assignee->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->get()
|
||||
|
||||
@ -11,11 +11,23 @@
|
||||
use App\Services\Findings\FindingNotificationService;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Collection
|
||||
{
|
||||
return $user->notifications()
|
||||
@ -54,6 +66,19 @@ function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Coll
|
||||
->all())
|
||||
->toContain(AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||
|
||||
$assignedNotification = dispatchedFindingNotificationsFor($assignee)
|
||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||
$overdueNotification = dispatchedFindingNotificationsFor($owner)
|
||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||
|
||||
expect($assignedNotification)->not->toBeNull()
|
||||
->and(data_get($assignedNotification?->data, 'status'))->toBe('info')
|
||||
->and(data_get($assignedNotification?->data, 'actions.0.target'))->toBe('finding_detail');
|
||||
expect($overdueNotification)->not->toBeNull()
|
||||
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
|
||||
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
|
||||
->and(data_get($overdueNotification?->data, 'actions.0.target'))->toBe('finding_detail');
|
||||
|
||||
$fallbackFinding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
@ -195,5 +220,7 @@ function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Coll
|
||||
|
||||
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');
|
||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner')
|
||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.status'))->toBe('danger')
|
||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.actions.0.label'))->toBe('Open finding');
|
||||
});
|
||||
|
||||
@ -12,8 +12,20 @@
|
||||
use App\Services\Findings\FindingNotificationService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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']);
|
||||
@ -36,13 +48,58 @@
|
||||
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, 'status'))->toBe('info')
|
||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'))
|
||||
->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, 'actions.0.target'))->toBe('finding_detail')
|
||||
->and(data_get($notification?->data, 'supporting_lines'))->toBe(['You are the new assignee.'])
|
||||
->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');
|
||||
|
||||
$this->actingAs($assignee);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('maps due soon and overdue finding notifications onto the shared status and icon treatment', function (
|
||||
string $eventType,
|
||||
string $recipient,
|
||||
string $expectedStatus,
|
||||
string $findingStatus,
|
||||
string $relativeDueAt,
|
||||
): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = User::factory()->create(['name' => 'Urgency Operator']);
|
||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => $findingStatus,
|
||||
'owner_user_id' => (int) $owner->getKey(),
|
||||
'assignee_user_id' => (int) $assignee->getKey(),
|
||||
'due_at' => now()->modify($relativeDueAt),
|
||||
]);
|
||||
|
||||
app(FindingNotificationService::class)->dispatch($finding, $eventType);
|
||||
|
||||
$notifiable = $recipient === 'owner' ? $owner : $assignee;
|
||||
$notification = $notifiable->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'status'))->toBe($expectedStatus)
|
||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon($expectedStatus))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail');
|
||||
})->with([
|
||||
'due soon' => [AlertRule::EVENT_FINDINGS_DUE_SOON, 'assignee', 'warning', Finding::STATUS_TRIAGED, '+6 hours'],
|
||||
'overdue' => [AlertRule::EVENT_FINDINGS_OVERDUE, 'owner', 'danger', Finding::STATUS_IN_PROGRESS, '-2 hours'],
|
||||
]);
|
||||
|
||||
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']);
|
||||
|
||||
@ -1,11 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -37,8 +52,16 @@
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
||||
expect(data_get($notification->data, 'status'))->toBe('info');
|
||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'));
|
||||
expect(data_get($notification->data, 'actions'))->toHaveCount(1);
|
||||
expect(data_get($notification->data, 'actions.0.label'))->toBe(OperationRunLinks::openLabel());
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('admin_operation_run');
|
||||
expect(array_values(data_get($notification->data, 'supporting_lines', [])))->toBe([]);
|
||||
|
||||
$this->get(data_get($notification->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('does not emit queued notifications for runs without an initiator', function () {
|
||||
@ -88,8 +111,36 @@
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
||||
expect(data_get($notification->data, 'status'))->toBe('info');
|
||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'));
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::tenantlessView($run));
|
||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
||||
});
|
||||
|
||||
it('uses a tenantless view link for queued tenantless runs', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => null,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [],
|
||||
]);
|
||||
|
||||
$user->notify(new OperationRunQueued($run));
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(OperationRunLinks::tenantlessView($run))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
||||
|
||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('emits a terminal notification when an operation run transitions to completed', function () {
|
||||
@ -131,8 +182,15 @@
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toContain('Completed successfully.');
|
||||
expect($notification->data['body'] ?? null)->toContain('No action needed.');
|
||||
expect(data_get($notification->data, 'status'))->toBe('success');
|
||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('success'));
|
||||
expect(data_get($notification->data, 'actions'))->toHaveCount(1);
|
||||
expect(array_values(data_get($notification->data, 'supporting_lines', [])))->toContain('No action needed.', 'Total: 1');
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('admin_operation_run');
|
||||
|
||||
$this->get(data_get($notification->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('uses a tenantless view link for completed tenantless runs', function () {
|
||||
@ -168,7 +226,42 @@
|
||||
->and($notification->data['body'] ?? null)->toContain('Execution prerequisite changed')
|
||||
->and($notification->data['body'] ?? null)->toContain('queued execution prerequisites are no longer satisfied')
|
||||
->and($notification->data['body'] ?? null)->not->toContain('execution_prerequisite_invalid')
|
||||
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run));
|
||||
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
||||
|
||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('uses the system operation route for completed notifications delivered to platform users', function (): void {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
]);
|
||||
|
||||
$platformUser->notify(new OperationRunCompleted($run));
|
||||
|
||||
$notification = $platformUser->notifications()->latest('id')->first();
|
||||
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'status'))->toBe('success')
|
||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('success'))
|
||||
->and(data_get($notification?->data, 'actions.0.label'))->toBe(OperationRunLinks::openLabel())
|
||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(SystemOperationRunLinks::view($run))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('system_operation_run');
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('renders partial backup-set update notifications with RBAC foundation summary counts', function () {
|
||||
|
||||
@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\Findings\FindingEventNotification;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\Findings\FindingNotificationService;
|
||||
use App\Services\OperationRunService;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('spec230AssertSharedNotificationPayload')) {
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array{
|
||||
* title: string,
|
||||
* status: string,
|
||||
* actionLabel: string,
|
||||
* actionTarget: string,
|
||||
* supportingLines: list<string>,
|
||||
* primaryBody: string
|
||||
* } $expected
|
||||
*/
|
||||
function spec230AssertSharedNotificationPayload(array $payload, array $expected): void
|
||||
{
|
||||
expect(data_get($payload, 'format'))->toBe('filament')
|
||||
->and((string) data_get($payload, 'title'))->toBe($expected['title'])
|
||||
->and((string) data_get($payload, 'body'))->toStartWith($expected['primaryBody'])
|
||||
->and(data_get($payload, 'status'))->toBe($expected['status'])
|
||||
->and(data_get($payload, 'icon'))->toBe(spec230ExpectedNotificationIcon($expected['status']))
|
||||
->and(data_get($payload, 'actions', []))->toHaveCount(1)
|
||||
->and(data_get($payload, 'actions.0.label'))->toBe($expected['actionLabel'])
|
||||
->and(data_get($payload, 'actions.0.target'))->toBe($expected['actionTarget'])
|
||||
->and(array_values(data_get($payload, 'supporting_lines', [])))->toBe($expected['supportingLines']);
|
||||
|
||||
foreach ($expected['supportingLines'] as $line) {
|
||||
expect((string) data_get($payload, 'body'))->toContain($line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('enforces the shared database notification contract across finding queued and completed consumers', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = \App\Models\User::factory()->create(['name' => 'Shared Contract Assignee']);
|
||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||
|
||||
$this->actingAs($owner);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => Finding::STATUS_TRIAGED,
|
||||
'severity' => 'high',
|
||||
'owner_user_id' => (int) $owner->getKey(),
|
||||
'assignee_user_id' => (int) $assignee->getKey(),
|
||||
]);
|
||||
|
||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||
|
||||
$queuedRun = app(OperationRunService::class)->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync',
|
||||
inputs: ['scope' => 'all'],
|
||||
initiator: $owner,
|
||||
);
|
||||
|
||||
app(OperationRunService::class)->dispatchOrFail($queuedRun, function (): void {
|
||||
// no-op
|
||||
}, emitQueuedNotification: true);
|
||||
|
||||
$completedRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'initiator_name' => $owner->name,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
app(OperationRunService::class)->updateRun(
|
||||
$completedRun,
|
||||
status: 'completed',
|
||||
outcome: 'succeeded',
|
||||
summaryCounts: ['total' => 1],
|
||||
failures: [],
|
||||
);
|
||||
|
||||
$findingNotification = $assignee->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
$queuedNotification = $owner->notifications()
|
||||
->where('type', OperationRunQueued::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
$completedNotification = $owner->notifications()
|
||||
->where('type', OperationRunCompleted::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($findingNotification)->not->toBeNull();
|
||||
expect($queuedNotification)->not->toBeNull();
|
||||
expect($completedNotification)->not->toBeNull();
|
||||
|
||||
spec230AssertSharedNotificationPayload($findingNotification?->data ?? [], [
|
||||
'title' => 'Finding assigned',
|
||||
'primaryBody' => 'Finding #'.(int) $finding->getKey().' in '.$tenant->getFilamentName().' was assigned. High severity.',
|
||||
'status' => 'info',
|
||||
'actionLabel' => 'Open finding',
|
||||
'actionTarget' => 'finding_detail',
|
||||
'supportingLines' => ['You are the new assignee.'],
|
||||
]);
|
||||
|
||||
spec230AssertSharedNotificationPayload($queuedNotification?->data ?? [], [
|
||||
'title' => 'Policy sync queued',
|
||||
'primaryBody' => 'Queued for execution. Open the operation for progress and next steps.',
|
||||
'status' => 'info',
|
||||
'actionLabel' => 'Open operation',
|
||||
'actionTarget' => 'admin_operation_run',
|
||||
'supportingLines' => [],
|
||||
]);
|
||||
|
||||
spec230AssertSharedNotificationPayload($completedNotification?->data ?? [], [
|
||||
'title' => 'Inventory sync completed successfully',
|
||||
'primaryBody' => 'Completed successfully.',
|
||||
'status' => 'success',
|
||||
'actionLabel' => 'Open operation',
|
||||
'actionTarget' => 'admin_operation_run',
|
||||
'supportingLines' => ['No action needed.', 'Total: 1'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps exactly one primary action and preserves secondary metadata boundaries across in-scope consumers', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = \App\Models\User::factory()->create(['name' => 'Boundary Assignee']);
|
||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||
|
||||
$this->actingAs($owner);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$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);
|
||||
|
||||
$tenantlessQueuedRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => null,
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'initiator_name' => $owner->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
$owner->notify(new OperationRunQueued($tenantlessQueuedRun));
|
||||
|
||||
$tenantlessCompletedRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => null,
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'initiator_name' => $owner->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
app(OperationRunService::class)->updateRun(
|
||||
$tenantlessCompletedRun,
|
||||
status: 'completed',
|
||||
outcome: 'blocked',
|
||||
failures: [[
|
||||
'code' => 'operation.blocked',
|
||||
'reason_code' => 'execution_prerequisite_invalid',
|
||||
'message' => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
||||
]],
|
||||
);
|
||||
|
||||
$findingPayload = data_get(
|
||||
$assignee->notifications()->where('type', FindingEventNotification::class)->latest('id')->first(),
|
||||
'data',
|
||||
[],
|
||||
);
|
||||
$queuedPayload = data_get(
|
||||
$owner->notifications()->where('type', OperationRunQueued::class)->latest('id')->first(),
|
||||
'data',
|
||||
[],
|
||||
);
|
||||
$completedPayload = data_get(
|
||||
$owner->notifications()->where('type', OperationRunCompleted::class)->latest('id')->first(),
|
||||
'data',
|
||||
[],
|
||||
);
|
||||
|
||||
expect(data_get($findingPayload, 'actions', []))->toHaveCount(1)
|
||||
->and(data_get($queuedPayload, 'actions', []))->toHaveCount(1)
|
||||
->and(data_get($completedPayload, 'actions', []))->toHaveCount(1)
|
||||
->and(data_get($findingPayload, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
||||
->and(data_get($findingPayload, 'reason_translation'))->toBeNull()
|
||||
->and(data_get($queuedPayload, 'finding_event'))->toBeNull()
|
||||
->and(data_get($queuedPayload, 'reason_translation'))->toBeNull()
|
||||
->and(data_get($completedPayload, 'finding_event'))->toBeNull()
|
||||
->and(data_get($completedPayload, 'reason_translation.operator_label'))->toBe('Execution prerequisite changed')
|
||||
->and(data_get($completedPayload, 'actions.0.target'))->toBe('tenantless_operation_run')
|
||||
->and(array_values(data_get($queuedPayload, 'supporting_lines', [])))->toBe([])
|
||||
->and(array_values(data_get($completedPayload, 'supporting_lines', [])))->toContain('Execution prerequisite changed');
|
||||
});
|
||||
@ -52,3 +52,67 @@
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps in-scope database notifications routed through the shared presenter seam', function (): void {
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
$files = [
|
||||
$root.'/app/Notifications/Findings/FindingEventNotification.php',
|
||||
$root.'/app/Notifications/OperationRunQueued.php',
|
||||
$root.'/app/Notifications/OperationRunCompleted.php',
|
||||
];
|
||||
$needles = [
|
||||
'FilamentNotification::make(',
|
||||
'->getDatabaseMessage(',
|
||||
];
|
||||
$violations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = SourceFileScanner::read($file);
|
||||
|
||||
foreach ($needles as $needle) {
|
||||
if (! str_contains($source, $needle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
|
||||
while (($position = strpos($source, $needle, $offset)) !== false) {
|
||||
$line = substr_count(substr($source, 0, $position), "\n") + 1;
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($file),
|
||||
'line' => $line,
|
||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||
];
|
||||
|
||||
$offset = $position + strlen($needle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
$messages = array_map(static function (array $violation): string {
|
||||
return sprintf(
|
||||
"%s:%d\n%s",
|
||||
$violation['file'],
|
||||
$violation['line'],
|
||||
$violation['snippet'],
|
||||
);
|
||||
}, $violations);
|
||||
|
||||
$this->fail(
|
||||
"Local database-notification payload composition found in in-scope consumers:\n\n".implode("\n\n", $messages)
|
||||
);
|
||||
}
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps alert email delivery outside the shared database notification contract boundary', function (): void {
|
||||
$source = SourceFileScanner::read(
|
||||
SourceFileScanner::projectRoot().'/app/Notifications/Alerts/EmailAlertNotification.php'
|
||||
);
|
||||
|
||||
expect($source)->not->toContain('OperationUxPresenter')
|
||||
->and($source)->not->toContain('FilamentNotification');
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224, added `Findings Notification Presentation Convergence`, added three architecture contract-enforcement candidates from the 2026-04-22 drift audit, added the repository cleanup strand from the strict read-only legacy audit, reframed the compliance-control foundation candidate into a framework-neutral canonical control catalog foundation, and aligned the control-library candidates to the S1/S2/S3 layering language)
|
||||
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224, promoted `Findings Notification Presentation Convergence` to Spec 230, added three architecture contract-enforcement candidates from the 2026-04-22 drift audit, added the repository cleanup strand from the strict read-only legacy audit, reframed the compliance-control foundation candidate into a framework-neutral canonical control catalog foundation, and aligned the control-library candidates to the S1/S2/S3 layering language)
|
||||
|
||||
---
|
||||
|
||||
@ -47,6 +47,7 @@ ## Promoted to Spec
|
||||
- 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`)
|
||||
- Findings Notification Presentation Convergence → Spec 230 (`findings-notification-convergence`)
|
||||
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||
@ -443,25 +444,6 @@ ### Assignment Hygiene & Stale Work Detection
|
||||
- **Strategic sequencing**: Shortly after ownership semantics, ideally alongside or immediately after notifications.
|
||||
- **Priority**: high
|
||||
|
||||
### Findings Notification Presentation Convergence
|
||||
- **Type**: workflow hardening / cross-cutting presentation
|
||||
- **Source**: Spec 224 follow-up review 2026-04-22; shared notification-pattern drift analysis
|
||||
- **Problem**: Spec 224 closed the functional delivery gap for findings notifications, but the current in-app findings path appears to compose its presentation locally instead of fully extending the existing shared operator-facing notification presentation path. The result is not a second transport stack, but a second presentation path for the same interaction type.
|
||||
- **Why it matters**: Notifications are part of TenantPilot's operator-facing decision system, not just incidental UI. If findings notifications keep a local presentation language while operation or run notifications follow a different shared path, the product accumulates UX drift, duplicated payload semantics, and a higher risk that future alerts, assignment reminders, risk-acceptance renewals, and later `My Work` entry surfaces will grow another parallel path instead of converging.
|
||||
- **Proposed direction**:
|
||||
- inventory the current in-app / database-notification presentation paths and explicitly separate delivery/routing, stored payload, presentation contract, and deep-link semantics
|
||||
- define one repo-internal shared presentation contract for operator-facing database notifications that covers at least title, body, tone or status, icon, primary action, deep link, and optional supporting context
|
||||
- align findings in-app notifications to that shared path without changing the delivery semantics, recipient resolution, dedupe or fingerprint logic, or optional external-copy behavior introduced by Spec 224
|
||||
- add contract-level regression tests and guardrail notes so future notification types extend the shared presentation path instead of building local Filament payloads directly
|
||||
- **Explicit non-goals**: Not a redesign of the alerting or routing system. Not a remodelling of external notification targets or alert rules. Not a full `My Work` or inbox implementation. Not an immediate full-sweep unification of every historical notification class in the repo. Not a rewrite of escalation rules or notification-content priority.
|
||||
- **Dependencies**: Spec 224 (`findings-notifications-escalation`), existing operator-facing in-app notification paths (especially operation/run notifications), repo-wide cross-cutting presentation guardrails, and any current shared notification UX helpers or presenters.
|
||||
- **Boundary with Spec 224**: Spec 224 owns who gets notified, when, by which event type, with what fingerprint and optional external copies. This candidate keeps that delivery path intact and converges only the in-app presentation path.
|
||||
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: Presentation hardening is the broader shared-convention candidate across many lifecycle-driven surfaces. This candidate is narrower: it uses in-app notifications as the first bounded convergence target for a shared operator-facing notification presentation contract.
|
||||
- **Boundary with My Work — Actionable Alerts**: `My Work — Actionable Alerts` decides which alert-like items deserve admission into a personal work surface. This candidate decides how operator-facing in-app notifications should present themselves consistently before any future `My Work` routing consumes them.
|
||||
- **Roadmap fit**: Findings Workflow v2 hardening plus cross-cutting operator-notification consistency.
|
||||
- **Strategic sequencing**: Best tackled soon after Spec 224 while the findings notification path is still fresh and before more notification-bearing domains adopt the same local composition pattern.
|
||||
- **Priority**: high
|
||||
|
||||
### Finding Outcome Taxonomy & Verification Semantics
|
||||
- **Type**: workflow semantics / reporting hardening
|
||||
- **Source**: findings execution layer candidate pack 2026-04-17; status/outcome reporting gap analysis
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Findings Notification Presentation Convergence
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-22
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Repo-mandatory cross-cutting and validation sections reference current shared paths and test entry points, but the feature scope, user scenarios, requirements, and success criteria stay implementation-agnostic.
|
||||
- No open clarification markers remain. The candidate is ready for `/speckit.plan`.
|
||||
@ -0,0 +1,235 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Operator Database Notification Presentation Contract
|
||||
version: 1.0.0
|
||||
summary: Logical internal contract for Spec 230 shared database notification presentation and preserved destination routes.
|
||||
description: |
|
||||
This contract documents the shared primary payload structure that Spec 230 must enforce
|
||||
across the current operator-facing database notification consumers. It is intentionally
|
||||
logical rather than a public HTTP API because the feature reuses existing Filament database
|
||||
notifications, existing destination routes, and existing helper seams instead of introducing
|
||||
a new controller namespace.
|
||||
servers:
|
||||
- url: https://logical.internal
|
||||
description: Non-routable placeholder used to describe internal repository contracts.
|
||||
paths:
|
||||
/internal/operator-database-notifications/presentation:
|
||||
post:
|
||||
summary: Build one shared operator-facing database notification payload for an in-scope consumer.
|
||||
description: |
|
||||
Logical internal contract implemented by the shared presentation seam on top of the
|
||||
existing presenter and helper paths. It standardizes title, body, status with the
|
||||
corresponding existing Filament icon treatment, one primary action, and optional
|
||||
supporting context while preserving consumer-specific metadata.
|
||||
operationId: presentOperatorDatabaseNotification
|
||||
x-not-public-http: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/vnd.tenantpilot.operator-database-notification-input+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatorDatabaseNotificationInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Shared primary notification structure returned for storage in the existing notifications table.
|
||||
content:
|
||||
application/vnd.tenantpilot.operator-database-notification-message+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatorDatabaseNotificationMessage'
|
||||
/admin/notifications:
|
||||
get:
|
||||
summary: Existing Filament drawer renders converged finding and operation notifications.
|
||||
operationId: viewOperatorDatabaseNotifications
|
||||
responses:
|
||||
'200':
|
||||
description: Existing shell renders the shared card grammar for the current in-scope consumers.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.operator-notification-drawer+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NotificationDrawerSurface'
|
||||
/admin/t/{tenant}/findings/{finding}:
|
||||
get:
|
||||
summary: Finding notification action opens the existing tenant finding detail route.
|
||||
operationId: openFindingNotificationTarget
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: finding
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Existing finding detail route renders for an entitled tenant operator.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Recipient remains in scope but lacks current capability to inspect the finding.
|
||||
'404':
|
||||
description: Recipient no longer has tenant or record visibility.
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Admin or tenantless operation notification action opens the existing run detail route.
|
||||
operationId: openAdminOperationNotificationTarget
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Existing admin-plane or tenantless operation detail route renders for an entitled operator.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Recipient is in scope but lacks current operation-view capability.
|
||||
'404':
|
||||
description: Recipient no longer has access to the route scope or the run.
|
||||
/system/ops/runs/{run}:
|
||||
get:
|
||||
summary: Platform-user operation notification action opens the existing system-panel run detail route.
|
||||
operationId: openSystemOperationNotificationTarget
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Existing system-panel run detail renders for an entitled platform user.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Platform user lacks the required operations capability.
|
||||
'404':
|
||||
description: Platform user cannot access the run in the current plane.
|
||||
components:
|
||||
schemas:
|
||||
NotificationConsumer:
|
||||
type: string
|
||||
enum:
|
||||
- finding_event
|
||||
- operation_run_queued
|
||||
- operation_run_completed
|
||||
NotificationStatus:
|
||||
type: string
|
||||
description: Shared status emphasis that also drives the existing Filament icon treatment for the card.
|
||||
enum:
|
||||
- info
|
||||
- success
|
||||
- warning
|
||||
- danger
|
||||
NotificationTarget:
|
||||
type: string
|
||||
enum:
|
||||
- finding_detail
|
||||
- admin_operation_run
|
||||
- tenantless_operation_run
|
||||
- system_operation_run
|
||||
NotificationPrimaryAction:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- url
|
||||
- target
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
target:
|
||||
$ref: '#/components/schemas/NotificationTarget'
|
||||
OperatorDatabaseNotificationInput:
|
||||
type: object
|
||||
required:
|
||||
- consumer
|
||||
- title
|
||||
- body
|
||||
- status
|
||||
- primaryAction
|
||||
properties:
|
||||
consumer:
|
||||
$ref: '#/components/schemas/NotificationConsumer'
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/NotificationStatus'
|
||||
primaryAction:
|
||||
$ref: '#/components/schemas/NotificationPrimaryAction'
|
||||
supportingLines:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: |
|
||||
Shared input model for the bounded presentation seam. Metadata remains consumer-specific,
|
||||
but the primary title, body, status with the existing Filament icon treatment, and action structure must stay consistent.
|
||||
OperatorDatabaseNotificationMessage:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
- status
|
||||
- actions
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/NotificationStatus'
|
||||
actions:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 1
|
||||
items:
|
||||
$ref: '#/components/schemas/NotificationPrimaryAction'
|
||||
supportingLines:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: |
|
||||
Shared persisted message shape stored in the existing notifications table and rendered by
|
||||
the current Filament database-notification drawer.
|
||||
NotificationDrawerSurface:
|
||||
type: object
|
||||
required:
|
||||
- consumers
|
||||
- structureGuarantees
|
||||
properties:
|
||||
consumers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NotificationConsumer'
|
||||
structureGuarantees:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- one primary title
|
||||
- one primary body summarizing the change
|
||||
- one status emphasis with the existing Filament icon treatment
|
||||
- exactly one primary action
|
||||
- optional supporting lines remain secondary
|
||||
190
specs/230-findings-notification-convergence/data-model.md
Normal file
190
specs/230-findings-notification-convergence/data-model.md
Normal file
@ -0,0 +1,190 @@
|
||||
# Data Model: Findings Notification Presentation Convergence
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted business entity. Existing finding truth, operation-run truth, deep-link helpers, and database-notification rows remain canonical. The new work is a bounded derived presentation layer over those existing records.
|
||||
|
||||
## Existing Persistent Entities
|
||||
|
||||
### Database Notification (`notifications` table)
|
||||
|
||||
**Purpose**: Existing persisted delivery artifact for operator-facing in-app notifications.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- `type`
|
||||
- `notifiable_type`
|
||||
- `notifiable_id`
|
||||
- `data`
|
||||
- `read_at`
|
||||
- `created_at`
|
||||
|
||||
**Rules relevant to convergence**:
|
||||
|
||||
- The feature changes only the derived primary payload shape stored in `data`.
|
||||
- Existing namespaced metadata such as `finding_event`, `reason_translation`, and `diagnostic_reason_code` remains secondary and consumer-specific.
|
||||
- No new table or projection is added.
|
||||
|
||||
### Finding
|
||||
|
||||
**Purpose**: Canonical tenant-scoped truth for finding identity, severity, lifecycle, and notification-event context.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `severity`
|
||||
- `status`
|
||||
- `owner_user_id`
|
||||
- `assignee_user_id`
|
||||
- `due_at`
|
||||
|
||||
**Rules relevant to convergence**:
|
||||
|
||||
- The feature does not change how finding events are generated.
|
||||
- Finding links continue to resolve against the existing tenant-panel detail route.
|
||||
- Finding-event metadata remains available for downstream consumers and tests.
|
||||
|
||||
### OperationRun
|
||||
|
||||
**Purpose**: Canonical truth for operation lifecycle, scope, outcome, and supporting notification context.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `tenant_id`
|
||||
- `context`
|
||||
- `summary_counts`
|
||||
- `failure_summary`
|
||||
|
||||
**Rules relevant to convergence**:
|
||||
|
||||
- The feature does not change queued or terminal notification emit rules.
|
||||
- Existing admin-plane, tenantless, and system-plane link resolution remains authoritative.
|
||||
- Completed-run guidance and reason translation remain derived from current run truth.
|
||||
|
||||
### Notifiable Context
|
||||
|
||||
**Purpose**: Determines which route family and supporting context a notification may expose.
|
||||
|
||||
**Relevant notifiable cases**:
|
||||
|
||||
- tenant-scoped operator receiving a finding notification
|
||||
- workspace operator receiving an admin-plane operation notification
|
||||
- platform user receiving a system-plane operation notification
|
||||
|
||||
**Rules relevant to convergence**:
|
||||
|
||||
- Shared presentation must not erase plane-specific destination behavior.
|
||||
- The shared contract can adapt the action URL by notifiable context, but it cannot widen visibility or flatten authorization semantics.
|
||||
|
||||
## Derived Models
|
||||
|
||||
### OperatorDatabaseNotificationPresentation
|
||||
|
||||
**Purpose**: Shared derived contract for the primary structure rendered in the existing Filament notification drawer.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `title`
|
||||
- `body`
|
||||
- `status`
|
||||
- `primaryAction.label`
|
||||
- `primaryAction.url`
|
||||
- `primaryAction.target`
|
||||
- `supportingLines[]`
|
||||
- `metadata`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- Every in-scope consumer provides exactly one primary action.
|
||||
- `status` remains the single source for the existing Filament icon treatment; the feature does not introduce a second icon taxonomy.
|
||||
- `supportingLines` is optional and never replaces `body` or the primary action.
|
||||
- `metadata` may carry consumer-specific namespaced fields, but the shared primary structure remains stable.
|
||||
|
||||
### NotificationPrimaryAction
|
||||
|
||||
**Purpose**: Canonical one-action model for secondary context notifications.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `label`
|
||||
- `url`
|
||||
- `target`
|
||||
|
||||
**Allowed targets**:
|
||||
|
||||
- `finding_detail`
|
||||
- `admin_operation_run`
|
||||
- `tenantless_operation_run`
|
||||
- `system_operation_run`
|
||||
|
||||
**Rules**:
|
||||
|
||||
- There is exactly one primary action per in-scope card.
|
||||
- The action source remains the existing canonical link helper for that domain and plane.
|
||||
|
||||
### FindingNotificationPresentationInput
|
||||
|
||||
**Purpose**: Consumer-specific derived input used by the shared contract for `FindingEventNotification`.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `findingId`
|
||||
- `tenantId`
|
||||
- `eventType`
|
||||
- `title`
|
||||
- `body`
|
||||
- `recipientReason`
|
||||
- `tenantName`
|
||||
- `severity`
|
||||
- `fingerprintKey`
|
||||
- `dueCycleKey`
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Primary wording remains finding-first and uses `Open finding` as the action label.
|
||||
- `recipientReason` stays supporting context, not the headline.
|
||||
|
||||
### OperationRunNotificationPresentationInput
|
||||
|
||||
**Purpose**: Consumer-specific derived input used by the shared contract for queued and terminal run notifications.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `runId`
|
||||
- `operationType`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `targetPlane`
|
||||
- `openUrl`
|
||||
- `openLabel`
|
||||
- `guidanceLines[]`
|
||||
- `summaryLine`
|
||||
- `reasonTranslation`
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Queued notifications keep their existing queued vocabulary but adopt the shared card structure.
|
||||
- Completed notifications preserve terminal explanation, summary, and diagnostic fields as supporting context.
|
||||
- Platform users resolve to the system-panel run detail route; non-platform users keep current admin or tenantless behavior.
|
||||
|
||||
## Consumer Matrix
|
||||
|
||||
| Consumer | Source truth | Primary action target | Required shared fields | Preserved secondary metadata |
|
||||
|----------|--------------|-----------------------|------------------------|------------------------------|
|
||||
| `FindingEventNotification` | `Finding` plus existing event envelope | tenant finding detail | title, body, status with existing icon treatment, `Open finding`, tenant-safe URL | `finding_event` with recipient reason, fingerprint, tenant name, severity |
|
||||
| `OperationRunQueued` | `OperationRun` queued state | admin or tenantless operation run view | title, body, status with existing icon treatment, open-run label, resolved URL | minimal context derived from current run state only |
|
||||
| `OperationRunCompleted` | `OperationRun` terminal state | admin, tenantless, or system operation run view | title, body, status with existing icon treatment, open-run label, resolved URL | `reason_translation`, `diagnostic_reason_code`, summary lines, failure guidance |
|
||||
|
||||
## Persistence Boundaries
|
||||
|
||||
- No new table, enum-backed persistence, or presentation-only cache is introduced.
|
||||
- The shared notification contract remains derived from existing finding and operation-run truth.
|
||||
- Existing `notifications.data` remains the only persisted artifact for in-app delivery.
|
||||
- Existing event semantics from Spec 224 and current operation notification behavior remain unchanged.
|
||||
255
specs/230-findings-notification-convergence/plan.md
Normal file
255
specs/230-findings-notification-convergence/plan.md
Normal file
@ -0,0 +1,255 @@
|
||||
# Implementation Plan: Findings Notification Presentation Convergence
|
||||
|
||||
**Branch**: `230-findings-notification-convergence` | **Date**: 2026-04-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/230-findings-notification-convergence/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/230-findings-notification-convergence/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing Filament database-notification drawer, the current `notifications` table payload shape, the existing operation-link helpers, and the current `OperationUxPresenter` seam. The intended implementation converges the three in-scope operator-facing database notification consumers on one bounded shared presentation contract. It does not add a table, a notification center, a new panel, a preference system, a new asset family, or new notification-routing semantics.
|
||||
|
||||
## Summary
|
||||
|
||||
Extend the existing `OperationUxPresenter::terminalDatabaseNotification()` seam into one bounded operator-facing database-notification presentation contract, then align `FindingEventNotification`, `OperationRunQueued`, and `OperationRunCompleted` to that shared structure while preserving their existing deep-link sources, delivery semantics, and domain-specific metadata. Keep the existing Filament database-notification surface, keep the current admin-plane and system-plane run destinations, and add focused regression coverage that proves one primary structure, one status emphasis with the existing Filament icon treatment, one primary action, unchanged authorization behavior, and the preserved FR-015 out-of-scope boundary across the in-scope consumers.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
|
||||
**Primary Dependencies**: Laravel database notifications, Filament notifications and actions, `App\Support\OpsUx\OperationUxPresenter`, `App\Notifications\Findings\FindingEventNotification`, `App\Notifications\OperationRunQueued`, `App\Notifications\OperationRunCompleted`, `FindingResource`, `OperationRunLinks`, `SystemOperationRunLinks`, `ReasonPresenter`
|
||||
**Storage**: PostgreSQL via the existing `notifications` table and existing `findings` plus `operation_runs` truth; no schema changes planned
|
||||
**Testing**: Pest v4 feature tests with notification-payload assertions and route-authorization coverage
|
||||
**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 notification payload composition request-local, avoid extra N+1 lookups for titles or links, and preserve current drawer rendering without new polling or asset work
|
||||
**Constraints**: No schema migration, no new notification center, no new preference model, no new `OperationRun` type or emit point, no change to current queued or terminal run-notification semantics, no global-search changes, and no destructive action paths
|
||||
**Scale/Scope**: Three existing notification consumers, one existing shared presenter seam, three existing deep-link helpers, and six focused feature or guard suites
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament database-notification drawer and existing detail destinations only
|
||||
- **Shared-family relevance**: operator-facing database notifications, action links
|
||||
- **State layers in scope**: shell, detail
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: global-context-shell, standard-native-filament
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: one named exception only; platform-user operation notifications keep their existing system-panel destination while sharing the same card structure and primary action grammar
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `FindingEventNotification`, `OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`, `FindingResource`, `OperationRunLinks`, `SystemOperationRunLinks`, the existing Filament database-notification surface
|
||||
- **Shared abstractions reused**: `OperationUxPresenter::terminalDatabaseNotification()`, `FindingResource::getUrl(...)`, `OperationRunLinks`, `SystemOperationRunLinks`
|
||||
- **New abstraction introduced? why?**: one bounded shared operator-facing database-notification presentation contract, needed because three real consumers already exist and two still bypass the only shared presenter anchor
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the current `OperationUxPresenter` seam is sufficient as the starting anchor because it already owns the terminal run-notification grammar. It is insufficient as-is because findings and queued run notifications still build local Filament payloads, which leaves the shared interaction family without one explicit contract.
|
||||
- **Bounded deviation / spread control**: domain-specific metadata stays namespaced and secondary, and platform-user operation links keep their existing system-panel route. No other divergence from the shared primary structure is allowed in the in-scope consumers.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Shared pattern first (XCUT-001) | PASS | PASS | The plan extends the existing `OperationUxPresenter` seam rather than creating a second local notification grammar or a framework |
|
||||
| Proportionality / no premature abstraction | PASS | PASS | One bounded shared contract is justified by three real consumers; no registry, factory, or universal notification platform is introduced |
|
||||
| RBAC-UX / tenant isolation | PASS | PASS | Finding links remain tenant-scoped and operation links keep current admin-plane or system-plane resolution; `404` versus `403` behavior remains unchanged |
|
||||
| Ops-UX scope discipline | PASS | PASS | The feature does not add new `OperationRun` notifications or emit points; it only converges payload composition for the already-existing queued and terminal notification consumers |
|
||||
| Filament-native UI / action-surface contract | PASS | PASS | Existing Filament database notifications remain the only shell, each card keeps exactly one primary action, and no destructive action is introduced |
|
||||
| Livewire v4.0+ / Filament v5 compliance | PASS | PASS | The feature stays within the current Filament v5 and Livewire v4 notification primitives |
|
||||
| Provider registration / global search / assets | PASS | PASS | Provider registration remains in `apps/platform/bootstrap/providers.php`; no globally searchable resource changes; no new assets, and the existing deploy step `cd apps/platform && php artisan filament:assets` remains sufficient |
|
||||
| Test governance (TEST-GOV-001) | PASS | PASS | Focused feature and guard coverage prove payload convergence and route safety without browser or heavy-governance expansion |
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: `Feature` for notification presentation convergence, deep-link safety, and guardrail enforcement across the in-scope consumers
|
||||
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The risk is shared operator-facing payload drift and route-safety regression, not browser rendering or background orchestration. Focused feature suites plus one guard test prove the contract with minimal cost.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need a tenant user, a platform user, a tenant, findings with existing event payloads, operation runs in queued and terminal states, and existing authorization helpers for tenant and system planes.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any shared notification assertion helper should stay local to notification-contract tests and reuse existing factories and route helpers
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `global-context-shell` for drawer payloads that open tenant and system detail pages, with `standard-native-filament` relief because the shell itself remains native Filament
|
||||
- **Closing validation and reviewer handoff**: Reviewers should rely on the commands above and verify that the in-scope consumers now share the same primary title, body, status with the corresponding existing Filament icon treatment, and action structure; that `reason_translation` and `finding_event` metadata stay secondary; that the platform-user run destination remains `/system/ops/runs/{run}`; that alert delivery, escalation, and `My Work` admission behavior remain untouched; and that no in-scope class still builds its primary payload through a fully local `FilamentNotification::make()->getDatabaseMessage()` path.
|
||||
- **Budget / baseline / trend follow-up**: none
|
||||
- **Review-stop questions**: Did the implementation introduce a framework beyond one bounded shared contract? Did any in-scope consumer change its delivery semantics or action target instead of only its presentation path? Did the work widen payload disclosure or flatten plane-specific route rules? Did alert delivery, escalation, or `My Work` admission behavior change? Did a new asset, polling, or custom notification shell appear?
|
||||
- **Escalation path**: document-in-feature unless convergence pressure expands beyond the current three consumers or requires new persistence, in which case split or follow up with a dedicated spec
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: This feature closes one current-release drift seam across three real consumers without introducing broader notification-platform scope.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/230-findings-notification-convergence/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── findings-notification-convergence.logical.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Resources/
|
||||
│ │ │ └── FindingResource.php
|
||||
│ │ └── System/
|
||||
│ │ └── Pages/
|
||||
│ │ └── Ops/
|
||||
│ │ └── ViewRun.php
|
||||
│ ├── Notifications/
|
||||
│ │ ├── Findings/
|
||||
│ │ │ └── FindingEventNotification.php
|
||||
│ │ ├── OperationRunCompleted.php
|
||||
│ │ └── OperationRunQueued.php
|
||||
│ └── Support/
|
||||
│ ├── OperationRunLinks.php
|
||||
│ ├── OpsUx/
|
||||
│ │ └── OperationUxPresenter.php
|
||||
│ └── System/
|
||||
│ └── SystemOperationRunLinks.php
|
||||
└── tests/
|
||||
└── Feature/
|
||||
├── Findings/
|
||||
│ ├── FindingsNotificationEventTest.php
|
||||
│ └── FindingsNotificationRoutingTest.php
|
||||
├── Notifications/
|
||||
│ ├── FindingNotificationLinkTest.php
|
||||
│ ├── OperationRunNotificationTest.php
|
||||
│ └── SharedDatabaseNotificationContractTest.php
|
||||
└── OpsUx/
|
||||
└── Constitution/
|
||||
└── LegacyNotificationGuardTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith. The feature stays inside existing notification classes, presenter helpers, and Pest feature suites. No new base directory, no new panel, and no new persisted model are required.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| One bounded shared database-notification presentation contract | Three real consumers already exist and two still bypass the only shared presenter anchor | Separate local edits would preserve parallel primary grammars and keep the interaction family drifting |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators see finding and operation notifications through different primary structures even though both are the same secondary context surface.
|
||||
- **Existing structure is insufficient because**: `OperationUxPresenter` currently covers only terminal operation notifications, while findings and queued operation notifications still compose their payloads locally.
|
||||
- **Narrowest correct implementation**: Extend the existing presenter seam into one bounded database-notification contract, align the current three real consumers, and stop there.
|
||||
- **Ownership cost created**: One shared presentation seam, small contract-level assertions, and maintenance of the domain-specific secondary metadata boundaries.
|
||||
- **Alternative intentionally rejected**: A universal notification platform, a new notification page, or consumer-by-consumer local tweaks. These either add premature infrastructure or fail to solve the shared-interaction drift.
|
||||
- **Release truth**: Current-release truth. This is a convergence change for already-existing operator-facing notifications.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/230-findings-notification-convergence/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Reuse `OperationUxPresenter` as the shared anchor and extend it rather than creating a new notification framework.
|
||||
- Standardize the primary title, body, status, and action structure only; keep domain-specific metadata such as `finding_event` and `reason_translation` secondary and namespaced.
|
||||
- Preserve deep-link truth through `FindingResource::getUrl(...)`, `OperationRunLinks`, and `SystemOperationRunLinks` rather than rebuilding routes in notification classes.
|
||||
- Keep the existing Filament database-notification drawer, existing `notifications` table payload storage, and current deploy asset strategy unchanged.
|
||||
- Prove convergence through focused feature and guard tests instead of browser coverage, including an explicit guard on the FR-015 out-of-scope boundary.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/230-findings-notification-convergence/`:
|
||||
|
||||
- `research.md`: design decisions and rejected alternatives for the shared presentation seam
|
||||
- `data-model.md`: existing notification truth plus the derived shared presentation contract and consumer input shapes
|
||||
- `contracts/findings-notification-convergence.logical.openapi.yaml`: internal logical contract for shared database-notification presentation and the preserved destination routes
|
||||
- `quickstart.md`: focused implementation and review workflow
|
||||
|
||||
Design decisions:
|
||||
|
||||
- No schema migration is required; the feature only changes derived payload composition in the existing `notifications` table.
|
||||
- The canonical shared seam is an extension of the existing `OperationUxPresenter` path, not a new registry or interface family.
|
||||
- In-scope consumers retain one primary action and keep their current deep-link authority: tenant finding view, admin operation view, or system operation view.
|
||||
- Domain-specific metadata remains secondary and opt-in: findings keep `finding_event`, completed runs keep `reason_translation` and diagnostic fields, and queued runs keep only the minimal supporting context they already need.
|
||||
- Existing delivery semantics from Spec 224 and current operation notification behavior remain unchanged.
|
||||
|
||||
## 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 current notification and presenter seams with no new persistence, no Graph work, no new capability family, and no new frontend assets.
|
||||
- PASS - Livewire v4.0+ and Filament v5 constraints remain satisfied, provider registration stays in `apps/platform/bootstrap/providers.php`, no globally searchable resource behavior changes, no destructive action is introduced, and the existing deploy step `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A - Define the shared database-notification presentation seam on the existing presenter anchor
|
||||
|
||||
**Goal**: Establish one bounded shared primary payload structure without creating a new framework.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Add one shared operator-facing database-notification builder path that standardizes title, body, status with the existing Filament icon treatment, one primary action, and optional supporting lines for the in-scope consumers |
|
||||
| A.2 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Preserve the current terminal run-specific presentation logic by feeding it into the shared builder rather than bypassing it |
|
||||
| A.3 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Keep any extraction bounded to the existing namespace and avoid registries, factories, or a universal notification taxonomy |
|
||||
|
||||
### Phase B - Align findings notifications to the shared contract
|
||||
|
||||
**Goal**: Remove the local primary payload grammar from `FindingEventNotification` while preserving Spec 224 delivery and metadata semantics.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Replace the local `FilamentNotification::make()` primary payload build with the shared presentation seam |
|
||||
| B.2 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Preserve `FindingResource::getUrl('view', ...)` as the action target and keep `finding_event` metadata keys unchanged |
|
||||
| B.3 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Keep recipient-reason language secondary and subordinate to the shared primary body structure |
|
||||
|
||||
### Phase C - Align queued and terminal operation notifications to the same contract
|
||||
|
||||
**Goal**: Keep current operation notification semantics while eliminating the second local primary payload grammar.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Notifications/OperationRunQueued.php` | Replace local payload composition with the shared contract while preserving current queued copy and current link-resolution rules |
|
||||
| C.2 | `apps/platform/app/Notifications/OperationRunCompleted.php` | Keep terminal presentation, summary lines, and reason-translation metadata, but route the primary card structure through the same shared contract |
|
||||
| C.3 | `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/System/SystemOperationRunLinks.php` | Verify canonical action labels and plane-specific route generation remain authoritative, and apply only minimal normalization if the shared contract needs a common label accessor |
|
||||
|
||||
### Phase D - Preserve route and scope truth across tenant and system destinations
|
||||
|
||||
**Goal**: Ensure shared presentation does not flatten or widen current access rules.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Preserve tenant-panel finding detail routing and current `404` versus `403` behavior |
|
||||
| D.2 | `apps/platform/app/Notifications/OperationRunQueued.php` and `apps/platform/app/Notifications/OperationRunCompleted.php` | Preserve current admin-plane, tenantless, and platform-user system-plane route selection |
|
||||
| D.3 | Existing Filament notification shell | Keep the existing database-notification drawer as the only collection surface; do not add polling, a new page, or a second notification center |
|
||||
|
||||
### Phase E - Lock the shared contract with focused regression coverage
|
||||
|
||||
**Goal**: Make future local bypasses of the shared primary structure visible in CI.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php` | Add direct contract-level assertions for the shared primary structure, including shared status-to-icon treatment, across findings, queued runs, and completed runs |
|
||||
| E.2 | `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php` | Update queued and terminal operation notification tests to assert shared structure plus preserved route, status, and metadata behavior |
|
||||
| E.3 | `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php` | Update finding notification tests to assert shared structure, shared status semantics, and unchanged tenant-safe action behavior |
|
||||
| E.4 | `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php` and `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php` | Keep Spec 224 event and recipient semantics protected while the presentation path changes underneath them |
|
||||
| E.5 | `apps/platform/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` | Extend the guard so future in-scope notification consumers cannot silently bypass the shared primary presentation seam and so alert delivery, escalation, and `My Work` admission behavior stay outside this spec |
|
||||
|
||||
### Phase F - Validate formatting and the narrow proving set
|
||||
|
||||
**Goal**: Close the feature with the smallest executable proof set.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| F.1 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` | Normalize style for touched PHP files |
|
||||
| F.2 | `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php` | Prove the shared contract and preserved destination semantics |
|
||||
| F.3 | `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` | Prove no regression in Spec 224 behavior, no reintroduction of local notification bypasses, and no accidental expansion into alert or `My Work` behavior |
|
||||
87
specs/230-findings-notification-convergence/quickstart.md
Normal file
87
specs/230-findings-notification-convergence/quickstart.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Quickstart: Findings Notification Presentation Convergence
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start the local platform stack.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
2. Work with a workspace that has at least one tenant, one tenant operator, one platform user, one finding notification trigger, and one queued plus one completed operation run.
|
||||
|
||||
3. Remember that the feature changes only the existing Filament database-notification drawer. No new page or panel is expected during validation.
|
||||
|
||||
4. Filament database-notification polling is intentionally conservative in this repo, so reload or reopen the drawer after each trigger when validating manually.
|
||||
|
||||
## Automated Validation
|
||||
|
||||
Run formatting and the narrowest proving suites for this feature:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php
|
||||
```
|
||||
|
||||
## Manual Validation Flow
|
||||
|
||||
### 1. Compare finding and operation notifications in the same drawer
|
||||
|
||||
1. Trigger one finding notification using an existing Spec 224 event.
|
||||
2. Trigger one queued operation notification and one completed operation notification.
|
||||
3. Reload the shell and open the database-notification drawer.
|
||||
4. Confirm all three cards show the same primary structure order:
|
||||
- title naming the target object
|
||||
- primary body summarizing the change
|
||||
- one status emphasis with the existing Filament icon treatment
|
||||
- exactly one primary action
|
||||
- optional supporting context beneath the primary body
|
||||
|
||||
### 2. Validate finding notification semantics remain unchanged
|
||||
|
||||
1. Open the finding notification card.
|
||||
2. Confirm the action label remains `Open finding`.
|
||||
3. Confirm the action opens the existing tenant finding detail page.
|
||||
4. Confirm recipient-reason language and finding severity remain visible only as supporting context or metadata, not as a replacement for the primary title or body.
|
||||
|
||||
### 3. Validate queued and completed operation notification semantics remain unchanged
|
||||
|
||||
1. Open the queued run notification card.
|
||||
2. Confirm it still communicates the queued state and opens the existing run destination for that notifiable context.
|
||||
3. Open the completed run notification card.
|
||||
4. Confirm terminal summary, failure guidance, and reason translation remain present as supporting context when applicable.
|
||||
|
||||
### 4. Validate tenant-plane and system-plane route truth
|
||||
|
||||
1. Open a finding notification as an entitled tenant user and confirm the tenant detail route opens.
|
||||
2. Open an operation notification as a workspace operator and confirm the current admin or tenantless run destination opens.
|
||||
3. Open an operation notification as a platform user and confirm the action resolves to `/system/ops/runs/{run}`.
|
||||
4. Confirm no shared presentation change causes the wrong route family to appear.
|
||||
|
||||
### 5. Validate authorization behavior did not change
|
||||
|
||||
1. Create a finding notification for an entitled tenant user.
|
||||
2. Remove that user’s tenant visibility or capability.
|
||||
3. Open the finding notification link and confirm the existing `404` versus `403` semantics remain authoritative.
|
||||
4. Repeat the same check for an operation notification where route visibility changes by plane or entitlement.
|
||||
|
||||
### 6. Validate no second shell or asset change appeared
|
||||
|
||||
1. Confirm the existing Filament notification drawer remains the only collection surface.
|
||||
2. Confirm no custom notification page, custom card shell, or new asset bundle appears.
|
||||
3. Confirm the feature does not require any new deploy step beyond the existing Filament assets pipeline.
|
||||
|
||||
### 7. Validate the FR-015 boundary stayed intact
|
||||
|
||||
1. Confirm no alert-rule routing, alert delivery, or escalation behavior changed as part of this feature.
|
||||
2. Confirm no `My Work` queue, admission rule, or dashboard clone was introduced.
|
||||
3. Confirm the only changed surface is the existing in-app database notification presentation for the in-scope consumers.
|
||||
|
||||
## Reviewer Notes
|
||||
|
||||
- The feature is Livewire v4.0+ compatible and stays on existing Filament v5 primitives.
|
||||
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`.
|
||||
- No globally searchable resource behavior changes in this feature.
|
||||
- No new destructive action is introduced, so no new confirmation flow is required.
|
||||
- Asset strategy is unchanged: no new panel or shared assets, and the existing deploy `cd apps/platform && php artisan filament:assets` step remains sufficient.
|
||||
67
specs/230-findings-notification-convergence/research.md
Normal file
67
specs/230-findings-notification-convergence/research.md
Normal file
@ -0,0 +1,67 @@
|
||||
# Research: Findings Notification Presentation Convergence
|
||||
|
||||
## Decision 1: Extend the existing `OperationUxPresenter` seam instead of creating a notification framework
|
||||
|
||||
**Decision**: Reuse `App\Support\OpsUx\OperationUxPresenter` as the shared anchor for operator-facing database notification presentation and extend it with one bounded shared contract for the in-scope consumers.
|
||||
|
||||
**Rationale**: `OperationRunCompleted` already proves that the repository has one real shared presenter path for this interaction family. Extending that seam solves the current drift with the smallest change and follows XCUT-001 without introducing a new registry, interface family, or universal notification platform.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Create a new notification presenter package or registry. Rejected because only three real consumers are in scope and the spec explicitly forbids broader framework work.
|
||||
- Apply local copy changes in each notification class. Rejected because that would leave the shared interaction family without one explicit contract and would allow drift to continue.
|
||||
|
||||
## Decision 2: Standardize the primary structure only, not every metadata field
|
||||
|
||||
**Decision**: The shared contract will standardize title, body, one status emphasis with the existing Filament icon treatment, one primary action, and optional supporting lines. Existing domain-specific metadata remains namespaced and secondary.
|
||||
|
||||
**Rationale**: Findings notifications and operation notifications carry different domain truth. The operator needs one stable primary card structure, not one flattened metadata model. Keeping `finding_event`, `reason_translation`, and related diagnostic fields secondary preserves current domain fidelity without fragmenting the card grammar.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Normalize all notification metadata into one new envelope. Rejected because it adds unnecessary semantic machinery and risks losing useful domain-specific context.
|
||||
- Leave metadata and structure fully local. Rejected because primary structure drift is the operator problem this spec is solving.
|
||||
|
||||
## Decision 3: Preserve canonical deep-link helpers and plane-specific route resolution
|
||||
|
||||
**Decision**: Continue to source finding links from `FindingResource::getUrl(...)`, admin operation links from `OperationRunLinks`, and platform operation links from `SystemOperationRunLinks`.
|
||||
|
||||
**Rationale**: The feature is about presentation convergence, not route ownership. Existing helper seams already encode the correct tenant, admin, tenantless, and system-plane behavior, so the shared contract must consume them rather than replace them.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Build URLs inline inside the shared presenter. Rejected because it would duplicate existing routing truth and make access bugs more likely.
|
||||
- Collapse platform and admin operation destinations into one route family. Rejected because the spec requires the current plane-specific destination behavior to stay intact.
|
||||
|
||||
## Decision 4: Keep the existing Filament drawer and persistence artifacts unchanged
|
||||
|
||||
**Decision**: The feature will keep the current Filament database-notification drawer, the existing `notifications` table, and the current asset and polling behavior unchanged.
|
||||
|
||||
**Rationale**: The operator problem is inconsistent card grammar, not missing surfaces or storage. Reusing the current shell avoids scope creep and keeps the change inside the intended cleanup boundary.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Build a new notification center or work queue. Rejected because it adds a second collection surface that the spec explicitly forbids.
|
||||
- Add new persistence for shared presentation. Rejected because the shared contract is derived and does not represent new product truth.
|
||||
|
||||
## Decision 5: Prove convergence through focused feature and guard tests only
|
||||
|
||||
**Decision**: Add one contract-level notification test, extend current findings and operations notification tests, and update the existing legacy-notification guard for both local-bypass prevention and the preserved FR-015 boundary.
|
||||
|
||||
**Rationale**: The proving burden is payload structure and route safety across current consumers. Feature-level assertions already cover these concerns and are the narrowest executable proof.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Add browser coverage. Rejected because the Filament drawer shell itself is unchanged and browser cost would not prove more than payload-level feature tests.
|
||||
- Rely only on the legacy guard. Rejected because the guard can catch bypass patterns but cannot prove that the rendered payload structure stayed aligned across all current consumers.
|
||||
|
||||
## Decision 6: Preserve operation-completed guidance and reason translation as secondary context
|
||||
|
||||
**Decision**: `OperationRunCompleted` keeps its terminal presentation logic, summary lines, and `ReasonPresenter` output, but these remain secondary supporting context under the shared primary structure.
|
||||
|
||||
**Rationale**: Terminal run notifications carry more diagnostic signal than findings or queued runs. The shared contract should unify the card grammar without erasing the extra detail that makes completed-run notifications actionable.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Flatten completed-run detail into the same minimal body as queued notifications. Rejected because it would reduce useful operator signal on terminal outcomes.
|
||||
- Keep completed-run notifications fully separate because of richer detail. Rejected because that would leave the current interaction-family drift unresolved.
|
||||
254
specs/230-findings-notification-convergence/spec.md
Normal file
254
specs/230-findings-notification-convergence/spec.md
Normal file
@ -0,0 +1,254 @@
|
||||
# Feature Specification: Findings Notification Presentation Convergence
|
||||
|
||||
**Feature Branch**: `230-findings-notification-convergence`
|
||||
**Created**: 2026-04-22
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Findings Notification Presentation Convergence"
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Findings notifications now work functionally, but operator-facing database notifications speak through two different presentation paths. `FindingEventNotification` composes its payload locally, while operation notifications partially rely on an existing shared presentation path. The same interaction class therefore appears with different title/body/action grammar.
|
||||
- **Today's failure**: An operator who receives both finding and operation notifications must re-interpret which text is primary, what the action button means, and whether extra context is actionable or diagnostic. On the product side, each new notification-bearing domain is encouraged to ship another local payload shape.
|
||||
- **User-visible improvement**: Operators receive finding and operation notifications with one shared structure: a title naming the target object, a primary body summarizing the change, one status emphasis that drives the existing Filament icon treatment, exactly one primary action, consistent deep-link behavior, and optional supporting context that never displaces the next step.
|
||||
- **Smallest enterprise-capable version**: Inventory the current operator-facing database notification consumers, define one bounded shared presentation contract for database notification payloads, align finding event notifications and current operation-run notifications to it, and add regression guard coverage.
|
||||
- **Explicit non-goals**: No redesign of alert routing or recipient resolution, no notification preference center, no second notification center, no new workflow queue, no full historical sweep of every notification class in the repo, and no generalized notification platform beyond current operator-facing database notifications.
|
||||
- **Permanent complexity imported**: One bounded shared database-notification presentation contract, one small shared presenter or builder path for current consumers, focused regression tests, and explicit guardrail notes for future in-scope notification types.
|
||||
- **Why now**: Spec 224 just introduced findings notifications, and Constitution XCUT-001 now explicitly blocks silent parallel shared-interaction paths. This is the cheapest moment to converge before more domains copy the local findings pattern.
|
||||
- **Why not local**: A finding-only wording tweak would still leave findings and operation notifications on separate payload grammars. The gap is shared interaction drift, not one isolated title string.
|
||||
- **Approval class**: Cleanup
|
||||
- **Red flags triggered**: One bounded shared presenter or builder path and one multi-consumer convergence slice. This remains acceptable because three real consumers already exist today (`FindingEventNotification`, `OperationRunQueued`, `OperationRunCompleted`) and the feature removes parallel UX language instead of creating a speculative framework.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitat: 2 | Produktnahe: 2 | Wiederverwendung: 2 | **Gesamt: 12/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- Existing Filament database notification surface in admin `/admin` and tenant `/admin/t/{tenant}/...` shells
|
||||
- `/admin/t/{tenant}/findings/{finding}` as the finding follow-up destination
|
||||
- `/admin/operations/{run}` as the existing admin-plane operation destination
|
||||
- Existing system-panel run detail destination for platform users receiving operation notifications
|
||||
- **Data Ownership**:
|
||||
- Existing database notification rows remain delivery artifacts only and do not become a second workflow state store.
|
||||
- Tenant-owned findings remain the only source of truth for finding notification content, severity, and deep links.
|
||||
- Operation runs remain the only source of truth for operation notification content, status or outcome meaning, and deep links.
|
||||
- Workspace-owned alert rules and alert deliveries remain outside the change scope except where current Spec 224 behavior must remain intact.
|
||||
- **RBAC**:
|
||||
- Existing workspace and tenant membership plus findings-view authorization continue to gate finding notification payload visibility and deep links.
|
||||
- Existing admin-plane or system-plane run visibility continues to gate operation notification links.
|
||||
- Notification presentation must not widen disclosure; payloads and action links only expose records the recipient may already inspect.
|
||||
- Non-members and out-of-scope users remain `404`; in-scope users missing a required capability remain `403` on protected destinations.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: notifications, action links
|
||||
- **Systems touched**:
|
||||
- `App\Notifications\Findings\FindingEventNotification`
|
||||
- `App\Notifications\OperationRunQueued`
|
||||
- `App\Notifications\OperationRunCompleted`
|
||||
- Existing Filament database notification surface
|
||||
- Existing finding and operation deep-link helpers
|
||||
- **Existing pattern(s) to extend**:
|
||||
- Existing operator-facing database notification surface
|
||||
- `App\Support\OpsUx\OperationUxPresenter::terminalDatabaseNotification()`
|
||||
- Existing canonical deep-link helpers for findings and operation runs
|
||||
- **Shared contract / presenter / builder / renderer to reuse**:
|
||||
- Extend the current `OperationUxPresenter::terminalDatabaseNotification()` path into one bounded shared operator-facing database notification contract reused by `FindingEventNotification`, `OperationRunQueued`, and `OperationRunCompleted`
|
||||
- Preserve current deep-link generation through `FindingResource::getUrl(...)`, `OperationRunLinks`, and `SystemOperationRunLinks`
|
||||
- **Why the existing shared path is sufficient or insufficient**: The existing path is sufficient as a starting anchor because a real shared presenter already exists for terminal operation notifications and stable deep-link helpers already exist. It is insufficient in its current form because queued operation notifications and findings notifications still compose local Filament payloads, so the interaction class does not yet have one explicit shared contract.
|
||||
- **Allowed deviation and why**: bounded exception only. Domain-specific supporting context may differ between finding and operation notifications, and platform-user operation links may keep their system-plane destination, but title/body/action structure and primary deep-link behavior must stay aligned.
|
||||
- **Consistency impact**: Title/body hierarchy, status semantics with the corresponding existing Filament icon treatment, primary action placement, action-label grammar, optional supporting context rules, and deep-link trust must stay aligned across in-scope notification families.
|
||||
- **Review focus**: Reviewers must verify that in-scope operator-facing database notifications extend the shared contract instead of locally calling `FilamentNotification::make()->getDatabaseMessage()` for primary structure, and that deep-link helpers remain canonical rather than being rebuilt inside notification classes.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Finding event database notifications | yes | Existing Filament database notification surface plus existing finding notification class | Same operator-facing database notification family as operation notifications | title, body, tone, primary action, supporting context, deep link | no | Existing drawer or shell only; no new notification center |
|
||||
| Operation run database notifications | yes | Existing Filament database notification surface plus existing operation notification classes and presenter | Same operator-facing database notification family as findings notifications | title, body, tone, primary action, supporting context, deep link | yes | Existing admin and system drawers remain; only plane-specific destination logic stays exceptional |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Finding event database notifications | Secondary Context Surface | An operator receives a finding update and decides whether to open the finding now | What changed, which finding is affected, tenant context, and exactly one primary action | Full finding detail, evidence, audit trail, and workflow actions after opening the finding | Secondary because the notification points into the real work surface instead of replacing it | Keeps notifications aligned with the existing findings workflow surfaces | Removes re-learning of notification structure between findings and other work updates |
|
||||
| Operation run database notifications | Secondary Context Surface | An operator receives run lifecycle feedback and decides whether to open the canonical run view now | What happened to the run, affected context, and exactly one primary action | Full operation detail, reason translation, diagnostics, and history after opening the run | Secondary because the notification is an entry point into the canonical run surface, not the destination itself | Preserves monitoring and operations as the durable work context | Reduces interpretation cost when findings and operations both notify through the same shared drawer |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding event database notifications | Utility / System | Notification / drill-in entry point | Open finding | Finding | forbidden | none; one primary action only | none | Existing in-app database notification surface | `/admin/t/{tenant}/findings/{finding}` | Tenant name, severity, event label, recipient reason | Findings / Finding | Why the operator was notified and what finding needs follow-up | none |
|
||||
| Operation run database notifications | Utility / System | Notification / drill-in entry point | Open operation run | Operation run | forbidden | none; one primary action only | none | Existing in-app database notification surface | `/admin/operations/{run}` for admin-plane users; existing system run detail destination for platform users | Operation label, tenant or workspace context, lifecycle outcome | Operations / Operation run | What happened to the run and which canonical detail surface explains it | Plane-specific detail-route exception because platform users do not use the admin run destination |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding event database notifications | Tenant operator or tenant manager | Decide whether to open a finding now because work changed hands, reopened, or aged | Notification entry point | Why am I being notified, and what do I need to open? | Tenant, finding summary, severity, event label, recipient reason, and one action | Full finding evidence, audit trail, and lifecycle history after opening | assignment change, reopen truth, due-state aging | none on the notification itself | Open finding | none |
|
||||
| Operation run database notifications | Workspace operator or platform operator | Decide whether to inspect a run now because it queued or reached a terminal state | Notification entry point | What happened to this run, and should I open it now? | Operation label, primary status or outcome summary, context hint, and one action | Full run diagnostics, reason translation, payload context, and history after opening | execution lifecycle, terminal outcome, context scope | none on the notification itself | Open operation | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes - one bounded shared operator-facing database notification contract over existing finding and operation notification payload builders
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Operators currently receive the same interaction class through different presentation grammars, which weakens trust and makes future notification-bearing work harder to learn.
|
||||
- **Existing structure is insufficient because**: One real shared presenter exists, but it only covers terminal operation notifications. Findings notifications and queued operation notifications still compose local payloads, so the product has no explicit shared contract for operator-facing database notifications.
|
||||
- **Narrowest correct implementation**: Reuse the existing shared presenter anchor and canonical link helpers, extend that path to the current three real consumers, and stop there.
|
||||
- **Ownership cost**: Ongoing maintenance for one small shared contract, a small supporting-context rule set, and regression coverage for payload structure and deep-link safety.
|
||||
- **Alternative intentionally rejected**: A full notification platform, preferences center, or domain-by-domain local fixes were rejected because they either import premature infrastructure or leave the parallel-path problem unsolved.
|
||||
- **Release truth**: Current-release truth. This is a narrow convergence step for an interaction class that already has multiple real consumers.
|
||||
|
||||
### 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 proving burden is operator-visible payload structure and deep-link safety across real notification consumers. Focused feature coverage is enough to prove the shared contract without introducing browser or heavy-governance cost.
|
||||
- **New or expanded test families**: Add one shared database notification contract test and extend finding or operation notification payload tests plus deep-link authorization coverage.
|
||||
- **Fixture / helper cost impact**: Moderate. Tests need a tenant user, a platform user, findings, operation runs, visible and hidden tenant contexts, and the existing notification helper paths.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: global-context-shell
|
||||
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit proof that finding links preserve tenant safety and operation links preserve current admin versus system routing semantics while still sharing one primary payload structure.
|
||||
- **Reviewer handoff**: Reviewers must confirm that the shared contract stays bounded to current operator-facing database notifications, that no second local payload grammar survives in the in-scope consumers, and that exactly one primary action remains the only primary affordance on each notification.
|
||||
- **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/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
**Story sequencing note**: These stories are incremental slices over one shared notification-presentation seam. US1 establishes the converged primary structure, US2 validates preserved route and scope truth on top of that seam, and US3 hardens the seam against future in-scope bypasses.
|
||||
|
||||
### User Story 1 - Scan Notifications Without Relearning The Surface (Priority: P1)
|
||||
|
||||
As a responsible operator, I want finding and operation notifications to use the same primary structure, so I can decide what to open without decoding each domain's private language.
|
||||
|
||||
**Why this priority**: This is the smallest direct operator win. If the shared notification surface still speaks two different grammars, the product keeps adding cognitive load exactly where it should be lower-friction.
|
||||
|
||||
**Independent Test**: Can be fully tested by generating one finding notification and one operation notification, then verifying that both expose the same primary structure: a title naming the target object, a primary body summarizing the change, one status emphasis with the existing Filament icon treatment, exactly one primary action, and optional supporting context that stays secondary.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user receives a finding notification and an operation notification, **When** both are rendered in the same database-notification surface, **Then** each shows a title naming the target object, a primary body summarizing the change, one status emphasis with the existing Filament icon treatment, and exactly one primary action in the same structural order.
|
||||
2. **Given** one notification includes extra supporting context while another does not, **When** both are rendered, **Then** the missing supporting context does not collapse the shared primary structure or displace the action.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Trust Notification Actions And Scope Rules (Priority: P1)
|
||||
|
||||
As an operator, I want notification actions to open the right record without leaking scope, so I can trust the shortcut instead of treating notifications as decorative text.
|
||||
|
||||
**Why this priority**: Shared presentation only matters if the action affordance remains truthful. A consistent button that opens the wrong place or leaks scope would make the surface less trustworthy, not more.
|
||||
|
||||
**Dependency Note**: This is a follow-on slice after US1 establishes the converged primary structure.
|
||||
|
||||
**Independent Test**: After US1 is in place, this slice can be fully tested by opening finding and operation notification actions as entitled and non-entitled users, then verifying that the correct destination opens and existing `404` versus `403` semantics remain intact.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an entitled tenant user opens a finding notification action, **When** the link resolves, **Then** the existing finding detail page opens.
|
||||
2. **Given** a user is not entitled to the tenant behind a finding notification, **When** that link is opened, **Then** the system responds with deny-as-not-found behavior and does not reveal tenant or finding details.
|
||||
3. **Given** a platform user receives an operation notification, **When** the action is opened, **Then** the existing system-plane run destination opens without falling back to tenant assumptions.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep Future Work-Update Notifications Familiar (Priority: P2)
|
||||
|
||||
As a workspace operator, I want later work-update notifications to keep the same field order and action placement, so the database-notification surface stays predictable as more domains adopt it.
|
||||
|
||||
**Why this priority**: This is the bounded spread-control value of the spec. Without it, every future notification-bearing domain can add a new local grammar and undo the immediate operator benefit.
|
||||
|
||||
**Dependency Note**: This is a follow-on slice after US1 and US2 establish the converged structure and preserved route rules.
|
||||
|
||||
**Independent Test**: After US1 and US2 are in place, this slice can be fully tested by asserting that the in-scope finding and operation consumers already share the same required primary field set and by requiring that same field set for any additional in-scope consumer added during the feature.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a new or refactored in-scope operator-facing database notification joins the shared surface, **When** it is delivered, **Then** it uses the same title, body, action, and deep-link structure rather than inventing a local primary payload grammar.
|
||||
2. **Given** findings and operation notifications coexist in the drawer, **When** an operator compares them, **Then** the domain nouns differ where needed but field order, action placement, and deep-link affordance remain the same.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Finding and operation notifications may require different destination routes; the shared contract must keep one structure without forcing one destination family.
|
||||
- Platform users may receive operation notifications while findings remain tenant-scoped; the shared contract must not assume tenant context or leak cross-plane details.
|
||||
- Some notification types may have supporting context while others do not; absent context must not create blank or malformed notification cards.
|
||||
- Notification classes outside the current bounded scope must keep their current behavior until they are explicitly brought into the shared contract, rather than being partially rewritten ad hoc.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no new write workflow, no new queued job, and no new `OperationRun` type. It changes only how existing operator-facing database notifications compose their payloads and actions.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one bounded shared presenter or builder path because current-release operator experience already has three real consumers for the same interaction class. It adds no persistence, no new states, and no new taxonomy.
|
||||
|
||||
**Constitution alignment (XCUT-001):** This spec exists to close a documented shared-interaction bypass. It must name the current shared notification path, extend it, and stop silent local payload composition in the in-scope consumers.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** The proving burden is payload-structure truth and deep-link safety across current consumers. Focused feature tests are sufficient; no browser or heavy-governance lane is justified.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing operation-notification emission rules remain unchanged. This feature may change `OperationRunQueued` and `OperationRunCompleted` payload composition only. It must not alter service-owned lifecycle transitions, terminal-notification suppression for system runs, or current canonical run-link behavior.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature spans admin-plane, tenant-context, and system-plane notification consumers, but it does not change authorization rules. Finding notification links must continue to enforce tenant-safe `404` versus `403` semantics. Operation notification links must continue to honor current admin or system-plane entitlement rules. No notification payload may widen visibility beyond the recipient's current scope.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** Existing Filament database-notification primitives must be reused. No custom notification drawer, custom card markup, or local page-specific notification shell may be introduced.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing notification copy must stay domain-first. Findings remain `finding` notifications with verbs such as `assigned`, `reopened`, `due soon`, or `overdue`. Operation notifications remain `operation` or `run` updates with verbs such as `queued` and `completed`. Implementation-first terms such as payload, presenter, fingerprint, or envelope remain secondary.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** Notifications remain secondary context surfaces. They must support one quick open decision and then defer to the existing canonical work surface for real investigation or mutation.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** Each in-scope notification card has exactly one primary inspect model and one primary action. Row click is forbidden. There are no destructive actions and no mixed action groups on the notification surface.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature solves a real shared-interaction divergence but must stay shallow. It must not grow a new notification taxonomy or explanation framework. Tests should focus on the business consequence: the operator sees one consistent structure and safe action path.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST define one shared operator-facing database-notification presentation contract for the current in-scope notification families.
|
||||
- **FR-002**: The shared contract MUST cover, at minimum, title, body, one status emphasis field with the corresponding existing Filament icon treatment, one primary action label, one primary deep link, and optional supporting context.
|
||||
- **FR-003**: `FindingEventNotification` MUST use the shared contract without changing the delivery semantics, event types, recipient resolution, dedupe, fingerprint logic, or optional external-copy behavior introduced by Spec 224.
|
||||
- **FR-004**: `OperationRunCompleted` MUST continue to use the shared contract for terminal database notifications, preserving existing reason-translation behavior only as secondary supporting context.
|
||||
- **FR-005**: `OperationRunQueued` MUST align to the same shared contract and must no longer remain a divergent local payload shape for the same interaction class.
|
||||
- **FR-006**: Every in-scope notification MUST expose exactly one primary action. Supporting context, if present, MUST remain supplementary and MUST NOT displace the action.
|
||||
- **FR-007**: Primary action labels MUST remain domain-first and operator-readable. Findings keep `Open finding`; operation notifications keep the existing operator-readable run-open grammar.
|
||||
- **FR-008**: Notification deep links MUST continue to use the existing canonical link helpers and existing destination routes. Notification classes MUST NOT assemble raw routes locally when a canonical helper already exists.
|
||||
- **FR-009**: Notification payloads MUST remain delivery artifacts only. The feature MUST NOT introduce a second workflow state store, notification-preference model, or presentation-only persistence layer.
|
||||
- **FR-010**: The feature MUST NOT change recipient eligibility, alert-rule routing, alert delivery behavior, or external notification semantics from Spec 224.
|
||||
- **FR-011**: The feature MUST NOT change operation-run notification emission policy, queued-notification allow rules, terminal-notification suppression rules, or service ownership of run lifecycle state.
|
||||
- **FR-012**: In-scope notification titles and bodies MUST NOT expose raw event keys, reason codes, payload fields, or implementation-first terms as their primary operator language.
|
||||
- **FR-013**: The shared contract MUST support the current three real consumers and remain bounded to current operator-facing database notifications. It MUST NOT expand into a universal notification framework in v1.
|
||||
- **FR-014**: Contract-level regression coverage MUST prove that findings and operation notifications share the same primary payload structure and that future in-scope consumers cannot silently bypass that structure.
|
||||
- **FR-015**: Existing alert rules, alert deliveries, escalation logic, and any future `My Work` admission rules remain outside this spec unless they are only consuming the already-converged in-app notification presentation.
|
||||
- **FR-016**: The existing database-notification surface remains the only collection surface for the in-scope payloads. This feature MUST NOT create a second notification center, a `My Work` queue, or a dashboard clone.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding event database notification card | Existing Filament database-notification surface | none | Primary action button only: `Open finding` | none | none | none | n/a | n/a | No new audit because the card stays read-only | Action Surface Contract satisfied. One inspect model, no redundant `View`, no destructive action, no custom notification shell. |
|
||||
| Operation run database notification card | Existing Filament database-notification surface | none | Primary action button only: existing run-open label | none | none | none | n/a | n/a | No new audit because the card stays read-only | Action Surface Contract satisfied. One inspect model, no redundant `View`, no destructive action. Approved exception: platform-user cards keep their system-plane destination while sharing the same card structure. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Operator-facing database notification payload**: A derived delivery artifact that carries the visible title, body, status emphasis with the existing Filament icon treatment, primary action, deep link, and optional supporting context shown in the existing notification surface.
|
||||
- **Shared database-notification presentation contract**: A bounded cross-cutting contract that standardizes the primary structure for current finding and operation notification payloads.
|
||||
- **Notification consumer family**: The current set of real notification classes that present work updates to operators through database notifications.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In acceptance review, every in-scope finding and operation notification shows the target object, the change summary, one status emphasis with the existing Filament icon treatment, and exactly one primary action before any optional supporting context.
|
||||
- **SC-002**: 100% of covered payload tests assert the same primary structure across the in-scope finding and operation notification consumers.
|
||||
- **SC-003**: 100% of covered authorization tests preserve existing `404` versus `403` and admin versus system-plane deep-link semantics for the in-scope notification actions.
|
||||
- **SC-004**: No in-scope operator-facing database notification ships with a divergent local primary structure at feature completion.
|
||||
217
specs/230-findings-notification-convergence/tasks.md
Normal file
217
specs/230-findings-notification-convergence/tasks.md
Normal file
@ -0,0 +1,217 @@
|
||||
# Tasks: Findings Notification Presentation Convergence
|
||||
|
||||
**Input**: Design documents from `/specs/230-findings-notification-convergence/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/findings-notification-convergence.logical.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This feature changes runtime behavior in operator-facing database notification payload composition and preserved route targeting, so Pest coverage must be added or updated in `apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php`, `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`, `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, and `apps/platform/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`.
|
||||
**Operations**: No new `OperationRun` is introduced. Existing queued and terminal operation notification consumers remain in scope only for presentation convergence in `apps/platform/app/Notifications/OperationRunQueued.php` and `apps/platform/app/Notifications/OperationRunCompleted.php`; emit policy and run lifecycle semantics must remain unchanged.
|
||||
**RBAC**: Finding notifications stay on the tenant `/admin/t/{tenant}/findings/{finding}` plane and operation notifications keep their current admin `/admin/operations/{run}` or platform `/system/ops/runs/{run}` destinations. The implementation must preserve non-member or hidden-scope `404`, in-scope missing-capability `403`, tenant-safe deep links, and current plane-specific visibility semantics.
|
||||
**UI / Surface Guardrails**: The existing Filament database-notification drawer remains the only collection surface and is a `global-context-shell` seam. Every in-scope card must keep exactly one primary action, no row click, no destructive action, and no custom notification shell.
|
||||
**Filament UI Action Surfaces**: `FindingEventNotification`, `OperationRunQueued`, and `OperationRunCompleted` remain utility or system notification entry points with one inspect model each. No new page family, no new action group, and no second notification center may be introduced.
|
||||
**Badges**: Existing severity, status, and outcome semantics remain authoritative. This feature must not introduce ad-hoc badge mappings or a new status taxonomy.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each slice stays incrementally testable once the shared seam from earlier phases is present. Recommended delivery order is `US1 -> US2 -> US3` because the shared primary card structure must stabilize before route-truth and future-bypass guardrails are extended.
|
||||
|
||||
## 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 (Shared Notification Test Surfaces)
|
||||
|
||||
**Purpose**: Prepare the focused regression surfaces that will prove the shared notification contract.
|
||||
|
||||
- [X] T001 [P] Create the shared contract test scaffold in `apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php`
|
||||
- [X] T002 [P] Extend the finding notification test scaffold in `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||
- [X] T003 [P] Extend the operation notification test scaffold in `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`
|
||||
|
||||
**Checkpoint**: Shared contract, finding notification, and operation notification test surfaces are ready for implementation work.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Shared Presentation Seam)
|
||||
|
||||
**Purpose**: Establish the canonical shared presentation path and repo guardrails before any story implementation begins.
|
||||
|
||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Implement the shared operator database-notification builder, status-to-icon mapping, and supporting-line composition in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
|
||||
- [X] T005 Add baseline cross-consumer contract assertions for shared structure and status-to-icon treatment in `apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php`
|
||||
- [X] T006 Extend the in-scope local-payload bypass guard in `apps/platform/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
|
||||
|
||||
**Checkpoint**: The shared presentation seam exists, the baseline contract is testable, and CI can detect direct local bypasses of the in-scope notification family.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Scan Notifications Without Relearning The Surface (Priority: P1)
|
||||
|
||||
**Goal**: Give finding and operation notifications one shared primary card structure so operators can read both without reinterpreting the surface.
|
||||
|
||||
**Independent Test**: Trigger one finding notification and one operation notification, then verify both render the same primary structure: a title naming the target object, a primary body summarizing the change, one status emphasis with the existing Filament icon treatment, exactly one primary action, and optional supporting context that stays secondary.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T007 [P] [US1] Add finding shared-card structure and status-to-icon assertions in `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||
- [X] T008 [P] [US1] Add queued and completed run shared-card structure and status-to-icon assertions in `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T009 [P] [US1] Route `apps/platform/app/Notifications/Findings/FindingEventNotification.php` through the shared presentation builder while preserving `finding_event` metadata
|
||||
- [X] T010 [P] [US1] Route `apps/platform/app/Notifications/OperationRunQueued.php` through the shared presentation builder while preserving queued-state copy and one primary action
|
||||
- [X] T011 [P] [US1] Normalize `apps/platform/app/Notifications/OperationRunCompleted.php` onto the same primary structure while keeping summary and reason translation as secondary context
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional and finding plus operation notifications share one primary card grammar.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Trust Notification Actions And Scope Rules (Priority: P1)
|
||||
|
||||
**Goal**: Preserve correct finding and operation destinations plus current `404` versus `403` behavior while the shared presentation seam is introduced.
|
||||
|
||||
**Independent Test**: After US1 is in place, open finding and operation notification actions as entitled and non-entitled users, then verify the correct tenant, admin, tenantless, or system destination opens and existing authorization semantics remain unchanged.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T012 [P] [US2] Add tenant-safe `404` versus `403` action-link assertions in `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||
- [X] T013 [P] [US2] Add admin, tenantless, and system-plane action-link assertions in `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`
|
||||
- [X] T014 [P] [US2] Keep Spec 224 finding event and routing behavior covered under the new presentation path in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php` and `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T015 [US2] Preserve the `Open finding` label and tenant detail route authority in `apps/platform/app/Notifications/Findings/FindingEventNotification.php`
|
||||
- [X] T016 [US2] Preserve admin, tenantless, and platform-user system destinations in `apps/platform/app/Notifications/OperationRunQueued.php` and `apps/platform/app/Notifications/OperationRunCompleted.php`
|
||||
- [X] T017 [US2] Verify canonical operation action labels and route helpers remain authoritative in `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/System/SystemOperationRunLinks.php`, applying only minimal normalization if the shared contract requires it
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional and the shared card structure does not alter route truth or authorization semantics.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep Future Work-Update Notifications Familiar (Priority: P2)
|
||||
|
||||
**Goal**: Lock the new shared contract in place so later in-scope notification consumers cannot silently reintroduce a local primary grammar.
|
||||
|
||||
**Independent Test**: After US1 and US2 are in place, assert that the in-scope finding and operation consumers expose exactly one primary action through the shared contract and that guard coverage fails if a future in-scope consumer builds its primary payload locally.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T018 [P] [US3] Add regression assertions that all in-scope consumers expose exactly one primary action, keep shared status-to-icon treatment, and keep secondary metadata namespaced in `apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php`
|
||||
- [X] T019 [P] [US3] Extend future-bypass and FR-015 boundary guard coverage in `apps/platform/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T020 [US3] Encapsulate consumer input mapping for finding, queued-run, and completed-run notifications inside `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
|
||||
- [X] T021 [US3] Keep consumer-specific metadata secondary in `apps/platform/app/Notifications/Findings/FindingEventNotification.php` and `apps/platform/app/Notifications/OperationRunCompleted.php`
|
||||
- [X] T022 [US3] Keep queued notification supporting context minimal and inside the shared contract in `apps/platform/app/Notifications/OperationRunQueued.php` and `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional and future in-scope notification work is steered toward the shared contract instead of local payload composition.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finish copy review, formatting, and the narrow proving workflow for the full feature.
|
||||
|
||||
- [X] T023 Review operator-facing notification copy, status-emphasis grammar, and one-primary-action ordering in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Notifications/Findings/FindingEventNotification.php`, `apps/platform/app/Notifications/OperationRunQueued.php`, and `apps/platform/app/Notifications/OperationRunCompleted.php`
|
||||
- [X] T024 Run formatting for `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Notifications/Findings/FindingEventNotification.php`, `apps/platform/app/Notifications/OperationRunQueued.php`, `apps/platform/app/Notifications/OperationRunCompleted.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/System/SystemOperationRunLinks.php`, `apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php`, `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`, `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, and `apps/platform/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T025 Run the focused validation workflow from `specs/230-findings-notification-convergence/quickstart.md` against `apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php`, `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`, `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, and `apps/platform/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately and prepares the focused test surfaces.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user story work until the shared presentation seam and baseline guard are in place.
|
||||
- **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 route and scope truth must be validated against the converged primary structure, not the pre-convergence local grammars.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because spread-control guardrails must lock the final shared contract and preserved route behavior, not an intermediate shape.
|
||||
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: Follow-on slice after US1 establishes the shared primary structure.
|
||||
- **US3**: Follow-on slice after US1 and US2 establish the settled contract and preserved route rules.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the story tests first and confirm they fail before implementation is considered complete.
|
||||
- Keep `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` authoritative for shared primary structure instead of duplicating card grammar in notification classes.
|
||||
- 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, followed by `T009`, `T010`, and `T011` in parallel after `T004` is complete.
|
||||
- `T012`, `T013`, and `T014` can run in parallel for User Story 2.
|
||||
- `T018` and `T019` 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/Notifications/FindingNotificationLinkTest.php
|
||||
T008 apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php
|
||||
|
||||
# User Story 1 implementation in parallel after T004
|
||||
T009 apps/platform/app/Notifications/Findings/FindingEventNotification.php
|
||||
T010 apps/platform/app/Notifications/OperationRunQueued.php
|
||||
T011 apps/platform/app/Notifications/OperationRunCompleted.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# User Story 2 tests in parallel
|
||||
T012 apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php
|
||||
T013 apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php
|
||||
T014 apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# User Story 3 guard and contract tests in parallel
|
||||
T018 apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php
|
||||
T019 apps/platform/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate the feature against the focused US1 tests before widening the slice.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to converge the primary card structure across findings and operations.
|
||||
2. Add US2 to prove route truth and authorization semantics stayed intact.
|
||||
3. Add US3 to lock the shared contract in place for future in-scope notification work.
|
||||
4. Finish with copy review, formatting, and the focused validation pack.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor can prepare the shared contract test and guard work while another extends finding and operation notification test surfaces.
|
||||
2. After Foundational work lands, finding, queued-run, and completed-run notification classes can be aligned in parallel against the shared presenter seam.
|
||||
3. Route-truth tests can proceed in parallel with preserved-link implementation once the shared primary structure is stable.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared.
|
||||
- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories.
|
||||
- The suggested MVP scope is Phase 1 through Phase 3 only.
|
||||
- All implementation tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.
|
||||
Loading…
Reference in New Issue
Block a user