T-HUB/apps/api/migrations/020_analytics.sql
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

93 lines
5.7 KiB
PL/PgSQL

-- 020: WP3 analytics snapshots — monthly per-workspace rollups.
--
-- One row per (workspace, month), refreshed in place by the snapshot writer
-- (apps/api/src/analytics/snapshot.service.ts) with set-based SQL and an
-- idempotent upsert on (workspace_id, month). Months are materialized as
-- range partitions of a `month date` key, mirroring 014_live_chat.sql.
--
-- Creating future partitions: RLS on the partitioned parent does NOT apply to
-- direct leaf access, so every leaf carries its own ENABLE/FORCE RLS and
-- tenant_isolation policy. `analytics_ensure_month(date)` creates a month's
-- partition idempotently with those controls; this migration precreates the
-- previous, current and next month. Administrators run
-- SELECT analytics_ensure_month(date '2025-09-01');
-- ahead of each new month (cron it monthly, one statement, no downtime). The
-- DEFAULT partition below catches any month whose partition was not created,
-- so a missed call never drops data — it only loses pruning until the
-- partition is created and the rows moved into it.
--
-- Partitioned-leaf register entry (proposal §4.4, exception (a)): PK carries
-- the partition key, the table is never the target of an FK, and rows are
-- referenced only by (workspace_id, month) / id soft links.
--
-- Query catalog (owning WP: WP3 — packages/db does not exist in this repo, so
-- the catalog entries live here and in snapshot.service.ts):
-- analytics.snapshot.refresh INSERT .. ON CONFLICT (workspace_id, month) —
-- served by the PK (workspace_id, month, id); write path, p95 <= 250 ms.
-- analytics.snapshot.list keyset page ORDER BY month DESC — same PK,
-- backward index scan, p95 <= 20 ms.
-- analytics.snapshot.summary two PK point lookups (current + previous
-- month), p95 <= 20 ms.
CREATE TABLE analytics_snapshots (
id uuid NOT NULL DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Always a month start; the CHECK keeps the value aligned with its partition.
month date NOT NULL CHECK (month = date_trunc('month', month)::date),
publications_total bigint NOT NULL DEFAULT 0 CHECK (publications_total >= 0),
-- {provider: count} for publications published in the month.
publications_by_provider jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(publications_by_provider) = 'object'),
posts_archived_total bigint NOT NULL DEFAULT 0 CHECK (posts_archived_total >= 0),
-- Point-in-time current state at snapshot time (not a monthly delta):
-- connections not disconnected when the snapshot was taken.
active_connections int NOT NULL DEFAULT 0 CHECK (active_connections >= 0),
drafts_created_total bigint NOT NULL DEFAULT 0 CHECK (drafts_created_total >= 0),
publishing_attempts_total bigint NOT NULL DEFAULT 0 CHECK (publishing_attempts_total >= 0),
-- Attempts whose terminal status is 'failed', counted by their creation month.
publish_failures_total bigint NOT NULL DEFAULT 0 CHECK (publish_failures_total >= 0),
snapshot_started_at timestamptz NOT NULL DEFAULT now(),
snapshot_completed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, month, id),
UNIQUE (workspace_id, month)
) PARTITION BY RANGE (month);
-- Idempotent: safe to re-run for the same month.
CREATE FUNCTION analytics_ensure_month(p_month date) RETURNS void
LANGUAGE plpgsql SET search_path = public AS $$
DECLARE v_start date := date_trunc('month', p_month)::date;
v_table text := 'analytics_snapshots_' || to_char(v_start, 'YYYYMM');
BEGIN
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF analytics_snapshots FOR VALUES FROM (%L) TO (%L)',
v_table, v_start, (v_start + interval '1 month')::date);
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', v_table);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', v_table);
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = v_table AND policyname = 'tenant_isolation') THEN
EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid)', v_table);
END IF;
END $$;
REVOKE ALL ON FUNCTION analytics_ensure_month(date) FROM PUBLIC;
SELECT analytics_ensure_month((current_date - interval '1 month')::date);
SELECT analytics_ensure_month(current_date);
SELECT analytics_ensure_month((current_date + interval '1 month')::date);
-- Safety net for months nobody pre-created (see header).
CREATE TABLE analytics_snapshots_default PARTITION OF analytics_snapshots DEFAULT;
ALTER TABLE analytics_snapshots_default ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_snapshots_default FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON analytics_snapshots_default
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE TRIGGER trg_analytics_snapshot_ws_immutable BEFORE UPDATE ON analytics_snapshots
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE analytics_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_snapshots FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON analytics_snapshots
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
-- Grants on the parent cover all current and future partitions for queries
-- routed through it (014 style); direct leaf access also inherits from the
-- owner's default privileges (003).
GRANT SELECT, INSERT, UPDATE, DELETE ON analytics_snapshots TO sp_app;