- webhook-routes.js: POST /api/webhooks/payment — signature-verified, idempotent, always 200 once verified (a forged/invalid signature is a clean quiet 400) - integration-routes.js: POST /api/integrations/registrations/:id/agreements (closes Phase 8's gap — agreement acceptance now on the public registration contract), POST .../:id/checkout (server-to-server, amount always server-side), GET .../:id/status (minimal safe status lookup for the website's success page — no PII, no amount, no provider reference) - public-offerings-routes.js: price_amount/price_currency/requires_payment in the public serializer - metrics.js: checkout/payment/webhook/conversion/seat-released counters, all low-cardinality - notifications.js: listForRegistration now unions by the registration's own enrollment_id too, so the post-conversion activation notification shows up in the registration's communication history (previously only pre-conversion notifications were visible)
267 lines
15 KiB
JavaScript
267 lines
15 KiB
JavaScript
// 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,
|
|
};
|