- 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.
45 lines
3.1 KiB
SQL
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';
|