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:
2026-08-25 14:42:34 -05:00
parent 9d382c95c5
commit 3c7f0ca59c
7 changed files with 140 additions and 22 deletions
+17 -4
View File
@@ -9,6 +9,8 @@
const programsLib = require('./programs');
const enrollmentLib = require('./enrollment');
const notificationsLib = require('./notifications');
const studentsAuth = require('./students-auth');
const { buildProgramStatus } = require('./program-status');
function enrollmentErrorStatus(code) {
@@ -104,19 +106,30 @@ module.exports = function registerAdminProgramsRoutes(app, deps) {
});
admin.post('/api/admin/enrollments', requireAdminOrOwner, async (req, res) => {
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, metadata } = req.body || {};
const { student_id, program_id, status, starts_at, access_ends_at, delivery_mode, external_reference, contact_email, metadata } = req.body || {};
if (!student_id || !program_id) return res.status(400).json({ error: 'student_id and program_id are required' });
// Phase 5C §10 — same transitional contact-email convention as the integration endpoint;
// folded into the same metadata JSONB an admin could already pass, not a new column.
const mergedMetadata = contact_email ? { ...(metadata || {}), contact_email: String(contact_email).trim() } : metadata;
let created;
try {
const created = await enrollmentLib.createManualEnrollment(getPool(), {
created = await enrollmentLib.createManualEnrollment(getPool(), {
studentId: student_id, programId: program_id, status, startsAt: starts_at, accessEndsAt: access_ends_at,
deliveryMode: delivery_mode, externalReference: external_reference, metadata, createdBy: req.instructor.instructor_id,
deliveryMode: delivery_mode, externalReference: external_reference, metadata: mergedMetadata, createdBy: req.instructor.instructor_id,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_created', resourceType: 'enrollment', resourceId: created.enrollment_id, result: 'success', sourceIp: req.ip, metadata: { student_id, program_id } });
res.status(201).json(created);
} catch (e) {
if (e.code) return res.status(enrollmentErrorStatus(e.code)).json({ error: e.code, message: e.message });
throw e;
}
// §11 — an admin-created enrollment notifies exactly like a website-triggered one: activation
// for a not-yet-credentialed student, enrollment confirmation for an already-active one.
const program = await programsLib.getProgram(getPool(), program_id);
const notification = await notificationsLib.notifyForEnrollment(getPool(), studentsAuth, {
studentId: student_id, enrollmentId: created.enrollment_id, programName: program?.name,
});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: `notification_queued_${notification?.notification_type || 'unknown'}`, resourceType: 'notification', resourceId: notification?.notification_id, result: 'success', sourceIp: req.ip });
res.status(201).json(created);
});
admin.get('/api/admin/enrollments/:id', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {