forked from UKSOURCE/cms.lams
feat: add programme module and blog integration
- Add Programme Mongoose model with sub-schemas and migrateFromJson static method - Add programme CRUD controller with audit logging and icon sanitization - Add programme admin views (index, edit) with tabbed form and dynamic arrays - Add seed migration script for programmes - Add /api/programmes and /api/programmes/:id public API endpoints - Register programme routes in admin and public router - Update dashboard with Programmes card and API endpoint entries - Update admin sidebar layout to include Programmes nav link
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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" });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user