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 policy — WITH 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
- 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.
- 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”.
- 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.
- 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.