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)
This commit is contained in:
@@ -183,10 +183,16 @@ async function notifyForEnrollment(pool, studentsAuth, { studentId, enrollmentId
|
||||
});
|
||||
}
|
||||
const { token } = await studentsAuth.issueCredentialToken(pool, studentId, 'activation');
|
||||
return enqueue(pool, {
|
||||
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) {
|
||||
@@ -195,8 +201,23 @@ async function listForStudent(pool, studentId) {
|
||||
}
|
||||
// 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 } = await pool.query(`SELECT * FROM notification_events WHERE registration_id = $1 ORDER BY created_at DESC`, [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) {
|
||||
|
||||
Reference in New Issue
Block a user