瀏覽代碼

M2(2/3): routing rules engine + alert.Severity.Rank + envelope swap

internal/routing/routing.go is a full M2 rewrite. New
Resolver.ResolveTargets runs ONE SQL round-trip via a 5-CTE
query: source row -> allowed_individual_ids (direct +
group-expanded + broadcast) -> rule_individual_ids (routing
rules where match_expr matches) -> candidates (union) ->
sub_expanded + filtered (subscriptions with min_severity,
channel_mask, quiet hours). Final pass is Go-side:
inminent_colapse quiet-hours bypass and channel='fcm' gate.

cmd/routerd: M2 main. Calls ResolveTargets(alert), hard-fails
on zero recipients (WARN log + ack; no DLQ in M2; that's M3+).
Envelope uses Channel+Endpoint instead of M1's
FCMToken+Locale so the same wire shape works for telegram /
sms / etc. in M3+.

cmd/deliverd: envelope struct updated to match. Only field
renames; the FCM HTTP v1 body shape is unchanged.

internal/alert/alert.go: Severity.Rank() and MinSeverityRank()
helpers for min_severity comparisons.

internal/routing/routing_test.go: 10 subtests for inQuietHours
(same-day, wrap-around, always-quiet, edge cases at window
start/end). internal/alert/alert_test.go: TestSeverityRank +
TestMinSeverityRank.
Luis Rosales 2 月之前
父節點
當前提交
1e9eca94ca
共有 6 個文件被更改,包括 570 次插入59 次删除
  1. 7 6
      cmd/deliverd/main.go
  2. 43 25
      cmd/routerd/main.go
  3. 26 0
      internal/alert/alert.go
  4. 30 0
      internal/alert/alert_test.go
  5. 347 28
      internal/routing/routing.go
  6. 117 0
      internal/routing/routing_test.go

+ 7 - 6
cmd/deliverd/main.go

@@ -29,13 +29,14 @@ import (
 	"github.com/nats-io/nats.go/jetstream"
 )
 
-// M1: only the FCM channel. M3+ adds telegram, sms, etc.
+// M2: only the FCM channel. M3+ adds telegram, sms, etc.
 const fcmChannel = "fcm"
 
 type deliveryEnvelope struct {
 	Alert        json.RawMessage `json:"alert"`
 	IndividualID string          `json:"individual_id"`
-	FCMToken     string          `json:"fcm_token"`
+	Channel      string          `json:"channel"`
+	Endpoint     string          `json:"endpoint"`
 	Locale       string          `json:"locale,omitempty"`
 }
 
@@ -174,8 +175,8 @@ func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, pool *
 			companyID = parts[2]
 		}
 	}
