# Why V2 — improvements over the Video Library notification system

This service replaces the in-app notification stack that lived inside Video Library (totumDashboard): the user_notifications table, the receive-review-notification edge function, and the notificationService singleton behind the header bell. That system worked, but it was a single-app feature with documented gaps (see the V1 doc’s Known gaps and improvement opportunities). V2 is a redesign, not a port — this page maps every V1 gap to how V2 addresses it, and is honest about what V2 deliberately does not change.

# The one-paragraph version

V1 was a Supabase-embedded feature of one app, writable by one producer over a shared API key, with a permissive insert policy, review-tool-specific dedupe logic baked into the ingest path, a hard 50-row limit, realtime that silently never worked, and a client that leaked channels on logout. V2 is a standalone service with one trusted write path, real OAuth for producers, generic idempotency, pagination, an enforced OpenAPI contract, a reliability-hardened npm client, ≥90% test coverage, and a fully automated release pipeline — usable by any Inovus app, not just Video Library.

# Gap-by-gap comparison

# Security and correctness

V1 problem V2
Open INSERT policyWITH CHECK (true) RLS meant anything holding the anon key could insert notifications for any user RLS is enabled with no policies (deny-all). Only the Worker’s service role touches the table; there is exactly one trusted write path
Shared API key (x-api-key = REVIEW_TOOL_API_KEY/INGEST_API_KEY) — a static secret duplicated across two projects, no identity, no rotation story Producers authenticate with Cognito OAuth tokens (client-credentials scope notifications/publish, or group membership for user tokens). Rotation, revocation, and audit come from Cognito; no shared secrets exist
Producer identity on trust — nothing recorded who wrote a row source is derived from the token (client_id/sub) and stored on every notification. A producer cannot spoof another
GUID alignment risk — webhook user_guid vs Cognito sub mismatches silently produced invisible rows One identity model: the inbox key is the verified JWT sub (claim configurable). Producers address the same value consumers authenticate with
activity_instance_id accepted but silently dropped — payload contract lied Strict validation: unknown fields are rejected (400), and arbitrary producer context has a real home in metadata (JSONB, ≤8 KB)
Check-then-insert dedupe — two concurrent webhook deliveries could double-notify Dedupe is race-safe at the database: a partial unique index on (user_guid, source, dedupe_key) with on_conflict resolution

# Reusability and design

V1 problem V2
Single-app, single-producer — creation logic, flag names, and styling all lived in Video Library; Assess had to mirror-write into VL’s private table Standalone service with its own domain (notifications.totumcloud.com). Any app publishes with a token; any app consumes with the npm client. No app owns the table
Review-tool semantics in the substrate — dedupe was special-cased on related_entity_type === 'review_case' Generic dedupeKey chosen by the producer (e.g. review_case:{id}). The service knows nothing about review cases; invite/mention/any future producers get idempotency for free
Two half-used columns (related_entity_type, related_entity_id) Replaced by dedupeKey + free-form metadata
Supabase-coupled — client spoke PostgREST directly with the anon key; auth was Supabase-JWT-shaped DB-agnostic NotificationStore interface (Supabase adapter today, memory adapter for dev/tests; D1/Postgres are one file away). Consumers speak a stable HTTP API, never the database
No API contract — behaviour documented only in prose OpenAPI 3.1 generated from the code, served at /docs and /openapi.json, snapshot committed and diffed in CI so contract changes are visible in review. Errors are standard RFC 9457 problem+json

# Reliability and client lifecycle

V1 problem V2
Realtime silently broken — the table was never added to the supabase_realtime publication; the “backstop” 30 s poll was the actual mechanism Honest design: polling is the mechanism, made cheap with ETag/If-None-Match (unchanged polls are 304s). No infrastructure that pretends to work. SSE can slot behind the same subscribe() API later
cleanup() never called — channels and timers leaked across logins subscribe() returns an unsubscribe function; the React hook tears down automatically on unmount. There is no global singleton to forget
Hard limit of 50, no pagination Keyset cursor pagination (default 50, max 200), plus since for cheap delta polls and unreadOnly filtering
No retry/timeout handling — every consumer re-implemented (or skipped) error handling; fresh Supabase client per operation The client ships retries with exponential backoff + jitter, Retry-After support, per-request timeouts, one-shot token refresh on 401, and typed errors. Publishing retries safely because dedupeKey makes it idempotent
Full reload on any table event, no user filter Every read is user-scoped at the store interface; polls return only the caller’s page, 304 when unchanged
Unbounded table growth Retention cron purges rows older than RETENTION_DAYS (default 90)

# Quality and operations

V1 problem V2
No automated tests documented for the notification path; a manual test plan 129 automated tests, ≥90% coverage enforced in CI (auth matrix with signed JWTs, store contract tests, route e2e, client retry/polling, React hook)
Manual deploys (supabase functions deploy … --no-verify-jwt, manual secret sync between two projects) Zero-touch pipeline: changesets → npm publish with provenance → migrate + deploy staging → smoke test → migrate + deploy production
Docs drift — schema/edge-function/flag docs disagreed; one big doc was the source of truth after the fact Docs that can’t drift: the OpenAPI spec is generated from code and CI fails if the snapshot is stale; compile-time assertions keep client types locked to server schemas
Config spread across two Supabase projects + app env One deployment configured entirely by env vars/secrets on the Worker (README table)

# What deliberately stayed the same

  • The mental model: a per-user inbox keyed by Cognito sub, with title/body/severity/actionUrl, unread badge, mark-read/mark-all/delete. V1 got this right.
  • Severity values (info/success/warning/error) — same union, so UI icon mapping carries over.
  • ~30 s polling cadence as the default freshness level (now configurable per subscriber).
  • Hard delete, no archive/snooze/preferences — no product demand yet (YAGNI).
  • 200-with-duplicate: true semantics for idempotent publishes, matching V1’s webhook behaviour so producer logic ports directly.

# What V2 does not do (yet, by choice)

  • No push/SSE realtime — updates use ~30 s ETag polling (configurable). Add Durable Objects SSE behind subscribe() when instant delivery is a real requirement.
  • No email/SMS channels, no notification preferences, no batch publish endpoint.
  • No UI components — toast rendering, bell/dropdown, and action-URL navigation stay app-owned; the client provides the data and lifecycle. (V1’s dropdown was hard-coded to Totum branding; extracting it was rejected in favour of keeping presentation in apps.)

# Migrating a V1 producer/consumer

  1. Producer (Assess): swap the x-api-key webhook call for client.publish(...) with an M2M token and dedupeKey: 'review_case:' + caseId. Response semantics are unchanged.
  2. Consumer (Video Library): replace notificationService + manual Supabase wiring with NotificationsClient / useNotifications, injecting the existing getValidAccessToken. Call the returned unsubscribe on logout — the V1 leak fix is just “use the return value”.
  3. V1-shaped self-notify calls (notification.success(title, message, url?)): configure once with createNotificationService(client) and re-export from your app’s lib/notificationService. Keep toast.* in the app — this package does not own toasts.
  4. Once both sides are switched, decommission receive-review-notification, the VL user_notifications table, and the shared REVIEW_TOOL_API_KEY/INGEST_API_KEY secrets.