-- 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.=, 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;