|
|
@@ -0,0 +1,139 @@
|
|
|
+-- 003_subscriptions_groups.up.sql
|
|
|
+-- M2 recipient resolution surface. See SPEC §4 (entities),
|
|
|
+-- SPEC §6 (recipient resolution algorithm).
|
|
|
+--
|
|
|
+-- What lands in M2:
|
|
|
+-- sources — the on-tenant source address book (M1 had it
|
|
|
+-- only as an env var for HMAC auth)
|
|
|
+-- groups — named groups of individuals (e.g. "sre",
|
|
|
+-- "noc", "on-call-rotation-A")
|
|
|
+-- group_members — many-to-many individuals ↔ groups
|
|
|
+-- subscriptions — per-individual opt-in to a source, with
|
|
|
+-- severity gate, channel mask, quiet hours
|
|
|
+-- routing_rules — per-company override: if alert matches
|
|
|
+-- match_expr, route to target_type+target_id
|
|
|
+-- instead of (or in addition to) source defaults
|
|
|
+--
|
|
|
+-- What stays out of M2:
|
|
|
+-- per-source HMAC secret in DB. Ingestd still reads
|
|
|
+-- BA_INGESTD_SOURCES env. The `sources` table here holds
|
|
|
+-- addressing metadata only.
|
|
|
+-- Routing rule match_expr is intentionally simple: keys are
|
|
|
+-- category, severity, data.<key>=<value>, all=true. Full
|
|
|
+-- JSONPath/expression language is M6 or later.
|
|
|
+
|
|
|
+-- ── sources ───────────────────────────────────────────────────────
|
|
|
+-- One row per (company, source_id). Pairs with ingestd's
|
|
|
+-- BA_INGESTD_SOURCES env (auth) — M2 just needs the row to exist
|
|
|
+-- for the resolver to know about it.
|
|
|
+CREATE TABLE IF NOT EXISTS sources (
|
|
|
+ id TEXT NOT NULL, -- sources are scoped to a company
|
|
|
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
|
+ name TEXT NOT NULL,
|
|
|
+ type TEXT NOT NULL DEFAULT 'http', -- http | mqtt | ws | grpc
|
|
|
+ rate_limit_per_sec INTEGER NOT NULL DEFAULT 100,
|
|
|
+ allowed_targets JSONB NOT NULL DEFAULT '[]'::jsonb, -- e.g. [{"type":"group","id":"sre"}, {"type":"individual","id":"ind-..."}]
|
|
|
+ match_expr JSONB NOT NULL DEFAULT '{}'::jsonb, -- default match (category, severity, data keys)
|
|
|
+ status TEXT NOT NULL DEFAULT 'active', -- active | suspended
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
+ PRIMARY KEY (company_id, id)
|
|
|
+);
|
|
|
+CREATE INDEX IF NOT EXISTS idx_sources_company ON sources(company_id) WHERE status = 'active';
|
|
|
+
|
|
|
+-- ── groups ────────────────────────────────────────────────────────
|
|
|
+CREATE TABLE IF NOT EXISTS groups (
|
|
|
+ id TEXT NOT NULL,
|
|
|
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
|
+ name TEXT NOT NULL,
|
|
|
+ description TEXT,
|
|
|
+ status TEXT NOT NULL DEFAULT 'active', -- active | archived
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
+ PRIMARY KEY (company_id, id)
|
|
|
+);
|
|
|
+CREATE INDEX IF NOT EXISTS idx_groups_company ON groups(company_id) WHERE status = 'active';
|
|
|
+
|
|
|
+-- ── group_members ─────────────────────────────────────────────────
|
|
|
+-- Junction: which individuals belong to which groups. M2 only adds
|
|
|
+-- rows; M3+ lets the admin UI manage them.
|
|
|
+CREATE TABLE IF NOT EXISTS group_members (
|
|
|
+ group_id TEXT NOT NULL,
|
|
|
+ company_id TEXT NOT NULL,
|
|
|
+ individual_id TEXT NOT NULL REFERENCES individuals(id) ON DELETE CASCADE,
|
|
|
+ added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
+ added_by TEXT,
|
|
|
+ PRIMARY KEY (company_id, group_id, individual_id),
|
|
|
+ FOREIGN KEY (company_id, group_id) REFERENCES groups(company_id, id) ON DELETE CASCADE
|
|
|
+);
|
|
|
+CREATE INDEX IF NOT EXISTS idx_group_members_individual ON group_members(individual_id);
|
|
|
+
|
|
|
+-- ── subscriptions ─────────────────────────────────────────────────
|
|
|
+-- Per-(individual, source) opt-in with severity gate, channel mask,
|
|
|
+-- and per-subscriber quiet hours. See SPEC §6.
|
|
|
+--
|
|
|
+-- channel_mask is a JSON array of channel names, e.g.
|
|
|
+-- ["fcm", "telegram"]
|
|
|
+-- M2 ships with only 'fcm' actually delivered (M3 brings telegram).
|
|
|
+-- The resolver will include 'telegram' in the result, but no worker
|
|
|
+-- consumes it yet; the alert gets dropped at the broker level with
|
|
|
+-- a "no worker" log. See SPEC §6 / M2 honest flag in PROMPT.md.
|
|
|
+--
|
|
|
+-- quiet_hours_start / quiet_hours_end are times of day in the
|
|
|
+-- subscriber's tz (HH:MM). Wrap-around (e.g. 22:00–06:00) is
|
|
|
+-- supported by the resolver.
|
|
|
+--
|
|
|
+-- min_severity is the lowest severity that gets through. Order is
|
|
|
+-- info < warning < critical < inminent_colapse. NULL = no filter
|
|
|
+-- (any severity).
|
|
|
+CREATE TABLE IF NOT EXISTS subscriptions (
|
|
|
+ id BIGSERIAL PRIMARY KEY,
|
|
|
+ individual_id TEXT NOT NULL REFERENCES individuals(id) ON DELETE CASCADE,
|
|
|
+ company_id TEXT NOT NULL, -- denormalized for indexing
|
|
|
+ source_id TEXT NOT NULL,
|
|
|
+ min_severity TEXT, -- info|warning|critical|inminent_colapse; NULL = any
|
|
|
+ channel_mask JSONB NOT NULL DEFAULT '["fcm"]'::jsonb,
|
|
|
+ quiet_hours_start TIME, -- in `tz`; NULL = no quiet hours
|
|
|
+ quiet_hours_end TIME,
|
|
|
+ tz TEXT NOT NULL DEFAULT 'UTC',
|
|
|
+ status TEXT NOT NULL DEFAULT 'active', -- active | paused
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
+ UNIQUE (individual_id, source_id),
|
|
|
+ FOREIGN KEY (company_id, source_id) REFERENCES sources(company_id, id) ON DELETE CASCADE
|
|
|
+);
|
|
|
+CREATE INDEX IF NOT EXISTS idx_subscriptions_source ON subscriptions(company_id, source_id) WHERE status = 'active';
|
|
|
+CREATE INDEX IF NOT EXISTS idx_subscriptions_individual ON subscriptions(individual_id) WHERE status = 'active';
|
|
|
+
|
|
|
+-- ── routing_rules ─────────────────────────────────────────────────
|
|
|
+-- Optional per-company overrides. A rule with priority=N is
|
|
|
+-- evaluated in priority order; first match wins, unless
|
|
|
+-- `continue` is true. The M2 resolver evaluates them in a single
|
|
|
+-- SQL pass (window function) and unions the targets.
|
|
|
+--
|
|
|
+-- match_expr shape (M2):
|
|
|
+-- {
|
|
|
+-- "category": "storage", // optional, exact match
|
|
|
+-- "severity": "critical", // optional, exact match
|
|
|
+-- "data": {"host": "db-prod-03"}, // optional, all keys must match
|
|
|
+-- "all": true // if true, match every alert for this company
|
|
|
+-- }
|
|
|
+--
|
|
|
+-- target shape:
|
|
|
+-- { "type": "group", "id": "sre" }
|
|
|
+-- { "type": "individual", "id": "ind-…" }
|
|
|
+-- { "type": "broadcast", "id": null } -- all active individuals in company
|
|
|
+-- { "type": "drop", "id": null } -- explicitly drop the alert for this company
|
|
|
+--
|
|
|
+-- enabled=false → skipped. Priorities are 0..N, lower is higher
|
|
|
+-- priority. Rules within the same priority are unioned.
|
|
|
+CREATE TABLE IF NOT EXISTS routing_rules (
|
|
|
+ id BIGSERIAL PRIMARY KEY,
|
|
|
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
|
+ name TEXT NOT NULL,
|
|
|
+ priority INTEGER NOT NULL DEFAULT 100,
|
|
|
+ match_expr JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
|
+ target JSONB NOT NULL, -- {type, id} as above
|
|
|
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
|
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
|
+ UNIQUE (company_id, name)
|
|
|
+);
|
|
|
+CREATE INDEX IF NOT EXISTS idx_routing_rules_company_priority
|
|
|
+ ON routing_rules(company_id, priority) WHERE enabled = TRUE;
|