Files
Oxicloud/migrations/20261026000000_notifications.sql
2026-09-11 23:14:44 +02:00

62 lines
2.7 KiB
SQL

-- notif.notifications — durable per-user notification records.
--
-- Backs the bell UI and the retention job. The message bus is best-effort
-- (a subscriber offline at publish time misses the push); this table is
-- the truth. Every `NotificationService::create` writes a row AND
-- publishes a `NotificationReceived` event on `user:{user_id}:notifications`.
-- A missed bus event recovers on the next `GET /api/notifications`.
--
-- See `docs/plan/message-bus.md § Slice E` for the wire contract and
-- retention policy.
CREATE SCHEMA IF NOT EXISTS notif;
CREATE TABLE IF NOT EXISTS notif.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Recipient. Every row is scoped to exactly one user; a share fanned
-- to N members is N rows. Fanout truncation for very-large groups
-- happens in the ingester (see plan § Notification fanout truncated),
-- not here.
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Notification kind — a stable slug the FE routes on for icon/label/
-- action-button choice. New kinds are additive; never repurpose an
-- existing one. Initial kinds:
-- share_granted, new_login_from_new_device,
-- job_completed_for_you, storage_quota_threshold
kind TEXT NOT NULL,
-- Per-kind opaque JSON with the fields the FE needs to render the
-- row without a follow-up API call (subject name, resource id,
-- action link…). Shape is a per-kind contract owned by the ingester;
-- the DB stays schema-free here so a new field doesn't require a
-- migration.
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Wall-clock creation stamp. Sort key for the bell. Server-clock,
-- not caller-clock — this is a DB-generated fact.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- NULL = unread; non-NULL = when the user explicitly marked it
-- read. Retention job deletes rows where read_at IS NOT NULL AND
-- read_at < now() - retention_days.
read_at TIMESTAMPTZ
);
-- Bell fetch — GET /api/notifications lists a user's rows newest-first,
-- typically capped at ~50, sometimes filtered on unread. This one index
-- covers both the list query and the mark-all-read filter, and the
-- INCLUDE clause keeps common bell renders (id, kind, created_at,
-- read_at) index-only.
CREATE INDEX IF NOT EXISTS notifications_user_created_read
ON notif.notifications (user_id, created_at DESC)
INCLUDE (read_at, kind);
-- Retention job DELETE — scans read-and-old rows only. Partial keeps
-- the index tiny in the typical steady state where most rows are
-- unread.
CREATE INDEX IF NOT EXISTS notifications_read_at
ON notif.notifications (read_at)
WHERE read_at IS NOT NULL;