188 lines
8.1 KiB
PL/PgSQL
188 lines
8.1 KiB
PL/PgSQL
\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;
|