- 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).
181 lines
12 KiB
JavaScript
181 lines
12 KiB
JavaScript
// Phase 5B §11 — instructor/admin program and enrollment management. Program authoring
|
|
// (create/edit) is admin/owner only, matching assessment_definitions' existing convention;
|
|
// every role can list/inspect (matches AssessmentsView's "viewed by all roles, only authoring
|
|
// restricted" pattern). Enrollment creation is admin/owner only (matches roster-creation
|
|
// precedent — POST /api/admin/students is the same restriction); a plain instructor can still
|
|
// view/activate/cancel an enrollment they created or that belongs to a student sharing one of
|
|
// their cohorts, via rbac.js's requireEnrollmentAccess — see that function's own comment for
|
|
// the exact documented rule.
|
|
|
|
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) {
|
|
if (code === 'NOT_FOUND') return 404;
|
|
if (code === 'PROGRAM_NOT_FOUND') return 404;
|
|
if (code === 'ALREADY_ENROLLED' || code === 'INVALID_STATE' || code === 'PROGRAM_INACTIVE') return 409;
|
|
return 500;
|
|
}
|
|
|
|
module.exports = function registerAdminProgramsRoutes(app, deps) {
|
|
const { instructorSession, rbac, curriculum, getPool, loadProgress, recordAudit } = deps;
|
|
const { requireAnyInstructor, requireAdminOrOwner, requireEnrollmentAccess } = rbac;
|
|
const statusDeps = { pool: getPool(), curriculum, loadProgress };
|
|
|
|
// Local wrap, same convention as admin-routes.js/admin-classroom-routes.js (NOT the stricter
|
|
// server.js-level `wrap`, which assumes fn always returns a promise — RBAC middleware like
|
|
// requireAnyInstructor is a plain sync `(req,res,next)` function, so it needs the
|
|
// Promise.resolve() guard here to be safely mixed into the same mw.map(wrap) list as the
|
|
// async route handlers).
|
|
function wrap(fn) {
|
|
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
|
}
|
|
const admin = {
|
|
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
|
|
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
|
|
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
|
|
};
|
|
|
|
// ── Programs ─────────────────────────────────────────────────────────────────────────────
|
|
admin.get('/api/admin/programs', requireAnyInstructor, async (req, res) => {
|
|
res.json(await programsLib.listPrograms(getPool()));
|
|
});
|
|
|
|
admin.get('/api/admin/programs/:id', requireAnyInstructor, async (req, res) => {
|
|
const program = await programsLib.getProgram(getPool(), req.params.id);
|
|
if (!program) return res.status(404).json({ error: 'not found' });
|
|
const { rows: statusCounts } = await getPool().query(
|
|
`SELECT status, count(*)::int AS n FROM enrollments WHERE program_id = $1 GROUP BY status`, [req.params.id]
|
|
);
|
|
res.json({ ...program, enrollment_counts: Object.fromEntries(statusCounts.map((r) => [r.status, r.n])) });
|
|
});
|
|
|
|
admin.post('/api/admin/programs', requireAdminOrOwner, async (req, res) => {
|
|
const { program_id, name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours } = req.body || {};
|
|
if (!program_id || !name || !family) return res.status(400).json({ error: 'program_id, name, and family are required' });
|
|
if (!['init', 'compile', 'root'].includes(family)) return res.status(400).json({ error: 'family must be init, compile, or root' });
|
|
try {
|
|
const program = await programsLib.createProgram(getPool(), {
|
|
programId: program_id, name, description, family, deliveryMode: delivery_mode, curriculumPath: curriculum_path,
|
|
completionAssessmentId: completion_assessment_id, estimatedDurationHours: estimated_duration_hours, createdBy: req.instructor.instructor_id,
|
|
});
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'program_created', resourceType: 'program', resourceId: program.program_id, result: 'success', sourceIp: req.ip });
|
|
res.status(201).json(program);
|
|
} catch (e) {
|
|
if (e.code === '23505') return res.status(409).json({ error: 'program_id already exists' });
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
admin.patch('/api/admin/programs/:id', requireAdminOrOwner, async (req, res) => {
|
|
const { name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, status } = req.body || {};
|
|
const map = { name, description, family, delivery_mode, curriculum_path, completion_assessment_id, estimated_duration_hours, status };
|
|
const fields = {};
|
|
for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v;
|
|
if (!Object.keys(fields).length) return res.status(400).json({ error: 'nothing to update' });
|
|
const program = await programsLib.updateProgram(getPool(), req.params.id, fields);
|
|
if (!program) return res.status(404).json({ error: 'not found' });
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'program_updated', resourceType: 'program', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: fields });
|
|
res.json(program);
|
|
});
|
|
|
|
// ── Enrollments ──────────────────────────────────────────────────────────────────────────
|
|
// Scoped list, same shape as GET /api/admin/students: owner/admin see every enrollment for
|
|
// the program; a plain instructor sees only the ones they created or that belong to a
|
|
// student sharing one of their cohorts (the same rule requireEnrollmentAccess enforces for
|
|
// single-resource routes, applied here as a query filter instead of a per-row check).
|
|
admin.get('/api/admin/programs/:id/enrollments', requireAnyInstructor, async (req, res) => {
|
|
const pool = getPool();
|
|
const { rows } = req.instructor.role === 'instructor'
|
|
? await pool.query(
|
|
`SELECT DISTINCT e.*, s.display_name AS student_display_name FROM enrollments e
|
|
JOIN students s ON s.student_id = e.student_id
|
|
LEFT JOIN cohort_enrollments ce ON ce.student_id = e.student_id AND ce.enrollment_status = 'enrolled'
|
|
LEFT JOIN cohort_instructors ci ON ci.cohort_id = ce.cohort_id AND ci.instructor_id = $2
|
|
WHERE e.program_id = $1 AND (e.created_by = $2 OR ci.instructor_id IS NOT NULL)
|
|
ORDER BY e.enrolled_at DESC`,
|
|
[req.params.id, req.instructor.instructor_id])
|
|
: await pool.query(
|
|
`SELECT e.*, s.display_name AS student_display_name FROM enrollments e
|
|
JOIN students s ON s.student_id = e.student_id WHERE e.program_id = $1 ORDER BY e.enrolled_at DESC`,
|
|
[req.params.id]);
|
|
res.json(rows);
|
|
});
|
|
|
|
admin.post('/api/admin/enrollments', requireAdminOrOwner, async (req, res) => {
|
|
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 {
|
|
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: 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 } });
|
|
} 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) => {
|
|
const row = await enrollmentLib.getEnrollment(getPool(), req.params.id);
|
|
res.json(await buildProgramStatus(statusDeps, row));
|
|
});
|
|
|
|
admin.post('/api/admin/enrollments/:id/activate', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
|
|
try {
|
|
const updated = await enrollmentLib.activateEnrollment(getPool(), req.params.id);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_activated', resourceType: 'enrollment', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
|
res.json(updated);
|
|
} catch (e) {
|
|
res.status(enrollmentErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
|
}
|
|
});
|
|
|
|
admin.post('/api/admin/enrollments/:id/cancel', requireAnyInstructor, requireEnrollmentAccess('id'), async (req, res) => {
|
|
try {
|
|
const updated = await enrollmentLib.cancelEnrollment(getPool(), req.params.id);
|
|
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_cancelled', resourceType: 'enrollment', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
|
res.json(updated);
|
|
} catch (e) {
|
|
res.status(enrollmentErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
|
}
|
|
});
|
|
|
|
// §11 — extends the existing student detail view: enrolled programs, status, progress,
|
|
// current stage, last activity, completion, delivery mode. Scoped the same way the
|
|
// enrollments-list route above is (owner/admin see everything; a plain instructor sees only
|
|
// enrollments they created or that share a cohort with them) rather than requiring
|
|
// requireStudentCohortAccess, which would 403 for a self-paced-only student with no cohort at
|
|
// all even for the instructor who manually enrolled them.
|
|
admin.get('/api/admin/students/:id/enrollments', requireAnyInstructor, async (req, res) => {
|
|
const pool = getPool();
|
|
const { rows } = req.instructor.role === 'instructor'
|
|
? await pool.query(
|
|
`SELECT DISTINCT e.* FROM enrollments e
|
|
LEFT JOIN cohort_enrollments ce ON ce.student_id = e.student_id AND ce.enrollment_status = 'enrolled'
|
|
LEFT JOIN cohort_instructors ci ON ci.cohort_id = ce.cohort_id AND ci.instructor_id = $2
|
|
WHERE e.student_id = $1 AND (e.created_by = $2 OR ci.instructor_id IS NOT NULL)
|
|
ORDER BY e.enrolled_at DESC`,
|
|
[req.params.id, req.instructor.instructor_id])
|
|
: await pool.query(`SELECT * FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC`, [req.params.id]);
|
|
const views = await Promise.all(rows.map((e) => buildProgramStatus(statusDeps, e)));
|
|
res.json(views);
|
|
});
|
|
};
|