// 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; } // Phase 8 §9 — prefer the real guardian model (Phase 7) over nothing: a no-email student with // no enrollment-level contact_email but a real, communications-enabled primary guardian still // gets notified. Tried last, after both of the above, so an existing student.email or the // established enrollment.metadata.contact_email always wins first — this is purely a new // fallback, never a behavior change for any student who already resolves correctly today. const { rows: guardianRows } = await pool.query( `SELECT g.email FROM student_guardians sg JOIN guardians g ON g.guardian_id = sg.guardian_id WHERE sg.student_id = $1 AND sg.can_receive_communications = true AND g.email IS NOT NULL ORDER BY sg.is_primary_contact DESC LIMIT 1`, [studentId] ); if (guardianRows[0]?.email) return guardianRows[0].email; return null; } // Phase 8 — the registration-stage counterpart to enqueue() above, for a notification that may // need to go out BEFORE any student row exists at all (a brand-new guardian-submitted // registration). Recipient resolution order: the registration's own student (if already // converted — reuses resolveRecipient/the guardian fallback above), else its linked guardian, // else the registrant's own supplied email. Never creates a second notification pipeline — // same notification_events table, same worker, same templates module. async function resolveRegistrationRecipient(pool, registration) { if (registration.student_id) { const viaStudent = await resolveRecipient(pool, registration.student_id, registration.enrollment_id); if (viaStudent) return viaStudent; } if (registration.guardian_id) { const { rows } = await pool.query(`SELECT email FROM guardians WHERE guardian_id = $1`, [registration.guardian_id]); if (rows[0]?.email) return rows[0].email; } return registration.registrant_email || null; } async function enqueueForRegistration(pool, { registration, notificationType, metadata }) { const recipient = await resolveRegistrationRecipient(pool, registration); if (!recipient) { const { rows } = await pool.query( `INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, status, last_error_code, failed_at, metadata) VALUES ($1, $2, $3, NULL, 'failed', 'no_recipient_address', now(), $4) RETURNING *`, [registration.student_id || null, registration.registration_id, notificationType, JSON.stringify({})] ); return rows[0]; } const { rows } = await pool.query( `INSERT INTO notification_events (student_id, registration_id, notification_type, recipient_email, metadata) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [registration.student_id || null, registration.registration_id, notificationType, recipient, JSON.stringify(metadata || {})] ); return rows[0]; } // 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'); const notification = await enqueue(pool, { studentId, enrollmentId, notificationType: 'activation', plaintextToken: token, metadata: { display_name: student.display_name, program_name: programName, resend: !!isResend }, }); // Phase 9 — the raw token is attached here (never persisted beyond the notification row's own // transient metadata, same as always) purely so a caller that itself needs to hand the // student a working activation link right now (offerings.js's registration-conversion flow, // matching the trust level POST /api/admin/students already has) doesn't have to re-issue a // second token that would silently invalidate this one. return { ...notification, _activationToken: token }; } 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; } // Phase 8 — every notification tied to a registration, including pre-conversion ones with no // student_id yet at all. // Phase 9 — a registration's full communication history spans two eras: pre-conversion // (registration_received/confirmed/needs_action, agreement_required — keyed by registration_id, // no student yet) and post-conversion (the activation/enrollment_confirmation email // notifyForEnrollment sends — keyed by student_id/enrollment_id like every other enrollment, // never by registration_id, since it isn't a registration-specific concept). Both belong in one // admin-visible timeline for the SAME registration, so this reads the registration row itself // to find its (possibly still-null) student_id/enrollment_id and unions both sources. async function listForRegistration(pool, registrationId) { const { rows: regRows } = await pool.query(`SELECT student_id, enrollment_id FROM offering_registrations WHERE registration_id = $1`, [registrationId]); const reg = regRows[0]; const { rows } = await pool.query( `SELECT * FROM notification_events WHERE registration_id = $1 OR (enrollment_id IS NOT NULL AND enrollment_id = $2) ORDER BY created_at DESC`, [registrationId, reg?.enrollment_id || null] ); 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, registration_id: row.registration_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, enqueueForRegistration, listForRegistration, };