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.
This commit is contained in:
2026-08-25 14:41:29 -05:00
parent 1c08a30c7f
commit 5183a6af09
3 changed files with 255 additions and 5 deletions
+22 -5
View File
@@ -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,
};