release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m33s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 5m42s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Applies rule 32's migration exception: each header still says what the schema was, what changes and what it prevents, but task, milestone, decision and rule ids are replaced by the practice they stood for, the operator quotes by the decision, single-install counts are dropped, and pointers to a deleted package and an external design doc are gone. golang-migrate keeps no checksum, so editing applied migrations' comments is safe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
3.2 KiB
SQL
75 lines
3.2 KiB
SQL
-- A persistent, per-user notifications inbox.
|
|
--
|
|
-- The event bus is fire-and-forget: a client that is not connected when a
|
|
-- request completes, or when the scanner marks tracks missing, never hears of
|
|
-- it. A row here is the durable record; the bus only nudges open clients to
|
|
-- come and read it.
|
|
--
|
|
-- `kind` is CHECK-gated. A new kind adds its value here, in the
|
|
-- same migration as the code that produces it. The list is mirrored in
|
|
-- internal/notifications/kinds.go, and both CHECKs below must agree with it.
|
|
|
|
CREATE TABLE user_notifications (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
kind text NOT NULL CHECK (kind IN (
|
|
'request_approved',
|
|
'request_rejected',
|
|
'request_completed',
|
|
'request_pending',
|
|
'quarantine_flagged',
|
|
'scan_failed',
|
|
'tracks_missing',
|
|
'duplicates_found',
|
|
'playback_errors'
|
|
)),
|
|
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
read_at timestamptz,
|
|
-- Burst-prone kinds share one key per user. While a row with that key is
|
|
-- unread, a new event updates it in place rather than adding another, so
|
|
-- fourteen missing tracks are one notification with a count.
|
|
coalesce_key text,
|
|
-- Stamped once the row has gone out in an email (or been judged not to
|
|
-- need one), so the digest never sends the same item twice.
|
|
emailed_at timestamptz
|
|
);
|
|
|
|
-- The inbox list, newest first.
|
|
CREATE INDEX user_notifications_user_created_idx
|
|
ON user_notifications (user_id, created_at DESC);
|
|
|
|
-- The unread badge, and the digest's selection of unread rows.
|
|
CREATE INDEX user_notifications_unread_idx
|
|
ON user_notifications (user_id, created_at DESC)
|
|
WHERE read_at IS NULL;
|
|
|
|
-- At most one unread row per coalesce key per user. The upsert in
|
|
-- notifications.sql targets this index.
|
|
CREATE UNIQUE INDEX user_notifications_coalesce_idx
|
|
ON user_notifications (user_id, coalesce_key)
|
|
WHERE read_at IS NULL AND coalesce_key IS NOT NULL;
|
|
|
|
-- Per user, per kind: which channels a kind reaches. A missing row means the
|
|
-- kind's defaults (internal/notifications/kinds.go), so nothing is seeded and
|
|
-- a new kind needs no backfill.
|
|
CREATE TABLE user_notification_prefs (
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
kind text NOT NULL CHECK (kind IN (
|
|
'request_approved',
|
|
'request_rejected',
|
|
'request_completed',
|
|
'request_pending',
|
|
'quarantine_flagged',
|
|
'scan_failed',
|
|
'tracks_missing',
|
|
'duplicates_found',
|
|
'playback_errors'
|
|
)),
|
|
inbox boolean NOT NULL,
|
|
phone boolean NOT NULL,
|
|
email boolean NOT NULL,
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (user_id, kind)
|
|
);
|