| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- // 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: 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
- //
- // M3: the resolver also resolves channel='telegram' targets
- // from the same subscription set, when:
- //
- // - the subscription's channel_mask contains 'telegram', AND
- // - the individual has a non-null telegram_chat_id
- //
- // The query is one DB round-trip (a single CTE) so it's
- // roughly the same DB cost as M1/M2.
- //
- // 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
- 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
- logger *slog.Logger
- }
- // New constructs a Resolver.
- func New(pool *postgres.Pool, logger *slog.Logger) *Resolver {
- return &Resolver{pool: pool, logger: logger}
- }
- // ResolveTargets returns every (individual, channel, endpoint) tuple
- // that should receive the alert. M3 contract:
- //
- // - 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 individual has at least one
- // active endpoint for that channel
- //
- // For M3 we resolve 'fcm' and 'telegram'. Other channels listed
- // in channel_mask pass through with empty endpoint, which the
- // routerd caller is expected to drop (or which M3+ deliverd-X
- // workers can pick up).
- //
- // One DB round-trip via a single CTE that UNION ALLs an
- // fcm-resolution branch and a telegram-resolution branch.
- 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)
- }
- 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'
- ),
- 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. Quiet hours
- -- and min_severity filtering happens here.
- 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 (FALSE)
- )
- ),
- -- FCM branch: pick the first active token for each individual.
- fcm_rows AS (
- SELECT
- f.individual_id,
- 'fcm'::text AS channel,
- t.token AS endpoint,
- COALESCE(t.locale, i.locale, 'en') AS locale,
- f.quiet_hours_start,
- f.quiet_hours_end,
- f.tz
- FROM filtered f
- JOIN individuals i ON i.id = f.individual_id AND i.status = 'active'
- JOIN fcm_tokens t ON t.individual_id = f.individual_id AND t.status = 'active'
- WHERE f.channel = 'fcm'
- ),
- -- Telegram branch: pick telegram_chat_id (only if linked).
- -- Quiet hours still apply; the resolver passes the row through
- -- and the Go-side filter handles the bypass.
- tg_rows AS (
- SELECT
- f.individual_id,
- 'telegram'::text AS channel,
- i.telegram_chat_id::text AS endpoint,
- COALESCE(i.locale, 'en') AS locale,
- f.quiet_hours_start,
- f.quiet_hours_end,
- f.tz
- FROM filtered f
- JOIN individuals i ON i.id = f.individual_id AND i.status = 'active'
- WHERE f.channel = 'telegram'
- AND i.telegram_chat_id IS NOT NULL
- )
- SELECT individual_id, channel, endpoint, locale,
- quiet_hours_start, quiet_hours_end, tz
- FROM fcm_rows
- UNION ALL
- SELECT individual_id, channel, endpoint, locale,
- quiet_hours_start, quiet_hours_end, tz
- FROM tg_rows
- ORDER BY channel, individual_id;
- `
- // $5 and $6: keys/values from alert.Data for routing rule data match.
- 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 targets: %w", err)
- }
- defer rows.Close()
- now := time.Now().UTC()
- var out []Target
- for rows.Next() {
- var t Target
- 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).
- if a.Severity != alert.SeverityInminentColapse && qStart != nil && qEnd != nil {
- if inQuietHours(now, *qStart, *qEnd, tz) {
- continue
- }
- }
- if t.Endpoint == "" {
- // The SQL filters the obvious case (NULL
- // telegram_chat_id), but be defensive.
- continue
- }
- out = append(out, t)
- }
- 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 M3 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/M3; 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)
- 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
- }
|