From 5183a6af09a7123da61eac72f33c72550469d4ca Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 14:41:29 -0500 Subject: [PATCH] feat(notifications): durable notification lifecycle + minor-safe recipient resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../migrations/021_notification_events.sql | 44 ++++ platform/api/src/enrollment.js | 27 ++- platform/api/src/notifications.js | 189 ++++++++++++++++++ 3 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 platform/api/migrations/021_notification_events.sql create mode 100644 platform/api/src/notifications.js diff --git a/platform/api/migrations/021_notification_events.sql b/platform/api/migrations/021_notification_events.sql new file mode 100644 index 0000000..ffd8788 --- /dev/null +++ b/platform/api/migrations/021_notification_events.sql @@ -0,0 +1,44 @@ +-- 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'; diff --git a/platform/api/src/enrollment.js b/platform/api/src/enrollment.js index c9baa2d..c9a026a 100644 --- a/platform/api/src/enrollment.js +++ b/platform/api/src/enrollment.js @@ -88,7 +88,7 @@ async function createManualEnrollment(pool, { studentId, programId, status, star // machinery admin-created students already use (students-auth.js) — this endpoint is a // trusted, authenticated, server-to-server caller, the same trust level an instructor's // "add student" admin action already has, not a public signup form. -async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, studentEmail, studentLoginIdentifier, studentDisplayName, programId, status }) { +async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, studentEmail, studentLoginIdentifier, studentDisplayName, contactEmail, programId, status }) { if (!eventId) throw err('EVENT_ID_REQUIRED', 'event_id is required for idempotent enrollment events.'); const { rows: programRows } = await pool.query(`SELECT * FROM programs WHERE program_id = $1`, [programId]); const program = programRows[0]; @@ -104,14 +104,22 @@ async function createFromIntegrationEvent(pool, studentsAuth, { eventId, source, const student = await resolveOrCreateStudent(pool, studentsAuth, { studentEmail, studentLoginIdentifier, studentDisplayName }); const initialStatus = status === 'active' ? 'active' : 'pending'; + // §10 — contactEmail (an integration-event-supplied "who to notify about THIS enrollment," + // e.g. a guardian's address for a minor with no email of their own) is stored on the + // enrollment's existing metadata JSONB, never written to students.email — see + // enrollmentContactEmail() below and notifications.js's resolveRecipient, the only two places + // this value is ever read back out. Not a guardian account, not a linked identity — just a + // delivery address for this one enrollment's notifications. + const metadata = contactEmail ? { contact_email: String(contactEmail).trim() } : null; + try { const { rows } = await pool.query( - `INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_event_id, activated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `INSERT INTO enrollments (student_id, program_id, delivery_mode, program_version, curriculum_path, status, source, external_event_id, activated_at, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (external_event_id) WHERE external_event_id IS NOT NULL DO NOTHING RETURNING *`, [student.student_id, programId, program.delivery_mode, program.version, program.curriculum_path, initialStatus, - source || 'integration', eventId || null, initialStatus === 'active' ? new Date() : null] + source || 'integration', eventId || null, initialStatus === 'active' ? new Date() : null, metadata ? JSON.stringify(metadata) : null] ); if (rows[0]) return { enrollment: rows[0], created: true, student }; // Lost the ON CONFLICT race (concurrent identical replay) — the winner's row is now there. @@ -220,8 +228,17 @@ async function maybeCompleteEnrollment(pool, studentId, programId = null) { return completed; } +// Phase 5C §10 — the one place `enrollments.metadata.contact_email` is read back out (its only +// writer is createFromIntegrationEvent/createManualEnrollment above). Kept as a named accessor +// rather than inline `metadata->>'contact_email'` scattered across notifications.js so this +// transitional, non-identity field has exactly one documented meaning and one call site per +// direction (write here, read here). +function enrollmentContactEmail(enrollment) { + return enrollment?.metadata?.contact_email || null; +} + module.exports = { deriveEnrollmentAccess, getEnrollment, listEnrollmentsForStudent, listEnrollmentsForProgram, createManualEnrollment, createFromIntegrationEvent, activateEnrollment, cancelEnrollment, - maybeCompleteEnrollment, + maybeCompleteEnrollment, enrollmentContactEmail, }; diff --git a/platform/api/src/notifications.js b/platform/api/src/notifications.js new file mode 100644 index 0000000..362d869 --- /dev/null +++ b/platform/api/src/notifications.js @@ -0,0 +1,189 @@ +// Phase 5C — durable notification lifecycle: enqueue (fast, synchronous, no network I/O), +// claim (worker-side, concurrency-safe), and terminal-state transitions. +// +// Transient plaintext token handling: activation and password-reset emails need the ACTUAL +// token value to build a working link, but Phase 5A's invariant is that only the HASH is ever +// persisted long-term (students.credential_token_hash). The token is minted synchronously by +// whichever request triggers it (admin create/convert/reset-password, self-service +// forgot-password) — exactly as Phase 5A already does — and its plaintext is handed to +// enqueueWithToken() here, which stores it in this row's `metadata` ONLY until the worker +// reaches a terminal state (sent/failed), then scrubs it (see markSent/markFailedOrRetry). +// This is a deliberate, narrow exception to "hashed at rest" for the row that exists +// specifically to deliver the plaintext once — not the canonical credential store, which +// remains hash-only and untouched by any of this. It is never returned by the admin +// notification API regardless of whether it's still present (see toAdminView). + +const MAX_ATTEMPTS = 5; +const RETRYABLE_BACKOFF_BASE_SEC = 30; +const RETRYABLE_BACKOFF_MAX_SEC = 3600; + +function backoffSeconds(attemptCount) { + return Math.min(RETRYABLE_BACKOFF_BASE_SEC * 2 ** attemptCount, RETRYABLE_BACKOFF_MAX_SEC); +} + +// §10 — minor/no-email recipient resolution. A student's OWN email always wins when present. +// Otherwise, for an enrollment-scoped notification, fall back to that ENROLLMENT's contact +// email (enrollments.metadata.contact_email — see enrollment.js's enrollmentContactEmail) — +// deliberately never written to students.email, which stays reserved for the student's own +// identity. A password-reset/password-changed notification has no enrollment to fall back to +// and, correctly, no fallback recipient for a no-email student — those actions require the +// student to already be able to log in, which requires an email or a login_identifier they use +// directly, not a guardian contact. +async function resolveRecipient(pool, studentId, enrollmentId) { + const { rows: studentRows } = await pool.query(`SELECT email FROM students WHERE student_id = $1`, [studentId]); + if (studentRows[0]?.email) return studentRows[0].email; + if (enrollmentId) { + const { enrollmentContactEmail } = require('./enrollment'); + const { rows: enrollmentRows } = await pool.query(`SELECT metadata FROM enrollments WHERE enrollment_id = $1`, [enrollmentId]); + const contact = enrollmentContactEmail(enrollmentRows[0]); + if (contact) return contact; + } + return null; +} + +// Inserts a queued row. plaintextToken is optional (activation/password_reset only) and, if +// given, is the ONLY place its value is persisted anywhere — purged on the row's terminal +// state. A notification with no resolvable recipient is still recorded (never silently +// dropped, §13) but created already-failed, so the worker never wastes an attempt on it. +async function enqueue(pool, { studentId, enrollmentId, notificationType, plaintextToken, metadata }) { + const recipient = await resolveRecipient(pool, studentId, enrollmentId); + const meta = { ...(metadata || {}) }; + if (plaintextToken) meta.token = plaintextToken; + + if (!recipient) { + const { rows } = await pool.query( + `INSERT INTO notification_events (student_id, enrollment_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata) + VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`, + [studentId, enrollmentId || null, notificationType, JSON.stringify({})] // no token stored — nothing to deliver, nothing to leak + ); + return rows[0]; + } + const { rows } = await pool.query( + `INSERT INTO notification_events (student_id, enrollment_id, notification_type, recipient_email, metadata) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [studentId, enrollmentId || null, notificationType, recipient, JSON.stringify(meta)] + ); + return rows[0]; +} + +// Atomic claim: one UPDATE, no separate transaction/client management needed. FOR UPDATE SKIP +// LOCKED inside the subquery is what makes this safe if two worker processes ever poll +// concurrently — each gets a disjoint set of rows, never the same one twice. +async function claimNext(pool) { + const { rows } = await pool.query( + `UPDATE notification_events SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now() + WHERE notification_id = ( + SELECT notification_id FROM notification_events + WHERE status = 'queued' AND next_attempt_at <= now() + ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED + ) RETURNING *` + ); + return rows[0] || null; +} + +// devPreview is ONLY ever populated by the caller when both the dev provider was used AND +// production is off (see notification-worker.js) — it replaces the row's metadata entirely +// (the token is gone either way), so a dev/test run can recover exactly what would have been +// sent without a real inbox. Never populated, never present, in a production deployment — see +// the dedicated dev-only GET route this exists for (notification-routes.js), which itself +// refuses to run in production regardless of what's actually stored. +async function markSent(pool, notificationId, providerMessageId, provider, devPreview) { + await pool.query( + `UPDATE notification_events SET status = 'sent', sent_at = now(), updated_at = now(), + provider = $2, provider_message_id = $3, metadata = $4 + WHERE notification_id = $1`, + [notificationId, provider, providerMessageId || null, devPreview ? JSON.stringify({ dev_preview: devPreview }) : '{}'] + ); +} + +// Permanent failures (bad recipient, misconfiguration) skip straight to 'failed' on the first +// attempt — retrying can't fix them and would just burn the attempt budget. Temporary failures +// (network/provider hiccup) go back to 'queued' with bounded exponential backoff until +// MAX_ATTEMPTS is exhausted, then 'failed' too — never an infinite loop. +async function markFailedOrRetry(pool, row, { errorCode, retryable }) { + const exhausted = row.attempt_count >= MAX_ATTEMPTS; + if (retryable && !exhausted) { + await pool.query( + `UPDATE notification_events SET status = 'queued', next_attempt_at = now() + ($2 || ' seconds')::interval, + last_error_code = $3, updated_at = now() WHERE notification_id = $1`, + [row.notification_id, backoffSeconds(row.attempt_count), errorCode] + ); + return 'retrying'; + } + await pool.query( + `UPDATE notification_events SET status = 'failed', failed_at = now(), last_error_code = $2, + updated_at = now(), metadata = metadata - 'token' WHERE notification_id = $1`, + [row.notification_id, errorCode] + ); + return 'failed'; +} + +// §10/§5 — the shared decision point every enrollment-creation path (integration + admin +// manual enroll) calls right after a NEW enrollment is actually created (never on an idempotent +// replay — see integration-routes.js/admin-programs-routes.js, which only call this when their +// own creation call reported created:true). Reused for the admin "Resend Activation"/"Send +// Enrollment Confirmation" actions too, parameterized by isResend for audit-action naming only. +async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId, programName, isResend }) { + const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [studentId]); + const student = rows[0]; + if (!student) return null; + + if (student.password_hash) { + return enqueue(pool, { + studentId, enrollmentId, notificationType: 'enrollment_confirmation', + metadata: { display_name: student.display_name, program_name: programName }, + }); + } + const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation'); + return enqueue(pool, { + studentId, enrollmentId, notificationType: 'activation', plaintextToken: token, + metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend }, + }); +} + +async function listForStudent(pool, studentId) { + const { rows } = await pool.query(`SELECT * FROM notification_events WHERE student_id = $1 ORDER BY created_at DESC`, [studentId]); + return rows; +} +async function getById(pool, notificationId) { + const { rows } = await pool.query(`SELECT * FROM notification_events WHERE notification_id = $1`, [notificationId]); + return rows[0] || null; +} + +// Phase 5C §20 — the ONLY function that ever returns a dev preview (recipient/subject/text/ +// activation-or-reset URL), and only the caller (notification-routes.js's dedicated dev-only +// route) decides whether to expose it at all — this function itself does no production check, +// it just returns whatever's there, which is nothing at all once a real (non-dev) provider was +// used or the row has moved past 'sent' via markFailedOrRetry (which never writes dev_preview). +async function getDevPreview(pool, notificationId) { + const row = await getById(pool, notificationId); + return row?.metadata?.dev_preview || null; +} + +// §16 — safe display: the local part is truncated, never the full address, and this is the +// ONLY shape any admin API ever returns for recipient_email. Nothing this module exports ever +// hands back an unmasked address or the metadata column at all. +function maskEmail(email) { + if (!email) return null; + const [local, domain] = email.split('@'); + if (!domain) return '***'; + const visible = local.slice(0, 1); + return `${visible}${'*'.repeat(Math.max(local.length - 1, 2))}@${domain}`; +} + +// The one and only shape returned by any notification-related API — deliberately excludes +// `metadata` entirely (which is where a still-in-flight token transiently lives) and masks the +// recipient address. +function toAdminView(row) { + return { + notification_id: row.notification_id, student_id: row.student_id, enrollment_id: row.enrollment_id, + notification_type: row.notification_type, channel: row.channel, recipient_email_masked: maskEmail(row.recipient_email), + provider: row.provider, status: row.status, attempt_count: row.attempt_count, last_error_code: row.last_error_code, + created_at: row.created_at, sent_at: row.sent_at, failed_at: row.failed_at, + }; +} + +module.exports = { + resolveRecipient, enqueue, claimNext, markSent, markFailedOrRetry, notifyForEnrollment, + listForStudent, getById, getDevPreview, maskEmail, toAdminView, MAX_ATTEMPTS, +};