## 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
195 lines
5.9 KiB
PHP
195 lines
5.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Alerts;
|
|
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\AlertRule;
|
|
use App\Models\Tenant;
|
|
use App\Models\Workspace;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Arr;
|
|
|
|
class AlertDispatchService
|
|
{
|
|
public function __construct(
|
|
private readonly AlertFingerprintService $fingerprintService,
|
|
private readonly AlertQuietHoursService $quietHoursService,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $event
|
|
*/
|
|
public function dispatchEvent(Workspace $workspace, array $event): int
|
|
{
|
|
$workspaceId = (int) $workspace->getKey();
|
|
$tenantId = (int) ($event['tenant_id'] ?? 0);
|
|
$eventType = trim((string) ($event['event_type'] ?? ''));
|
|
|
|
if ($workspaceId <= 0 || $tenantId <= 0 || $eventType === '') {
|
|
return 0;
|
|
}
|
|
|
|
$tenant = Tenant::query()
|
|
->whereKey($tenantId)
|
|
->where('workspace_id', $workspaceId)
|
|
->first();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return 0;
|
|
}
|
|
|
|
$now = CarbonImmutable::now('UTC');
|
|
$eventSeverity = $this->normalizeSeverity((string) ($event['severity'] ?? ''));
|
|
|
|
$rules = AlertRule::query()
|
|
->with(['destinations' => fn ($query) => $query->where('is_enabled', true)])
|
|
->where('workspace_id', $workspaceId)
|
|
->where('is_enabled', true)
|
|
->where('event_type', $eventType)
|
|
->orderBy('id')
|
|
->get();
|
|
|
|
$createdDeliveries = 0;
|
|
|
|
foreach ($rules as $rule) {
|
|
if (! $rule->appliesToTenant($tenantId)) {
|
|
continue;
|
|
}
|
|
|
|
if (! $this->meetsMinimumSeverity($eventSeverity, (string) $rule->minimum_severity)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($rule->destinations as $destination) {
|
|
$fingerprintHash = $this->fingerprintService->hash($rule, $destination, $tenantId, $event);
|
|
|
|
$isSuppressed = $this->shouldSuppress(
|
|
workspaceId: $workspaceId,
|
|
ruleId: (int) $rule->getKey(),
|
|
destinationId: (int) $destination->getKey(),
|
|
fingerprintHash: $fingerprintHash,
|
|
cooldownSeconds: (int) ($rule->cooldown_seconds ?? 0),
|
|
now: $now,
|
|
);
|
|
|
|
$sendAfter = null;
|
|
$status = AlertDelivery::STATUS_QUEUED;
|
|
|
|
if ($isSuppressed) {
|
|
$status = AlertDelivery::STATUS_SUPPRESSED;
|
|
} else {
|
|
$deferUntil = $this->quietHoursService->deferUntil($rule, $workspace, $now);
|
|
|
|
if ($deferUntil instanceof CarbonImmutable) {
|
|
$status = AlertDelivery::STATUS_DEFERRED;
|
|
$sendAfter = $deferUntil;
|
|
}
|
|
}
|
|
|
|
AlertDelivery::query()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenantId,
|
|
'alert_rule_id' => (int) $rule->getKey(),
|
|
'alert_destination_id' => (int) $destination->getKey(),
|
|
'event_type' => $eventType,
|
|
'severity' => $eventSeverity,
|
|
'status' => $status,
|
|
'fingerprint_hash' => $fingerprintHash,
|
|
'send_after' => $sendAfter,
|
|
'attempt_count' => 0,
|
|
'payload' => $this->buildPayload($event),
|
|
]);
|
|
|
|
$createdDeliveries++;
|
|
}
|
|
}
|
|
|
|
return $createdDeliveries;
|
|
}
|
|
|
|
private function normalizeSeverity(string $severity): string
|
|
{
|
|
$severity = strtolower(trim($severity));
|
|
|
|
return in_array($severity, ['low', 'medium', 'high', 'critical'], true)
|
|
? $severity
|
|
: 'high';
|
|
}
|
|
|
|
private function meetsMinimumSeverity(string $eventSeverity, string $minimumSeverity): bool
|
|
{
|
|
$rank = [
|
|
'low' => 1,
|
|
'medium' => 2,
|
|
'high' => 3,
|
|
'critical' => 4,
|
|
];
|
|
|
|
$eventRank = $rank[$eventSeverity] ?? 0;
|
|
$minimumRank = $rank[strtolower(trim($minimumSeverity))] ?? 0;
|
|
|
|
return $eventRank >= $minimumRank;
|
|
}
|
|
|
|
private function shouldSuppress(
|
|
int $workspaceId,
|
|
int $ruleId,
|
|
int $destinationId,
|
|
string $fingerprintHash,
|
|
int $cooldownSeconds,
|
|
CarbonImmutable $now,
|
|
): bool {
|
|
if ($cooldownSeconds <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$cutoff = $now->subSeconds($cooldownSeconds);
|
|
|
|
return AlertDelivery::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->where('alert_rule_id', $ruleId)
|
|
->where('alert_destination_id', $destinationId)
|
|
->where('fingerprint_hash', $fingerprintHash)
|
|
->whereNotIn('status', [
|
|
AlertDelivery::STATUS_SUPPRESSED,
|
|
AlertDelivery::STATUS_CANCELED,
|
|
])
|
|
->where('created_at', '>=', $cutoff)
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $event
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildPayload(array $event): array
|
|
{
|
|
$title = trim((string) ($event['title'] ?? 'Alert'));
|
|
$body = trim((string) ($event['body'] ?? 'A matching alert event was detected.'));
|
|
|
|
if ($title === '') {
|
|
$title = 'Alert';
|
|
}
|
|
|
|
if ($body === '') {
|
|
$body = 'A matching alert event was detected.';
|
|
}
|
|
|
|
$metadata = Arr::get($event, 'metadata', []);
|
|
|
|
if (! is_array($metadata)) {
|
|
$metadata = [];
|
|
}
|
|
|
|
return [
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'event_type' => trim((string) ($event['event_type'] ?? '')),
|
|
'fingerprint_key' => trim((string) ($event['fingerprint_key'] ?? '')),
|
|
'metadata' => $metadata,
|
|
];
|
|
}
|
|
}
|