Files
silo-server/migrations/sql/20260611100000_profile_release_notifications.sql
QuickandClaude Fable 5 b091f0c6c1 feat(notifications): in-app inbox, realtime, webhooks, web push + shared SMTP core
Implements the notification system foundation and all v1 delivery channels
that need no external infrastructure (specs 00/01/04/05 in
docs/superpowers/plans/notifications/):

Foundation (spec 01):
- episode_availability seeding + per-library seed markers: "newly available"
  means newly released to this server, so back-catalog imports and first
  scans never flood (verified on dev: 1.13M episodes seeded silently)
- release_events -> profile_series_interest fanout worker with settling
  delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims,
  and a guarded last-notified cursor
- interest index maintained via a userstore provider decorator so every
  favorites/watchlist/progress mutation path (REST, jellycompat, imports,
  playback) feeds it; progress writes only recompute on state transitions
- durable per-profile inbox + read state, forward-sync cursor API,
  websocket channel with short-lived single-use handshake tickets
- web UI: sidebar badge, inbox page, toasts, per-profile preferences
- startup/daily tasks: availability seeding, interest rebuild, retention

Outbound webhooks (spec 04):
- Discord embeds (text-only per the v1 privacy contract) and generic
  JSON signed Stripe-style with per-webhook secrets
- HTTPS-only + private-destination guard enforced at registration and at
  connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest
- durable per-target outbox enqueued in the fanout transaction, lease-based
  claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an
  in-app notice (loop-guarded)

Web push (spec 05):
- VAPID keypair self-provisioned at startup (single atomic JSON setting,
  private half encrypted at rest) — no third-party accounts needed
- payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe
- service worker + subscribe flow in Settings -> Notifications

Shared SMTP core (internal/mail):
- feature-agnostic mail.Sender over live email.* settings, STARTTLS or
  implicit TLS, encrypted password, admin Email settings page with
  synchronous test send; no consumer yet by design (digest is v1.5)

APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports
them unavailable so clients render truthfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -04:00

162 lines
6.8 KiB
SQL

