\c dcntrlized; -- ============================================================================ -- Alarm service v1 schema (contract frozen 2026-10-18). -- -- Identity comes from the shared dcntrlized-auth service: every *_user_id -- column is a plain UUID pointing at auth.users(id) -- NO foreign key, so the -- alarm schema never depends on the auth service's internals (platform -- convention, see Calendar/KeepNote). -- -- The whole file is idempotent: every statement is guarded, so the compose -- `migrate` one-shot can re-apply it against an already-migrated database. -- ============================================================================ CREATE SCHEMA IF NOT EXISTS alarm; -- --------------------------------------------------------------------------- -- Guarded enum types. CREATE TYPE has no IF NOT EXISTS, so each one is wrapped -- in a DO block that checks pg_type/pg_namespace first. -- --------------------------------------------------------------------------- DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typname = 'alert_status' AND n.nspname = 'alarm' ) THEN CREATE TYPE alarm.alert_status AS ENUM ('armed', 'triggered', 'disarmed'); END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typname = 'event_kind' AND n.nspname = 'alarm' ) THEN CREATE TYPE alarm.event_kind AS ENUM ('trigger', 'disarm'); END IF; END $$; -- --------------------------------------------------------------------------- -- alerts: one row per alert created by an admin (user A). -- -- admin_token_hash sha256(adminToken); the plaintext admin token is returned -- exactly once at creation and never stored. -- subscribe_token stored PLAINTEXT on purpose: it is shared through the QR -- code, and rotating it is a cheap UPDATE (token_version). -- status last COMMANDED state. `active` is derived, never stored: -- active = status = 'triggered' AND (auto_stop_seconds = 0 -- OR now() < triggered_at + auto_stop_seconds). -- Deriving it keeps auto-stop stateless and multi-instance -- safe (no timers, no background sweep). -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS alarm.alerts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), admin_user_id UUID NOT NULL, name VARCHAR(255) NOT NULL, message TEXT, status alarm.alert_status NOT NULL DEFAULT 'armed', admin_token_hash BYTEA NOT NULL, subscribe_token VARCHAR(128) NOT NULL, token_version INTEGER NOT NULL DEFAULT 1, auto_stop_seconds INTEGER NOT NULL DEFAULT 0, created TIMESTAMPTZ NOT NULL DEFAULT NOW(), modified TIMESTAMPTZ NOT NULL DEFAULT NOW(), triggered_at TIMESTAMPTZ, disarmed_at TIMESTAMPTZ, CONSTRAINT alerts_auto_stop_seconds_range CHECK (auto_stop_seconds >= 0 AND auto_stop_seconds <= 86400), CONSTRAINT alerts_token_version_positive CHECK (token_version >= 1) ); -- --------------------------------------------------------------------------- -- subscriptions: one row per (alert, subscriber user, device). A user may -- subscribe several devices; the same device is idempotent. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS alarm.subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE, subscriber_user_id UUID NOT NULL, device_id VARCHAR(128) NOT NULL, device_label VARCHAR(128), platform VARCHAR(32), enabled BOOLEAN NOT NULL DEFAULT TRUE, last_seen_at TIMESTAMPTZ, created TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT subscriptions_alert_user_device_key UNIQUE (alert_id, subscriber_user_id, device_id) ); -- --------------------------------------------------------------------------- -- alarm_events: append-only trigger/disarm log. It is the source of truth for -- alarmStatus (the polling fallback) and for the WS catch-up frame. -- -- The AFTER INSERT trigger publishes the event on the `alarm_events` NOTIFY -- channel, so the fan-out reaches EVERY service instance. NOTIFY is delivered -- on COMMIT, so a frame can never be observed before the alert state change is -- visible. -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS alarm.alarm_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE, kind alarm.event_kind NOT NULL, actor_user_id UUID NOT NULL, message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- --------------------------------------------------------------------------- -- Indexes. idx_alarm_events_latest backs the alarmStatus single-row lookup -- (alert_id, newest first); the others back the ownership/subscriber checks. -- --------------------------------------------------------------------------- CREATE INDEX IF NOT EXISTS idx_alerts_admin_user_id ON alarm.alerts(admin_user_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_alert_id ON alarm.subscriptions(alert_id); CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON alarm.subscriptions(subscriber_user_id); CREATE INDEX IF NOT EXISTS idx_alarm_events_alert_id ON alarm.alarm_events(alert_id); CREATE INDEX IF NOT EXISTS idx_alarm_events_latest ON alarm.alarm_events(alert_id, created_at DESC, id DESC); -- --------------------------------------------------------------------------- -- modified maintenance for alerts. -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION alarm.update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.modified = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS update_alerts_modtime ON alarm.alerts; CREATE TRIGGER update_alerts_modtime BEFORE UPDATE ON alarm.alerts FOR EACH ROW EXECUTE FUNCTION alarm.update_updated_at_column(); -- --------------------------------------------------------------------------- -- Cross-instance fan-out. The payload is the transport form of the WS frame: -- the bridge formats serverTime from createdAtMs, so the in-process fast path -- and the NOTIFY path agree on the wire shape. -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION alarm.notify_alarm_event() RETURNS TRIGGER AS $$ DECLARE auto_stop INTEGER; BEGIN SELECT a.auto_stop_seconds INTO auto_stop FROM alarm.alerts a WHERE a.id = NEW.alert_id; PERFORM pg_notify('alarm_events', json_build_object( 'alertId', NEW.alert_id::text, 'eventId', NEW.id::text, 'action', NEW.kind::text, 'message', COALESCE(NEW.message, ''), 'autoStopSeconds', COALESCE(auto_stop, 0), 'createdAtMs', (EXTRACT(EPOCH FROM NEW.created_at) * 1000)::bigint )::text); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS notify_alarm_event ON alarm.alarm_events; CREATE TRIGGER notify_alarm_event AFTER INSERT ON alarm.alarm_events FOR EACH ROW EXECUTE FUNCTION alarm.notify_alarm_event(); -- --------------------------------------------------------------------------- -- Least-privilege role, mirroring the Calendar/KeepNote house pattern. The -- shared database currently authenticates as the shared `postgres` role -- (contract rev 9), so these grants are inert-but-ready; they are idempotent -- and safe to re-run. -- --------------------------------------------------------------------------- DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'alarm_user') THEN CREATE USER alarm_user WITH PASSWORD 'alarm'; END IF; END $$; GRANT CONNECT ON DATABASE dcntrlized TO alarm_user; GRANT USAGE ON SCHEMA alarm TO alarm_user; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA alarm TO alarm_user; GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA alarm TO alarm_user; ALTER DEFAULT PRIVILEGES IN SCHEMA alarm GRANT ALL ON TABLES TO alarm_user;