Files
rootnode-academy/platform/api/src/notifications.js
T
ced4568 16622bd9ab feat(webhook): add verified payment webhook route and website checkout/agreement contract
- 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)
2026-08-27 09:05:29 -05:00

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,
};