TenantAtlas/apps/platform/app/Services/Findings/FindingNotificationService.php
ahmido e15d80cca5
Some checks failed
Main Confidence / confidence (push) Failing after 48s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
feat: implement findings notifications escalation (#261)
## Summary
- implement Spec 224 findings notifications and escalation v1 on top of the existing alerts and Filament database notification infrastructure
- add finding assignment, reopen, due soon, and overdue event handling with direct recipient routing, dedupe, and optional external alert fan-out
- extend alert rule and alert delivery surfaces plus add the Spec 224 planning bundle and candidate-list promotion cleanup

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Alerts/SlaDueAlertTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`

## Filament / Platform Notes
- Livewire v4.0+ compliance is preserved
- provider registration remains unchanged in `apps/platform/bootstrap/providers.php`
- no globally searchable resource behavior changed in this feature
- no new destructive action was introduced
- asset strategy is unchanged and the existing `cd apps/platform && php artisan filament:assets` deploy step remains sufficient

## Manual Smoke Note
- integrated-browser smoke testing confirmed the new alert rule event options, notification drawer entries, alert delivery history row, and tenant finding detail route on the active Sail host
- local notification deep links currently resolve from `APP_URL`, so a local `localhost` vs `127.0.0.1:8081` host mismatch can break the browser session if the app is opened on a different host/port combination

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #261
2026-04-22 00:54:38 +00:00

390 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\Findings\FindingEventNotification;
use App\Services\Alerts\AlertDispatchService;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Carbon\CarbonInterface;
use InvalidArgumentException;
final class FindingNotificationService
{
public function __construct(
private readonly AlertDispatchService $alertDispatchService,
private readonly CapabilityResolver $capabilityResolver,
) {}
/**
* @param array<string, mixed> $context
* @return array{
* event_type: string,
* fingerprint_key: string,
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
* external_delivery_count: int
* }
*/
public function dispatch(Finding $finding, string $eventType, array $context = []): array
{
$finding = $this->reloadFinding($finding);
$tenant = $finding->tenant;
if (! $tenant instanceof Tenant) {
return $this->dispatchResult(
eventType: $eventType,
fingerprintKey: '',
directDeliveryStatus: 'no_recipient',
externalDeliveryCount: 0,
);
}
if ($this->shouldSuppressEvent($finding, $eventType, $context)) {
return $this->dispatchResult(
eventType: $eventType,
fingerprintKey: $this->fingerprintFor($finding, $eventType, $context),
directDeliveryStatus: 'suppressed',
externalDeliveryCount: 0,
);
}
$resolution = $this->resolveRecipient($finding, $eventType, $context);
$event = $this->buildEventEnvelope($finding, $tenant, $eventType, $resolution['reason'], $context);
$directDeliveryStatus = $this->dispatchDirectNotification($finding, $tenant, $event, $resolution['user_id']);
$externalDeliveryCount = $this->dispatchExternalCopies($finding, $event);
return $this->dispatchResult(
eventType: $eventType,
fingerprintKey: (string) $event['fingerprint_key'],
directDeliveryStatus: $directDeliveryStatus,
externalDeliveryCount: $externalDeliveryCount,
);
}
/**
* @param array<string, mixed> $context
* @return array{user_id: ?int, reason: ?string}
*/
private function resolveRecipient(Finding $finding, string $eventType, array $context): array
{
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => [
'user_id' => $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id),
'reason' => 'new_assignee',
],
AlertRule::EVENT_FINDINGS_REOPENED => $this->preferredRecipient(
preferredUserId: $this->normalizeId($finding->assignee_user_id),
preferredReason: 'current_assignee',
fallbackUserId: $this->normalizeId($finding->owner_user_id),
fallbackReason: 'current_owner',
),
AlertRule::EVENT_FINDINGS_DUE_SOON => $this->preferredRecipient(
preferredUserId: $this->normalizeId($finding->assignee_user_id),
preferredReason: 'current_assignee',
fallbackUserId: $this->normalizeId($finding->owner_user_id),
fallbackReason: 'current_owner',
),
AlertRule::EVENT_FINDINGS_OVERDUE => $this->preferredRecipient(
preferredUserId: $this->normalizeId($finding->owner_user_id),
preferredReason: 'current_owner',
fallbackUserId: $this->normalizeId($finding->assignee_user_id),
fallbackReason: 'current_assignee',
),
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
};
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function buildEventEnvelope(
Finding $finding,
Tenant $tenant,
string $eventType,
?string $recipientReason,
array $context,
): array {
$severity = strtolower(trim((string) $finding->severity));
$summary = $finding->resolvedSubjectDisplayName() ?? 'Finding #'.(int) $finding->getKey();
$title = $this->eventLabel($eventType);
$fingerprintKey = $this->fingerprintFor($finding, $eventType, $context);
$dueCycleKey = $this->dueCycleKey($finding, $eventType);
return [
'event_type' => $eventType,
'workspace_id' => (int) $finding->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'severity' => $severity,
'title' => $title,
'body' => $this->eventBody($eventType, $tenant, $summary, ucfirst($severity)),
'fingerprint_key' => $fingerprintKey,
'due_cycle_key' => $dueCycleKey,
'metadata' => [
'tenant_name' => $tenant->getFilamentName(),
'summary' => $summary,
'recipient_reason' => $recipientReason,
'owner_user_id' => $this->normalizeId($finding->owner_user_id),
'assignee_user_id' => $this->normalizeId($finding->assignee_user_id),
'due_at' => $this->optionalIso8601($finding->due_at),
'reopened_at' => $this->optionalIso8601($finding->reopened_at),
'severity_label' => ucfirst($severity),
],
];
}
/**
* @param array<string, mixed> $event
*/
private function dispatchDirectNotification(Finding $finding, Tenant $tenant, array $event, ?int $userId): string
{
if (! is_int($userId) || $userId <= 0) {
return 'no_recipient';
}
$user = User::query()->find($userId);
if (! $user instanceof User) {
return 'no_recipient';
}
if (! $user->canAccessTenant($tenant)) {
return 'suppressed';
}
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)) {
return 'suppressed';
}
if ($this->alreadySentDirectNotification($user, (string) $event['fingerprint_key'])) {
return 'deduped';
}
$user->notify(new FindingEventNotification($finding, $tenant, $event));
return 'sent';
}
/**
* @param array<string, mixed> $event
*/
private function dispatchExternalCopies(Finding $finding, array $event): int
{
$workspace = Workspace::query()->whereKey((int) $finding->workspace_id)->first();
if (! $workspace instanceof Workspace) {
return 0;
}
return $this->alertDispatchService->dispatchEvent($workspace, $event);
}
private function alreadySentDirectNotification(User $user, string $fingerprintKey): bool
{
if ($fingerprintKey === '') {
return false;
}
return $user->notifications()
->where('type', FindingEventNotification::class)
->where('data->finding_event->fingerprint_key', $fingerprintKey)
->exists();
}
private function reloadFinding(Finding $finding): Finding
{
$fresh = Finding::query()
->with('tenant')
->withSubjectDisplayName()
->find($finding->getKey());
if ($fresh instanceof Finding) {
return $fresh;
}
$finding->loadMissing('tenant');
return $finding;
}
/**
* @param array<string, mixed> $context
*/
private function shouldSuppressEvent(Finding $finding, string $eventType, array $context): bool
{
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => ! $finding->hasOpenStatus()
|| $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) === null,
AlertRule::EVENT_FINDINGS_DUE_SOON, AlertRule::EVENT_FINDINGS_OVERDUE => ! $finding->hasOpenStatus()
|| ! $finding->due_at instanceof CarbonInterface,
default => false,
};
}
/**
* @param array<string, mixed> $context
*/
private function fingerprintFor(Finding $finding, string $eventType, array $context): string
{
$findingId = (int) $finding->getKey();
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
'finding:%d:%s:assignee:%d:updated:%s',
$findingId,
$eventType,
$this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) ?? 0,
$this->optionalIso8601($finding->updated_at) ?? 'none',
),
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
'finding:%d:%s:reopened:%s',
$findingId,
$eventType,
$this->optionalIso8601($finding->reopened_at) ?? 'none',
),
AlertRule::EVENT_FINDINGS_DUE_SOON,
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
'finding:%d:%s:due:%s',
$findingId,
$eventType,
$this->dueCycleKey($finding, $eventType) ?? 'none',
),
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
};
}
private function dueCycleKey(Finding $finding, string $eventType): ?string
{
if (! in_array($eventType, [
AlertRule::EVENT_FINDINGS_DUE_SOON,
AlertRule::EVENT_FINDINGS_OVERDUE,
], true)) {
return null;
}
return $this->optionalIso8601($finding->due_at);
}
private function eventLabel(string $eventType): string
{
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
};
}
private function eventBody(string $eventType, Tenant $tenant, string $summary, string $severityLabel): string
{
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
'%s in %s was assigned. %s severity.',
$summary,
$tenant->getFilamentName(),
$severityLabel,
),
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
'%s in %s reopened and needs follow-up. %s severity.',
$summary,
$tenant->getFilamentName(),
$severityLabel,
),
AlertRule::EVENT_FINDINGS_DUE_SOON => sprintf(
'%s in %s is due within 24 hours. %s severity.',
$summary,
$tenant->getFilamentName(),
$severityLabel,
),
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
'%s in %s is overdue. %s severity.',
$summary,
$tenant->getFilamentName(),
$severityLabel,
),
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
};
}
/**
* @return array{user_id: ?int, reason: ?string}
*/
private function preferredRecipient(
?int $preferredUserId,
string $preferredReason,
?int $fallbackUserId,
string $fallbackReason,
): array {
if (is_int($preferredUserId) && $preferredUserId > 0) {
return [
'user_id' => $preferredUserId,
'reason' => $preferredReason,
];
}
if (is_int($fallbackUserId) && $fallbackUserId > 0) {
return [
'user_id' => $fallbackUserId,
'reason' => $fallbackReason,
];
}
return [
'user_id' => null,
'reason' => null,
];
}
private function normalizeId(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$normalized = (int) $value;
return $normalized > 0 ? $normalized : null;
}
private function optionalIso8601(mixed $value): ?string
{
if (! $value instanceof CarbonInterface) {
return null;
}
return $value->toIso8601String();
}
/**
* @return array{
* event_type: string,
* fingerprint_key: string,
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
* external_delivery_count: int
* }
*/
private function dispatchResult(
string $eventType,
string $fingerprintKey,
string $directDeliveryStatus,
int $externalDeliveryCount,
): array {
return [
'event_type' => $eventType,
'fingerprint_key' => $fingerprintKey,
'direct_delivery_status' => $directDeliveryStatus,
'external_delivery_count' => $externalDeliveryCount,
];
}
}