0 fires only after escalate_after_s of the event still * being unacknowledged. That is what converts "we detected it" into "somebody was made to * look at it", and it is the only mechanism here that can wake a second person. * - An acknowledged event escalates no further. Escalation exists to find a human, and one has * already been found. * - A false-positive tag stops everything, for the same reason. * - Suppression (planned maintenance / work permit) blocks the SEND but is still recorded, as * status='suppressed'. An investigator has to be able to see that an alarm was raised and * deliberately withheld — a silently dropped notification is indistinguishable from one that * was never generated, and that difference is exactly what an inquiry turns on. */ final class AlertDispatcher { /** Ranked so a channel's min_severity acts as a floor, not an equality test. */ private const SEVERITY_RANK = ['info' => 0, 'warning' => 1, 'high' => 2, 'critical' => 3]; public function __construct( private EventRepository $events, private AlertRepository $alerts, private SuppressionRepository $suppressions, ) {} /** * @param array $event a row from EventRepository (mapRow shape) * @param array $channels channel rows for this event's site * @return array deliveries to attempt */ public function planFor(array $event, array $channels, int $now): array { // Resolved events stop the chain. Note this covers the escalation steps too: an event // acknowledged 30 seconds in never reaches the 5-minute escalation channel. if (!empty($event['acknowledged_by']) || !empty($event['false_positive_by'])) return []; $rank = self::SEVERITY_RANK[$event['severity']] ?? 0; $age = $now - (int)$event['occurred_at']; // One suppression lookup per event covers all of its scopes. Zone is included because a // permit is usually issued for an area, not for a camera. $scopes = [['device', (string)($event['device_id'] ?? '')], ['camera', (string)($event['camera_id'] ?? '')]]; if (!empty($event['zone_id'])) $scopes[] = ['zone', (string)$event['zone_id']]; $suppressed = $this->suppressions->activeFor($scopes, $now) !== []; $out = []; foreach ($channels as $ch) { if (empty($ch['enabled'])) continue; // `local` beacons are fired by the edge node itself the moment its own detector // produces the event — no server round trip, and no dependency on this job running // at all. Their rows exist only to carry the GPIO configuration down through // GET /api/config, so the server has nothing to deliver and must not claim a // delivery row for them (which would show up as a permanent failure). if ($ch['kind'] === 'local') continue; if ($rank < (self::SEVERITY_RANK[$ch['min_severity']] ?? 0)) continue; // A site-scoped channel only serves its own site; a NULL-site channel is global. if ($ch['site'] !== null && $ch['site'] !== ($event['site'] ?? null)) continue; // Not yet due: a later dispatcher run will pick it up once the event has aged. if ((int)$ch['step'] > 0 && $age < (int)$ch['escalate_after_s']) continue; $out[] = ['channel' => $ch, 'suppressed' => $suppressed]; } return $out; } /** * Run one dispatch pass over the recent window. * * @param callable(array $event, array $channel): void $send throws on failure * @return array{considered:int,sent:int,suppressed:int,failed:int,skipped:int} */ public function run(int $now, int $windowSeconds, int $limit, callable $send): array { $stats = ['considered' => 0, 'sent' => 0, 'suppressed' => 0, 'failed' => 0, 'skipped' => 0]; $events = $this->events->forNotification((float)($now - $windowSeconds), $limit); // One channel read per site, not per event: a busy window is hundreds of events over a // handful of sites, and this job runs on shared hosting where every query costs. $channelsBySite = []; foreach ($events as $event) { $stats['considered']++; $site = $event['site'] ?? null; $key = $site ?? "\x00global"; if (!array_key_exists($key, $channelsBySite)) { $channelsBySite[$key] = $this->alerts->channels($site); } foreach ($this->planFor($event, $channelsBySite[$key], $now) as $delivery) { $channelId = (int)$delivery['channel']['channel_id']; $eventId = (int)$event['event_id']; // claim() is the duplicate guard. Losing it means another (possibly overlapping) // dispatcher run owns this delivery — skip, do not send. if (!$this->alerts->claim($eventId, $channelId, $now)) { $stats['skipped']++; continue; } if ($delivery['suppressed']) { $this->alerts->markSuppressed($eventId, $channelId, $now); $stats['suppressed']++; continue; } try { $send($event, $delivery['channel']); $this->alerts->markSent($eventId, $channelId, $now); $stats['sent']++; } catch (\Throwable $e) { // Never fatal: one unreachable SMTP relay must not stop the webhook that // would have reached somebody. The row keeps the error for the retry pass. $this->alerts->markFailed($eventId, $channelId, $e->getMessage(), $now); $stats['failed']++; } } } return $stats; } }