feat(activation): wire notifications into enrollment/auth, real password-reset delivery
- integration-routes.js: accepts student.contact_email, calls notifyForEnrollment on a genuinely new enrollment only (never on an idempotent replay — inherits Phase 5B's proven enrollment-level idempotency, no separate notification dedup needed). - admin-routes.js: create-student/convert/reset-password now enqueue the corresponding notification using the SAME token already minted synchronously (never mints twice, which would silently invalidate the token just handed back in the response). Fixed a real bug: request-password-reset would mint a reset token for a never-activated student, which makes no sense and would confuse them — now falls through to the generic anti-enumeration response. - admin-programs-routes.js: manual enrollment triggers the same notification decision as the website path. - student-auth-routes.js: /activate records a metric; /change-password and /reset-password both queue a password_changed security notice; /request-password-reset now actually delivers email (production still never returns the token — unchanged anti-enumeration/isProduction gating, just now with a real send behind it). - rbac.js: requireStudentRelationshipAccess/checkStudentRelationshipAccess — the notification visibility rule (cohort-shared or an enrollment they created), reusable beyond just enrollments. - server.js: wires notification-routes.js and starts the notification worker in main(). - docker-compose.dev.yml: PUBLIC_APP_ORIGIN, EMAIL_FROM_NAME, and a parametrized COOKIE_SECURE (was hard-commented-out; needed so e2e-phase5c.js can actually prove production gating).
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
|
||||
const enrollment = require('./enrollment');
|
||||
const studentsAuth = require('./students-auth');
|
||||
const notifications = require('./notifications');
|
||||
const programsLib = require('./programs');
|
||||
const metrics = require('./metrics');
|
||||
|
||||
const INTEGRATION_SHARED_SECRET = process.env.INTEGRATION_SHARED_SECRET;
|
||||
@@ -47,6 +49,10 @@ module.exports = function registerIntegrationRoutes(app, deps) {
|
||||
const { event_id, source, student, program_slug, status } = req.body || {};
|
||||
const email = student?.email ? String(student.email).trim() : null;
|
||||
const loginIdentifier = student?.login_identifier ? String(student.login_identifier).trim() : null;
|
||||
// Phase 5C §10 — an OPTIONAL enrollment-contact address, for a minor with no email of their
|
||||
// own (e.g. a guardian's address collected at checkout). Never treated as the student's own
|
||||
// identity — see enrollment.js's enrollmentContactEmail.
|
||||
const contactEmail = student?.contact_email ? String(student.contact_email).trim() : null;
|
||||
const mappedStatus = STATUS_MAP[status];
|
||||
|
||||
if (!event_id || typeof event_id !== 'string') {
|
||||
@@ -70,7 +76,7 @@ module.exports = function registerIntegrationRoutes(app, deps) {
|
||||
try {
|
||||
result = await enrollment.createFromIntegrationEvent(getPool(), studentsAuth, {
|
||||
eventId: event_id, source: source || 'integration', studentEmail: email, studentLoginIdentifier: loginIdentifier,
|
||||
studentDisplayName: student?.display_name, programId: program_slug, status: mappedStatus,
|
||||
studentDisplayName: student?.display_name, contactEmail, programId: program_slug, status: mappedStatus,
|
||||
});
|
||||
} catch (e) {
|
||||
metrics.recordEnrollmentEvent('rejected');
|
||||
@@ -88,6 +94,18 @@ module.exports = function registerIntegrationRoutes(app, deps) {
|
||||
metadata: { event_id, program_slug, student_id: result.enrollment.student_id },
|
||||
});
|
||||
|
||||
// Phase 5C §11/§12 — notification decision, ONLY on a genuinely new enrollment
|
||||
// (result.created), never on an idempotent replay. This is the entire duplicate-webhook
|
||||
// safety net: the enrollment's own DB-level idempotency (proven in Phase 5B) already
|
||||
// guarantees at most one caller ever sees created:true for a given event/race, so at most
|
||||
// one notification is ever queued, with no separate notification-level dedup needed.
|
||||
if (result.created) {
|
||||
const program = await programsLib.getProgram(getPool(), program_slug);
|
||||
await notifications.notifyForEnrollment(getPool(), studentsAuth, {
|
||||
studentId: result.enrollment.student_id, enrollmentId: result.enrollment.enrollment_id, programName: program?.name,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(result.created ? 201 : 200).json({
|
||||
enrollment_id: result.enrollment.enrollment_id,
|
||||
student_id: result.enrollment.student_id,
|
||||
|
||||
Reference in New Issue
Block a user