From f24dfa52147b566a968e74a3b801ea2273da01a2 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 19:20:37 +0100 Subject: [PATCH] =?UTF-8?q?store:=20migration=200014=20=E2=80=94=20notific?= =?UTF-8?q?ation=5Fchannels=20+=20notification=5Flog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/store/migrate_test.go | 33 +++++++++++++++ .../store/migrations/0014_notifications.sql | 42 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 internal/store/migrations/0014_notifications.sql diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 6bed70a..a02908e 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -36,3 +36,36 @@ func TestMigration0013AlertsLastSeen(t *testing.T) { t.Fatalf("alerts.last_seen_at not present after migration; cols=%v", cols) } } + +func TestMigration0014NotificationsTables(t *testing.T) { + t.Parallel() + dir := t.TempDir() + st, err := Open(context.Background(), filepath.Join(dir, "rm.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer st.Close() + + for _, want := range []string{"notification_channels", "notification_log"} { + var n int + if err := st.DB().QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, want, + ).Scan(&n); err != nil { + t.Fatalf("scan: %v", err) + } + if n != 1 { + t.Errorf("table %q missing after migration", want) + } + } + + // Sanity: kind CHECK accepts all three v1 kinds. + for _, k := range []string{"webhook", "ntfy", "smtp"} { + _, err := st.DB().Exec( + `INSERT INTO notification_channels (id, kind, name, config, created_at, updated_at) + VALUES (?, ?, ?, x'00', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, + "test-"+k, k, "test-"+k) + if err != nil { + t.Errorf("insert %q rejected by CHECK: %v", k, err) + } + } +} diff --git a/internal/store/migrations/0014_notifications.sql b/internal/store/migrations/0014_notifications.sql new file mode 100644 index 0000000..130fb61 --- /dev/null +++ b/internal/store/migrations/0014_notifications.sql @@ -0,0 +1,42 @@ +-- 0014_notifications.sql +-- +-- Notification channels (operator-configured destinations: webhook, +-- ntfy, SMTP) and the dispatch log. Both are net-new — no rebuild +-- pattern needed. +-- +-- config is an AEAD-encrypted JSON blob. Per-kind shape lives in +-- internal/notification/{webhook,ntfy,smtp}.go. The CHECK keeps wire +-- consistency — adding a new kind requires a follow-up migration +-- (forces the implementer to think about it). + +CREATE TABLE notification_channels ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('webhook', 'ntfy', 'smtp')), + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + config BLOB NOT NULL, -- AEAD-encrypted JSON; per-kind shape + default_priority TEXT, -- ntfy only; null for webhook + smtp + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_fired_at TEXT +); + +CREATE INDEX notification_channels_enabled + ON notification_channels(enabled) WHERE enabled = 1; + +CREATE TABLE notification_log ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, + alert_id TEXT REFERENCES alerts(id) ON DELETE SET NULL, + event TEXT NOT NULL, -- alert.raised | alert.acknowledged | alert.resolved | alert.test + ok INTEGER NOT NULL CHECK (ok IN (0, 1)), + status_code INTEGER, + latency_ms INTEGER, + error TEXT, + fired_at TEXT NOT NULL +); + +CREATE INDEX notification_log_channel + ON notification_log(channel_id, fired_at DESC); +CREATE INDEX notification_log_alert + ON notification_log(alert_id);