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