Adding backend of Student Support, Contact, Request

This commit is contained in:
2026-04-20 16:22:37 +07:00
parent fab72e86a6
commit ac5fa14c97
23 changed files with 3167 additions and 2324 deletions
+100 -204
View File
@@ -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" });
}
};
+171
View File
@@ -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");
}
};
+186
View File
@@ -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");
}
};