forked from UKSOURCE/cms.lams
Merge branch 'develop' into fea/dat-20042026-CMS-Partnerships-Accreditation-History-Admissions-Policies
This commit is contained in:
+100
-204
@@ -1,110 +1,89 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const { ICON_OPTIONS, normalizeIconClass } = require("../utils/iconOptions");
|
||||
const Contact = require("../models/contact");
|
||||
const ContactSubmission = require("../models/contactSubmission");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
// Get contact data from MongoDB
|
||||
const getContactData = async () => {
|
||||
const contact = await Contact.findOne({ name: "default" });
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
return contact.toObject();
|
||||
};
|
||||
const SLUG = "contact";
|
||||
|
||||
// API to get contact data
|
||||
function parseJsonField(raw) {
|
||||
if (raw == null || raw === "") return null;
|
||||
if (typeof raw === "object") return raw;
|
||||
try { return JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
|
||||
async function getDocument() {
|
||||
return Contact.findOne({ slug: SLUG }).lean();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/contact — public payload for frontend LAMS.
|
||||
*/
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const contact = await getContactData();
|
||||
if (!contact) {
|
||||
const doc = await getDocument();
|
||||
if (!doc) {
|
||||
return res.status(404).json({ error: "Contact data not found" });
|
||||
}
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const processedData = addBaseUrlToImages(contact, baseUrl);
|
||||
res.json(processedData);
|
||||
const body = {
|
||||
metadata: doc.metadata || {},
|
||||
hero: doc.hero || {},
|
||||
infoCards: doc.infoCards || {},
|
||||
form: doc.form || {},
|
||||
faq: doc.faq || {},
|
||||
cta: doc.cta || {},
|
||||
};
|
||||
res.json(addBaseUrlToImages(body, baseUrl));
|
||||
} catch (err) {
|
||||
console.error("API Error:", err);
|
||||
console.error("contact.api:", err);
|
||||
res.status(500).json({ error: "Error loading contact data" });
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy toàn bộ contact data
|
||||
exports.getContactData = async (req, res) => {
|
||||
try {
|
||||
const contactData = await getContactData();
|
||||
if (!contactData) {
|
||||
return res.status(404).json({ error: "Contact data not found" });
|
||||
}
|
||||
res.json(contactData);
|
||||
} catch (error) {
|
||||
console.error("Error getting contact data:", error);
|
||||
res.status(500).json({ error: "Error loading contact data" });
|
||||
}
|
||||
};
|
||||
// Legacy alias
|
||||
exports.getContactData = exports.api;
|
||||
|
||||
// Render admin view
|
||||
/**
|
||||
* GET /admin/contact — Admin view.
|
||||
*/
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = (await getContactData()) || {
|
||||
hero: {
|
||||
title: "Contact Us",
|
||||
backgroundImage: "",
|
||||
overlayColor: "rgba(0, 0, 0, 0)",
|
||||
sectionClass: "",
|
||||
titleClass: "",
|
||||
enableScrollspy: false,
|
||||
backgroundPosition: "center",
|
||||
},
|
||||
contactCards: [],
|
||||
map: {
|
||||
coordinates: { lat: 0, lng: 0 },
|
||||
zoom: 15,
|
||||
location: "",
|
||||
markerTitle: "",
|
||||
embedUrl: "",
|
||||
tileLayer: {
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: "",
|
||||
maxZoom: 18,
|
||||
minZoom: 0,
|
||||
},
|
||||
},
|
||||
form: {
|
||||
sectionLabel: "",
|
||||
heading: "",
|
||||
description: "",
|
||||
fields: [],
|
||||
submitButton: {
|
||||
text: "Send Message",
|
||||
icon: "fa-solid fa-arrow-right",
|
||||
buttonClass: "theme-btn style-2",
|
||||
},
|
||||
},
|
||||
};
|
||||
const doc = await getDocument();
|
||||
const data = doc
|
||||
? {
|
||||
metadata: doc.metadata || {},
|
||||
hero: doc.hero || {},
|
||||
infoCards: doc.infoCards || {},
|
||||
form: doc.form || {},
|
||||
faq: doc.faq || {},
|
||||
cta: doc.cta || {},
|
||||
}
|
||||
: {
|
||||
metadata: {},
|
||||
hero: { badge: "", titleMain: "", titleHighlight: "", description: "" },
|
||||
infoCards: { contactInfo: { title: "", channels: [] }, supportHours: { title: "", hours: [], footer: {} } },
|
||||
form: { heading: "", description: "", fields: {}, submitLabel: "Send Message", successMessage: "", errorMessage: "" },
|
||||
faq: { title: "", subtitle: "", items: [] },
|
||||
cta: { title: "", description: "", primaryButton: {}, secondaryButton: {} },
|
||||
};
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const query = {};
|
||||
|
||||
if (startDate || endDate) {
|
||||
query.createdAt = {};
|
||||
if (startDate) {
|
||||
query.createdAt.$gte = new Date(startDate);
|
||||
}
|
||||
if (startDate) query.createdAt.$gte = new Date(startDate);
|
||||
if (endDate) {
|
||||
// Set end date to end of day
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
query.createdAt.$lte = end;
|
||||
}
|
||||
}
|
||||
|
||||
const submissions = await ContactSubmission.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(50);
|
||||
const frontendUrl = process.env.FRONTEND_URL;
|
||||
const submissions = await ContactSubmission.find(query).sort({ createdAt: -1 }).limit(50);
|
||||
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||
|
||||
res.render("admin/contact/index", {
|
||||
title: "Contact Management",
|
||||
@@ -114,124 +93,61 @@ exports.index = async (req, res) => {
|
||||
startDate,
|
||||
endDate,
|
||||
frontendUrl,
|
||||
iconOptions: ICON_OPTIONS,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in contact index:", error);
|
||||
console.error("contact.index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật dữ liệu contact
|
||||
/**
|
||||
* POST /admin/contact/update — Save & audit.
|
||||
*/
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { hero, contactCards, map, form } = req.body;
|
||||
const metadata = parseJsonField(req.body.metadata);
|
||||
const hero = parseJsonField(req.body.hero);
|
||||
const infoCards = parseJsonField(req.body.infoCards);
|
||||
const form = parseJsonField(req.body.form);
|
||||
const faq = parseJsonField(req.body.faq);
|
||||
const cta = parseJsonField(req.body.cta);
|
||||
|
||||
// Parse JSON strings nếu cần
|
||||
const parseJson = (data) => {
|
||||
if (!data) return null;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const heroData = parseJson(hero);
|
||||
const contactCardsData = parseJson(contactCards);
|
||||
const mapData = parseJson(map);
|
||||
const formData = parseJson(form);
|
||||
|
||||
// Tìm hoặc tạo contact
|
||||
let contact = await Contact.findOne({ name: "default" });
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = contact
|
||||
? JSON.parse(JSON.stringify(contact.toObject()))
|
||||
: {};
|
||||
|
||||
if (!contact) {
|
||||
// Tạo mới với default values
|
||||
contact = new Contact({
|
||||
name: "default",
|
||||
hero: heroData || {
|
||||
title: "Contact Us",
|
||||
backgroundImage: "",
|
||||
overlayColor: "rgba(0, 0, 0, 0)",
|
||||
sectionClass: "",
|
||||
titleClass: "",
|
||||
enableScrollspy: false,
|
||||
backgroundPosition: "center",
|
||||
},
|
||||
contactCards: (contactCardsData || []).map((card) => ({
|
||||
...card,
|
||||
iconType: card.iconType || "",
|
||||
iconSource:
|
||||
card.iconSource ||
|
||||
(card.iconType && card.iconType.startsWith("/uploads/")
|
||||
? "image"
|
||||
: "fontawesome"),
|
||||
})),
|
||||
map: mapData || {
|
||||
coordinates: { lat: 0, lng: 0 },
|
||||
zoom: 15,
|
||||
location: "",
|
||||
markerTitle: "",
|
||||
embedUrl: "",
|
||||
tileLayer: {
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: "",
|
||||
maxZoom: 18,
|
||||
minZoom: 0,
|
||||
},
|
||||
},
|
||||
form: formData || {
|
||||
sectionLabel: "",
|
||||
heading: "",
|
||||
description: "",
|
||||
fields: [],
|
||||
submitButton: {
|
||||
text: "Send Message",
|
||||
icon: "fa-solid fa-arrow-right",
|
||||
buttonClass: "theme-btn style-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Cập nhật dữ liệu
|
||||
if (heroData) contact.hero = heroData;
|
||||
if (contactCardsData && Array.isArray(contactCardsData)) {
|
||||
// Đảm bảo mỗi card có iconType và iconSource
|
||||
contact.contactCards = contactCardsData.map((card) => ({
|
||||
...card,
|
||||
iconType: card.iconType || "",
|
||||
iconSource:
|
||||
card.iconSource ||
|
||||
(card.iconType && card.iconType.startsWith("/uploads/")
|
||||
? "image"
|
||||
: "fontawesome"),
|
||||
}));
|
||||
}
|
||||
if (mapData) contact.map = mapData;
|
||||
if (formData) contact.form = formData;
|
||||
if (!metadata || !hero || !infoCards || !form || !faq || !cta) {
|
||||
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||
return res.redirect("/admin/contact");
|
||||
}
|
||||
|
||||
await contact.save();
|
||||
// Normalize channel icons against whitelist
|
||||
if (infoCards?.contactInfo?.channels?.length) {
|
||||
infoCards.contactInfo.channels = infoCards.contactInfo.channels.map((ch) => ({
|
||||
...ch,
|
||||
icon: normalizeIconClass(ch.icon),
|
||||
}));
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(JSON.stringify(contact.toObject()));
|
||||
const payload = { metadata, hero, infoCards, form, faq, cta };
|
||||
|
||||
let doc = await Contact.findOne({ slug: SLUG });
|
||||
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||
|
||||
if (!doc) {
|
||||
doc = new Contact({ slug: SLUG, ...payload });
|
||||
} else {
|
||||
doc.set(payload);
|
||||
}
|
||||
|
||||
await doc.save();
|
||||
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
|
||||
// ✅ AUDIT LOGGING - Contact Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Contact",
|
||||
documentId: contact._id,
|
||||
documentId: doc._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_CONTACT,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
@@ -243,18 +159,20 @@ exports.update = async (req, res) => {
|
||||
req.flash("success_msg", "Contact updated successfully");
|
||||
res.redirect("/admin/contact");
|
||||
} catch (err) {
|
||||
console.error("Error updating contact:", err);
|
||||
console.error("contact.update:", err);
|
||||
req.flash("error_msg", err.message || "Error updating contact");
|
||||
res.redirect("/admin/contact");
|
||||
}
|
||||
};
|
||||
|
||||
// API để submit contact form (từ frontend)
|
||||
// ─────────────────────────────────────────────
|
||||
// Form Submissions (unchanged)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
exports.submitForm = async (req, res) => {
|
||||
try {
|
||||
const { name, email, phone, address, date, message } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name || !email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -262,7 +180,6 @@ exports.submitForm = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create new submission
|
||||
const submission = new ContactSubmission({
|
||||
name: name.trim(),
|
||||
email: email.trim().toLowerCase(),
|
||||
@@ -288,13 +205,9 @@ exports.submitForm = async (req, res) => {
|
||||
} catch (err) {
|
||||
console.error("Error submitting contact form:", err);
|
||||
|
||||
// Handle validation errors
|
||||
if (err.name === "ValidationError") {
|
||||
const errors = Object.values(err.errors).map((e) => e.message);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: errors.join(", "),
|
||||
});
|
||||
return res.status(400).json({ success: false, error: errors.join(", ") });
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
@@ -304,7 +217,6 @@ exports.submitForm = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy danh sách submissions (cho admin)
|
||||
exports.getSubmissions = async (req, res) => {
|
||||
try {
|
||||
const { status, page = 1, limit = 20 } = req.query;
|
||||
@@ -336,14 +248,10 @@ exports.getSubmissions = async (req, res) => {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error getting submissions:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading submissions",
|
||||
});
|
||||
res.status(500).json({ success: false, error: "Error loading submissions" });
|
||||
}
|
||||
};
|
||||
|
||||
// API để cập nhật status của submission
|
||||
exports.updateSubmissionStatus = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -351,10 +259,7 @@ exports.updateSubmissionStatus = async (req, res) => {
|
||||
|
||||
const validStatuses = ["pending", "read", "replied", "archived"];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid status",
|
||||
});
|
||||
return res.status(400).json({ success: false, error: "Invalid status" });
|
||||
}
|
||||
|
||||
const updateData = { status };
|
||||
@@ -364,25 +269,16 @@ exports.updateSubmissionStatus = async (req, res) => {
|
||||
const submission = await ContactSubmission.findByIdAndUpdate(
|
||||
id,
|
||||
updateData,
|
||||
{ new: true },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!submission) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Submission not found",
|
||||
});
|
||||
return res.status(404).json({ success: false, error: "Submission not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: submission,
|
||||
});
|
||||
res.json({ success: true, data: submission });
|
||||
} catch (err) {
|
||||
console.error("Error updating submission:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error updating submission",
|
||||
});
|
||||
res.status(500).json({ success: false, error: "Error updating submission" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const { normalizeIconClass } = require("../utils/iconOptions");
|
||||
const RequestInfo = require("../models/requestInfo");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
const SLUG = "request-info";
|
||||
|
||||
function emptyPageShape() {
|
||||
return {
|
||||
metadata: { title: "", description: "", keywords: "", ogImage: "" },
|
||||
hero: {
|
||||
badge: "",
|
||||
titleMain: "",
|
||||
titleHighlight: "",
|
||||
description: "",
|
||||
valueProps: [],
|
||||
},
|
||||
form: {
|
||||
heading: "",
|
||||
description: "",
|
||||
fields: {
|
||||
firstName: { name: "", label: "", placeholder: "", required: true },
|
||||
lastName: { name: "", label: "", placeholder: "", required: true },
|
||||
email: { name: "", label: "", placeholder: "", required: true },
|
||||
phone: { name: "", label: "", placeholder: "", required: false },
|
||||
program: { name: "", label: "", placeholder: "", required: true, options: [] },
|
||||
timeline: { name: "", label: "", options: [] },
|
||||
},
|
||||
consentText: "",
|
||||
submitLabel: "",
|
||||
footerText: "",
|
||||
successMessage: "",
|
||||
errorMessage: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonField(raw) {
|
||||
if (raw == null || raw === "") return null;
|
||||
if (typeof raw === "object") return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Chuẩn hoá icon theo whitelist (valueProps). */
|
||||
function sanitizeIcons(payload) {
|
||||
if (!payload) return payload;
|
||||
if (payload.hero?.valueProps?.length) {
|
||||
payload.hero.valueProps = payload.hero.valueProps.map((vp) => ({
|
||||
...vp,
|
||||
icon: normalizeIconClass(vp.icon),
|
||||
}));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function getDocument() {
|
||||
return RequestInfo.findOne({ slug: SLUG }).lean();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/request-info — public payload cho frontend LAMS.
|
||||
* Response: { metadata, hero, form } với ảnh đã gắn full URL.
|
||||
*/
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const doc = await getDocument();
|
||||
if (!doc) {
|
||||
return res.status(404).json({ error: "Request Info content not found" });
|
||||
}
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const body = {
|
||||
metadata: doc.metadata || {},
|
||||
hero: doc.hero || {},
|
||||
form: doc.form || {},
|
||||
};
|
||||
res.json(addBaseUrlToImages(body, baseUrl));
|
||||
} catch (err) {
|
||||
console.error("requestInfo.api:", err);
|
||||
res.status(500).json({ error: "Error loading Request Info data" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /admin/request-info — Admin view.
|
||||
*/
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const doc = await getDocument();
|
||||
const data = doc
|
||||
? {
|
||||
metadata: doc.metadata || {},
|
||||
hero: doc.hero || {},
|
||||
form: doc.form || {},
|
||||
}
|
||||
: emptyPageShape();
|
||||
|
||||
const { ICON_OPTIONS } = require("../utils/iconOptions");
|
||||
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||
|
||||
res.render("admin/request-info/index", {
|
||||
title: "Request Info",
|
||||
layout: "layouts/main",
|
||||
data,
|
||||
iconOptions: ICON_OPTIONS,
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("requestInfo.index:", error);
|
||||
req.flash("error_msg", "Could not load Request Info page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /admin/request-info/update — Lưu dữ liệu, tạo audit log.
|
||||
*/
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const metadata = parseJsonField(req.body.metadata);
|
||||
const hero = parseJsonField(req.body.hero);
|
||||
const form = parseJsonField(req.body.form);
|
||||
|
||||
if (!metadata || !hero || !form) {
|
||||
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||
return res.redirect("/admin/request-info");
|
||||
}
|
||||
|
||||
const payload = sanitizeIcons({ metadata, hero, form });
|
||||
|
||||
let doc = await RequestInfo.findOne({ slug: SLUG });
|
||||
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||
|
||||
if (!doc) {
|
||||
doc = new RequestInfo({ slug: SLUG, ...payload });
|
||||
} else {
|
||||
doc.set(payload);
|
||||
}
|
||||
|
||||
await doc.save();
|
||||
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "RequestInfo",
|
||||
documentId: doc._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_REQUEST_INFO,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Request Info updated successfully");
|
||||
res.redirect("/admin/request-info");
|
||||
} catch (err) {
|
||||
console.error("requestInfo.update:", err);
|
||||
req.flash("error_msg", err.message || "Error updating Request Info");
|
||||
res.redirect("/admin/request-info");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const { normalizeIconClass } = require("../utils/iconOptions");
|
||||
const StudentSupport = require("../models/studentSupport");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
const SLUG = "student-support";
|
||||
|
||||
function emptyPageShape() {
|
||||
return {
|
||||
metadata: { title: "", description: "" },
|
||||
hero: {
|
||||
badge: "",
|
||||
title: "",
|
||||
description: "",
|
||||
primaryButton: { label: "", href: "" },
|
||||
secondaryButton: { label: "", href: "" },
|
||||
image: "",
|
||||
imageAlt: "",
|
||||
},
|
||||
directory: { title: "", subtitle: "", services: [] },
|
||||
contactSection: {
|
||||
heading: "",
|
||||
description: "",
|
||||
channels: [],
|
||||
form: {
|
||||
heading: "",
|
||||
fields: {
|
||||
firstName: { label: "", placeholder: "" },
|
||||
lastName: { label: "", placeholder: "" },
|
||||
studentId: { label: "", placeholder: "" },
|
||||
department: { label: "" },
|
||||
message: { label: "", placeholder: "" },
|
||||
},
|
||||
departments: [],
|
||||
submitLabel: "",
|
||||
successMessage: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonField(raw) {
|
||||
if (raw == null || raw === "") return null;
|
||||
if (typeof raw === "object") return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Chuẩn hoá mọi field icon theo whitelist (dropdown admin). */
|
||||
function sanitizeIcons(payload) {
|
||||
if (!payload) return payload;
|
||||
if (payload.hero) {
|
||||
/* hero không có icon list */
|
||||
}
|
||||
if (payload.directory?.services?.length) {
|
||||
payload.directory.services = payload.directory.services.map((s) => ({
|
||||
...s,
|
||||
icon: normalizeIconClass(s.icon),
|
||||
hoursIcon: normalizeIconClass(s.hoursIcon),
|
||||
}));
|
||||
}
|
||||
if (payload.contactSection?.channels?.length) {
|
||||
payload.contactSection.channels = payload.contactSection.channels.map((c) => ({
|
||||
...c,
|
||||
icon: normalizeIconClass(c.icon),
|
||||
}));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function getDocument() {
|
||||
return StudentSupport.findOne({ slug: SLUG }).lean();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/student-support — public payload giống file JSON frontend (không kèm _id/slug).
|
||||
*/
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const doc = await getDocument();
|
||||
if (!doc) {
|
||||
return res.status(404).json({ error: "Student Support content not found" });
|
||||
}
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const body = {
|
||||
metadata: doc.metadata || {},
|
||||
hero: doc.hero || {},
|
||||
directory: doc.directory || { services: [] },
|
||||
contactSection: doc.contactSection || {},
|
||||
};
|
||||
res.json(addBaseUrlToImages(body, baseUrl));
|
||||
} catch (err) {
|
||||
console.error("studentSupport.api:", err);
|
||||
res.status(500).json({ error: "Error loading Student Support data" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const doc = await getDocument();
|
||||
const data = doc
|
||||
? {
|
||||
metadata: doc.metadata || {},
|
||||
hero: doc.hero || {},
|
||||
directory: doc.directory || { services: [] },
|
||||
contactSection: doc.contactSection || {},
|
||||
}
|
||||
: emptyPageShape();
|
||||
|
||||
const { ICON_OPTIONS } = require("../utils/iconOptions");
|
||||
const frontendUrl = process.env.FRONTEND_URL || "";
|
||||
|
||||
res.render("admin/student-support/index", {
|
||||
title: "Student Support",
|
||||
layout: "layouts/main",
|
||||
data,
|
||||
iconOptions: ICON_OPTIONS,
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("studentSupport.index:", error);
|
||||
req.flash("error_msg", "Could not load Student Support page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const metadata = parseJsonField(req.body.metadata);
|
||||
const hero = parseJsonField(req.body.hero);
|
||||
const directory = parseJsonField(req.body.directory);
|
||||
const contactSection = parseJsonField(req.body.contactSection);
|
||||
|
||||
if (!metadata || !hero || !directory || !contactSection) {
|
||||
req.flash("error_msg", "Invalid form payload. Please try again.");
|
||||
return res.redirect("/admin/student-support");
|
||||
}
|
||||
|
||||
const payload = sanitizeIcons({
|
||||
metadata,
|
||||
hero,
|
||||
directory,
|
||||
contactSection,
|
||||
});
|
||||
|
||||
let doc = await StudentSupport.findOne({ slug: SLUG });
|
||||
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
||||
|
||||
if (!doc) {
|
||||
doc = new StudentSupport({ slug: SLUG, ...payload });
|
||||
} else {
|
||||
doc.set(payload);
|
||||
}
|
||||
|
||||
await doc.save();
|
||||
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
||||
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "StudentSupport",
|
||||
documentId: doc._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_STUDENT_SUPPORT,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Student Support updated successfully");
|
||||
res.redirect("/admin/student-support");
|
||||
} catch (err) {
|
||||
console.error("studentSupport.update:", err);
|
||||
req.flash("error_msg", err.message || "Error updating Student Support");
|
||||
res.redirect("/admin/student-support");
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user