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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user