diff --git a/controllers/programmeController.js b/controllers/programmeController.js index 55f7afb..01c42a5 100644 --- a/controllers/programmeController.js +++ b/controllers/programmeController.js @@ -4,6 +4,45 @@ 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. @@ -69,15 +108,24 @@ 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: "", @@ -103,11 +151,19 @@ exports.edit = async (req, res) => { 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, @@ -145,7 +201,9 @@ exports.update = async (req, res) => { 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(), @@ -272,6 +330,98 @@ exports.api = async (req, res) => { } }; +/** + * 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. diff --git a/data/programme-filters.json b/data/programme-filters.json new file mode 100644 index 0000000..4629ff6 --- /dev/null +++ b/data/programme-filters.json @@ -0,0 +1,22 @@ +{ + "levels": [ + "Bachelor's", + "Master's", + "truong's" + ], + "fields": [ + "Computer Science", + "Data Analytics", + "Business & Admin", + "Healthcare", + "Finance & Accounting", + "Marketing" + ], + "durations": [ + "< 6 months", + "6-12 months", + "12-24 months", + "24-48 months", + "48+ months" + ] +} \ No newline at end of file diff --git a/models/programme.js b/models/programme.js index 800a354..cbcc989 100644 --- a/models/programme.js +++ b/models/programme.js @@ -34,7 +34,9 @@ const programmeSchema = new mongoose.Schema( title: { type: String, default: "" }, description: { type: String, default: "" }, duration: { type: String, default: "" }, + durationInMonths: { type: Number, default: 0 }, cost: { type: String, default: "" }, + fieldOfStudy: { type: String, default: "" }, selected: { type: Boolean, default: false }, link: { type: String, default: "" }, shortName: { type: String, default: "" }, diff --git a/routes/admin.js b/routes/admin.js index d6201f6..fb01069 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -289,6 +289,8 @@ router.get("/test-images", ensureAuthenticated, (req, res) => { // Programme Management router.get("/programme", ensureAuthenticated, programmeController.index); +router.get("/programme/filters", ensureAuthenticated, programmeController.filtersPage); +router.post("/programme/filters", ensureAuthenticated, programmeController.updateFilters); router.get("/programme/edit/:id", ensureAuthenticated, programmeController.edit); router.post("/programme/update/:id", ensureAuthenticated, upload.single('heroImage'), programmeController.update); router.post("/programme/delete/:id", ensureAuthenticated, programmeController.delete); diff --git a/routes/index.js b/routes/index.js index d22df96..8e1a23e 100644 --- a/routes/index.js +++ b/routes/index.js @@ -93,6 +93,7 @@ router.get("/api/blog/:slug", blogController.apiShow); // Programmes API router.get("/api/programmes", programmeController.api); +router.get("/api/programmes/filters", programmeController.apiFilters); router.get("/api/programmes/:id", programmeController.apiDetail); diff --git a/views/admin/programme/edit.ejs b/views/admin/programme/edit.ejs index d5ba435..359daf3 100644 --- a/views/admin/programme/edit.ejs +++ b/views/admin/programme/edit.ejs @@ -78,12 +78,33 @@
Manage centralized filter options used by frontend programme page.
+