init
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
\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;
|
||||
Reference in New Issue
Block a user