// Phase 5C §15/§16 — instructor/admin communication operations. Global notification visibility // is owner/admin only (§16: "Do not grant global notification visibility to every instructor"); // a plain instructor can still see/act on notifications for a SPECIFIC student they're // authorized to manage via rbac.js's requireStudentRelationshipAccess (cohort-shared or an // enrollment they created — the same rule admin-programs-routes.js already established for // enrollment visibility). Every response here goes through notifications.js's toAdminView, // which masks the recipient address and omits `metadata` entirely — no token, no full email, // ever, through any route in this file. const notificationsLib = require('./notifications'); const enrollmentLib = require('./enrollment'); const programsLib = require('./programs'); const studentsAuth = require('./students-auth'); const { parsePagination, paginatedResponse } = require('./pagination'); module.exports = function registerNotificationRoutes(app, deps) { const { instructorSession, rbac, getPool, recordAudit } = deps; const { requireAdminOrOwner, requireStudentRelationshipAccess } = rbac; 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)), }; // Global operational view — owner/admin only. admin.get('/api/admin/notifications', requireAdminOrOwner, async (req, res) => { const pool = getPool(); const { page, pageSize, offset } = parsePagination(req); const [{ rows }, { rows: countRows }] = await Promise.all([ pool.query(`SELECT * FROM notification_events ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset]), pool.query(`SELECT count(*)::int AS n FROM notification_events`), ]); res.json(paginatedResponse(rows.map(notificationsLib.toAdminView), countRows[0].n, { page, pageSize })); }); // Per-student view — the scoped surface a plain instructor can actually reach. admin.get('/api/admin/students/:id/notifications', requireStudentRelationshipAccess('id'), async (req, res) => { const rows = await notificationsLib.listForStudent(getPool(), req.params.id); res.json(rows.map(notificationsLib.toAdminView)); }); // §20 — dev/test ONLY: lets a test script (or a developer) see exactly what the dev provider // would have sent, including the activation/reset URL, without a real inbox. Unconditionally // refuses in production regardless of what's stored (mirrors student-auth-routes.js's // dev_reset_token gate on the same COOKIE_SECURE-derived signal) — this is never how an // admin inspects a real delivery, only how a dev/CI run verifies one. admin.get('/api/admin/notifications/:id/dev-preview', requireAdminOrOwner, async (req, res) => { if (process.env.COOKIE_SECURE === '1') return res.status(404).json({ error: 'not_found' }); const preview = await notificationsLib.getDevPreview(getPool(), req.params.id); if (!preview) return res.status(404).json({ error: 'no_preview_available' }); res.json(preview); }); // §15 — one action covers both "resend" and "regenerate": Phase 5A's issueCredentialToken // already unconditionally invalidates any previous outstanding token on mint, so there is // never a moment with two simultaneously valid activation links — a second button that did // something different would have nothing different to do. See // docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6 for the full reasoning. admin.post('/api/admin/students/:id/resend-activation', requireStudentRelationshipAccess('id'), async (req, res) => { const pool = getPool(); const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]); if (!rows.length) return res.status(404).json({ error: 'not found' }); if (rows[0].password_hash) return res.status(409).json({ error: 'already_activated', message: 'This student already has real credentials — use Send Password Reset instead.' }); const { rows: enrollmentRows } = await pool.query( `SELECT enrollment_id, program_id FROM enrollments WHERE student_id = $1 ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id] ); const enrollmentId = enrollmentRows[0]?.enrollment_id || null; const program = enrollmentRows[0] ? await programsLib.getProgram(pool, enrollmentRows[0].program_id) : null; const notification = await notificationsLib.notifyForEnrollment(pool, studentsAuth, { studentId: req.params.id, enrollmentId, programName: program?.name, isResend: true, }); await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'activation_resent', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); res.json(notificationsLib.toAdminView(notification)); }); admin.post('/api/admin/students/:id/send-enrollment-confirmation', requireStudentRelationshipAccess('id'), async (req, res) => { const pool = getPool(); const { enrollment_id } = req.body || {}; const { rows } = await pool.query(`SELECT student_id, display_name, password_hash FROM students WHERE student_id = $1`, [req.params.id]); if (!rows.length) return res.status(404).json({ error: 'not found' }); if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_activated', message: 'This student has no credentials yet — use Resend Activation instead.' }); let targetEnrollmentId = enrollment_id; if (!targetEnrollmentId) { const { rows: latest } = await pool.query( `SELECT enrollment_id FROM enrollments WHERE student_id = $1 AND status = 'active' ORDER BY enrolled_at DESC LIMIT 1`, [req.params.id] ); targetEnrollmentId = latest[0]?.enrollment_id; } if (!targetEnrollmentId) return res.status(400).json({ error: 'no_active_enrollment', message: 'This student has no active enrollment to confirm.' }); const enrollmentRow = await enrollmentLib.getEnrollment(pool, targetEnrollmentId); const program = await programsLib.getProgram(pool, enrollmentRow.program_id); const notification = await notificationsLib.enqueue(pool, { studentId: req.params.id, enrollmentId: targetEnrollmentId, notificationType: 'enrollment_confirmation', metadata: { display_name: rows[0].display_name, program_name: program?.name }, }); await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'enrollment_confirmation_sent', resourceType: 'enrollment', resourceId: targetEnrollmentId, result: 'success', sourceIp: req.ip }); res.json(notificationsLib.toAdminView(notification)); }); // §8/§15's "admin may need a safe SEND PASSWORD RESET operation" is the EXISTING // POST /api/admin/students/:id/reset-password (admin-routes.js, Phase 5A) — Phase 5C extended // it to also enqueue a real email (see that route) rather than duplicating a second endpoint // here. It still returns `reset_token` in its response, unchanged from Phase 5A: for a // student with no deliverable address at all (no email, no enrollment contact), manually // relaying that token is the ONLY way they can ever get in, so removing it would leave that // population with no recovery path. §21's "admin cannot view token" is enforced for // resend-activation/send-enrollment-confirmation above, which have no legitimate reason to // ever expose one — reset-password's token-in-response is a deliberate, documented, // unchanged exception for exactly the case automatic delivery can't cover. See // docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §6. };