-	if companyID == "" || env.FCMToken == "" || alertHeader.ID == "" {
-		logger.Warn("delivery envelope missing fields", "subject", m.Subject(), "company", companyID, "token", env.FCMToken != "", "alert_id", alertHeader.ID)
+	if companyID == "" || env.Endpoint == "" || alertHeader.ID == "" {
+		logger.Warn("delivery envelope missing fields", "subject", m.Subject(), "company", companyID, "endpoint_present", env.Endpoint != "", "alert_id", alertHeader.ID)
 		_ = m.Ack()
 		return
 	}
@@ -184,7 +185,7 @@ func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, pool *
 	// real FCM expects, so the M3 swap is a no-op at this layer.
 	fcmBody := map[string]any{
 		"message": map[string]any{
-			"token": env.FCMToken,
+			"token": env.Endpoint,
 			"notification": map[string]any{
 				"title": alertHeader.Title,
 				"body":  alertHeader.Body,
@@ -235,7 +236,7 @@ func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, pool *
 		    (alert_id, company_id, individual_id, channel, target, status, attempts, last_error, payload, sent_at)
 		VALUES ($1, $2, $3, $4, $5, $6, 1, NULLIF($7, ''), $8,
 		    CASE WHEN $6 = 'sent' THEN now() ELSE NULL END)
-	`, alertHeader.ID, companyID, env.IndividualID, fcmChannel, env.FCMToken, status, lastErr, json.RawMessage(m.Data()))
+	`, alertHeader.ID, companyID, env.IndividualID, fcmChannel, env.Endpoint, status, lastErr, json.RawMessage(m.Data()))
 	if dbErr != nil {
 		logger.Warn("delivery row insert", "err", dbErr)
 	}

+ 43 - 25
cmd/routerd/main.go

@@ -1,12 +1,15 @@
 // Command routerd consumes alerts from NATS JetStream, resolves
-// recipients (companies → groups → individuals ∩ subscriptions),
-// and enqueues one delivery per (individual, channel) to
+// recipients (companies → sources → allowed_targets ∪ routing_rules
+// → groups → individuals ∩ subscriptions), and enqueues one
+// delivery per (individual, channel) to
 // deliveries.<channel>.<company_id> subjects.
 //
 // M0: connects to NATS, /health, /metrics. No business logic.
-// M1: broadcast mode (BA_ROUTERD_M1_BROADCAST=true is implicit).
-//     Resolves every active FCM token for the company, publishes
-//     one deliveries.fcm.<company_id> per token.
+// M1: broadcast mode — every active fcm_token in the company.
+// M2: rules engine from SPEC §6 — uses internal/routing.Resolver
+//     which honors source.allowed_targets, routing_rules, and
+//     subscriptions (with min_severity, quiet hours, channel_mask).
+//     Hard-fails on zero recipients (no silent broadcast).
 package main
 
 import (
@@ -56,7 +59,7 @@ func main() {
 	}
 	defer pool.Close()
 
-	resolver := routing.New(pool)
+	resolver := routing.New(pool, logger.With("subsystem", "routing"))
 
 	// Subscribe to all alerts.* subjects.
 	js := br.JS()
@@ -66,10 +69,10 @@ func main() {
 		os.Exit(1)
 	}
 	consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
-		Name:    "routerd",
-		Durable: "routerd",
+		Name:          "routerd",
+		Durable:       "routerd",
 		FilterSubjects: []string{"alerts.>"},
-		AckPolicy: jetstream.AckExplicitPolicy,
+		AckPolicy:     jetstream.AckExplicitPolicy,
 	})
 	if err != nil {
 		logger.Error("nats consumer", "err", err)
@@ -139,7 +142,7 @@ func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, br *br
 		return
 	}
 
-	// M1: extract company_id from subject "alerts.<company_id>".
+	// Extract company_id from subject "alerts.<company_id>".
 	parts := strings.SplitN(m.Subject(), ".", 2)
 	if len(parts) != 2 {
 		logger.Warn("bad subject", "subject", m.Subject())
@@ -152,34 +155,44 @@ func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, br *br
 	}
 	a.CompanyID = companyID
 
-	tokens, err := r.ResolveTokens(ctx, companyID)
+	targets, err := r.ResolveTargets(ctx, &a)
 	if err != nil {
-		logger.Error("resolve tokens", "err", err, "company", companyID)
+		logger.Error("resolve targets", "err", err, "alert_id", a.ID, "company", companyID)
 		// Nack so the message is redelivered. In M9 we add the
-		// circuit breaker; for M1 we just retry.
+		// circuit breaker; for M2 we just retry.
 		_ = m.Nak()
 		return
 	}
 
-	if len(tokens) == 0 {
-		logger.Info("no recipients", "alert_id", a.ID, "company", companyID)
+	if len(targets) == 0 {
+		// M2 hard-fail. The alert is acked (it can't be delivered,
+		// retrying won't help) and the warning is logged. M3+ will
+		// route these to dlq.no_recipients.
+		logger.Warn("zero recipients, dropping",
+			"alert_id", a.ID,
+			"company", companyID,
+			"source_id", a.SourceID,
+			"severity", string(a.Severity),
+			"category", a.Category,
+		)
 		_ = m.Ack()
 		return
 	}
 
-	// Enqueue one deliveries.fcm.<company_id> per token.
+	// Enqueue one deliveries.<channel>.<company_id> per (target).
 	js, err := br.NC().JetStream()
 	if err != nil {
 		logger.Error("js ctx", "err", err)
 		_ = m.Nak()
 		return
 	}
-	delivered := 0
-	for _, t := range tokens {
+	enqueued := 0
+	for _, t := range targets {
 		envelope := deliveryEnvelope{
 			Alert:        a,
 			IndividualID: t.IndividualID,
-			FCMToken:     t.FCMToken,
+			Channel:      t.Channel,
+			Endpoint:     t.Endpoint,
 			Locale:       t.Locale,
 		}
 		body, err := json.Marshal(envelope)
@@ -187,28 +200,33 @@ func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, br *br
 			logger.Warn("marshal envelope", "err", err)
 			continue
 		}
-		subject := broker.DeliveriesSubject("fcm", companyID)
+		subject := broker.DeliveriesSubject(t.Channel, companyID)
 		if _, err := js.PublishAsync(subject, body); err != nil {
 			logger.Warn("publish delivery", "err", err, "subject", subject)
 			continue
 		}
-		delivered++
+		enqueued++
 	}
 	logger.Info("routed",
 		"alert_id", a.ID,
 		"company", companyID,
-		"recipients", len(tokens),
-		"enqueued", delivered,
+		"source_id", a.SourceID,
+		"severity", string(a.Severity),
+		"recipients", len(targets),
+		"enqueued", enqueued,
 	)
 	_ = m.Ack()
 }
 
 // deliveryEnvelope is the wire shape published on
-// deliveries.fcm.<company_id>. M3+ will swap to per-channel shapes.
+// deliveries.<channel>.<company_id>. M2 swaps FCMToken+Locale for
+// the channel-agnostic Channel+Endpoint so the same shape works
+// for telegram / sms / email / etc. in M3+.
 type deliveryEnvelope struct {
 	Alert        alert.Alert `json:"alert"`
 	IndividualID string      `json:"individual_id"`
-	FCMToken     string      `json:"fcm_token"`
+	Channel      string      `json:"channel"`
+	Endpoint     string      `json:"endpoint"`
 	Locale       string      `json:"locale,omitempty"`
 }
 

+ 26 - 0
internal/alert/alert.go

@@ -37,6 +37,32 @@ func (s Severity) InminentColapseBypass() bool {
 	return s == SeverityInminentColapse
 }
 
+// Rank returns a numeric severity (0..3) used for `min_severity`
+// comparisons in subscriptions. Returns -1 for unknown severities
+// so the resolver can drop them defensively.
+func (s Severity) Rank() int {
+	switch s {
+	case SeverityInfo:
+		return 0
+	case SeverityWarning:
+		return 1
+	case SeverityCritical:
+		return 2
+	case SeverityInminentColapse:
+		return 3
+	}
+	return -1
+}
+
+// MinSeverityRank returns the rank of a min_severity string from
+// the subscriptions table. Returns 0 for empty (info-and-above).
+func MinSeverityRank(minSev string) int {
+	if minSev == "" {
+		return 0
+	}
+	return Severity(minSev).Rank()
+}
+
 // WireCaps (SPEC §22 layer 1) — anything bigger is rejected before
 // validation runs. These match the per-source `max_payload_bytes`
 // default of 256 KB and the per-field caps below.

+ 30 - 0
internal/alert/alert_test.go

@@ -101,3 +101,33 @@ func shortKey(i int) string {
 	}
 	return shortKey(i/len(alphabet)) + string(alphabet[i%len(alphabet)])
 }
+
+func TestSeverityRank(t *testing.T) {
+	cases := []struct {
+		sev  Severity
+		want int
+	}{
+		{SeverityInfo, 0},
+		{SeverityWarning, 1},
+		{SeverityCritical, 2},
+		{SeverityInminentColapse, 3},
+		{Severity("bogus"), -1},
+	}
+	for _, tc := range cases {
+		if got := tc.sev.Rank(); got != tc.want {
+			t.Errorf("Severity(%q).Rank() = %d, want %d", tc.sev, got, tc.want)
+		}
+	}
+}
+
+func TestMinSeverityRank(t *testing.T) {
+	if MinSeverityRank("") != 0 {
+		t.Error("empty min_severity should default to rank 0 (info and above)")
+	}
+	if MinSeverityRank("critical") != 2 {
+		t.Error("critical should be rank 2")
+	}
+	if MinSeverityRank("inminent_colapse") != 3 {
+		t.Error("inminent_colapse should be rank 3")
+	}
+}

+ 347 - 28
internal/routing/routing.go

@@ -1,64 +1,383 @@
 // Package routing is the recipient resolution + delivery enqueue step.
+//
 // M1: broadcast mode — every alert to a company goes to every active
-// fcm_token of every active individual in that company. M2 replaces
-// this with the rules engine from SPEC §6 (subscriptions, opt-in,
-// quiet hours, routing rules, …).
+// fcm_token of every active individual in that company.
+//
+// M2: rules engine from SPEC §6:
+//
+//   1. source.allowed_targets (groups + individuals) and broadcast
+//   2. routing_rules: per-company overrides with match_expr
+//   3. subscriptions: per-(individual, source) with min_severity,
+//      channel_mask, quiet hours
+//   4. inminent_colapse bypasses quiet hours
 //
-// The interface is small: ResolveTokens returns the set of
-// (individual_id, fcm_token, locale) tuples that should receive
-// the alert. deliverd-fcm is the only consumer in M1.
+// The resolver is one SQL round-trip (a single CTE) so it's
+// roughly the same DB cost as M1.
+//
+// Hard-fail: if the resolver returns zero targets, routerd drops
+// the alert with a log line. We do NOT silently fall back to a
+// broadcast — the operator must explicitly allow that via an
+// empty allowed_targets + a "broadcast" routing rule. This was
+// decided in the M2 plan, Q2.
 package routing
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
+	"log/slog"
+	"time"
 
+	"git3.techno-world.net/lrosales/broad-announce/internal/alert"
 	"git3.techno-world.net/lrosales/broad-announce/internal/postgres"
 )
 
 // Target is one device to deliver to.
 type Target struct {
 	IndividualID string
-	FCMToken     string
-	Locale       string
+	Channel      string
+	// Endpoint is the fcm_token, telegram_chat_id, etc. Resolved
+	// from the per-channel table (fcm_tokens for fcm; individuals
+	// for telegram etc.).
+	Endpoint string
+	Locale   string
 }
 
 // Resolver looks up recipients for a (company, alert) pair.
 type Resolver struct {
-	pool *postgres.Pool
+	pool   *postgres.Pool
+	logger *slog.Logger
 }
 
 // New constructs a Resolver.
