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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user