Files
rootnode-academy/platform/api/migrations/021_notification_events.sql
T
ced4568 5183a6af09 feat(notifications): durable notification lifecycle + minor-safe recipient resolution
- migrations/021: notification_events (queued/sending/sent/failed/cancelled, bounded
  exponential backoff via attempt_count/next_attempt_at, a classified last_error_code, never a
  raw stack trace or provider credential).
- notifications.js: enqueue (records a no-recipient case as already-failed rather than
  silently dropping it), claimNext (FOR UPDATE SKIP LOCKED — safe if two worker processes ever
  run), markSent/markFailedOrRetry (bounded retry, terminal-state token scrubbing),
  notifyForEnrollment (the shared activation-vs-confirmation decision every enrollment-creation
  path calls), maskEmail/toAdminView (the only shapes any admin API ever returns).
- enrollment.js: enrollmentContactEmail() — reads back the minor/no-email transitional
  contact-email convention (stored in the existing enrollments.metadata JSONB, never written to
  students.email) that createFromIntegrationEvent/createManualEnrollment now accept.
2026-08-25 14:41:29 -05:00

45 lines
3.1 KiB
SQL

-- Phase 5C §4 — durable notification lifecycle. Every "we should email this student" moment
-- (activation, enrollment confirmation, password reset, password-changed) becomes a row here
-- FIRST, committed in the same transaction/request as the business action, and a separate
-- worker (notification-worker.js) is what actually talks to the email provider — the HTTP
-- request that triggers a notification never waits on outbound email network calls.
--
-- recipient_email is a SNAPSHOT taken at enqueue time, not a live join to students.email —
-- documented privacy note: this duplicates PII already stored on `students`/in an enrollment's
-- metadata (the minor/no-email contact address, see 020_enrollments.sql's `metadata` column,
-- Phase 5C's `contact_email` convention) purely so the worker and any later delivery audit
-- don't need to reconstruct "what address would this have gone to" after the fact — same
-- sensitivity class as data already in this database, not a new exposure. Never rendered
-- unmasked through admin APIs (see notifications.js's maskEmail).
--
-- metadata MAY transiently hold plaintext activation/reset token material between enqueue and
-- the worker's terminal (sent/failed) update — see notifications.js's header comment for the
-- exact reasoning. It is scrubbed the moment a row reaches a terminal state. NEVER a raw error
-- stack trace, NEVER a Google credential, NEVER a full provider payload.
CREATE TABLE IF NOT EXISTS notification_events (
notification_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
student_id TEXT NOT NULL REFERENCES students(student_id),
enrollment_id UUID REFERENCES enrollments(enrollment_id),
notification_type TEXT NOT NULL CHECK (notification_type IN ('activation', 'enrollment_confirmation', 'password_reset', 'password_changed')),
channel TEXT NOT NULL DEFAULT 'email' CHECK (channel IN ('email')),
recipient_email TEXT,
provider TEXT,
provider_message_id TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sending', 'sent', 'failed', 'cancelled')),
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error_code TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB
);
CREATE INDEX IF NOT EXISTS idx_notification_events_student ON notification_events(student_id);
CREATE INDEX IF NOT EXISTS idx_notification_events_enrollment ON notification_events(enrollment_id);
CREATE INDEX IF NOT EXISTS idx_notification_events_status ON notification_events(status);
-- The worker's claim query — partial index scoped to exactly the rows it ever selects from.
CREATE INDEX IF NOT EXISTS idx_notification_events_claimable ON notification_events(next_attempt_at) WHERE status = 'queued';