Files
silo-server/migrations/sql/20260612020209_server_notification_channels.sql
T
QuickandClaude Fable 5 3d2368aed7 feat(notifications): admin server channels broadcasting new content and request activity
Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).

- Extend release_events with a kind discriminator and add a movie
  availability spine (movie_availability + kind-keyed
  notification_content_seed_state; first full scan seeds silently so
  upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
  grouping, per-channel backoff, and auto-disable; request events post
  best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
  SSRF guard, embed limits, HMAC signing; shared type/name validation
  extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
  Channels section in the notifications admin settings UI

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

118 lines
5.5 KiB
SQL

-- +goose Up
-- +goose StatementBegin
-- Admin server notification channels ("community channels") + movie event
-- spine. Server channels are admin-owned outbound webhooks fed straight from
-- release_events via a per-channel watermark sweep (no per-profile fanout),
-- announcing newly added movies/episodes and media request lifecycle events.
-- 1) release_events gains a kind discriminator so movie events share the
-- table (the dedupe_key column was designed for exactly this). Episode rows
-- keep their shape; movie rows carry item_id (media_items.content_id) with
-- dedupe keys in a distinct "movie:{library_id}:{item_id}" keyspace.
ALTER TABLE public.release_events
ADD COLUMN kind text NOT NULL DEFAULT 'episode',
ADD COLUMN item_id text;
ALTER TABLE public.release_events
ALTER COLUMN series_id DROP NOT NULL,
ALTER COLUMN episode_id DROP NOT NULL,
ALTER COLUMN season_number DROP NOT NULL,
ALTER COLUMN episode_number DROP NOT NULL,
ALTER COLUMN episode_key DROP NOT NULL;
ALTER TABLE public.release_events
ADD CONSTRAINT release_events_kind_check CHECK (kind IN ('episode', 'movie')),
ADD CONSTRAINT release_events_kind_shape_check CHECK (
(kind = 'episode' AND series_id IS NOT NULL AND episode_id IS NOT NULL
AND season_number IS NOT NULL AND episode_number IS NOT NULL
AND episode_key IS NOT NULL)
OR (kind = 'movie' AND item_id IS NOT NULL)
);
-- Server-channel sweep cursor: strictly increasing (created_at, id) walk.
CREATE INDEX release_events_sweep_idx
ON public.release_events (created_at, id);
-- 2) Movie availability facts: one-way "movie first became available in this
-- library" rows, mirror of episode_availability. Persist across file churn so
-- re-added files never re-notify.
CREATE TABLE public.movie_availability (
library_id integer NOT NULL,
item_id text NOT NULL,
available_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT movie_availability_pkey PRIMARY KEY (library_id, item_id)
);
-- 3) Per-kind seed markers for every content kind after episodes (movies
-- now; audiobooks/music later need only a new kind value). Deliberately
-- separate from notification_library_seed_state: that table was already
-- seed-marked for every scanned library (including movie libraries) by the
-- episode-only seeding pass, with zero movie availability rows. Reusing it
-- would emit release events for the entire movie back catalog on the first
-- post-upgrade scan. A kind seeds silently until a row lands here.
CREATE TABLE public.notification_content_seed_state (
library_id integer NOT NULL REFERENCES public.media_folders(id) ON DELETE CASCADE,
kind text NOT NULL,
seeded_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT notification_content_seed_state_pkey PRIMARY KEY (library_id, kind)
);
-- 4) Admin server channels. url/signing_secret ciphertexts hold enc:v1:
-- envelopes (internal/secret) with channel-scoped AADs distinct from the
-- profile webhook AAD namespace. created_by_user_id has no FK by convention
-- (informational only). The watermark defaults to creation time so a new
-- channel never replays history; re-enabling a disabled channel fast-forwards
-- it to now() in code.
CREATE TABLE public.notification_server_channels (
id text PRIMARY KEY,
name varchar(64) NOT NULL,
type text NOT NULL,
url_ciphertext text NOT NULL,
url_host varchar(253) NOT NULL,
signing_secret_ciphertext text,
enabled boolean NOT NULL DEFAULT true,
notify_new_movies boolean NOT NULL DEFAULT true,
notify_new_episodes boolean NOT NULL DEFAULT true,
notify_request_submitted boolean NOT NULL DEFAULT false,
notify_request_approved boolean NOT NULL DEFAULT false,
notify_request_declined boolean NOT NULL DEFAULT false,
notify_request_fulfilled boolean NOT NULL DEFAULT false,
watermark_created_at timestamptz NOT NULL DEFAULT now(),
watermark_id text NOT NULL DEFAULT '',
last_attempt_at timestamptz,
consecutive_failures integer NOT NULL DEFAULT 0,
disabled_reason varchar(256),
last_success_at timestamptz,
last_failure_at timestamptz,
last_failure_status integer,
last_failure_message varchar(256),
created_by_user_id integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT notification_server_channels_name_key UNIQUE (name),
CONSTRAINT notification_server_channels_type_check CHECK (type IN ('discord', 'generic')),
CONSTRAINT notification_server_channels_secret_check
CHECK (type = 'discord' OR signing_secret_ciphertext IS NOT NULL)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS public.notification_server_channels;
DROP TABLE IF EXISTS public.notification_content_seed_state;
DROP TABLE IF EXISTS public.movie_availability;
DROP INDEX IF EXISTS public.release_events_sweep_idx;
DELETE FROM public.release_events WHERE kind <> 'episode';
ALTER TABLE public.release_events
DROP CONSTRAINT IF EXISTS release_events_kind_shape_check,
DROP CONSTRAINT IF EXISTS release_events_kind_check;
ALTER TABLE public.release_events
ALTER COLUMN series_id SET NOT NULL,
ALTER COLUMN episode_id SET NOT NULL,
ALTER COLUMN season_number SET NOT NULL,
ALTER COLUMN episode_number SET NOT NULL,
ALTER COLUMN episode_key SET NOT NULL;
ALTER TABLE public.release_events
DROP COLUMN IF EXISTS item_id,
DROP COLUMN IF EXISTS kind;
-- +goose StatementEnd