Files
cms.techvanguard.vn/controllers/programmeController.js
T
nhuttruong 30b90051c9 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
2026-04-20 18:40:29 +07:00

290 lines
9.0 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");
/**
* 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" });
}
};