forked from UKSOURCE/cms.lams
440 lines
14 KiB
JavaScript
440 lines
14 KiB
JavaScript
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");
|
|
const { readJsonFile, writeJsonFile } = require("../utils/jsonHelper");
|
|
const DEFAULT_PROGRAMME_FIELDS = [
|
|
"Computer Science",
|
|
"Data Analytics",
|
|
"Business & Admin",
|
|
"Healthcare",
|
|
"Finance & Accounting",
|
|
"Marketing",
|
|
"Education",
|
|
"Engineering",
|
|
"Psychology",
|
|
"Cybersecurity",
|
|
];
|
|
const DEFAULT_DURATION_RANGES = [
|
|
"< 6 months",
|
|
"6-12 months",
|
|
"12-24 months",
|
|
"24-48 months",
|
|
"48+ months",
|
|
];
|
|
const DEFAULT_DEGREE_LEVELS = ["Bachelor's", "Master's"];
|
|
|
|
function readProgrammeFiltersConfig() {
|
|
const saved = readJsonFile("programme-filters");
|
|
return {
|
|
levels:
|
|
saved && Array.isArray(saved.levels) && saved.levels.length > 0
|
|
? saved.levels
|
|
: DEFAULT_DEGREE_LEVELS,
|
|
fields:
|
|
saved && Array.isArray(saved.fields) && saved.fields.length > 0
|
|
? saved.fields
|
|
: DEFAULT_PROGRAMME_FIELDS,
|
|
durations:
|
|
saved && Array.isArray(saved.durations) && saved.durations.length > 0
|
|
? saved.durations
|
|
: DEFAULT_DURATION_RANGES,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
const filtersConfig = readProgrammeFiltersConfig();
|
|
const levelOptions = Array.isArray(filtersConfig.levels)
|
|
? [...filtersConfig.levels]
|
|
: [];
|
|
const durationOptions = Array.isArray(filtersConfig.durations)
|
|
? [...filtersConfig.durations]
|
|
: [];
|
|
|
|
if (id === "new") {
|
|
programme = {
|
|
_id: null,
|
|
id: "",
|
|
level: "",
|
|
fieldOfStudy: "",
|
|
title: "",
|
|
description: "",
|
|
duration: "",
|
|
durationInMonths: 0,
|
|
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");
|
|
}
|
|
if (programme.level && !levelOptions.includes(programme.level)) {
|
|
levelOptions.push(programme.level);
|
|
}
|
|
if (programme.duration && !durationOptions.includes(programme.duration)) {
|
|
durationOptions.push(programme.duration);
|
|
}
|
|
}
|
|
|
|
res.render("admin/programme/edit", {
|
|
title: id === "new" ? "Add New Programme" : `Edit: ${programme.title}`,
|
|
programme,
|
|
levelOptions,
|
|
durationOptions,
|
|
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(),
|
|
durationInMonths: parseInt(body.durationInMonths, 10) || 0,
|
|
cost: (body.cost || "").trim(),
|
|
fieldOfStudy: (body.fieldOfStudy || "").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/filters
|
|
* Return filter options for FE (field of study + duration ranges + levels).
|
|
*/
|
|
exports.apiFilters = async (req, res) => {
|
|
try {
|
|
const data = await Programme.find().lean();
|
|
const cfg = readProgrammeFiltersConfig();
|
|
|
|
const normalize = (value) =>
|
|
String(value || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]/g, "");
|
|
const levels = cfg.levels.map((label) => {
|
|
const normalizedLabel = normalize(label);
|
|
const count = data.filter((p) =>
|
|
normalize(p.level).includes(normalizedLabel)
|
|
).length;
|
|
return { label, count };
|
|
});
|
|
|
|
const fieldsMap = new Map(cfg.fields.map((f) => [f, 0]));
|
|
data.forEach((p) => {
|
|
if (p.fieldOfStudy && fieldsMap.has(p.fieldOfStudy)) {
|
|
fieldsMap.set(p.fieldOfStudy, (fieldsMap.get(p.fieldOfStudy) || 0) + 1);
|
|
}
|
|
});
|
|
const fields = [...fieldsMap.entries()].map(([label, count]) => ({ label, count }));
|
|
|
|
res.json({
|
|
levels,
|
|
fields,
|
|
durations: cfg.durations.map((label) => ({ label })),
|
|
});
|
|
} catch (err) {
|
|
console.error("programme.apiFilters:", err);
|
|
res.status(500).json({ error: "Error loading programme filters" });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* GET /admin/programme/filters
|
|
* CMS page to manage programme filter options centrally.
|
|
*/
|
|
exports.filtersPage = async (req, res) => {
|
|
try {
|
|
const cfg = readProgrammeFiltersConfig();
|
|
res.render("admin/programme/filters", {
|
|
title: "Programme Filters",
|
|
filters: cfg,
|
|
frontendUrl: process.env.FRONTEND_URL || "",
|
|
currentPath: req.path,
|
|
user: req.session.user,
|
|
});
|
|
} catch (err) {
|
|
console.error("programme.filtersPage:", err);
|
|
req.flash("error_msg", "Error loading programme filters");
|
|
res.redirect("/admin/programme");
|
|
}
|
|
};
|
|
|
|
/**
|
|
* POST /admin/programme/filters
|
|
* Save centralized programme filters.
|
|
*/
|
|
exports.updateFilters = async (req, res) => {
|
|
try {
|
|
const normalizeLines = (text) =>
|
|
String(text || "")
|
|
.split(/\r?\n/)
|
|
.map((x) => x.trim())
|
|
.filter(Boolean);
|
|
|
|
const levels = normalizeLines(req.body.levels);
|
|
const fields = normalizeLines(req.body.fields);
|
|
const durations = normalizeLines(req.body.durations);
|
|
|
|
if (!levels.length || !fields.length || !durations.length) {
|
|
req.flash("error_msg", "Degree levels, fields and duration ranges cannot be empty.");
|
|
return res.redirect("/admin/programme/filters");
|
|
}
|
|
|
|
writeJsonFile("programme-filters", { levels, fields, durations });
|
|
req.flash("success_msg", "Programme filters updated successfully.");
|
|
res.redirect("/admin/programme/filters");
|
|
} catch (err) {
|
|
console.error("programme.updateFilters:", err);
|
|
req.flash("error_msg", "Error updating programme filters.");
|
|
res.redirect("/admin/programme/filters");
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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" });
|
|
}
|
|
};
|