-- +goose Up
-- +goose StatementBegin
-- Foundation schema for profile-scoped release notifications
-- (docs/superpowers/plans/notifications/01-release-events-and-inbox.md).
--
-- episode_availability is a one-way "episode first became available in this
-- library" fact. Rows are inserted by live ingest and by silent seeding
-- (initial library scans, feature-enable backfill); they persist across file
-- churn so re-added files never re-notify.
CREATE TABLE public.episode_availability (
library_id integer NOT NULL,
episode_id text NOT NULL,
series_id text NOT NULL,
season_number integer NOT NULL,
episode_number integer NOT NULL,
episode_key integer NOT NULL,
available_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT episode_availability_pkey PRIMARY KEY (library_id, episode_id)
);
CREATE INDEX episode_availability_series_idx
ON public.episode_availability (library_id, series_id, episode_key DESC);
-- Per-library marker that availability seeding completed. Release events are
-- emitted only for libraries with a row here; unseeded libraries insert
-- availability silently ("newly available" means newly released to this
-- server, not newly seen by the notifications feature).
CREATE TABLE public.notification_library_seed_state (
library_id integer PRIMARY KEY REFERENCES public.media_folders(id) ON DELETE CASCADE,
seeded_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE public.release_events (
id text PRIMARY KEY,
library_id integer NOT NULL,
series_id text NOT NULL,
episode_id text NOT NULL,
season_number integer NOT NULL,
episode_number integer NOT NULL,
episode_key integer NOT NULL,
available_at timestamptz NOT NULL,
-- Explicit column (rather than a composite unique) so future event kinds
-- can share the table with their own key shapes. Composed as
-- "{library_id}:{episode_id}".
dedupe_key text NOT NULL,
processed_at timestamptz,
-- NULL for fanned-out events; 'series_burst' when the per-series burst
-- cap consumed this event without fanout.
suppressed_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT release_events_dedupe_key_key UNIQUE (dedupe_key)
);
CREATE INDEX release_events_unprocessed_idx
ON public.release_events (processed_at, created_at);
CREATE INDEX release_events_series_idx
ON public.release_events (library_id, series_id, created_at DESC);
-- Compact recipient index used by the fanout worker. profile_id has no FK:
-- profiles may live in per-user SQLite stores rather than Postgres, so
-- profile deletion cleans these rows up in code instead of via cascade.
CREATE TABLE public.profile_series_interest (
user_id integer NOT NULL,
profile_id text NOT NULL,
library_id integer NOT NULL,
series_id text NOT NULL,
favorite boolean NOT NULL DEFAULT false,
watchlist boolean NOT NULL DEFAULT false,
continue_watching boolean NOT NULL DEFAULT false,
next_up_candidate boolean NOT NULL DEFAULT false,
last_completed_episode_key integer,
next_expected_episode_key integer,
last_notified_episode_key integer,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT profile_series_interest_pkey PRIMARY KEY (profile_id, library_id, series_id)
);
CREATE INDEX profile_series_interest_series_idx
ON public.profile_series_interest (library_id, series_id);
-- Hot fanout path: only rows with at least one active interest flag matter.
CREATE INDEX profile_series_interest_active_idx
ON public.profile_series_interest (library_id, series_id)
WHERE favorite OR watchlist OR continue_watching OR next_up_candidate;
CREATE INDEX profile_series_interest_profile_idx
ON public.profile_series_interest (profile_id, updated_at DESC);
-- Durable per-profile inbox rows. release_event_id is nullable: operational
-- types (e.g. webhook.auto_disabled) have no release event, and retention
-- pruning of old release_events must not delete inbox rows.
CREATE TABLE public.notification_deliveries (
id text PRIMARY KEY,
release_event_id text REFERENCES public.release_events(id) ON DELETE SET NULL,
user_id integer NOT NULL,
profile_id text NOT NULL,
library_id integer,
series_id text,
episode_id text,
type text NOT NULL,
reason_flags jsonb NOT NULL,
status text NOT NULL DEFAULT 'delivered',
read_at timestamptz,
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT notification_deliveries_episode_fields_check CHECK (
type <> 'episode.available'
OR (release_event_id IS NOT NULL AND library_id IS NOT NULL
AND series_id IS NOT NULL AND episode_id IS NOT NULL)
)
);
CREATE UNIQUE INDEX notification_deliveries_profile_event_key
ON public.notification_deliveries (profile_id, release_event_id)
WHERE release_event_id IS NOT NULL;
-- Cross-library dedupe: the same episode landing in two libraries (e.g.
-- "TV" and "TV 4K") shares one episode_id; the first release event processed
-- wins and later inserts no-op.
CREATE UNIQUE INDEX notification_deliveries_profile_episode_key
ON public.notification_deliveries (profile_id, episode_id)
WHERE type = 'episode.available';
CREATE INDEX notification_deliveries_inbox_idx
ON public.notification_deliveries (profile_id, created_at DESC);
CREATE INDEX notification_deliveries_unread_idx
ON public.notification_deliveries (profile_id, read_at, created_at DESC);
CREATE INDEX notification_deliveries_status_idx
ON public.notification_deliveries (status, created_at);
-- Forward-sync cursor support.
CREATE INDEX notification_deliveries_sync_idx
ON public.notification_deliveries (created_at, id);
CREATE TABLE public.notification_preferences (
profile_id text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT true,
notify_favorites boolean NOT NULL DEFAULT true,
notify_watchlist boolean NOT NULL DEFAULT true,
notify_continue_watching boolean NOT NULL DEFAULT true,
notify_next_up boolean NOT NULL DEFAULT true,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Checkpoint state for the interest backfill / availability seeding tasks.
CREATE TABLE public.notification_backfill_state (
task text PRIMARY KEY,
last_processed_key text,
started_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS public.notification_backfill_state;
DROP TABLE IF EXISTS public.notification_preferences;
DROP TABLE IF EXISTS public.notification_deliveries;
DROP TABLE IF EXISTS public.profile_series_interest;
DROP TABLE IF EXISTS public.release_events;
DROP TABLE IF EXISTS public.notification_library_seed_state;
DROP TABLE IF EXISTS public.episode_availability;
-- +goose StatementEnd