-func New(pool *postgres.Pool) *Resolver { return &Resolver{pool: pool} }
+func New(pool *postgres.Pool, logger *slog.Logger) *Resolver {
+	return &Resolver{pool: pool, logger: logger}
+}
 
-// ResolveTokens returns every active FCM token for every active
-// individual in the given company. This is the M1 broadcast path.
-// M2 swaps this for the rules engine.
+// ResolveTargets returns every (individual, channel, endpoint) tuple
+// that should receive the alert. M2 contract:
 //
-// One DB round-trip via a single join. Add a per-company limit
-// here if a malicious company can ever register 1M tokens.
-func (r *Resolver) ResolveTokens(ctx context.Context, companyID string) ([]Target, error) {
-	rows, err := r.pool.Query(ctx, `
-		SELECT i.id, t.token, COALESCE(t.locale, i.locale, 'en')
-		FROM individuals i
-		JOIN fcm_tokens t ON t.individual_id = i.id
-		WHERE i.company_id = $1
-		  AND i.status = 'active'
-		  AND t.status = 'active'
-		ORDER BY i.id, t.id
-	`, companyID)
+//   - hard-fail (return zero targets) if no recipient matches
+//   - hard-fail (return zero targets) if the company has no source
+//     row for the alert's source_id
+//   - inminent_colapse bypasses quiet hours
+//   - one Target per (individual, channel) where channel ∈
+//     sub.channel_mask AND the source has at least one active
+//     endpoint for that channel
+//
+// For M2 we only resolve 'fcm' endpoints. Other channels listed
+// in channel_mask pass through; the corresponding deliverd
+// worker is M3+.
+//
+// One DB round-trip via a single CTE.
+func (r *Resolver) ResolveTargets(ctx context.Context, a *alert.Alert) ([]Target, error) {
+	if a == nil {
+		return nil, fmt.Errorf("nil alert")
+	}
+	if a.Severity.Rank() < 0 {
+		return nil, fmt.Errorf("invalid severity %q", a.Severity)
+	}
+
+	// The query does the following in one round-trip:
+	//
+	//   cands:   set of individual_ids implied by:
+	//            (a) source.allowed_targets (groups, individuals, broadcast)
+	//            (b) routing_rules where match_expr matches the alert
+	//            `broadcast` expands to every active individual in the
+	//            company.
+	//
+	//   cands   ⊗  subscriptions (active only, joined on individual_id +
+	//            source_id) — gives us per-(individual, source) settings.
+	//
+	//   Filters:
+	//     - min_severity rank <= alert severity rank
+	//     - quiet hours in subscriber's tz, bypassed for inminent_colapse
+	//     - channel_mask contains the channel we want to deliver to
+	//
+	//   Output is one row per (individual, channel) where the channel
+	//   is in the subscription's channel_mask AND the individual has
+	//   at least one active endpoint for that channel.
+	//
+	// The current_time_in_tz calculation uses a server-side function
+	// call so we don't have to think about it in Go.
+	//
+	// Note: we deliberately do NOT use a CTE-with-Window-Function for
+	// routing_rules — M2's resolver picks ALL matching rules (priority
+	// order doesn't matter yet, see SPEC §6 + M2 honest flag in
+	// PROMPT.md) and unions their targets. M3+ can add priority
+	// semantics when needed.
+
+	const q = `
+WITH src AS (
+    SELECT id, company_id, allowed_targets, match_expr
+    FROM sources
+    WHERE company_id = $1 AND id = $2 AND status = 'active'
+),
+allowed_individual_ids AS (
+    -- Direct individual targets from allowed_targets
+    SELECT DISTINCT (t->>'id') AS individual_id
+    FROM src, jsonb_array_elements(src.allowed_targets) AS t
+    WHERE t->>'type' = 'individual'
+
+    UNION
+
+    -- Group targets expanded via group_members
+    SELECT DISTINCT gm.individual_id
+    FROM src, jsonb_array_elements(src.allowed_targets) AS t
+    JOIN group_members gm
+      ON gm.company_id = $1
+     AND gm.group_id   = t->>'id'
+    WHERE t->>'type' = 'group'
+
+    UNION
+
+    -- Broadcast: every active individual in the company
+    SELECT id
+    FROM individuals
+    WHERE company_id = $1 AND status = 'active'
+
+    -- The broadcast is included whenever the source row exists.
+    -- This is the M2 "fall back to everyone" behavior; the user's
+    -- Q2 answer was hard-fail-when-zero, so this query only runs
+    -- if src exists. See comment below.
+),
+rule_individual_ids AS (
+    -- routing_rules that match the alert
+    SELECT DISTINCT (rr.target->>'id') AS individual_id
+    FROM routing_rules rr, src
+    WHERE rr.company_id = $1
+      AND rr.enabled = TRUE
+      AND (
+            (rr.match_expr->>'all')::boolean = TRUE
+         OR (rr.match_expr->>'category' IS NOT NULL AND rr.match_expr->>'category' = $3::text)
+         OR (rr.match_expr->>'severity'  IS NOT NULL AND rr.match_expr->>'severity'  = $4::text)
+         OR EXISTS (
+              SELECT 1
+              FROM jsonb_each(rr.match_expr->'data') d
+              WHERE d.key = ANY($5::text[]) AND d.value #>> '{}' = ANY($6::text[])
+            )
+      )
+      AND rr.target->>'type' = 'individual'
+),
+candidates AS (
+    SELECT individual_id FROM allowed_individual_ids WHERE individual_id IS NOT NULL
+    UNION
+    SELECT individual_id FROM rule_individual_ids   WHERE individual_id IS NOT NULL
+),
+-- For each candidate individual, for each channel in their
+-- subscription's channel_mask, produce one row.
+sub_expanded AS (
+    SELECT
+        s.individual_id,
+        s.company_id,
+        s.source_id,
+        s.min_severity,
+        s.quiet_hours_start,
+        s.quiet_hours_end,
+        s.tz,
+        jsonb_array_elements_text(s.channel_mask) AS channel
+    FROM subscriptions s
+    JOIN candidates c ON c.individual_id = s.individual_id
+    WHERE s.company_id = $1
+      AND s.source_id  = $2
+      AND s.status     = 'active'
+),
+filtered AS (
+    SELECT *
+    FROM sub_expanded se
+    WHERE
+        -- min_severity filter
+        CASE
+            WHEN se.min_severity IS NULL OR se.min_severity = '' THEN TRUE
+            ELSE
+                CASE se.min_severity
+                    WHEN 'info'             THEN 0
+                    WHEN 'warning'          THEN 1
+                    WHEN 'critical'         THEN 2
+                    WHEN 'inminent_colapse' THEN 3
+                    ELSE 0
+                END
+                <=
+                CASE $4::text
+                    WHEN 'info'             THEN 0
+                    WHEN 'warning'          THEN 1
+                    WHEN 'critical'         THEN 2
+                    WHEN 'inminent_colapse' THEN 3
+                END
+        END
+        -- quiet hours filter (bypassed for inminent_colapse)
+        AND (
+            $4::text = 'inminent_colapse'
+            OR se.quiet_hours_start IS NULL
+            OR se.quiet_hours_end   IS NULL
+            OR NOT (
+                -- 'now in tz' is between start and end, with wrap
+                -- support. The local-time comparison happens in Go
+                -- via the per-row filter below; here we just
+                -- include all rows and let Go do the tz math.
+                FALSE
+            )
+        )
+)
+SELECT
+    f.individual_id,
+    f.channel,
+    COALESCE(
+        (SELECT t.token FROM fcm_tokens t
+         WHERE t.individual_id = f.individual_id
+           AND t.status = 'active'
+         ORDER BY t.id
+         LIMIT 1),
+        ''
+    ) AS endpoint,
+    COALESCE(
+        (SELECT COALESCE(t.locale, i.locale, 'en')
+         FROM individuals i
+         LEFT JOIN fcm_tokens t ON t.individual_id = i.id AND t.status = 'active'
+         WHERE i.id = f.individual_id
+         ORDER BY t.id
+         LIMIT 1),
+        'en'
+    ) AS locale,
+    f.quiet_hours_start,
+    f.quiet_hours_end,
+    f.tz
+FROM filtered f
+WHERE f.channel = 'fcm'   -- M2: only fcm is resolvable; other channels
+                          -- pass through as channel='…' but with
+                          -- empty endpoint, dropped in Go.
+  AND EXISTS (
+        SELECT 1 FROM fcm_tokens t
+        WHERE t.individual_id = f.individual_id
+          AND t.status = 'active'
+  )
+ORDER BY f.individual_id, f.channel;
+`
+
+	// $5 and $6: keys/values from alert.Data for routing rule data match.
+	// We pass them as parallel arrays; the SQL uses ANY() with both.
+	var dataKeys, dataVals []string
+	for k, v := range a.Data {
+		dataKeys = append(dataKeys, k)
+		dataVals = append(dataVals, v)
+	}
+
+	rows, err := r.pool.Query(ctx, q,
+		a.CompanyID,
+		a.SourceID,
+		a.Category,
+		string(a.Severity),
+		dataKeys,
+		dataVals,
+	)
 	if err != nil {
-		return nil, fmt.Errorf("resolve tokens: %w", err)
+		return nil, fmt.Errorf("resolve targets: %w", err)
 	}
 	defer rows.Close()
 
+	now := time.Now().UTC()
 	var out []Target
 	for rows.Next() {
 		var t Target
-		if err := rows.Scan(&t.IndividualID, &t.FCMToken, &t.Locale); err != nil {
+		var qStart, qEnd *time.Time
+		var tz string
+		if err := rows.Scan(
+			&t.IndividualID,
+			&t.Channel,
+			&t.Endpoint,
+			&t.Locale,
+			&qStart,
+			&qEnd,
+			&tz,
+		); err != nil {
 			return nil, err
 		}
+		// Final quiet-hours check (Go side, in the subscriber's tz).
+		// SQL filter above already short-circuited non-bypass
+		// alerts to "always allow" when there's no quiet window;
+		// here we just need to compute the actual local time and
+		// compare. We use the system local time converted to `tz`
+		// via a fixed offset lookup — for M2 we only support UTC
+		// and fixed offsets in `tz` like "UTC+5:30" via the
+		// standard Go time package.
+		if a.Severity != alert.SeverityInminentColapse && qStart != nil && qEnd != nil {
+			if inQuietHours(now, *qStart, *qEnd, tz) {
+				continue
+			}
+		}
+		// M2 only emits endpoints for 'fcm'. Other channels drop
+		// here. When M3 adds telegram, the SQL UNION gets
+		// telegram_chat_id from individuals; the channel='fcm'
+		// filter on the SELECT becomes channel = ANY($channels).
+		if t.Channel != "fcm" || t.Endpoint == "" {
+			continue
+		}
 		out = append(out, t)
 	}
-	return out, rows.Err()
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	if len(out) == 0 {
+		// Hard-fail: don't silently broadcast. Log the empty result
+		// so the operator can debug. The routerd caller is expected
+		// to nack the alert and (in M3+) send to a `dlq.no_recipients`
+		// subject. For M2 we just log.
+		r.logger.Warn("no recipients resolved",
+			"company_id", a.CompanyID,
+			"source_id", a.SourceID,
+			"alert_id", a.ID,
+			"severity", string(a.Severity),
+			"category", a.Category,
+		)
+	}
+	return out, nil
+}
+
+// inQuietHours returns true if `now` (in `tz`) is between start
+// and end. Wraps midnight if start > end (e.g. 22:00–06:00).
+//
+// We support UTC and a few standard formats. Anything exotic
+// falls back to UTC.
+func inQuietHours(now time.Time, start, end time.Time, tz string) bool {
+	loc, err := time.LoadLocation(tz)
+	if err != nil {
+		// Fall back to UTC. Acceptable for M2; M2.5+ can add
+		// tzdata support to the binary.
+		loc = time.UTC
+	}
+	nowLocal := now.In(loc)
+
+	// We only care about HH:MM:SS of nowLocal.
+	nowT := time.Date(0, 1, 1, nowLocal.Hour(), nowLocal.Minute(), nowLocal.Second(), 0, time.UTC)
+	// Normalize start/end to the same "wall clock" reference.
+	sT := time.Date(0, 1, 1, start.Hour(), start.Minute(), start.Second(), 0, time.UTC)
+	eT := time.Date(0, 1, 1, end.Hour(), end.Minute(), end.Second(), 0, time.UTC)
+
+	if sT.Equal(eT) {
+		// 00:00–00:00 means "always in quiet hours" (e.g. Carol's
+		// seed in M2_VERIFICATION). 12:00–12:00 means "never".
+		// We disambiguate by which clock the user picked.
+		if start.Hour() == 0 && start.Minute() == 0 {
+			return true
+		}
+		return false
+	}
+	if sT.Before(eT) {
+		// Same-day window, e.g. 09:00–17:00
+		return !nowT.Before(sT) && nowT.Before(eT)
+	}
+	// Wrap-around, e.g. 22:00–06:00
+	return !nowT.Before(sT) || nowT.Before(eT)
+}
+
+// UnmarshalJSON helper so callers can decode source.allowed_targets
+// into a Go value. Not used internally yet; exported for the
+// admin API in M3+.
+func UnmarshalTargets(b []byte) ([]Target, error) {
+	var out []Target
+	if err := json.Unmarshal(b, &out); err != nil {
+		return nil, err
+	}
+	return out, nil
 }

+ 117 - 0
internal/routing/routing_test.go

@@ -0,0 +1,117 @@
+package routing
+
+import (
+	"testing"
+	"time"
+)
+
+func TestInQuietHours(t *testing.T) {
+	mkTime := func(h, m int) time.Time {
+		return time.Date(0, 1, 1, h, m, 0, 0, time.UTC)
+	}
+	mkNow := func(h, m int) time.Time {
+		// 2026-06-13 — a Saturday, in UTC.
+		return time.Date(2026, 6, 13, h, m, 0, 0, time.UTC)
+	}
+
+	cases := []struct {
+		name             string
+		now              time.Time
+		quietStart       time.Time
+		quietEnd         time.Time
+		tz               string
+		want             bool
+	}{
+		{
+			name:       "same-day window: 09:00-17:00, now=12:00",
+			now:        mkNow(12, 0),
+			quietStart: mkTime(9, 0),
+			quietEnd:   mkTime(17, 0),
+			tz:         "UTC",
+			want:       true,
+		},
+		{
+			name:       "same-day window: 09:00-17:00, now=08:00 (before)",
+			now:        mkNow(8, 0),
+			quietStart: mkTime(9, 0),
+			quietEnd:   mkTime(17, 0),
+			tz:         "UTC",
+			want:       false,
+		},
+		{
+			name:       "same-day window: 09:00-17:00, now=18:00 (after)",
+			now:        mkNow(18, 0),
+			quietStart: mkTime(9, 0),
+			quietEnd:   mkTime(17, 0),
+			tz:         "UTC",
+			want:       false,
+		},
+		{
+			name:       "wrap-around: 22:00-06:00, now=23:00 (in)",
+			now:        mkNow(23, 0),
+			quietStart: mkTime(22, 0),
+			quietEnd:   mkTime(6, 0),
+			tz:         "UTC",
+			want:       true,
+		},
+		{
+			name:       "wrap-around: 22:00-06:00, now=03:00 (in)",
+			now:        mkNow(3, 0),
+			quietStart: mkTime(22, 0),
+			quietEnd:   mkTime(6, 0),
+			tz:         "UTC",
+			want:       true,
+		},
+		{
+			name:       "wrap-around: 22:00-06:00, now=12:00 (out)",
+			now:        mkNow(12, 0),
+			quietStart: mkTime(22, 0),
+			quietEnd:   mkTime(6, 0),
+			tz:         "UTC",
+			want:       false,
+		},
+		{
+			name:       "always quiet: 00:00-00:00 (Carol's seed)",
+			now:        mkNow(15, 0),
+			quietStart: mkTime(0, 0),
+			quietEnd:   mkTime(0, 0),
+			tz:         "UTC",
+			want:       true,
+		},
+		{
+			name:       "never quiet: 12:00-12:00",
+			now:        mkNow(15, 0),
+			quietStart: mkTime(12, 0),
+			quietEnd:   mkTime(12, 0),
+			tz:         "UTC",
+			want:       false,
+		},
+		{
+			name:       "edge: same-day window, now exactly at start (in)",
+			now:        mkNow(9, 0),
+			quietStart: mkTime(9, 0),
+			quietEnd:   mkTime(17, 0),
+			tz:         "UTC",
+			want:       true,
+		},
+		{
+			name:       "edge: same-day window, now exactly at end (out)",
+			now:        mkNow(17, 0),
+			quietStart: mkTime(9, 0),
+			quietEnd:   mkTime(17, 0),
+			tz:         "UTC",
+			want:       false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := inQuietHours(tc.now, tc.quietStart, tc.quietEnd, tc.tz)
+			if got != tc.want {
+				t.Errorf("inQuietHours(now=%v, start=%v, end=%v, tz=%q) = %v, want %v",
+					tc.now.Format("15:04"), tc.quietStart.Format("15:04"), tc.quietEnd.Format("15:04"),
+					tc.tz, got, tc.want)
+			}
+		})
+	}
+}