003_subscriptions_groups.up.sql 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. -- 003_subscriptions_groups.up.sql
  2. -- M2 recipient resolution surface. See SPEC §4 (entities),
  3. -- SPEC §6 (recipient resolution algorithm).
  4. --
  5. -- What lands in M2:
  6. -- sources — the on-tenant source address book (M1 had it
  7. -- only as an env var for HMAC auth)
  8. -- groups — named groups of individuals (e.g. "sre",
  9. -- "noc", "on-call-rotation-A")
  10. -- group_members — many-to-many individuals ↔ groups
  11. -- subscriptions — per-individual opt-in to a source, with
  12. -- severity gate, channel mask, quiet hours
  13. -- routing_rules — per-company override: if alert matches
  14. -- match_expr, route to target_type+target_id
  15. -- instead of (or in addition to) source defaults
  16. --
  17. -- What stays out of M2:
  18. -- per-source HMAC secret in DB. Ingestd still reads
  19. -- BA_INGESTD_SOURCES env. The `sources` table here holds
  20. -- addressing metadata only.
  21. -- Routing rule match_expr is intentionally simple: keys are
  22. -- category, severity, data.<key>=<value>, all=true. Full
  23. -- JSONPath/expression language is M6 or later.
  24. -- ── sources ───────────────────────────────────────────────────────
  25. -- One row per (company, source_id). Pairs with ingestd's
  26. -- BA_INGESTD_SOURCES env (auth) — M2 just needs the row to exist
  27. -- for the resolver to know about it.
  28. CREATE TABLE IF NOT EXISTS sources (
  29. id TEXT NOT NULL, -- sources are scoped to a company
  30. company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
  31. name TEXT NOT NULL,
  32. type TEXT NOT NULL DEFAULT 'http', -- http | mqtt | ws | grpc
  33. rate_limit_per_sec INTEGER NOT NULL DEFAULT 100,
  34. allowed_targets JSONB NOT NULL DEFAULT '[]'::jsonb, -- e.g. [{"type":"group","id":"sre"}, {"type":"individual","id":"ind-..."}]
  35. match_expr JSONB NOT NULL DEFAULT '{}'::jsonb, -- default match (category, severity, data keys)
  36. status TEXT NOT NULL DEFAULT 'active', -- active | suspended
  37. created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  38. PRIMARY KEY (company_id, id)
  39. );
  40. CREATE INDEX IF NOT EXISTS idx_sources_company ON sources(company_id) WHERE status = 'active';
  41. -- ── groups ────────────────────────────────────────────────────────
  42. CREATE TABLE IF NOT EXISTS groups (
  43. id TEXT NOT NULL,
  44. company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
  45. name TEXT NOT NULL,
  46. description TEXT,
  47. status TEXT NOT NULL DEFAULT 'active', -- active | archived
  48. created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  49. PRIMARY KEY (company_id, id)
  50. );
  51. CREATE INDEX IF NOT EXISTS idx_groups_company ON groups(company_id) WHERE status = 'active';
  52. -- ── group_members ─────────────────────────────────────────────────
  53. -- Junction: which individuals belong to which groups. M2 only adds
  54. -- rows; M3+ lets the admin UI manage them.
  55. CREATE TABLE IF NOT EXISTS group_members (
  56. group_id TEXT NOT NULL,
  57. company_id TEXT NOT NULL,
  58. individual_id TEXT NOT NULL REFERENCES individuals(id) ON DELETE CASCADE,
  59. added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  60. added_by TEXT,
  61. PRIMARY KEY (company_id, group_id, individual_id),
  62. FOREIGN KEY (company_id, group_id) REFERENCES groups(company_id, id) ON DELETE CASCADE
  63. );
  64. CREATE INDEX IF NOT EXISTS idx_group_members_individual ON group_members(individual_id);
  65. -- ── subscriptions ─────────────────────────────────────────────────
  66. -- Per-(individual, source) opt-in with severity gate, channel mask,
  67. -- and per-subscriber quiet hours. See SPEC §6.
  68. --
  69. -- channel_mask is a JSON array of channel names, e.g.
  70. -- ["fcm", "telegram"]
  71. -- M2 ships with only 'fcm' actually delivered (M3 brings telegram).
  72. -- The resolver will include 'telegram' in the result, but no worker
  73. -- consumes it yet; the alert gets dropped at the broker level with
  74. -- a "no worker" log. See SPEC §6 / M2 honest flag in PROMPT.md.
  75. --
  76. -- quiet_hours_start / quiet_hours_end are times of day in the
  77. -- subscriber's tz (HH:MM). Wrap-around (e.g. 22:00–06:00) is
  78. -- supported by the resolver.
  79. --
  80. -- min_severity is the lowest severity that gets through. Order is
  81. -- info < warning < critical < inminent_colapse. NULL = no filter
  82. -- (any severity).
  83. CREATE TABLE IF NOT EXISTS subscriptions (
  84. id BIGSERIAL PRIMARY KEY,
  85. individual_id TEXT NOT NULL REFERENCES individuals(id) ON DELETE CASCADE,
  86. company_id TEXT NOT NULL, -- denormalized for indexing
  87. source_id TEXT NOT NULL,
  88. min_severity TEXT, -- info|warning|critical|inminent_colapse; NULL = any
  89. channel_mask JSONB NOT NULL DEFAULT '["fcm"]'::jsonb,
  90. quiet_hours_start TIME, -- in `tz`; NULL = no quiet hours
  91. quiet_hours_end TIME,
  92. tz TEXT NOT NULL DEFAULT 'UTC',
  93. status TEXT NOT NULL DEFAULT 'active', -- active | paused
  94. created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  95. UNIQUE (individual_id, source_id),
  96. FOREIGN KEY (company_id, source_id) REFERENCES sources(company_id, id) ON DELETE CASCADE
  97. );
  98. CREATE INDEX IF NOT EXISTS idx_subscriptions_source ON subscriptions(company_id, source_id) WHERE status = 'active';
  99. CREATE INDEX IF NOT EXISTS idx_subscriptions_individual ON subscriptions(individual_id) WHERE status = 'active';
  100. -- ── routing_rules ─────────────────────────────────────────────────
  101. -- Optional per-company overrides. A rule with priority=N is
  102. -- evaluated in priority order; first match wins, unless
  103. -- `continue` is true. The M2 resolver evaluates them in a single
  104. -- SQL pass (window function) and unions the targets.
  105. --
  106. -- match_expr shape (M2):
  107. -- {
  108. -- "category": "storage", // optional, exact match
  109. -- "severity": "critical", // optional, exact match
  110. -- "data": {"host": "db-prod-03"}, // optional, all keys must match
  111. -- "all": true // if true, match every alert for this company
  112. -- }
  113. --
  114. -- target shape:
  115. -- { "type": "group", "id": "sre" }
  116. -- { "type": "individual", "id": "ind-…" }
  117. -- { "type": "broadcast", "id": null } -- all active individuals in company
  118. -- { "type": "drop", "id": null } -- explicitly drop the alert for this company
  119. --
  120. -- enabled=false → skipped. Priorities are 0..N, lower is higher
  121. -- priority. Rules within the same priority are unioned.
  122. CREATE TABLE IF NOT EXISTS routing_rules (
  123. id BIGSERIAL PRIMARY KEY,
  124. company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
  125. name TEXT NOT NULL,
  126. priority INTEGER NOT NULL DEFAULT 100,
  127. match_expr JSONB NOT NULL DEFAULT '{}'::jsonb,
  128. target JSONB NOT NULL, -- {type, id} as above
  129. enabled BOOLEAN NOT NULL DEFAULT TRUE,
  130. created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  131. UNIQUE (company_id, name)
  132. );
  133. CREATE INDEX IF NOT EXISTS idx_routing_rules_company_priority
  134. ON routing_rules(company_id, priority) WHERE enabled = TRUE;