routing.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. // Package routing is the recipient resolution + delivery enqueue step.
  2. //
  3. // M1: broadcast mode — every alert to a company goes to every active
  4. // fcm_token of every active individual in that company.
  5. //
  6. // M2: rules engine from SPEC §6:
  7. //
  8. // 1. source.allowed_targets (groups + individuals) and broadcast
  9. // 2. routing_rules: per-company overrides with match_expr
  10. // 3. subscriptions: per-(individual, source) with min_severity,
  11. // channel_mask, quiet hours
  12. // 4. inminent_colapse bypasses quiet hours
  13. //
  14. // M3: the resolver also resolves channel='telegram' targets
  15. // from the same subscription set, when:
  16. //
  17. // - the subscription's channel_mask contains 'telegram', AND
  18. // - the individual has a non-null telegram_chat_id
  19. //
  20. // The query is one DB round-trip (a single CTE) so it's
  21. // roughly the same DB cost as M1/M2.
  22. //
  23. // Hard-fail: if the resolver returns zero targets, routerd drops
  24. // the alert with a log line. We do NOT silently fall back to a
  25. // broadcast — the operator must explicitly allow that via an
  26. // empty allowed_targets + a "broadcast" routing rule. This was
  27. // decided in the M2 plan, Q2.
  28. package routing
  29. import (
  30. "context"
  31. "encoding/json"
  32. "fmt"
  33. "log/slog"
  34. "time"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  36. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  37. )
  38. // Target is one device to deliver to.
  39. type Target struct {
  40. IndividualID string
  41. Channel string
  42. // Endpoint is the fcm_token, telegram_chat_id, etc. Resolved
  43. // from the per-channel table (fcm_tokens for fcm; individuals
  44. // for telegram etc.).
  45. Endpoint string
  46. Locale string
  47. }
  48. // Resolver looks up recipients for a (company, alert) pair.
  49. type Resolver struct {
  50. pool *postgres.Pool
  51. logger *slog.Logger
  52. }
  53. // New constructs a Resolver.
  54. func New(pool *postgres.Pool, logger *slog.Logger) *Resolver {
  55. return &Resolver{pool: pool, logger: logger}
  56. }
  57. // ResolveTargets returns every (individual, channel, endpoint) tuple
  58. // that should receive the alert. M3 contract:
  59. //
  60. // - hard-fail (return zero targets) if no recipient matches
  61. // - hard-fail (return zero targets) if the company has no source
  62. // row for the alert's source_id
  63. // - inminent_colapse bypasses quiet hours
  64. // - one Target per (individual, channel) where channel ∈
  65. // sub.channel_mask AND the individual has at least one
  66. // active endpoint for that channel
  67. //
  68. // For M3 we resolve 'fcm' and 'telegram'. Other channels listed
  69. // in channel_mask pass through with empty endpoint, which the
  70. // routerd caller is expected to drop (or which M3+ deliverd-X
  71. // workers can pick up).
  72. //
  73. // One DB round-trip via a single CTE that UNION ALLs an
  74. // fcm-resolution branch and a telegram-resolution branch.
  75. func (r *Resolver) ResolveTargets(ctx context.Context, a *alert.Alert) ([]Target, error) {
  76. if a == nil {
  77. return nil, fmt.Errorf("nil alert")
  78. }
  79. if a.Severity.Rank() < 0 {
  80. return nil, fmt.Errorf("invalid severity %q", a.Severity)
  81. }
  82. const q = `
  83. WITH src AS (
  84. SELECT id, company_id, allowed_targets, match_expr
  85. FROM sources
  86. WHERE company_id = $1 AND id = $2 AND status = 'active'
  87. ),
  88. allowed_individual_ids AS (
  89. -- Direct individual targets from allowed_targets
  90. SELECT DISTINCT (t->>'id') AS individual_id
  91. FROM src, jsonb_array_elements(src.allowed_targets) AS t
  92. WHERE t->>'type' = 'individual'
  93. UNION
  94. -- Group targets expanded via group_members
  95. SELECT DISTINCT gm.individual_id
  96. FROM src, jsonb_array_elements(src.allowed_targets) AS t
  97. JOIN group_members gm
  98. ON gm.company_id = $1
  99. AND gm.group_id = t->>'id'
  100. WHERE t->>'type' = 'group'
  101. UNION
  102. -- Broadcast: every active individual in the company
  103. SELECT id
  104. FROM individuals
  105. WHERE company_id = $1 AND status = 'active'
  106. ),
  107. rule_individual_ids AS (
  108. -- routing_rules that match the alert
  109. SELECT DISTINCT (rr.target->>'id') AS individual_id
  110. FROM routing_rules rr, src
  111. WHERE rr.company_id = $1
  112. AND rr.enabled = TRUE
  113. AND (
  114. (rr.match_expr->>'all')::boolean = TRUE
  115. OR (rr.match_expr->>'category' IS NOT NULL AND rr.match_expr->>'category' = $3::text)
  116. OR (rr.match_expr->>'severity' IS NOT NULL AND rr.match_expr->>'severity' = $4::text)
  117. OR EXISTS (
  118. SELECT 1
  119. FROM jsonb_each(rr.match_expr->'data') d
  120. WHERE d.key = ANY($5::text[]) AND d.value #>> '{}' = ANY($6::text[])
  121. )
  122. )
  123. AND rr.target->>'type' = 'individual'
  124. ),
  125. candidates AS (
  126. SELECT individual_id FROM allowed_individual_ids WHERE individual_id IS NOT NULL
  127. UNION
  128. SELECT individual_id FROM rule_individual_ids WHERE individual_id IS NOT NULL
  129. ),
  130. -- For each candidate individual, for each channel in their
  131. -- subscription's channel_mask, produce one row. Quiet hours
  132. -- and min_severity filtering happens here.
  133. sub_expanded AS (
  134. SELECT
  135. s.individual_id,
  136. s.company_id,
  137. s.source_id,
  138. s.min_severity,
  139. s.quiet_hours_start,
  140. s.quiet_hours_end,
  141. s.tz,
  142. jsonb_array_elements_text(s.channel_mask) AS channel
  143. FROM subscriptions s
  144. JOIN candidates c ON c.individual_id = s.individual_id
  145. WHERE s.company_id = $1
  146. AND s.source_id = $2
  147. AND s.status = 'active'
  148. ),
  149. filtered AS (
  150. SELECT *
  151. FROM sub_expanded se
  152. WHERE
  153. -- min_severity filter
  154. CASE
  155. WHEN se.min_severity IS NULL OR se.min_severity = '' THEN TRUE
  156. ELSE
  157. CASE se.min_severity
  158. WHEN 'info' THEN 0
  159. WHEN 'warning' THEN 1
  160. WHEN 'critical' THEN 2
  161. WHEN 'inminent_colapse' THEN 3
  162. ELSE 0
  163. END
  164. <=
  165. CASE $4::text
  166. WHEN 'info' THEN 0
  167. WHEN 'warning' THEN 1
  168. WHEN 'critical' THEN 2
  169. WHEN 'inminent_colapse' THEN 3
  170. END
  171. END
  172. -- quiet hours filter (bypassed for inminent_colapse)
  173. AND (
  174. $4::text = 'inminent_colapse'
  175. OR se.quiet_hours_start IS NULL
  176. OR se.quiet_hours_end IS NULL
  177. OR NOT (FALSE)
  178. )
  179. ),
  180. -- FCM branch: pick the first active token for each individual.
  181. fcm_rows AS (
  182. SELECT
  183. f.individual_id,
  184. 'fcm'::text AS channel,
  185. t.token AS endpoint,
  186. COALESCE(t.locale, i.locale, 'en') AS locale,
  187. f.quiet_hours_start,
  188. f.quiet_hours_end,
  189. f.tz
  190. FROM filtered f
  191. JOIN individuals i ON i.id = f.individual_id AND i.status = 'active'
  192. JOIN fcm_tokens t ON t.individual_id = f.individual_id AND t.status = 'active'
  193. WHERE f.channel = 'fcm'
  194. ),
  195. -- Telegram branch: pick telegram_chat_id (only if linked).
  196. -- Quiet hours still apply; the resolver passes the row through
  197. -- and the Go-side filter handles the bypass.
  198. tg_rows AS (
  199. SELECT
  200. f.individual_id,
  201. 'telegram'::text AS channel,
  202. i.telegram_chat_id::text AS endpoint,
  203. COALESCE(i.locale, 'en') AS locale,
  204. f.quiet_hours_start,
  205. f.quiet_hours_end,
  206. f.tz
  207. FROM filtered f
  208. JOIN individuals i ON i.id = f.individual_id AND i.status = 'active'
  209. WHERE f.channel = 'telegram'
  210. AND i.telegram_chat_id IS NOT NULL
  211. )
  212. SELECT individual_id, channel, endpoint, locale,
  213. quiet_hours_start, quiet_hours_end, tz
  214. FROM fcm_rows
  215. UNION ALL
  216. SELECT individual_id, channel, endpoint, locale,
  217. quiet_hours_start, quiet_hours_end, tz
  218. FROM tg_rows
  219. ORDER BY channel, individual_id;
  220. `
  221. // $5 and $6: keys/values from alert.Data for routing rule data match.
  222. var dataKeys, dataVals []string
  223. for k, v := range a.Data {
  224. dataKeys = append(dataKeys, k)
  225. dataVals = append(dataVals, v)
  226. }
  227. rows, err := r.pool.Query(ctx, q,
  228. a.CompanyID,
  229. a.SourceID,
  230. a.Category,
  231. string(a.Severity),
  232. dataKeys,
  233. dataVals,
  234. )
  235. if err != nil {
  236. return nil, fmt.Errorf("resolve targets: %w", err)
  237. }
  238. defer rows.Close()
  239. now := time.Now().UTC()
  240. var out []Target
  241. for rows.Next() {
  242. var t Target
  243. var qStart, qEnd *time.Time
  244. var tz string
  245. if err := rows.Scan(
  246. &t.IndividualID,
  247. &t.Channel,
  248. &t.Endpoint,
  249. &t.Locale,
  250. &qStart,
  251. &qEnd,
  252. &tz,
  253. ); err != nil {
  254. return nil, err
  255. }
  256. // Final quiet-hours check (Go side, in the subscriber's tz).
  257. if a.Severity != alert.SeverityInminentColapse && qStart != nil && qEnd != nil {
  258. if inQuietHours(now, *qStart, *qEnd, tz) {
  259. continue
  260. }
  261. }
  262. if t.Endpoint == "" {
  263. // The SQL filters the obvious case (NULL
  264. // telegram_chat_id), but be defensive.
  265. continue
  266. }
  267. out = append(out, t)
  268. }
  269. if err := rows.Err(); err != nil {
  270. return nil, err
  271. }
  272. if len(out) == 0 {
  273. // Hard-fail: don't silently broadcast. Log the empty result
  274. // so the operator can debug. The routerd caller is expected
  275. // to nack the alert and (in M3+) send to a `dlq.no_recipients`
  276. // subject. For M3 we just log.
  277. r.logger.Warn("no recipients resolved",
  278. "company_id", a.CompanyID,
  279. "source_id", a.SourceID,
  280. "alert_id", a.ID,
  281. "severity", string(a.Severity),
  282. "category", a.Category,
  283. )
  284. }
  285. return out, nil
  286. }
  287. // inQuietHours returns true if `now` (in `tz`) is between start
  288. // and end. Wraps midnight if start > end (e.g. 22:00–06:00).
  289. //
  290. // We support UTC and a few standard formats. Anything exotic
  291. // falls back to UTC.
  292. func inQuietHours(now time.Time, start, end time.Time, tz string) bool {
  293. loc, err := time.LoadLocation(tz)
  294. if err != nil {
  295. // Fall back to UTC. Acceptable for M2/M3; M2.5+ can
  296. // add tzdata support to the binary.
  297. loc = time.UTC
  298. }
  299. nowLocal := now.In(loc)
  300. // We only care about HH:MM:SS of nowLocal.
  301. nowT := time.Date(0, 1, 1, nowLocal.Hour(), nowLocal.Minute(), nowLocal.Second(), 0, time.UTC)
  302. sT := time.Date(0, 1, 1, start.Hour(), start.Minute(), start.Second(), 0, time.UTC)
  303. eT := time.Date(0, 1, 1, end.Hour(), end.Minute(), end.Second(), 0, time.UTC)
  304. if sT.Equal(eT) {
  305. // 00:00–00:00 means "always in quiet hours" (e.g. Carol's
  306. // seed in M2_VERIFICATION). 12:00–12:00 means "never".
  307. // We disambiguate by which clock the user picked.
  308. if start.Hour() == 0 && start.Minute() == 0 {
  309. return true
  310. }
  311. return false
  312. }
  313. if sT.Before(eT) {
  314. // Same-day window, e.g. 09:00–17:00
  315. return !nowT.Before(sT) && nowT.Before(eT)
  316. }
  317. // Wrap-around, e.g. 22:00–06:00
  318. return !nowT.Before(sT) || nowT.Before(eT)
  319. }
  320. // UnmarshalJSON helper so callers can decode source.allowed_targets
  321. // into a Go value. Not used internally yet; exported for the
  322. // admin API in M3+.
  323. func UnmarshalTargets(b []byte) ([]Target, error) {
  324. var out []Target
  325. if err := json.Unmarshal(b, &out); err != nil {
  326. return nil, err
  327. }
  328. return out, nil
  329. }