const { addBaseUrlToImages } = require("../utils/imageHelper"); const { ICON_OPTIONS, normalizeIconClass } = require("../utils/iconOptions"); const Programme = require("../models/programme"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); /** * Normalize all icon fields in the payload against the ICON_OPTIONS whitelist. */ function sanitizeProgrammeIcons(payload) { if (!payload) return payload; if (Array.isArray(payload.coreCourses)) { payload.coreCourses = payload.coreCourses.map((c) => ({ ...c, icon: normalizeIconClass(c.icon), })); } if (Array.isArray(payload.outcomes)) { payload.outcomes = payload.outcomes.map((o) => ({ ...o, icon: normalizeIconClass(o.icon), })); } return payload; } /** * Safely parse a JSON field that may arrive as a string or already-parsed object. */ function parseJsonField(raw) { if (raw == null || raw === "") return null; if (typeof raw === "object") return raw; try { return JSON.parse(raw); } catch { return null; } } // ─── ADMIN ROUTES ───────────────────────────────────────────────────────────── /** * GET /admin/programme * List all programmes. */ exports.index = async (req, res) => { try { const data = await Programme.find().sort({ updatedAt: -1 }).lean(); res.render("admin/programme/index", { title: "Programmes Management", data, frontendUrl: process.env.FRONTEND_URL || "", currentPath: req.path, user: req.session.user, }); } catch (err) { console.error("programme.index:", err); req.flash("error_msg", "Error loading programmes"); res.redirect("/admin/dashboard"); } }; /** * GET /admin/programme/edit/:id * Show edit form. Pass id = "new" to create. */ exports.edit = async (req, res) => { try { const { id } = req.params; let programme; if (id === "new") { programme = { _id: null, id: "", level: "", title: "", description: "", duration: "", cost: "", selected: false, link: "", shortName: "", detailBadge: "", detailTitle: "", detailDescription: "", heroImage: "", overview: "", credits: 120, format: "100% Online", nextStartDate: "", monthlyCost: "", perCourseCost: "", coreCourses: [], electives: [], outcomes: [], faqs: [], }; } else { programme = await Programme.findById(id).lean(); if (!programme) { req.flash("error_msg", "Programme not found"); return res.redirect("/admin/programme"); } } res.render("admin/programme/edit", { title: id === "new" ? "Add New Programme" : `Edit: ${programme.title}`, programme, iconOptions: ICON_OPTIONS, frontendUrl: process.env.FRONTEND_URL || "", currentPath: req.path, user: req.session.user, }); } catch (err) { console.error("programme.edit:", err); req.flash("error_msg", "Error loading programme"); res.redirect("/admin/programme"); } }; /** * POST /admin/programme/update/:id * Save a new or existing programme. */ exports.update = async (req, res) => { const { id } = req.params; try { const body = req.body; const coreCourses = parseJsonField(body.coreCourses) || []; const outcomes = parseJsonField(body.outcomes) || []; const faqs = parseJsonField(body.faqs) || []; const electivesRaw = parseJsonField(body.electives) || []; // Electives arrive as [{value: "..."}, ...] from the form const electives = electivesRaw.map((e) => typeof e === "object" ? (e.value || "") : String(e) ); let payload = { id: (body.id || "").trim(), level: (body.level || "").trim(), title: (body.title || "").trim(), description: (body.description || "").trim(), duration: (body.duration || "").trim(), cost: (body.cost || "").trim(), selected: body.selected === "true" || body.selected === true, link: (body.link || "").trim(), shortName: (body.shortName || "").trim(), detailBadge: (body.detailBadge || "").trim(), detailTitle: (body.detailTitle || "").trim(), detailDescription: (body.detailDescription || "").trim(), overview: (body.overview || "").trim(), credits: parseInt(body.credits, 10) || 0, format: (body.format || "").trim(), nextStartDate: (body.nextStartDate || "").trim(), monthlyCost: (body.monthlyCost || "").trim(), perCourseCost: (body.perCourseCost || "").trim(), coreCourses, electives, outcomes, faqs, }; // Hero image: file upload takes priority over the text URL field if (req.file) { payload.heroImage = `/uploads/programmes/${req.file.filename}`; } else if (body.heroImageUrl) { payload.heroImage = body.heroImageUrl.trim(); } // If link is empty, generate from id if (!payload.link && payload.id) { payload.link = `/programmes/${payload.id}`; } payload = sanitizeProgrammeIcons(payload); if (id === "new") { if (!payload.id) { req.flash("error_msg", "Programme ID / Code is required"); return res.redirect("/admin/programme/edit/new"); } const doc = await Programme.create(payload); await writeAuditLog({ model: "Programme", documentId: doc._id, action: AUDIT_ACTIONS.CREATE, before: {}, after: doc.toObject(), changes: [], req, }); req.flash("success_msg", `Programme "${payload.title}" created successfully`); } else { const existing = await Programme.findById(id); if (!existing) { req.flash("error_msg", "Programme not found"); return res.redirect("/admin/programme"); } const beforeData = JSON.parse(JSON.stringify(existing.toObject())); existing.set(payload); await existing.save(); const afterData = JSON.parse(JSON.stringify(existing.toObject())); const changes = diffObject(beforeData, afterData); if (changes.length > 0) { await writeAuditLog({ model: "Programme", documentId: existing._id, action: AUDIT_ACTIONS.UPDATE, before: beforeData, after: afterData, changes, req, }); } req.flash("success_msg", `Programme "${existing.title}" updated successfully`); } res.redirect("/admin/programme"); } catch (err) { console.error("programme.update:", err); req.flash("error_msg", `Update error: ${err.message}`); res.redirect(id === "new" ? "/admin/programme/edit/new" : `/admin/programme/edit/${id}`); } }; /** * POST /admin/programme/delete/:id * Delete a programme by MongoDB _id. */ exports.delete = async (req, res) => { try { const doc = await Programme.findByIdAndDelete(req.params.id); if (doc) { await writeAuditLog({ model: "Programme", documentId: req.params.id, action: AUDIT_ACTIONS.DELETE, before: doc.toObject(), after: {}, changes: [], req, }); } req.flash("success_msg", "Programme deleted successfully"); res.redirect("/admin/programme"); } catch (err) { console.error("programme.delete:", err); req.flash("error_msg", "Error deleting programme"); res.redirect("/admin/programme"); } }; // ─── PUBLIC API ──────────────────────────────────────────────────────────────── /** * GET /api/programmes * Return all programmes as JSON. */ exports.api = async (req, res) => { try { const data = await Programme.find().sort({ updatedAt: -1 }).lean(); const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; res.json(addBaseUrlToImages(data, baseUrl)); } catch (err) { console.error("programme.api:", err); res.status(500).json({ error: "Error loading programmes" }); } }; /** * GET /api/programmes/:id * Return a single programme matched by the slug `id` field. */ exports.apiDetail = async (req, res) => { try { const data = await Programme.findOne({ id: req.params.id }).lean(); if (!data) return res.status(404).json({ error: "Programme not found" }); const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; res.json(addBaseUrlToImages(data, baseUrl)); } catch (err) { console.error("programme.apiDetail:", err); res.status(500).json({ error: "Error loading programme" }); } };