From 970fcbac7d645d04e765f9b5fa3e8a86f43512c0 Mon Sep 17 00:00:00 2001 From: nguyenvanbao Date: Tue, 10 Feb 2026 16:42:35 +0700 Subject: [PATCH] feat: implement comprehensive audit logging system --- audit/diffObject.js | 40 ++ audit/writeAuditLog.js | 25 + constants/auditAction.js | 72 +++ controllers/aboutUsController.js | 347 +++++++------ controllers/appointmentController.js | 693 +++++++++++++------------ controllers/auditLogController.js | 178 +++++++ controllers/blogController.js | 600 ++++++++++++---------- controllers/contactController.js | 55 +- controllers/faqController.js | 71 ++- controllers/footerController.js | 250 ++++----- controllers/headerController.js | 647 +++++++++++++----------- controllers/homeController.js | 91 +++- controllers/insuranceController.js | 316 +++++++----- controllers/pricingController.js | 354 +++++++------ controllers/safetyController.js | 289 ++++++----- controllers/serviceController.js | 44 +- controllers/termsController.js | 388 +++++++------- controllers/testimonialController.js | 187 ++++--- controllers/travelController.js | 416 ++++++++------- controllers/videoGalleryController.js | 155 +++--- controllers/visaController.js | 107 +++- models/AuditLog.js | 64 +++ routes/admin.js | 550 +++++++++++++++----- scripts/create-audit-log.js | 29 ++ utils/requestMeta.js | 18 + views/admin/audit-log/index.ejs | 699 ++++++++++++++++++++++++++ views/admin/audit-log/show.ejs | 314 ++++++++++++ views/layouts/main.ejs | 5 + 28 files changed, 4783 insertions(+), 2221 deletions(-) create mode 100644 audit/diffObject.js create mode 100644 audit/writeAuditLog.js create mode 100644 constants/auditAction.js create mode 100644 controllers/auditLogController.js create mode 100644 models/AuditLog.js create mode 100644 scripts/create-audit-log.js create mode 100644 utils/requestMeta.js create mode 100644 views/admin/audit-log/index.ejs create mode 100644 views/admin/audit-log/show.ejs diff --git a/audit/diffObject.js b/audit/diffObject.js new file mode 100644 index 0000000..e0521de --- /dev/null +++ b/audit/diffObject.js @@ -0,0 +1,40 @@ +function diffObject(before = {}, after = {}, parentPath = "") { + const changes = []; + + const allKeys = new Set([ + ...Object.keys(before || {}), + ...Object.keys(after || {}), + ]); + + for (const key of allKeys) { + const beforeValue = before?.[key]; + const afterValue = after?.[key]; + const currentPath = parentPath ? `${parentPath}.${key}` : key; + + // Nếu cả hai đều là object (không phải array) + if ( + typeof beforeValue === "object" && + typeof afterValue === "object" && + beforeValue !== null && + afterValue !== null && + !Array.isArray(beforeValue) && + !Array.isArray(afterValue) + ) { + changes.push(...diffObject(beforeValue, afterValue, currentPath)); + continue; + } + + // So sánh primitive hoặc array + if (JSON.stringify(beforeValue) !== JSON.stringify(afterValue)) { + changes.push({ + field: currentPath, + before: beforeValue, + after: afterValue, + }); + } + } + + return changes; +} + +module.exports = diffObject; diff --git a/audit/writeAuditLog.js b/audit/writeAuditLog.js new file mode 100644 index 0000000..7b63d17 --- /dev/null +++ b/audit/writeAuditLog.js @@ -0,0 +1,25 @@ +const AuditLog = require("../models/auditLog"); +const RequestMeta = require("../utils/requestMeta"); +async function writeAuditLog({ + model, + documentId, + action, + before, + after, + changes = [], + req, +}) { + await AuditLog.create({ + model, + documentId, + action, + before, + after, + changes, + ipAddress: RequestMeta.getClientIp(req), + userAgent: RequestMeta.getUserAgent(req), + performedBy: req.session?.user?.id || req.user?.id || null, + }); +} + +module.exports = writeAuditLog; diff --git a/constants/auditAction.js b/constants/auditAction.js new file mode 100644 index 0000000..a7a8fd8 --- /dev/null +++ b/constants/auditAction.js @@ -0,0 +1,72 @@ +const AUDIT_ACTIONS = Object.freeze({ + CREATE: "CREATE", + UPDATE: "UPDATE", + DELETE: "DELETE", + + // Service + UPDATE_SERVICE: "UPDATE_SERVICE", + UPDATE_SERVICE_DETAILS: "UPDATE_SERVICE_DETAILS", + + // Blog + CREATE_BLOG: "CREATE_BLOG", + UPDATE_BLOG: "UPDATE_BLOG", + DELETE_BLOG: "DELETE_BLOG", + + // Category + CREATE_CATEGORY: "CREATE_CATEGORY", + UPDATE_CATEGORY: "UPDATE_CATEGORY", + DELETE_CATEGORY: "DELETE_CATEGORY", + + // Home + UPDATE_HOME: "UPDATE_HOME", + + // About Us + UPDATE_ABOUT_US: "UPDATE_ABOUT_US", + + // Header + UPDATE_HEADER: "UPDATE_HEADER", + + // Footer + UPDATE_FOOTER: "UPDATE_FOOTER", + + // Contact + UPDATE_CONTACT: "UPDATE_CONTACT", + + // Pricing + UPDATE_PRICING: "UPDATE_PRICING", + + // FAQ + UPDATE_FAQ: "UPDATE_FAQ", + + // Terms + UPDATE_TERMS: "UPDATE_TERMS", + + // Safety + UPDATE_SAFETY: "UPDATE_SAFETY", + + // Insurance + UPDATE_INSURANCE: "UPDATE_INSURANCE", + + // Travel + UPDATE_TRAVEL: "UPDATE_TRAVEL", + + // Visa + UPDATE_VISA: "UPDATE_VISA", + + // Appointment + UPDATE_APPOINTMENT: "UPDATE_APPOINTMENT", + UPDATE_APPOINTMENT_STATUS: "UPDATE_APPOINTMENT_STATUS", + DELETE_APPOINTMENT: "DELETE_APPOINTMENT", + + // Testimonial + UPDATE_TESTIMONIAL: "UPDATE_TESTIMONIAL", + + // Video Gallery + UPDATE_VIDEO_GALLERY: "UPDATE_VIDEO_GALLERY", + + // Auth / System + LOGIN: "LOGIN", + LOGOUT: "LOGOUT", +}); + +module.exports = AUDIT_ACTIONS; diff --git a/controllers/aboutUsController.js b/controllers/aboutUsController.js index 862b45a..b46b1ee 100644 --- a/controllers/aboutUsController.js +++ b/controllers/aboutUsController.js @@ -2,77 +2,87 @@ const { addBaseUrlToImages } = require("../utils/imageHelper"); const AboutUs = require("../models/aboutUs"); const Blog = require("../models/blog"); const jsonHelper = require("../utils/jsonHelper"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); /** * GET /api/about * Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu) */ exports.getAbout = async (req, res) => { - try { - // Force no-cache headers - res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); + try { + // Force no-cache headers + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); - const data = await AboutUs.getSingle(); - const rawData = data.toObject(); + const data = await AboutUs.getSingle(); + const rawData = data.toObject(); - // === Dynamic Blog News Section === - const news = rawData.news || {}; - let blogs = []; + // === Dynamic Blog News Section === + const news = rawData.news || {}; + let blogs = []; - // Nếu có chọn blog cụ thể - if (news.selectedBlogIds && news.selectedBlogIds.length > 0) { - blogs = await Blog.find({ - _id: { $in: news.selectedBlogIds }, - status: "published", - }).lean(); + // Nếu có chọn blog cụ thể + if (news.selectedBlogIds && news.selectedBlogIds.length > 0) { + blogs = await Blog.find({ + _id: { $in: news.selectedBlogIds }, + status: "published", + }).lean(); - // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds - blogs.sort((a, b) => { - return news.selectedBlogIds.indexOf(a._id.toString()) - news.selectedBlogIds.indexOf(b._id.toString()); - }); - } - - // Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất - if (blogs.length === 0) { - blogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).limit(3).lean(); - } - - // Map dữ liệu blog sang format mà frontend mong đợi - news.items = blogs.map((blog) => ({ - title: blog.title, - category: blog.category && blog.category[0] ? blog.category[0] : "Visa", - date: - blog.publishedAt || - new Date(blog.createdAt).toLocaleDateString("en-GB", { - day: "numeric", - month: "long", - year: "numeric", - }), - comments: blog.commentsCount || 0, - author: { - name: blog.author || "Admin", - avatar: "/assets/img/home-1/news/client.png", // Default avatar - }, - link: `/blog/${blog.slug}`, - thumbnail: blog.featuredImage, - })); - - rawData.news = news; - // =============================== - - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; - const processedData = addBaseUrlToImages(rawData, baseUrl); - - res.json(processedData); - } catch (error) { - console.error("Error getting about data:", error); - res.status(500).json({ - success: false, - error: "Failed to get about data", - }); + // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds + blogs.sort((a, b) => { + return ( + news.selectedBlogIds.indexOf(a._id.toString()) - + news.selectedBlogIds.indexOf(b._id.toString()) + ); + }); } + + // Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất + if (blogs.length === 0) { + blogs = await Blog.find({ status: "published" }) + .sort({ createdAt: -1 }) + .limit(3) + .lean(); + } + + // Map dữ liệu blog sang format mà frontend mong đợi + news.items = blogs.map((blog) => ({ + title: blog.title, + category: blog.category && blog.category[0] ? blog.category[0] : "Visa", + date: + blog.publishedAt || + new Date(blog.createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }), + comments: blog.commentsCount || 0, + author: { + name: blog.author || "Admin", + avatar: "/assets/img/home-1/news/client.png", // Default avatar + }, + link: `/blog/${blog.slug}`, + thumbnail: blog.featuredImage, + })); + + rawData.news = news; + // =============================== + + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; + const processedData = addBaseUrlToImages(rawData, baseUrl); + + res.json(processedData); + } catch (error) { + console.error("Error getting about data:", error); + res.status(500).json({ + success: false, + error: "Failed to get about data", + }); + } }; /** @@ -80,106 +90,159 @@ exports.getAbout = async (req, res) => { * Cập nhật dữ liệu About Us (Dùng cho AJAX từ CMS) */ exports.updateAbout = async (req, res) => { - try { - let updateData = req.body; + try { + let updateData = req.body; - // Nếu dữ liệu gửi qua trường aboutJson (dạng string JSON) - if (updateData.aboutJson && typeof updateData.aboutJson === "string") { - try { - updateData = JSON.parse(updateData.aboutJson); - } catch (e) { - return res.status(400).json({ - success: false, - message: "Invalid JSON in aboutJson", - }); - } - } - - const doc = await AboutUs.getSingle(); - // Use .set() for better handling of nested objects/arrays in Mongoose - doc.set(updateData); - await doc.save(); - - // Fetch fresh data for syncing and returning - const finalData = await AboutUs.findOne().select("-_id -__v -createdAt -updatedAt").lean(); - - // Update about.json file to keep it in sync - jsonHelper.writeJsonFile("about", finalData); - - res.json({ - success: true, - message: "About Us updated successfully", - data: finalData, - }); - } catch (error) { - console.error("Error updating about data:", error); - res.status(500).json({ - success: false, - error: "Failed to update about data: " + error.message, + // Nếu dữ liệu gửi qua trường aboutJson (dạng string JSON) + if (updateData.aboutJson && typeof updateData.aboutJson === "string") { + try { + updateData = JSON.parse(updateData.aboutJson); + } catch (e) { + return res.status(400).json({ + success: false, + message: "Invalid JSON in aboutJson", }); + } } + + const doc = await AboutUs.getSingle(); + + // ✅ Capture BEFORE state + const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + + // Use .set() for better handling of nested objects/arrays in Mongoose + doc.set(updateData); + await doc.save(); + + // ✅ Capture AFTER state + const afterData = JSON.parse(JSON.stringify(doc.toObject())); + + // ✅ AUDIT LOGGING - About Us Updated + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "AboutUs", + documentId: doc._id, + action: AUDIT_ACTIONS.UPDATE_ABOUT_US, + before: beforeData, + after: afterData, + changes, + req, + }); + console.log( + `✅ Audit log created for About Us update: ${changes.length} changes`, + ); + } else { + console.log("ℹ️ No changes detected for About Us update"); + } + + // Fetch fresh data for syncing and returning + const finalData = await AboutUs.findOne() + .select("-_id -__v -createdAt -updatedAt") + .lean(); + + // Update about.json file to keep it in sync + jsonHelper.writeJsonFile("about", finalData); + + res.json({ + success: true, + message: "About Us updated successfully", + data: finalData, + }); + } catch (error) { + console.error("Error updating about data:", error); + res.status(500).json({ + success: false, + error: "Failed to update about data: " + error.message, + }); + } }; /** * Render admin page (Dùng cho Admin UI) */ exports.index = async (req, res) => { - try { - const data = await AboutUs.getSingle(); - const rawData = data.toObject(); + try { + const data = await AboutUs.getSingle(); + const rawData = data.toObject(); - // Lấy tất cả blog để chọn trong CMS - const allBlogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).lean(); + // Lấy tất cả blog để chọn trong CMS + const allBlogs = await Blog.find({ status: "published" }) + .sort({ createdAt: -1 }) + .lean(); - const activeTab = req.query.activeTab || "hero"; - res.render("admin/aboutUs/index", { - layout: "layouts/main", - title: "About Us Management", - data: rawData, - allBlogs, - activeTab, - user: req.session.user, - currentPath: req.path, - frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", - backendUrl: process.env.BACKEND_URL || "http://localhost:3001", - }); - } catch (err) { - console.error("Error in about index:", err); - req.flash("error_msg", "Error loading About Us page"); - res.redirect("/admin/dashboard"); - } + const activeTab = req.query.activeTab || "hero"; + res.render("admin/aboutUs/index", { + layout: "layouts/main", + title: "About Us Management", + data: rawData, + allBlogs, + activeTab, + user: req.session.user, + currentPath: req.path, + frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", + backendUrl: process.env.BACKEND_URL || "http://localhost:3001", + }); + } catch (err) { + console.error("Error in about index:", err); + req.flash("error_msg", "Error loading About Us page"); + res.redirect("/admin/dashboard"); + } }; /** * Update method cho form-based submission (Admin UI - Post fallback) */ exports.update = async (req, res) => { - try { - let updateData = req.body; - if (updateData.aboutJson && typeof updateData.aboutJson === "string") { - try { - updateData = JSON.parse(updateData.aboutJson); - } catch (e) { - req.flash("error_msg", "Invalid JSON data"); - return res.redirect("/admin/about-us"); - } - } - - const doc = await AboutUs.getSingle(); - doc.set(updateData); - await doc.save(); - - const finalData = await AboutUs.findOne().select("-_id -__v -createdAt -updatedAt").lean(); - jsonHelper.writeJsonFile("about", finalData); - - req.flash("success_msg", "About Us updated successfully"); - const activeTab = req.query.activeTab || "hero"; - res.redirect(`/admin/about-us?activeTab=${activeTab}`); - } catch (err) { - console.error("Update error:", err); - req.flash("error_msg", "Error updating About Us: " + err.message); - res.redirect("/admin/about-us"); + try { + let updateData = req.body; + if (updateData.aboutJson && typeof updateData.aboutJson === "string") { + try { + updateData = JSON.parse(updateData.aboutJson); + } catch (e) { + req.flash("error_msg", "Invalid JSON data"); + return res.redirect("/admin/about-us"); + } } + + const doc = await AboutUs.getSingle(); + + // ✅ Capture BEFORE state + const beforeData = JSON.parse(JSON.stringify(doc.toObject())); + + doc.set(updateData); + await doc.save(); + + // ✅ Capture AFTER state + const afterData = JSON.parse(JSON.stringify(doc.toObject())); + + // ✅ AUDIT LOGGING - About Us Updated + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "AboutUs", + documentId: doc._id, + action: AUDIT_ACTIONS.UPDATE_ABOUT_US, + before: beforeData, + after: afterData, + changes, + req, + }); + } + + const finalData = await AboutUs.findOne() + .select("-_id -__v -createdAt -updatedAt") + .lean(); + jsonHelper.writeJsonFile("about", finalData); + + req.flash("success_msg", "About Us updated successfully"); + const activeTab = req.query.activeTab || "hero"; + res.redirect(`/admin/about-us?activeTab=${activeTab}`); + } catch (err) { + console.error("Update error:", err); + req.flash("error_msg", "Error updating About Us: " + err.message); + res.redirect("/admin/about-us"); + } }; // Aliases for compatibility diff --git a/controllers/appointmentController.js b/controllers/appointmentController.js index 076d246..cfd9b09 100644 --- a/controllers/appointmentController.js +++ b/controllers/appointmentController.js @@ -1,377 +1,450 @@ const AppointmentSubmission = require("../models/appointmentSubmission"); const Appointment = require("../models/appointment"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); // ==================== CMS ADMIN FUNCTIONS ==================== // Render admin page for appointment management exports.index = async (req, res) => { - try { - let appointment = await Appointment.findOne({ name: "default" }); + try { + let appointment = await Appointment.findOne({ name: "default" }); - // If no data in DB, try to load from JSON file - if (!appointment) { - const fs = require("fs"); - const path = require("path"); - const jsonPath = path.join(__dirname, "../data/appointment.json"); + // If no data in DB, try to load from JSON file + if (!appointment) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/appointment.json"); - if (fs.existsSync(jsonPath)) { - const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); - appointment = await Appointment.migrateFromJson(jsonData); - } else { - // Create default appointment - appointment = await Appointment.create({ - name: "default", - hero: { - title: "Make Appointment", - backgroundImage: "", - subtitle: "", - heading: "", - description: "", - }, - visaOptions: [], - form: { - heading: "Request Appointment", - fields: [], - submitButton: { - text: "Request Appointment", - icon: "fa-solid fa-arrow-right", - buttonClass: "theme-btn", - }, - }, - }); - } - } - - const { startDate, endDate } = req.query; - const query = {}; - - if (startDate || endDate) { - query.createdAt = {}; - 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 AppointmentSubmission.find(query).sort({ createdAt: -1 }).limit(50); - - res.render("admin/appointment/index", { - layout: "layouts/main", - title: "Appointment Management", - data: appointment, - submissions, - startDate, - endDate, - user: req.session.user, - frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + appointment = await Appointment.migrateFromJson(jsonData); + } else { + // Create default appointment + appointment = await Appointment.create({ + name: "default", + hero: { + title: "Make Appointment", + backgroundImage: "", + subtitle: "", + heading: "", + description: "", + }, + visaOptions: [], + form: { + heading: "Request Appointment", + fields: [], + submitButton: { + text: "Request Appointment", + icon: "fa-solid fa-arrow-right", + buttonClass: "theme-btn", + }, + }, }); - } catch (err) { - console.error("Error loading appointment admin page:", err); - req.flash("error", "Error loading appointment data"); - res.redirect("/admin/dashboard"); + } } + + const { startDate, endDate } = req.query; + const query = {}; + + if (startDate || endDate) { + query.createdAt = {}; + 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 AppointmentSubmission.find(query) + .sort({ createdAt: -1 }) + .limit(50); + + res.render("admin/appointment/index", { + layout: "layouts/main", + title: "Appointment Management", + data: appointment, + submissions, + startDate, + endDate, + user: req.session.user, + frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", + }); + } catch (err) { + console.error("Error loading appointment admin page:", err); + req.flash("error", "Error loading appointment data"); + res.redirect("/admin/dashboard"); + } }; // Update appointment data exports.update = async (req, res) => { - try { - const { hero, visaOptions, form } = req.body; + try { + const { hero, visaOptions, form } = req.body; - // Parse JSON strings if needed - const heroData = typeof hero === "string" ? JSON.parse(hero) : hero; - const visaOptionsData = typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions; - const formData = typeof form === "string" ? JSON.parse(form) : form; + // Parse JSON strings if needed + const heroData = typeof hero === "string" ? JSON.parse(hero) : hero; + const visaOptionsData = + typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions; + const formData = typeof form === "string" ? JSON.parse(form) : form; - let appointment = await Appointment.findOne({ name: "default" }); + let appointment = await Appointment.findOne({ name: "default" }); - if (appointment) { - appointment.hero = heroData; - appointment.visaOptions = visaOptionsData; - appointment.form = formData; - await appointment.save(); - } else { - appointment = await Appointment.create({ - name: "default", - hero: heroData, - visaOptions: visaOptionsData, - form: formData, - }); - } + // Capture before state for audit logging + const beforeState = appointment + ? JSON.parse(JSON.stringify(appointment.toObject())) + : null; - req.flash("success", "Appointment data updated successfully"); - res.redirect("/admin/appointment"); - } catch (err) { - console.error("Error updating appointment:", err); - req.flash("error", "Error updating appointment data"); - res.redirect("/admin/appointment"); + if (appointment) { + appointment.hero = heroData; + appointment.visaOptions = visaOptionsData; + appointment.form = formData; + await appointment.save(); + } else { + appointment = await Appointment.create({ + name: "default", + hero: heroData, + visaOptions: visaOptionsData, + form: formData, + }); } + + // Capture after state for audit logging + const afterState = JSON.parse(JSON.stringify(appointment.toObject())); + + // Generate changes diff + const changes = beforeState ? diffObject(beforeState, afterState) : []; + + // Write audit log + await writeAuditLog({ + model: "Appointment", + documentId: appointment._id, + action: AUDIT_ACTIONS.UPDATE_APPOINTMENT, + before: beforeState, + after: afterState, + changes, + req, + }); + + req.flash("success", "Appointment data updated successfully"); + res.redirect("/admin/appointment"); + } catch (err) { + console.error("Error updating appointment:", err); + req.flash("error", "Error updating appointment data"); + res.redirect("/admin/appointment"); + } }; // API to get appointment data exports.getAppointmentData = async (req, res) => { - try { - let appointment = await Appointment.findOne({ name: "default" }); + try { + let appointment = await Appointment.findOne({ name: "default" }); - if (!appointment) { - const fs = require("fs"); - const path = require("path"); - const jsonPath = path.join(__dirname, "../data/appointment.json"); + if (!appointment) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/appointment.json"); - if (fs.existsSync(jsonPath)) { - const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); - appointment = await Appointment.migrateFromJson(jsonData); - } - } - - res.json({ - success: true, - data: appointment, - }); - } catch (err) { - console.error("Error getting appointment data:", err); - res.status(500).json({ - success: false, - error: "Error loading appointment data", - }); + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + appointment = await Appointment.migrateFromJson(jsonData); + } } + + res.json({ + success: true, + data: appointment, + }); + } catch (err) { + console.error("Error getting appointment data:", err); + res.status(500).json({ + success: false, + error: "Error loading appointment data", + }); + } }; // Public API to get appointment page data (for frontend) exports.api = async (req, res) => { - try { - let appointment = await Appointment.findOne({ name: "default" }); + try { + let appointment = await Appointment.findOne({ name: "default" }); - if (!appointment) { - const fs = require("fs"); - const path = require("path"); - const jsonPath = path.join(__dirname, "../data/appointment.json"); + if (!appointment) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/appointment.json"); - if (fs.existsSync(jsonPath)) { - const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); - appointment = await Appointment.migrateFromJson(jsonData); - } - } - - if (!appointment) { - return res.status(404).json({ - success: false, - error: "Appointment data not found", - }); - } - - res.json({ - success: true, - data: { - hero: appointment.hero, - visaOptions: appointment.visaOptions, - form: appointment.form, - }, - }); - } catch (err) { - console.error("Error getting appointment API data:", err); - res.status(500).json({ - success: false, - error: "Error loading appointment data", - }); + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + appointment = await Appointment.migrateFromJson(jsonData); + } } + + if (!appointment) { + return res.status(404).json({ + success: false, + error: "Appointment data not found", + }); + } + + res.json({ + success: true, + data: { + hero: appointment.hero, + visaOptions: appointment.visaOptions, + form: appointment.form, + }, + }); + } catch (err) { + console.error("Error getting appointment API data:", err); + res.status(500).json({ + success: false, + error: "Error loading appointment data", + }); + } }; // ==================== APPOINTMENT SUBMISSIONS API ==================== // API để submit appointment form (từ frontend) exports.submitAppointment = async (req, res) => { - try { - const { name, email, phone, address, appointmentDate, message, visaTypes } = req.body; + try { + const { name, email, phone, address, appointmentDate, message, visaTypes } = + req.body; - // Validation - if (!name || !email) { - return res.status(400).json({ - success: false, - error: "Name and email are required", - }); - } - - // Create new submission - const submission = new AppointmentSubmission({ - name: name.trim(), - email: email.trim().toLowerCase(), - phone: phone?.trim() || "", - address: address?.trim() || "", - appointmentDate: appointmentDate?.trim() || "", - message: message?.trim() || "", - visaTypes: Array.isArray(visaTypes) ? visaTypes : [], - ipAddress: req.ip || req.connection?.remoteAddress || "", - userAgent: req.get("User-Agent") || "", - }); - - await submission.save(); - - res.status(201).json({ - success: true, - message: "Thank you! Your appointment request has been submitted. We will contact you soon.", - data: { - id: submission._id, - name: submission.name, - email: submission.email, - appointmentDate: submission.appointmentDate, - }, - }); - } catch (err) { - console.error("Error submitting appointment:", 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(", "), - }); - } - - res.status(500).json({ - success: false, - error: "Error submitting appointment. Please try again later.", - }); + // Validation + if (!name || !email) { + return res.status(400).json({ + success: false, + error: "Name and email are required", + }); } + + // Create new submission + const submission = new AppointmentSubmission({ + name: name.trim(), + email: email.trim().toLowerCase(), + phone: phone?.trim() || "", + address: address?.trim() || "", + appointmentDate: appointmentDate?.trim() || "", + message: message?.trim() || "", + visaTypes: Array.isArray(visaTypes) ? visaTypes : [], + ipAddress: req.ip || req.connection?.remoteAddress || "", + userAgent: req.get("User-Agent") || "", + }); + + await submission.save(); + + res.status(201).json({ + success: true, + message: + "Thank you! Your appointment request has been submitted. We will contact you soon.", + data: { + id: submission._id, + name: submission.name, + email: submission.email, + appointmentDate: submission.appointmentDate, + }, + }); + } catch (err) { + console.error("Error submitting appointment:", 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(", "), + }); + } + + res.status(500).json({ + success: false, + error: "Error submitting appointment. Please try again later.", + }); + } }; // API để lấy danh sách appointments (cho admin) exports.getAppointments = async (req, res) => { - try { - const { status, page = 1, limit = 20 } = req.query; + try { + const { status, page = 1, limit = 20 } = req.query; - const query = {}; - if (status && ["pending", "confirmed", "completed", "cancelled"].includes(status)) { - query.status = status; - } - - const skip = (parseInt(page) - 1) * parseInt(limit); - - const [appointments, total] = await Promise.all([ - AppointmentSubmission.find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(parseInt(limit)), - AppointmentSubmission.countDocuments(query), - ]); - - res.json({ - success: true, - data: appointments, - pagination: { - page: parseInt(page), - limit: parseInt(limit), - total, - totalPages: Math.ceil(total / parseInt(limit)), - }, - }); - } catch (err) { - console.error("Error getting appointments:", err); - res.status(500).json({ - success: false, - error: "Error loading appointments", - }); + const query = {}; + if ( + status && + ["pending", "confirmed", "completed", "cancelled"].includes(status) + ) { + query.status = status; } + + const skip = (parseInt(page) - 1) * parseInt(limit); + + const [appointments, total] = await Promise.all([ + AppointmentSubmission.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit)), + AppointmentSubmission.countDocuments(query), + ]); + + res.json({ + success: true, + data: appointments, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + totalPages: Math.ceil(total / parseInt(limit)), + }, + }); + } catch (err) { + console.error("Error getting appointments:", err); + res.status(500).json({ + success: false, + error: "Error loading appointments", + }); + } }; // API để cập nhật status của appointment exports.updateAppointmentStatus = async (req, res) => { - try { - const { id } = req.params; - const { status, notes } = req.body; + try { + const { id } = req.params; + const { status, notes } = req.body; - const validStatuses = ["pending", "confirmed", "completed", "cancelled"]; - if (!validStatuses.includes(status)) { - return res.status(400).json({ - success: false, - error: "Invalid status", - }); - } - - const updateData = { status }; - if (notes !== undefined) updateData.notes = notes; - if (status === "confirmed") updateData.confirmedAt = new Date(); - if (status === "completed") updateData.completedAt = new Date(); - - const appointment = await AppointmentSubmission.findByIdAndUpdate( - id, - updateData, - { new: true } - ); - - if (!appointment) { - return res.status(404).json({ - success: false, - error: "Appointment not found", - }); - } - - res.json({ - success: true, - data: appointment, - }); - } catch (err) { - console.error("Error updating appointment:", err); - res.status(500).json({ - success: false, - error: "Error updating appointment", - }); + const validStatuses = ["pending", "confirmed", "completed", "cancelled"]; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + success: false, + error: "Invalid status", + }); } + + // Get the appointment before update for audit logging + const beforeAppointment = await AppointmentSubmission.findById(id); + if (!beforeAppointment) { + return res.status(404).json({ + success: false, + error: "Appointment not found", + }); + } + + const beforeState = JSON.parse( + JSON.stringify(beforeAppointment.toObject()), + ); + + const updateData = { status }; + if (notes !== undefined) updateData.notes = notes; + if (status === "confirmed") updateData.confirmedAt = new Date(); + if (status === "completed") updateData.completedAt = new Date(); + + const appointment = await AppointmentSubmission.findByIdAndUpdate( + id, + updateData, + { new: true }, + ); + + // Capture after state for audit logging + const afterState = JSON.parse(JSON.stringify(appointment.toObject())); + + // Generate changes diff + const changes = diffObject(beforeState, afterState); + + // Write audit log + await writeAuditLog({ + model: "AppointmentSubmission", + documentId: appointment._id, + action: AUDIT_ACTIONS.UPDATE_APPOINTMENT_STATUS, + before: beforeState, + after: afterState, + changes, + req, + }); + + res.json({ + success: true, + data: appointment, + }); + } catch (err) { + console.error("Error updating appointment:", err); + res.status(500).json({ + success: false, + error: "Error updating appointment", + }); + } }; // API để lấy chi tiết một appointment exports.getAppointmentById = async (req, res) => { - try { - const { id } = req.params; - const appointment = await AppointmentSubmission.findById(id); + try { + const { id } = req.params; + const appointment = await AppointmentSubmission.findById(id); - if (!appointment) { - return res.status(404).json({ - success: false, - error: "Appointment not found", - }); - } - - res.json({ - success: true, - data: appointment, - }); - } catch (err) { - console.error("Error getting appointment:", err); - res.status(500).json({ - success: false, - error: "Error loading appointment", - }); + if (!appointment) { + return res.status(404).json({ + success: false, + error: "Appointment not found", + }); } + + res.json({ + success: true, + data: appointment, + }); + } catch (err) { + console.error("Error getting appointment:", err); + res.status(500).json({ + success: false, + error: "Error loading appointment", + }); + } }; // API để xóa appointment exports.deleteAppointment = async (req, res) => { - try { - const { id } = req.params; - const appointment = await AppointmentSubmission.findByIdAndDelete(id); + try { + const { id } = req.params; - if (!appointment) { - return res.status(404).json({ - success: false, - error: "Appointment not found", - }); - } - - res.json({ - success: true, - message: "Appointment deleted successfully", - }); - } catch (err) { - console.error("Error deleting appointment:", err); - res.status(500).json({ - success: false, - error: "Error deleting appointment", - }); + // Get the appointment before deletion for audit logging + const appointment = await AppointmentSubmission.findById(id); + if (!appointment) { + return res.status(404).json({ + success: false, + error: "Appointment not found", + }); } + + const beforeState = JSON.parse(JSON.stringify(appointment.toObject())); + + // Delete the appointment + await AppointmentSubmission.findByIdAndDelete(id); + + // Write audit log + await writeAuditLog({ + model: "AppointmentSubmission", + documentId: appointment._id, + action: AUDIT_ACTIONS.DELETE_APPOINTMENT, + before: beforeState, + after: null, + changes: [], + req, + }); + + res.json({ + success: true, + message: "Appointment deleted successfully", + }); + } catch (err) { + console.error("Error deleting appointment:", err); + res.status(500).json({ + success: false, + error: "Error deleting appointment", + }); + } }; diff --git a/controllers/auditLogController.js b/controllers/auditLogController.js new file mode 100644 index 0000000..ed5e967 --- /dev/null +++ b/controllers/auditLogController.js @@ -0,0 +1,178 @@ +const AuditLog = require("../models/auditLog"); +const User = require("../models/User"); + +// Display audit logs with pagination and filtering +exports.index = async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 8; // Default to 8, but allow override + const skip = (page - 1) * limit; + + // Build filter query + const filter = {}; + + if (req.query.model) { + filter.model = req.query.model; + } + + if (req.query.action) { + filter.action = req.query.action; + } + + if (req.query.user) { + filter.performedBy = req.query.user; + } + + if (req.query.dateFrom || req.query.dateTo) { + filter.createdAt = {}; + if (req.query.dateFrom) { + filter.createdAt.$gte = new Date(req.query.dateFrom); + } + if (req.query.dateTo) { + const dateTo = new Date(req.query.dateTo); + dateTo.setHours(23, 59, 59, 999); // End of day + filter.createdAt.$lte = dateTo; + } + } + + // Get audit logs with user population + const auditLogs = await AuditLog.find(filter) + .populate("performedBy", "username email") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit); + + // Get total count for pagination + const totalCount = await AuditLog.countDocuments(filter); + const totalPages = Math.ceil(totalCount / limit); + + // Get unique models and actions for filter dropdowns + const uniqueModels = await AuditLog.distinct("model"); + const uniqueActions = await AuditLog.distinct("action"); + const users = await User.find({}, "username email").sort({ username: 1 }); + + res.render("admin/audit-log/index", { + title: "Audit Logs", + layout: "layouts/main", + auditLogs, + pagination: { + current: page, + total: totalPages, + limit, + totalCount, + }, + query: req.query, + uniqueModels, + uniqueActions, + users, + currentPath: req.path, + user: req.session.user, + }); + } catch (err) { + console.error("Error loading audit logs:", err); + req.flash("error_msg", "Error loading audit logs"); + res.redirect("/admin/dashboard"); + } +}; + +// Display single audit log details +exports.show = async (req, res) => { + try { + const auditLog = await AuditLog.findById(req.params.id).populate( + "performedBy", + "username email", + ); + + if (!auditLog) { + req.flash("error_msg", "Audit log not found"); + return res.redirect("/admin/audit-logs"); + } + + res.render("admin/audit-log/show", { + title: "Audit Log Details", + layout: "layouts/main", + auditLog, + currentPath: req.path, + user: req.session.user, + }); + } catch (err) { + console.error("Error loading audit log:", err); + req.flash("error_msg", "Error loading audit log"); + res.redirect("/admin/audit-logs"); + } +}; + +// API endpoint to get audit logs (for AJAX requests) +exports.api = async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 7; // Default to 7, but allow override + const skip = (page - 1) * limit; + + const filter = {}; + + if (req.query.model) filter.model = req.query.model; + if (req.query.action) filter.action = req.query.action; + if (req.query.user) filter.performedBy = req.query.user; + + if (req.query.dateFrom || req.query.dateTo) { + filter.createdAt = {}; + if (req.query.dateFrom) { + filter.createdAt.$gte = new Date(req.query.dateFrom); + } + if (req.query.dateTo) { + const dateTo = new Date(req.query.dateTo); + dateTo.setHours(23, 59, 59, 999); + filter.createdAt.$lte = dateTo; + } + } + + const auditLogs = await AuditLog.find(filter) + .populate("performedBy", "username email") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit); + + const totalCount = await AuditLog.countDocuments(filter); + + res.json({ + success: true, + data: auditLogs, + pagination: { + current: page, + total: Math.ceil(totalCount / limit), + limit, + totalCount, + }, + }); + } catch (err) { + console.error("API Error:", err); + res.status(500).json({ + success: false, + error: "Error loading audit logs", + }); + } +}; + +// Delete old audit logs (cleanup) +exports.cleanup = async (req, res) => { + try { + const daysToKeep = parseInt(req.body.days) || 90; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const result = await AuditLog.deleteMany({ + createdAt: { $lt: cutoffDate }, + }); + + req.flash( + "success_msg", + `Deleted ${result.deletedCount} old audit logs (older than ${daysToKeep} days)`, + ); + res.redirect("/admin/audit-logs"); + } catch (err) { + console.error("Error cleaning up audit logs:", err); + req.flash("error_msg", "Error cleaning up audit logs"); + res.redirect("/admin/audit-logs"); + } +}; diff --git a/controllers/blogController.js b/controllers/blogController.js index 0abad0b..dbe89ea 100644 --- a/controllers/blogController.js +++ b/controllers/blogController.js @@ -1,10 +1,13 @@ -const Blog = require('../models/blog'); -const BlogCategory = require('../models/blogCategory'); -const BlogTag = require('../models/blogTag'); -const BlogComment = require('../models/blogComment'); -const RecentPost = require('../models/recentPost'); -const { addBaseUrlToImages, getFullImageUrl } = require('../utils/imageHelper'); -const slugify = require('slugify'); +const Blog = require("../models/blog"); +const BlogCategory = require("../models/blogCategory"); +const BlogTag = require("../models/blogTag"); +const BlogComment = require("../models/blogComment"); +const RecentPost = require("../models/recentPost"); +const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); +const slugify = require("slugify"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); // -------------------- Helper Functions -------------------- @@ -13,7 +16,7 @@ const generateSlug = (title) => { return slugify(title, { lower: true, strict: true, - locale: 'vi' + locale: "vi", }); }; @@ -41,7 +44,7 @@ exports.index = async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const skip = (page - 1) * limit; - + // Build filter const filter = {}; if (req.query.status) { @@ -52,30 +55,30 @@ exports.index = async (req, res) => { } if (req.query.search) { filter.$or = [ - { title: { $regex: req.query.search, $options: 'i' } }, - { excerpt: { $regex: req.query.search, $options: 'i' } } + { title: { $regex: req.query.search, $options: "i" } }, + { excerpt: { $regex: req.query.search, $options: "i" } }, ]; } - + // Get blogs with pagination const blogs = await Blog.find(filter) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) .lean(); - + const totalBlogs = await Blog.countDocuments(filter); const totalPages = Math.ceil(totalBlogs / limit); - + // Get categories for filter const categories = await BlogCategory.getActive(); - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; - const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001'; - - res.render('admin/blog/index', { - layout: 'layouts/main', - title: 'Blog Management', + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; + + res.render("admin/blog/index", { + layout: "layouts/main", + title: "Blog Management", blogs, categories, frontendUrl, @@ -85,16 +88,16 @@ exports.index = async (req, res) => { current: page, total: totalPages, limit, - totalItems: totalBlogs + totalItems: totalBlogs, }, query: req.query, currentPath: req.path, - user: req.session.user + user: req.session.user, }); } catch (err) { - console.error('Blog index error:', err); - req.flash('error_msg', 'Error loading blogs'); - res.redirect('/admin/dashboard'); + console.error("Blog index error:", err); + req.flash("error_msg", "Error loading blogs"); + res.redirect("/admin/dashboard"); } }; @@ -103,25 +106,25 @@ exports.create = async (req, res) => { try { const categories = await BlogCategory.getActive(); const tags = await BlogTag.getActive(); - - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; - const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001'; - - res.render('admin/blog/create', { - layout: 'layouts/main', - title: 'Create New Blog Post', + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; + + res.render("admin/blog/create", { + layout: "layouts/main", + title: "Create New Blog Post", categories, tags, currentPath: req.path, user: req.session.user, frontendUrl, backendUrl, - getFullImageUrl // Truyền helper function vào template + getFullImageUrl, // Truyền helper function vào template }); } catch (err) { - console.error('Blog create form error:', err); - req.flash('error_msg', 'Error loading create form'); - res.redirect('/admin/blog'); + console.error("Blog create form error:", err); + req.flash("error_msg", "Error loading create form"); + res.redirect("/admin/blog"); } }; @@ -139,55 +142,74 @@ exports.store = async (req, res) => { author, galleryImages, quote, - contentAfterQuote + contentAfterQuote, } = req.body; - + // Generate slug const slug = generateSlug(title); - + // Check if slug exists const existingBlog = await Blog.findOne({ slug }); if (existingBlog) { - req.flash('error_msg', 'A blog post with this title already exists'); - return res.redirect('/admin/blog/create'); + req.flash("error_msg", "A blog post with this title already exists"); + return res.redirect("/admin/blog/create"); } - + // Create blog data const blogData = { title, slug, excerpt, content, - category: category ? (Array.isArray(category) ? category : [category]) : [], // Array categories + category: category + ? Array.isArray(category) + ? category + : [category] + : [], // Array categories tags: tags ? (Array.isArray(tags) ? tags : [tags]) : [], - status: status || 'published', - isFeatured: isFeatured === 'on', - author: author || 'Admin', - galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [], - quote: quote || '', - contentAfterQuote: contentAfterQuote || '' + status: status || "published", + isFeatured: isFeatured === "on", + author: author || "Admin", + galleryImages: galleryImages + ? Array.isArray(galleryImages) + ? galleryImages + : [galleryImages] + : [], + quote: quote || "", + contentAfterQuote: contentAfterQuote || "", }; - + // Handle featured image - using featuredImageUrl from form (uploaded via AJAX) if (req.body.featuredImageUrl) { blogData.featuredImage = req.body.featuredImageUrl; } - + // Create blog const blog = new Blog(blogData); await blog.save(); - + + // AUDIT LOGGING - Blog Created + await writeAuditLog({ + model: "Blog", + documentId: blog._id, + action: AUDIT_ACTIONS.CREATE_BLOG, + before: null, // No before state for CREATE + after: JSON.parse(JSON.stringify(blog.toObject())), + changes: [], // No changes for CREATE + req, + }); + // Update counts await updateCategoryPostCounts(); await updateTagPostCounts(); await RecentPost.syncFromBlogs(); - - req.flash('success_msg', 'Blog post created successfully'); - res.redirect('/admin/blog'); + + req.flash("success_msg", "Blog post created successfully"); + res.redirect("/admin/blog"); } catch (err) { - console.error('Blog store error:', err); - req.flash('error_msg', 'Error creating blog post'); - res.redirect('/admin/blog/create'); + console.error("Blog store error:", err); + req.flash("error_msg", "Error creating blog post"); + res.redirect("/admin/blog/create"); } }; @@ -195,36 +217,38 @@ exports.store = async (req, res) => { exports.edit = async (req, res) => { try { const blog = await Blog.findById(req.params.id); - + if (!blog) { - req.flash('error_msg', 'Blog post not found'); - return res.redirect('/admin/blog'); + req.flash("error_msg", "Blog post not found"); + return res.redirect("/admin/blog"); } - + const categories = await BlogCategory.getActive(); const tags = await BlogTag.getActive(); - + // Get all comments for this blog post (including pending, approved, rejected) const allComments = await BlogComment.find({ postId: blog._id }) .sort({ createdAt: -1 }) .lean(); - + // Organize comments with replies - const parentComments = allComments.filter(c => !c.parentId); - const commentsWithReplies = parentComments.map(parent => { - const replies = allComments.filter(c => c.parentId && c.parentId.toString() === parent._id.toString()); + const parentComments = allComments.filter((c) => !c.parentId); + const commentsWithReplies = parentComments.map((parent) => { + const replies = allComments.filter( + (c) => c.parentId && c.parentId.toString() === parent._id.toString(), + ); return { ...parent, - replies: replies + replies: replies, }; }); - - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; - const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001'; - - res.render('admin/blog/edit', { - layout: 'layouts/main', - title: 'Edit Blog Post', + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; + + res.render("admin/blog/edit", { + layout: "layouts/main", + title: "Edit Blog Post", blog, categories, tags, @@ -234,12 +258,12 @@ exports.edit = async (req, res) => { user: req.session.user, frontendUrl, backendUrl, - getFullImageUrl // Truyền helper function vào template + getFullImageUrl, // Truyền helper function vào template }); } catch (err) { - console.error('Blog edit form error:', err); - req.flash('error_msg', 'Error loading blog post'); - res.redirect('/admin/blog'); + console.error("Blog edit form error:", err); + req.flash("error_msg", "Error loading blog post"); + res.redirect("/admin/blog"); } }; @@ -247,12 +271,15 @@ exports.edit = async (req, res) => { exports.update = async (req, res) => { try { const blog = await Blog.findById(req.params.id); - + if (!blog) { - req.flash('error_msg', 'Blog post not found'); - return res.redirect('/admin/blog'); + req.flash("error_msg", "Blog post not found"); + return res.redirect("/admin/blog"); } - + + // Capture BEFORE state + const beforeData = JSON.parse(JSON.stringify(blog.toObject())); + const { title, excerpt, @@ -264,50 +291,78 @@ exports.update = async (req, res) => { author, galleryImages, quote, - contentAfterQuote + contentAfterQuote, } = req.body; - + // Update blog data blog.title = title; blog.excerpt = excerpt; blog.content = content; - blog.category = category ? (Array.isArray(category) ? category : [category]) : []; // Array categories + blog.category = category + ? Array.isArray(category) + ? category + : [category] + : []; // Array categories blog.tags = tags ? (Array.isArray(tags) ? tags : [tags]) : []; - blog.status = status || 'published'; - blog.isFeatured = isFeatured === 'on'; - blog.author = author || 'Admin'; - blog.galleryImages = galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : []; - blog.quote = quote || ''; - blog.contentAfterQuote = contentAfterQuote || ''; - + blog.status = status || "published"; + blog.isFeatured = isFeatured === "on"; + blog.author = author || "Admin"; + blog.galleryImages = galleryImages + ? Array.isArray(galleryImages) + ? galleryImages + : [galleryImages] + : []; + blog.quote = quote || ""; + blog.contentAfterQuote = contentAfterQuote || ""; + // Handle featured image - using featuredImageUrl from form (uploaded via AJAX) if (req.body.featuredImageUrl) { blog.featuredImage = req.body.featuredImageUrl; } - + // Generate new slug if title changed const newSlug = generateSlug(title); if (newSlug !== blog.slug) { - const existingBlog = await Blog.findOne({ slug: newSlug, _id: { $ne: blog._id } }); + const existingBlog = await Blog.findOne({ + slug: newSlug, + _id: { $ne: blog._id }, + }); if (existingBlog) { - req.flash('error_msg', 'A blog post with this title already exists'); + req.flash("error_msg", "A blog post with this title already exists"); return res.redirect(`/admin/blog/${blog._id}/edit`); } blog.slug = newSlug; } - + await blog.save(); - + + // Capture AFTER state + const afterData = JSON.parse(JSON.stringify(blog.toObject())); + + // AUDIT LOGGING - Blog Updated + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "Blog", + documentId: blog._id, + action: AUDIT_ACTIONS.UPDATE_BLOG, + before: beforeData, + after: afterData, + changes, + req, + }); + } + // Update counts await updateCategoryPostCounts(); await updateTagPostCounts(); await RecentPost.syncFromBlogs(); - - req.flash('success_msg', 'Blog post updated successfully'); - res.redirect('/admin/blog'); + + req.flash("success_msg", "Blog post updated successfully"); + res.redirect("/admin/blog"); } catch (err) { - console.error('Blog update error:', err); - req.flash('error_msg', 'Error updating blog post'); + console.error("Blog update error:", err); + req.flash("error_msg", "Error updating blog post"); res.redirect(`/admin/blog/${req.params.id}/edit`); } }; @@ -316,25 +371,39 @@ exports.update = async (req, res) => { exports.destroy = async (req, res) => { try { const blog = await Blog.findById(req.params.id); - + if (!blog) { - req.flash('error_msg', 'Blog post not found'); - return res.redirect('/admin/blog'); + req.flash("error_msg", "Blog post not found"); + return res.redirect("/admin/blog"); } - + + // ✅ Capture BEFORE state + const beforeData = JSON.parse(JSON.stringify(blog.toObject())); + await Blog.findByIdAndDelete(req.params.id); - + + // ✅ AUDIT LOGGING - Blog Deleted + await writeAuditLog({ + model: "Blog", + documentId: req.params.id, + action: AUDIT_ACTIONS.DELETE_BLOG, + before: beforeData, + after: null, // No after state for DELETE + changes: [], + req, + }); + // Update counts await updateCategoryPostCounts(); await updateTagPostCounts(); await RecentPost.syncFromBlogs(); - - req.flash('success_msg', 'Blog post deleted successfully'); - res.redirect('/admin/blog'); + + req.flash("success_msg", "Blog post deleted successfully"); + res.redirect("/admin/blog"); } catch (err) { - console.error('Blog delete error:', err); - req.flash('error_msg', 'Error deleting blog post'); - res.redirect('/admin/blog'); + console.error("Blog delete error:", err); + req.flash("error_msg", "Error deleting blog post"); + res.redirect("/admin/blog"); } }; @@ -346,57 +415,60 @@ exports.api = async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const skip = (page - 1) * limit; - + // Build filter - const filter = { status: 'published' }; - + const filter = { status: "published" }; + if (req.query.category) { filter.category = { $in: [req.query.category] }; // Tìm trong array categories } - + if (req.query.tag) { filter.tags = { $in: [req.query.tag] }; // Tìm trong array tags } - + if (req.query.search) { filter.$or = [ - { title: { $regex: req.query.search, $options: 'i' } }, - { excerpt: { $regex: req.query.search, $options: 'i' } } + { title: { $regex: req.query.search, $options: "i" } }, + { excerpt: { $regex: req.query.search, $options: "i" } }, ]; } - + // Get blogs const blogs = await Blog.find(filter) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) .lean(); - + const totalBlogs = await Blog.countDocuments(filter); - + // Add base URL to images - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; - const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl)); - + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; + const processedBlogs = blogs.map((blog) => + addBaseUrlToImages(blog, baseUrl), + ); + res.json({ success: true, - message: 'Blogs fetched successfully', + message: "Blogs fetched successfully", data: { blogs: processedBlogs, pagination: { current: page, total: Math.ceil(totalBlogs / limit), limit, - totalItems: totalBlogs - } - } + totalItems: totalBlogs, + }, + }, }); } catch (err) { - console.error('Blog API error:', err); + console.error("Blog API error:", err); res.status(500).json({ success: false, - message: 'Error loading blogs', - error: err.message || 'Error loading blogs' + message: "Error loading blogs", + error: err.message || "Error loading blogs", }); } }; @@ -404,58 +476,59 @@ exports.api = async (req, res) => { // Get single blog by slug exports.apiShow = async (req, res) => { try { - const blog = await Blog.findOne({ - slug: req.params.slug, - status: 'published' + const blog = await Blog.findOne({ + slug: req.params.slug, + status: "published", }).lean(); - + if (!blog) { return res.status(404).json({ success: false, - message: 'Blog post not found' + message: "Blog post not found", }); } - + // Get comments for this post (parent comments only) const parentComments = await BlogComment.getApprovedByPost(blog._id); - + // Get replies for each parent comment const commentsWithReplies = await Promise.all( parentComments.map(async (parentComment) => { const replies = await BlogComment.getReplies(parentComment._id); return { ...parentComment.toObject(), - replies: replies.map(reply => reply.toObject()) + replies: replies.map((reply) => reply.toObject()), }; - }) + }), ); - + // Flatten comments array (parent + replies) - const allComments = commentsWithReplies.flatMap(comment => [ + const allComments = commentsWithReplies.flatMap((comment) => [ comment, - ...comment.replies + ...comment.replies, ]); - + // Add comments to blog blog.comments = allComments; // Keep commentsCount in sync for frontend blog.commentsCount = allComments.length; - + // Add base URL to images - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; const processedBlog = addBaseUrlToImages(blog, baseUrl); - + res.json({ success: true, - message: 'Blog post fetched successfully', - data: processedBlog + message: "Blog post fetched successfully", + data: processedBlog, }); } catch (err) { - console.error('Blog show API error:', err); + console.error("Blog show API error:", err); res.status(500).json({ success: false, - message: 'Error loading blog post', - error: err.message || 'Error loading blog post' + message: "Error loading blog post", + error: err.message || "Error loading blog post", }); } }; @@ -463,7 +536,15 @@ exports.apiShow = async (req, res) => { // Create a comment (no moderation for now: default approved) exports.apiCreateComment = async (req, res) => { try { - const { authorName, authorEmail, authorPhone, authorAddress, authorDate, content, parentId } = req.body || {}; + const { + authorName, + authorEmail, + authorPhone, + authorAddress, + authorDate, + content, + parentId, + } = req.body || {}; if (!authorName || !String(authorName).trim()) { return res.status(400).json({ @@ -479,7 +560,10 @@ exports.apiCreateComment = async (req, res) => { }); } - const blog = await Blog.findOne({ slug: req.params.slug, status: "published" }).lean(); + const blog = await Blog.findOne({ + slug: req.params.slug, + status: "published", + }).lean(); if (!blog) { return res.status(404).json({ success: false, @@ -490,7 +574,10 @@ exports.apiCreateComment = async (req, res) => { // If replying, ensure parent exists and belongs to same post let parentObjectId = null; if (parentId) { - const parent = await BlogComment.findOne({ _id: parentId, postId: blog._id }).lean(); + const parent = await BlogComment.findOne({ + _id: parentId, + postId: blog._id, + }).lean(); if (!parent) { return res.status(400).json({ success: false, @@ -534,26 +621,27 @@ exports.apiCreateComment = async (req, res) => { exports.apiFeatured = async (req, res) => { try { const limit = parseInt(req.query.limit) || 3; - - const blogs = await Blog.getFeatured() - .limit(limit) - .lean(); - + + const blogs = await Blog.getFeatured().limit(limit).lean(); + // Add base URL to images - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('port')}`; - const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl)); - + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("port")}`; + const processedBlogs = blogs.map((blog) => + addBaseUrlToImages(blog, baseUrl), + ); + res.json({ success: true, - message: 'Featured blogs fetched successfully', - data: processedBlogs + message: "Featured blogs fetched successfully", + data: processedBlogs, }); } catch (err) { - console.error('Featured blogs API error:', err); + console.error("Featured blogs API error:", err); res.status(500).json({ success: false, - message: 'Error loading featured blogs', - error: err.message || 'Error loading featured blogs' + message: "Error loading featured blogs", + error: err.message || "Error loading featured blogs", }); } }; @@ -562,31 +650,34 @@ exports.apiFeatured = async (req, res) => { exports.apiRecent = async (req, res) => { try { const limit = parseInt(req.query.limit) || 5; - + // Try to get from RecentPost first let recentPosts = await RecentPost.getRecent(limit); - + // If no recent posts, sync from blogs if (recentPosts.length === 0) { await RecentPost.syncFromBlogs(limit); recentPosts = await RecentPost.getRecent(limit); } - + // Add base URL to images - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; - const processedPosts = recentPosts.map(post => addBaseUrlToImages(post, baseUrl)); - + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; + const processedPosts = recentPosts.map((post) => + addBaseUrlToImages(post, baseUrl), + ); + res.json({ success: true, - message: 'Recent blogs fetched successfully', - data: processedPosts + message: "Recent blogs fetched successfully", + data: processedPosts, }); } catch (err) { - console.error('Recent blogs API error:', err); + console.error("Recent blogs API error:", err); res.status(500).json({ success: false, - message: 'Error loading recent blogs', - error: err.message || 'Error loading recent blogs' + message: "Error loading recent blogs", + error: err.message || "Error loading recent blogs", }); } }; @@ -594,45 +685,45 @@ exports.apiRecent = async (req, res) => { // Get categories of a specific blog post exports.apiCategories = async (req, res) => { try { - const mongoose = require('mongoose'); + const mongoose = require("mongoose"); let query; - + // Check if it's a valid ObjectId if (mongoose.Types.ObjectId.isValid(req.params.id)) { query = { _id: req.params.id }; } else { query = { slug: req.params.id }; } - - query.status = 'published'; - + + query.status = "published"; + const blog = await Blog.findOne(query).lean(); - + if (!blog) { return res.status(404).json({ success: false, - message: 'Blog post not found' + message: "Blog post not found", }); } - + // Get category details - const BlogCategory = require('../models/blogCategory'); - const categories = await BlogCategory.find({ + const BlogCategory = require("../models/blogCategory"); + const categories = await BlogCategory.find({ name: { $in: blog.category }, - isActive: true + isActive: true, }).lean(); - + res.json({ success: true, - message: 'Blog categories fetched successfully', - data: categories + message: "Blog categories fetched successfully", + data: categories, }); } catch (err) { - console.error('Blog categories API error:', err); + console.error("Blog categories API error:", err); res.status(500).json({ success: false, - message: 'Error loading blog categories', - error: err.message || 'Error loading blog categories' + message: "Error loading blog categories", + error: err.message || "Error loading blog categories", }); } }; @@ -640,45 +731,45 @@ exports.apiCategories = async (req, res) => { // Get tags of a specific blog post exports.apiTags = async (req, res) => { try { - const mongoose = require('mongoose'); + const mongoose = require("mongoose"); let query; - + // Check if it's a valid ObjectId if (mongoose.Types.ObjectId.isValid(req.params.id)) { query = { _id: req.params.id }; } else { query = { slug: req.params.id }; } - - query.status = 'published'; - + + query.status = "published"; + const blog = await Blog.findOne(query).lean(); - + if (!blog) { return res.status(404).json({ success: false, - message: 'Blog post not found' + message: "Blog post not found", }); } - + // Get tag details - const BlogTag = require('../models/blogTag'); - const tags = await BlogTag.find({ + const BlogTag = require("../models/blogTag"); + const tags = await BlogTag.find({ name: { $in: blog.tags }, - isActive: true + isActive: true, }).lean(); - + res.json({ success: true, - message: 'Blog tags fetched successfully', - data: tags + message: "Blog tags fetched successfully", + data: tags, }); } catch (err) { - console.error('Blog tags API error:', err); + console.error("Blog tags API error:", err); res.status(500).json({ success: false, - message: 'Error loading blog tags', - error: err.message || 'Error loading blog tags' + message: "Error loading blog tags", + error: err.message || "Error loading blog tags", }); } }; @@ -689,36 +780,36 @@ exports.apiTags = async (req, res) => { exports.approveComment = async (req, res) => { try { const { blogId, commentId } = req.params; - + const blog = await Blog.findById(blogId); if (!blog) { return res.status(404).json({ success: false, - message: 'Blog post not found' + message: "Blog post not found", }); } - + const comment = await BlogComment.findById(commentId); if (!comment || comment.postId.toString() !== blogId) { return res.status(404).json({ success: false, - message: 'Comment not found' + message: "Comment not found", }); } - - comment.status = 'approved'; + + comment.status = "approved"; await comment.save(); - + res.json({ success: true, - message: 'Comment approved successfully' + message: "Comment approved successfully", }); } catch (err) { - console.error('Approve comment error:', err); + console.error("Approve comment error:", err); res.status(500).json({ success: false, - message: 'Error approving comment', - error: err.message || 'Error approving comment' + message: "Error approving comment", + error: err.message || "Error approving comment", }); } }; @@ -727,36 +818,36 @@ exports.approveComment = async (req, res) => { exports.rejectComment = async (req, res) => { try { const { blogId, commentId } = req.params; - + const blog = await Blog.findById(blogId); if (!blog) { return res.status(404).json({ success: false, - message: 'Blog post not found' + message: "Blog post not found", }); } - + const comment = await BlogComment.findById(commentId); if (!comment || comment.postId.toString() !== blogId) { return res.status(404).json({ success: false, - message: 'Comment not found' + message: "Comment not found", }); } - - comment.status = 'rejected'; + + comment.status = "rejected"; await comment.save(); - + res.json({ success: true, - message: 'Comment rejected successfully' + message: "Comment rejected successfully", }); } catch (err) { - console.error('Reject comment error:', err); + console.error("Reject comment error:", err); res.status(500).json({ success: false, - message: 'Error rejecting comment', - error: err.message || 'Error rejecting comment' + message: "Error rejecting comment", + error: err.message || "Error rejecting comment", }); } }; @@ -765,47 +856,46 @@ exports.rejectComment = async (req, res) => { exports.deleteComment = async (req, res) => { try { const { blogId, commentId } = req.params; - + const blog = await Blog.findById(blogId); if (!blog) { return res.status(404).json({ success: false, - message: 'Blog post not found' + message: "Blog post not found", }); } - + const comment = await BlogComment.findById(commentId); if (!comment || comment.postId.toString() !== blogId) { return res.status(404).json({ success: false, - message: 'Comment not found' + message: "Comment not found", }); } - + // Delete the comment and all its replies await BlogComment.deleteMany({ - $or: [ - { _id: commentId }, - { parentId: commentId } - ] + $or: [{ _id: commentId }, { parentId: commentId }], }); - + // Update blog comment count - const remainingComments = await BlogComment.countDocuments({ postId: blogId }); + const remainingComments = await BlogComment.countDocuments({ + postId: blogId, + }); await Blog.updateOne({ _id: blogId }, { commentsCount: remainingComments }); - + res.json({ success: true, - message: 'Comment deleted successfully' + message: "Comment deleted successfully", }); } catch (err) { - console.error('Delete comment error:', err); + console.error("Delete comment error:", err); res.status(500).json({ success: false, - message: 'Error deleting comment', - error: err.message || 'Error deleting comment' + message: "Error deleting comment", + error: err.message || "Error deleting comment", }); } }; -module.exports = exports; \ No newline at end of file +module.exports = exports; diff --git a/controllers/contactController.js b/controllers/contactController.js index 55890f4..ecd4ea3 100644 --- a/controllers/contactController.js +++ b/controllers/contactController.js @@ -1,6 +1,9 @@ const { addBaseUrlToImages } = require("../utils/imageHelper"); 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 () => { @@ -74,7 +77,11 @@ exports.index = async (req, res) => { heading: "", description: "", fields: [], - submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" }, + submitButton: { + text: "Send Message", + icon: "fa-solid fa-arrow-right", + buttonClass: "theme-btn style-2", + }, }, }; @@ -94,7 +101,9 @@ exports.index = async (req, res) => { } } - const submissions = await ContactSubmission.find(query).sort({ createdAt: -1 }).limit(50); + const submissions = await ContactSubmission.find(query) + .sort({ createdAt: -1 }) + .limit(50); const frontendUrl = process.env.FRONTEND_URL; res.render("admin/contact/index", { @@ -141,6 +150,11 @@ exports.update = async (req, res) => { // 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({ @@ -157,7 +171,11 @@ exports.update = async (req, res) => { contactCards: (contactCardsData || []).map((card) => ({ ...card, iconType: card.iconType || "", - iconSource: card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'), + iconSource: + card.iconSource || + (card.iconType && card.iconType.startsWith("/uploads/") + ? "image" + : "fontawesome"), })), map: mapData || { coordinates: { lat: 0, lng: 0 }, @@ -177,7 +195,11 @@ exports.update = async (req, res) => { heading: "", description: "", fields: [], - submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" }, + submitButton: { + text: "Send Message", + icon: "fa-solid fa-arrow-right", + buttonClass: "theme-btn style-2", + }, }, }); } else { @@ -188,7 +210,11 @@ exports.update = async (req, res) => { contact.contactCards = contactCardsData.map((card) => ({ ...card, iconType: card.iconType || "", - iconSource: card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'), + iconSource: + card.iconSource || + (card.iconType && card.iconType.startsWith("/uploads/") + ? "image" + : "fontawesome"), })); } if (mapData) contact.map = mapData; @@ -197,6 +223,23 @@ exports.update = async (req, res) => { await contact.save(); + // ✅ Capture AFTER state + const afterData = JSON.parse(JSON.stringify(contact.toObject())); + + // ✅ AUDIT LOGGING - Contact Updated + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "Contact", + documentId: contact._id, + action: AUDIT_ACTIONS.UPDATE_CONTACT, + before: beforeData, + after: afterData, + changes, + req, + }); + } + req.flash("success_msg", "Contact updated successfully"); res.redirect("/admin/contact"); } catch (err) { @@ -321,7 +364,7 @@ exports.updateSubmissionStatus = async (req, res) => { const submission = await ContactSubmission.findByIdAndUpdate( id, updateData, - { new: true } + { new: true }, ); if (!submission) { diff --git a/controllers/faqController.js b/controllers/faqController.js index e0e89be..ffec4b7 100644 --- a/controllers/faqController.js +++ b/controllers/faqController.js @@ -1,4 +1,7 @@ const Home = require("../models/home"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); // Helper to get FAQ data from Home model const getFaqData = async () => { @@ -9,7 +12,7 @@ const getFaqData = async () => { subheading: "", description: "", items: [], - ctaButton: { label: "", href: "" } + ctaButton: { label: "", href: "" }, }; } return home.faq.toObject ? home.faq.toObject() : home.faq; @@ -41,7 +44,7 @@ exports.index = async (req, res) => { subheading: data.subheading || "", description: data.description || "", ctaButton: data.ctaButton || { label: "", href: "" }, - items: data.items || [] + items: data.items || [], }; const frontendUrl = process.env.FRONTEND_URL; @@ -64,12 +67,13 @@ exports.index = async (req, res) => { // Update FAQ data exports.update = async (req, res) => { try { - const { heading, subheading, description, ctaLabel, ctaHref, items } = req.body; + const { heading, subheading, description, ctaLabel, ctaHref, items } = + req.body; let parsedItems = []; if (items) { try { - parsedItems = typeof items === 'string' ? JSON.parse(items) : items; + parsedItems = typeof items === "string" ? JSON.parse(items) : items; } catch (e) { console.error("Error parsing items JSON:", e); parsedItems = []; @@ -81,22 +85,47 @@ exports.update = async (req, res) => { home = new Home({}); } - home.faq = { + // ✅ Capture BEFORE state + const beforeData = home.faq + ? JSON.parse( + JSON.stringify(home.faq.toObject ? home.faq.toObject() : home.faq), + ) + : {}; + + const updatedFaqData = { heading: heading || "", subheading: subheading || "", description: description || "", ctaButton: { label: ctaLabel || "", - href: ctaHref || "" + href: ctaHref || "", }, - items: parsedItems.map(item => ({ + items: parsedItems.map((item) => ({ question: item.question || "", - answer: item.answer || "" - })) + answer: item.answer || "", + })), }; + home.faq = updatedFaqData; await home.save(); + // ✅ Capture AFTER state + const afterData = JSON.parse(JSON.stringify(updatedFaqData)); + + // ✅ AUDIT LOGGING - FAQ Updated + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "Home", + documentId: home._id, + action: AUDIT_ACTIONS.UPDATE_FAQ, + before: beforeData, + after: afterData, + changes, + req, + }); + } + req.flash("success_msg", "FAQ section updated successfully"); res.redirect("/admin/home/faq"); } catch (err) { @@ -107,11 +136,19 @@ exports.update = async (req, res) => { }; // Placeholder methods to prevent route crashes if routes are not cleaned up immediately -exports.addFAQ = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); -exports.updateFAQItem = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); -exports.deleteFAQItem = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); -exports.addFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); -exports.updateFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); -exports.deleteFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); -exports.reorderFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); -exports.updateSidebarNav = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); \ No newline at end of file +exports.addFAQ = (req, res) => + res.status(404).json({ error: "Endpoint deprecated" }); +exports.updateFAQItem = (req, res) => + res.status(404).json({ error: "Endpoint deprecated" }); +exports.deleteFAQItem = (req, res) => + res.status(404).json({ error: "Endpoint deprecated" }); +exports.addFAQSection = (req, res) => + res.status(404).json({ error: "Endpoint deprecated" }); +exports.updateFAQSection = (req, res) => + res.status(404).json({ error: "Endpoint deprecated" }); +exports.deleteFAQSection = (req, res) => + res.status(404).json({ error: "Endpoint deprecated" }); +exports.reorderFAQSection = (req, res) => + res.status(404).json({ error: "Endpoint deprecated" }); +exports.updateSidebarNav = (req, res) => + res.status(404).json({ error: "Endpoint deprecated" }); diff --git a/controllers/footerController.js b/controllers/footerController.js index d6fda12..3f0212e 100644 --- a/controllers/footerController.js +++ b/controllers/footerController.js @@ -1,141 +1,167 @@ const { addBaseUrlToImages } = require("../utils/imageHelper"); const Footer = require("../models/footer"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); // GET /api/footer - Public API cho website và CMS load dữ liệu exports.getFooter = async (req, res) => { - try { - const footer = await Footer.getSingle(); - const processedData = addBaseUrlToImages(footer.toObject()); + try { + const footer = await Footer.getSingle(); + const processedData = addBaseUrlToImages(footer.toObject()); - res.json(processedData); - } catch (error) { - console.error("Error getting footer:", error); - res.status(500).json({ - error: "Failed to get footer data", - }); - } + res.json(processedData); + } catch (error) { + console.error("Error getting footer:", error); + res.status(500).json({ + error: "Failed to get footer data", + }); + } }; // PUT /api/admin/footer - Update toàn bộ footer cho CMS exports.updateFooter = async (req, res) => { - try { - let updateData = req.body; + try { + let updateData = req.body; - console.log("=== FOOTER UPDATE REQUEST RECEIVED ==="); - console.log("Raw body:", JSON.stringify(req.body, null, 2)); + console.log("=== FOOTER UPDATE REQUEST RECEIVED ==="); + console.log("Raw body:", JSON.stringify(req.body, null, 2)); - // Nếu có footerJson, parse nó (tương tự Header logic) - if (updateData.footerJson && typeof updateData.footerJson === "string") { - try { - const parsedData = JSON.parse(updateData.footerJson); - console.log("✓ Parsed footerJson successfully:", parsedData); - updateData = parsedData; - } catch (e) { - console.error("✗ Error parsing footerJson:", e.message); - return res.status(400).json({ - success: false, - message: "Invalid JSON in footerJson: " + e.message, - }); - } - } - - // Lấy footer hiện tại hoặc tạo mới (giống Header logic) - let footer = await Footer.findOne(); - - if (!footer) { - console.log("No existing footer found, creating new one"); - footer = new Footer(updateData); - await footer.save(); - console.log("✓ Footer created:", footer._id); - } else { - console.log("✓ Found existing footer:", footer._id); - // Merge với dữ liệu cũ thay vì overwrite (giống Header) - Object.assign(footer, updateData); - await footer.save(); - console.log("✓ Footer updated successfully"); - } - - const processedData = addBaseUrlToImages(footer.toObject()); - - console.log("Updated footer data:", JSON.stringify(processedData, null, 2)); - - res.json({ - success: true, - message: "Footer updated successfully", - data: processedData, - }); - } catch (error) { - console.error("✗ Error updating footer:", error); - res.status(500).json({ - success: false, - error: "Failed to update footer: " + error.message, + // Nếu có footerJson, parse nó (tương tự Header logic) + if (updateData.footerJson && typeof updateData.footerJson === "string") { + try { + const parsedData = JSON.parse(updateData.footerJson); + console.log("✓ Parsed footerJson successfully:", parsedData); + updateData = parsedData; + } catch (e) { + console.error("✗ Error parsing footerJson:", e.message); + return res.status(400).json({ + success: false, + message: "Invalid JSON in footerJson: " + e.message, }); + } } + + // Lấy footer hiện tại hoặc tạo mới (giống Header logic) + let footer = await Footer.findOne(); + + if (!footer) { + console.log("No existing footer found, creating new one"); + footer = new Footer(updateData); + await footer.save(); + console.log("✓ Footer created:", footer._id); + } else { + console.log("✓ Found existing footer:", footer._id); + // Merge với dữ liệu cũ thay vì overwrite (giống Header) + Object.assign(footer, updateData); + await footer.save(); + console.log("✓ Footer updated successfully"); + } + + const processedData = addBaseUrlToImages(footer.toObject()); + + console.log("Updated footer data:", JSON.stringify(processedData, null, 2)); + + res.json({ + success: true, + message: "Footer updated successfully", + data: processedData, + }); + } catch (error) { + console.error("✗ Error updating footer:", error); + res.status(500).json({ + success: false, + error: "Failed to update footer: " + error.message, + }); + } }; // Render admin view (giữ lại cho UI hiện tại) exports.index = async (req, res) => { - try { - const data = await Footer.getSingle(); - const processedData = addBaseUrlToImages(data.toObject()); + try { + const data = await Footer.getSingle(); + const processedData = addBaseUrlToImages(data.toObject()); - res.render("admin/footer/index", { - title: "Footer Management", - data: processedData, - }); - } catch (error) { - console.error("Error in footer index:", error); - req.flash("error_msg", "An error occurred while loading the page"); - res.redirect("/admin/dashboard"); - } + res.render("admin/footer/index", { + title: "Footer Management", + data: processedData, + }); + } catch (error) { + console.error("Error in footer index:", error); + req.flash("error_msg", "An error occurred while loading the page"); + res.redirect("/admin/dashboard"); + } }; // Update method cho form hiện tại (giống Header pattern) exports.update = async (req, res) => { - try { - let updateData = req.body; + try { + let updateData = req.body; - console.log("=== FOOTER FORM UPDATE REQUEST RECEIVED ==="); - console.log("Raw body:", JSON.stringify(req.body, null, 2)); + console.log("=== FOOTER FORM UPDATE REQUEST RECEIVED ==="); + console.log("Raw body:", JSON.stringify(req.body, null, 2)); - // Nếu có footerJson, parse nó (giống Header logic) - if (updateData.footerJson && typeof updateData.footerJson === "string") { - try { - const parsedData = JSON.parse(updateData.footerJson); - console.log("✓ Parsed footerJson successfully:", parsedData); - updateData = parsedData; - } catch (e) { - console.error("✗ Error parsing footerJson:", e.message); - req.flash("error_msg", "Invalid JSON in footerJson: " + e.message); - return res.redirect("/admin/footer"); - } - } - - // Lấy footer hiện tại hoặc tạo mới (giống Header) - let footer = await Footer.findOne(); - - if (!footer) { - console.log("No existing footer found, creating new one"); - footer = new Footer(updateData); - await footer.save(); - console.log("✓ Footer created:", footer._id); - req.flash("success_msg", "Footer created successfully"); - } else { - console.log("✓ Found existing footer:", footer._id); - // Merge với dữ liệu cũ (giống Header) - Object.assign(footer, updateData); - await footer.save(); - console.log("✓ Footer updated successfully"); - req.flash("success_msg", "Footer updated successfully"); - } - - const activeTab = req.body.activeTab || "about"; - res.redirect(`/admin/footer?activeTab=${activeTab}`); - } catch (err) { - console.error("✗ Error updating footer:", err); - req.flash("error_msg", err.message || "Error updating footer"); - res.redirect("/admin/footer"); + // Nếu có footerJson, parse nó (giống Header logic) + if (updateData.footerJson && typeof updateData.footerJson === "string") { + try { + const parsedData = JSON.parse(updateData.footerJson); + console.log("✓ Parsed footerJson successfully:", parsedData); + updateData = parsedData; + } catch (e) { + console.error("✗ Error parsing footerJson:", e.message); + req.flash("error_msg", "Invalid JSON in footerJson: " + e.message); + return res.redirect("/admin/footer"); + } } + + // Lấy footer hiện tại hoặc tạo mới (giống Header) + let footer = await Footer.findOne(); + + // ✅ Capture BEFORE state + const beforeData = footer + ? JSON.parse(JSON.stringify(footer.toObject())) + : {}; + + if (!footer) { + console.log("No existing footer found, creating new one"); + footer = new Footer(updateData); + await footer.save(); + console.log("✓ Footer created:", footer._id); + req.flash("success_msg", "Footer created successfully"); + } else { + console.log("✓ Found existing footer:", footer._id); + // Merge với dữ liệu cũ (giống Header) + Object.assign(footer, updateData); + await footer.save(); + + // ✅ Capture AFTER state + const afterData = JSON.parse(JSON.stringify(footer.toObject())); + + // ✅ AUDIT LOGGING - Footer Updated + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "Footer", + documentId: footer._id, + action: AUDIT_ACTIONS.UPDATE_FOOTER, + before: beforeData, + after: afterData, + changes, + req, + }); + } + + console.log("✓ Footer updated successfully"); + req.flash("success_msg", "Footer updated successfully"); + } + + const activeTab = req.body.activeTab || "about"; + res.redirect(`/admin/footer?activeTab=${activeTab}`); + } catch (err) { + console.error("✗ Error updating footer:", err); + req.flash("error_msg", err.message || "Error updating footer"); + res.redirect("/admin/footer"); + } }; // Legacy API endpoints (giữ lại cho tương thích) diff --git a/controllers/headerController.js b/controllers/headerController.js index 85a0d4d..0594d08 100644 --- a/controllers/headerController.js +++ b/controllers/headerController.js @@ -1,367 +1,410 @@ const Header = require("../models/header"); const HeaderMenu = require("../models/HeaderMenu"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); /** * Helper function to build a tree structure (Mirroring logic in headerMenuController) */ const buildTree = (items, parentId = null) => { - const branch = []; - const children = items.filter(item => - String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null) - ); + const branch = []; + const children = items.filter( + (item) => + String(item.parentId) === String(parentId) || + (item.parentId === null && parentId === null), + ); - for (const child of children) { - const item = child.toObject ? child.toObject() : { ...child }; - const subChildren = buildTree(items, item._id); - item.children = subChildren.length > 0 ? subChildren : []; - branch.push(item); - } + for (const child of children) { + const item = child.toObject ? child.toObject() : { ...child }; + const subChildren = buildTree(items, item._id); + item.children = subChildren.length > 0 ? subChildren : []; + branch.push(item); + } - return branch.sort((a, b) => a.order - b.order); + return branch.sort((a, b) => a.order - b.order); }; // Admin: Render header management page exports.index = async (req, res) => { - try { - const header = await Header.findOne().sort({ order: 1 }); + try { + const header = await Header.findOne().sort({ order: 1 }); - // Prepare data for view - const data = header - ? { - topbar: { - contactInfo: { - phone: header.top?.phone || "", - email: header.top?.email || "", - location: header.top?.location || "", - }, - socialLinks: header.top?.socialLinks || [], - }, - logo: header.logo?.light || "", - } - : { - topbar: { - contactInfo: { - phone: "", - email: "", - location: "", - }, - socialLinks: [], - }, - logo: "", - }; - - const activeTab = req.query.tab || "topbar"; - - // Always fetch menu items to ensure they are available even if the user - // switches tabs client-side - const items = await HeaderMenu.find().sort({ order: 1 }); - const menuData = { - flat: items, - tree: buildTree(items) + // Prepare data for view + const data = header + ? { + topbar: { + contactInfo: { + phone: header.top?.phone || "", + email: header.top?.email || "", + location: header.top?.location || "", + }, + socialLinks: header.top?.socialLinks || [], + }, + logo: header.logo?.light || "", + } + : { + topbar: { + contactInfo: { + phone: "", + email: "", + location: "", + }, + socialLinks: [], + }, + logo: "", }; - res.render("admin/header/index", { - layout: "layouts/main", - title: "Header Management", - user: req.session.user || null, - data: data, - activeTab: activeTab, - menuData: menuData - }); - } catch (error) { - console.error("Error loading header management:", error); - res.status(500).render("page/error", { - title: "Error", - message: "Failed to load header management page", - }); - } + const activeTab = req.query.tab || "topbar"; + + // Always fetch menu items to ensure they are available even if the user + // switches tabs client-side + const items = await HeaderMenu.find().sort({ order: 1 }); + const menuData = { + flat: items, + tree: buildTree(items), + }; + + res.render("admin/header/index", { + layout: "layouts/main", + title: "Header Management", + user: req.session.user || null, + data: data, + activeTab: activeTab, + menuData: menuData, + }); + } catch (error) { + console.error("Error loading header management:", error); + res.status(500).render("page/error", { + title: "Error", + message: "Failed to load header management page", + }); + } }; // Admin: Get all headers (API) exports.getAll = async (req, res) => { - try { - const headers = await Header.find().sort({ order: 1 }); - res.json({ - success: true, - data: headers, - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message, - }); - } + try { + const headers = await Header.find().sort({ order: 1 }); + res.json({ + success: true, + data: headers, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } }; // Admin: Get single header exports.show = async (req, res) => { - try { - const header = await Header.findById(req.params.id); - if (!header) { - return res.status(404).json({ - success: false, - message: "Header not found", - }); - } - res.json({ - success: true, - data: header, - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message, - }); + try { + const header = await Header.findById(req.params.id); + if (!header) { + return res.status(404).json({ + success: false, + message: "Header not found", + }); } + res.json({ + success: true, + data: header, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } }; // Admin: Create header exports.store = async (req, res) => { - try { - const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body; + try { + const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body; - const header = new Header({ - top, - offcanvas, - menu, - logo, - ctaButton, - status: status || "active", - order: order || 1, - }); + const header = new Header({ + top, + offcanvas, + menu, + logo, + ctaButton, + status: status || "active", + order: order || 1, + }); - await header.save(); - res.status(201).json({ - success: true, - message: "Header created successfully", - data: header, - }); - } catch (error) { - res.status(400).json({ - success: false, - message: error.message, - }); - } + await header.save(); + res.status(201).json({ + success: true, + message: "Header created successfully", + data: header, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } }; // Admin: Update header exports.update = async (req, res) => { - try { - let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } = req.body; + try { + let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } = + req.body; - console.log("=== UPDATE REQUEST RECEIVED ==="); - console.log("Raw body:", JSON.stringify(req.body, null, 2)); - console.log("topbarJson type:", typeof topbarJson); - console.log("topbarJson value:", topbarJson); + console.log("=== UPDATE REQUEST RECEIVED ==="); + console.log("Raw body:", JSON.stringify(req.body, null, 2)); + console.log("topbarJson type:", typeof topbarJson); + console.log("topbarJson value:", topbarJson); - // Nếu có topbarJson, parse nó - if (topbarJson && typeof topbarJson === "string") { - try { - const parsedData = JSON.parse(topbarJson); - console.log("✓ Parsed topbarJson successfully:", parsedData); - // Chuyển đổi từ topbarData sang top format - top = { - phone: parsedData.contactInfo?.phone || "", - email: parsedData.contactInfo?.email || "", - location: parsedData.contactInfo?.location || "", - socialLinks: parsedData.socialLinks || [], - }; - console.log("✓ Converted to top object:", top); - } catch (e) { - console.error("✗ Error parsing topbarJson:", e.message); - return res.status(400).json({ - success: false, - message: "Invalid JSON in topbarJson: " + e.message, - }); - } - } - - // Nếu không có id, tìm header đầu tiên hoặc tạo mới - let headerId = req.params.id; - - if (!headerId) { - // Tìm header đầu tiên - let header = await Header.findOne().sort({ order: 1 }); - if (!header) { - console.log("No existing header found, creating new one"); - // Tạo header mới nếu chưa có - header = new Header({ - top, - offcanvas, - menu, - logo: logo ? { light: logo } : {}, - ctaButton, - status: status || "active", - order: order || 1, - }); - await header.save(); - console.log("✓ Header created:", header._id); - return res.json({ - success: true, - message: "Header created successfully", - data: header, - }); - } - headerId = header._id; - console.log("✓ Found existing header:", headerId); - } - - // Chuẩn bị dữ liệu logo - merge với dữ liệu cũ - let logoData = {}; - if (logo) { - // Nếu có logo mới, lấy dữ liệu cũ và update light - const existingHeader = await Header.findById(headerId); - logoData = { - light: logo, - dark: existingHeader?.logo?.dark || "", - alt: existingHeader?.logo?.alt || "", - }; - } - - const updateData = { - top, - offcanvas, - menu, - ctaButton, - status, - order, + // Nếu có topbarJson, parse nó + if (topbarJson && typeof topbarJson === "string") { + try { + const parsedData = JSON.parse(topbarJson); + console.log("✓ Parsed topbarJson successfully:", parsedData); + // Chuyển đổi từ topbarData sang top format + top = { + phone: parsedData.contactInfo?.phone || "", + email: parsedData.contactInfo?.email || "", + location: parsedData.contactInfo?.location || "", + socialLinks: parsedData.socialLinks || [], }; - - if (logo) { - updateData.logo = logoData; - } - - console.log("Preparing to update header with data:", JSON.stringify(updateData, null, 2)); - - const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, { new: true, runValidators: true }); - - if (!updatedHeader) { - console.error("✗ Header not found with ID:", headerId); - return res.status(404).json({ - success: false, - message: "Header not found", - }); - } - - console.log("✓ Header updated successfully:", updatedHeader._id); - console.log("Updated header data:", JSON.stringify(updatedHeader, null, 2)); - - res.json({ - success: true, - message: "Header updated successfully", - data: updatedHeader, - }); - } catch (error) { - console.error("✗ Error updating header:", error); - res.status(400).json({ - success: false, - message: error.message, + console.log("✓ Converted to top object:", top); + } catch (e) { + console.error("✗ Error parsing topbarJson:", e.message); + return res.status(400).json({ + success: false, + message: "Invalid JSON in topbarJson: " + e.message, }); + } } + + // Nếu không có id, tìm header đầu tiên hoặc tạo mới + let headerId = req.params.id; + + if (!headerId) { + // Tìm header đầu tiên + let header = await Header.findOne().sort({ order: 1 }); + if (!header) { + console.log("No existing header found, creating new one"); + // Tạo header mới nếu chưa có + header = new Header({ + top, + offcanvas, + menu, + logo: logo ? { light: logo } : {}, + ctaButton, + status: status || "active", + order: order || 1, + }); + await header.save(); + console.log("✓ Header created:", header._id); + return res.json({ + success: true, + message: "Header created successfully", + data: header, + }); + } + headerId = header._id; + console.log("✓ Found existing header:", headerId); + } + + // Chuẩn bị dữ liệu logo - merge với dữ liệu cũ + let logoData = {}; + if (logo) { + // Nếu có logo mới, lấy dữ liệu cũ và update light + const existingHeader = await Header.findById(headerId); + logoData = { + light: logo, + dark: existingHeader?.logo?.dark || "", + alt: existingHeader?.logo?.alt || "", + }; + } + + const updateData = { + top, + offcanvas, + menu, + ctaButton, + status, + order, + }; + + if (logo) { + updateData.logo = logoData; + } + + console.log( + "Preparing to update header with data:", + JSON.stringify(updateData, null, 2), + ); + + // ✅ Capture BEFORE state + const beforeHeader = await Header.findById(headerId); + const beforeData = beforeHeader + ? JSON.parse(JSON.stringify(beforeHeader.toObject())) + : {}; + + const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, { + new: true, + runValidators: true, + }); + + if (!updatedHeader) { + console.error("✗ Header not found with ID:", headerId); + return res.status(404).json({ + success: false, + message: "Header not found", + }); + } + + // ✅ Capture AFTER state + const afterData = JSON.parse(JSON.stringify(updatedHeader.toObject())); + + // ✅ AUDIT LOGGING - Header Updated + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "Header", + documentId: updatedHeader._id, + action: AUDIT_ACTIONS.UPDATE_HEADER, + before: beforeData, + after: afterData, + changes, + req, + }); + } + + console.log("✓ Header updated successfully:", updatedHeader._id); + console.log("Updated header data:", JSON.stringify(updatedHeader, null, 2)); + + res.json({ + success: true, + message: "Header updated successfully", + data: updatedHeader, + }); + } catch (error) { + console.error("✗ Error updating header:", error); + res.status(400).json({ + success: false, + message: error.message, + }); + } }; // Admin: Update status exports.updateStatus = async (req, res) => { - try { - const { status } = req.body; + try { + const { status } = req.body; - if (!["active", "inactive"].includes(status)) { - return res.status(400).json({ - success: false, - message: "Invalid status", - }); - } - - const header = await Header.findByIdAndUpdate(req.params.id, { status }, { new: true }); - - if (!header) { - return res.status(404).json({ - success: false, - message: "Header not found", - }); - } - - res.json({ - success: true, - message: "Header status updated", - data: header, - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message, - }); + if (!["active", "inactive"].includes(status)) { + return res.status(400).json({ + success: false, + message: "Invalid status", + }); } + + const header = await Header.findByIdAndUpdate( + req.params.id, + { status }, + { new: true }, + ); + + if (!header) { + return res.status(404).json({ + success: false, + message: "Header not found", + }); + } + + res.json({ + success: true, + message: "Header status updated", + data: header, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } }; // Admin: Delete header exports.destroy = async (req, res) => { - try { - const header = await Header.findByIdAndDelete(req.params.id); + try { + const header = await Header.findByIdAndDelete(req.params.id); - if (!header) { - return res.status(404).json({ - success: false, - message: "Header not found", - }); - } - - res.json({ - success: true, - message: "Header deleted successfully", - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message, - }); + if (!header) { + return res.status(404).json({ + success: false, + message: "Header not found", + }); } + + res.json({ + success: true, + message: "Header deleted successfully", + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } }; // Public API: Get active header exports.api = async (req, res) => { - try { - const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + try { + const header = await Header.findOne({ status: "active" }).sort({ + order: 1, + }); - if (!header) { - return res.status(404).json({ - success: false, - message: "No active header found", - }); - } - - res.json({ - success: true, - data: header, - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message, - }); + if (!header) { + return res.status(404).json({ + success: false, + message: "No active header found", + }); } + + res.json({ + success: true, + data: header, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } }; // Public API: Get menu tree structure exports.getMenuTreeAPI = async (req, res) => { - try { - const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + try { + const header = await Header.findOne({ status: "active" }).sort({ + order: 1, + }); - if (!header || !header.menu) { - return res.status(404).json({ - success: false, - message: "No active menu found", - }); - } - - res.json({ - success: true, - data: header.menu, - }); - } catch (error) { - res.status(500).json({ - success: false, - message: error.message, - }); + if (!header || !header.menu) { + return res.status(404).json({ + success: false, + message: "No active menu found", + }); } + + res.json({ + success: true, + data: header.menu, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } }; diff --git a/controllers/homeController.js b/controllers/homeController.js index fbc55e8..e070c6c 100644 --- a/controllers/homeController.js +++ b/controllers/homeController.js @@ -1,6 +1,9 @@ const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); const Home = require("../models/home"); const Blog = require("../models/blog"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); // Các hàm hỗ trợ const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 }); @@ -30,10 +33,28 @@ const getDefaultHomeData = () => ({ ctaButton: {}, }, visaSolutions: { heading: "", subheading: "", items: [] }, - visaCountries: { heading: "", subheading: "", description: "", countries: [], ctaButton: {} }, - testimonials: { heading: "", subheading: "", videoUrl: "", videoThumbnail: "", items: [] }, + visaCountries: { + heading: "", + subheading: "", + description: "", + countries: [], + ctaButton: {}, + }, + testimonials: { + heading: "", + subheading: "", + videoUrl: "", + videoThumbnail: "", + items: [], + }, videoGallery: { heading: "", videoUrl: "", thumbnail: "" }, - faq: { heading: "", subheading: "", description: "", ctaButton: {}, items: [] }, + faq: { + heading: "", + subheading: "", + description: "", + ctaButton: {}, + items: [], + }, achievements: { heading: "", subheading: "", items: [] }, partners: { visaConsultancy: { items: [] }, brands: { items: [] } }, blogPreview: { @@ -41,7 +62,7 @@ const getDefaultHomeData = () => ({ subheading: "Visa Tips & Guides", ctaButton: { label: "View All Articles", href: "/blog" }, items: [], - selectedBlogIds: [] // Array of manually selected blog IDs + selectedBlogIds: [], // Array of manually selected blog IDs }, }); @@ -53,7 +74,7 @@ exports.index = async (req, res) => { // Merge dữ liệu mặc định cho tất cả các phần const sections = Object.keys(defaults); - sections.forEach(s => { + sections.forEach((s) => { data[s] = data[s] || defaults[s]; }); @@ -61,7 +82,9 @@ exports.index = async (req, res) => { const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; // Lấy tất cả blog để chọn trong CMS - const allBlogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).lean(); + const allBlogs = await Blog.find({ status: "published" }) + .sort({ createdAt: -1 }) + .lean(); return res.render("admin/home/index", { layout: "layouts/main", @@ -85,17 +108,28 @@ exports.index = async (req, res) => { exports.update = async (req, res) => { try { const sections = [ - "hero", "whyChooseUs", "visaSolutions", "visaCountries", - "testimonials", "videoGallery", "faq", "achievements", - "partners", "blogPreview" + "hero", + "whyChooseUs", + "visaSolutions", + "visaCountries", + "testimonials", + "videoGallery", + "faq", + "achievements", + "partners", + "blogPreview", ]; let doc = await getHomeDoc(); + const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {}; + if (!doc) { doc = new Home({}); } let hasChanges = false; + const updatedSections = []; + for (const section of sections) { if (req.body[section]) { try { @@ -104,6 +138,7 @@ exports.update = async (req, res) => { doc[section] = payload; doc.markModified(section); hasChanges = true; + updatedSections.push(section); } catch (e) { console.error(`Invalid JSON for ${section}:`, e); } @@ -116,6 +151,22 @@ exports.update = async (req, res) => { } await doc.save(); + const afterData = JSON.parse(JSON.stringify(doc.toObject())); + + // ✅ AUDIT LOGGING - Home Update + const changes = diffObject(beforeData, afterData); + if (changes.length > 0) { + await writeAuditLog({ + model: "Home", + documentId: doc._id, + action: AUDIT_ACTIONS.UPDATE_HOME, + before: beforeData, + after: afterData, + changes, + req, + }); + } + req.flash("success_msg", "Home page configuration has been updated!"); return req.session.save(() => res.redirect("/admin/home")); } catch (err) { @@ -128,7 +179,10 @@ exports.update = async (req, res) => { // Public API// API lấy danh sách blog cho CMS exports.apiGetBlogs = async (req, res) => { try { - const blogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).select("title slug featuredImage author publishedAt").lean(); + const blogs = await Blog.find({ status: "published" }) + .sort({ createdAt: -1 }) + .select("title slug featuredImage author publishedAt") + .lean(); res.json(blogs); } catch (err) { res.status(500).json({ error: err.message }); @@ -137,7 +191,8 @@ exports.apiGetBlogs = async (req, res) => { exports.api = async (req, res) => { try { let data = await getHomeData(); - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; // === Xử lý Blog Preview động === const blogPreview = data.blogPreview || {}; @@ -147,12 +202,15 @@ exports.api = async (req, res) => { if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) { blogs = await Blog.find({ _id: { $in: blogPreview.selectedBlogIds }, - status: "published" + status: "published", }).lean(); // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds blogs.sort((a, b) => { - return blogPreview.selectedBlogIds.indexOf(a._id.toString()) - blogPreview.selectedBlogIds.indexOf(b._id.toString()); + return ( + blogPreview.selectedBlogIds.indexOf(a._id.toString()) - + blogPreview.selectedBlogIds.indexOf(b._id.toString()) + ); }); } @@ -165,18 +223,18 @@ exports.api = async (req, res) => { } // Map dữ liệu blog sang format mà frontend mong đợi - blogPreview.items = blogs.map(blog => ({ + blogPreview.items = blogs.map((blog) => ({ title: blog.title, excerpt: blog.excerpt, category: blog.category && blog.category[0] ? blog.category[0] : "Visa", date: blog.publishedAt || blog.createdAt, author: { name: blog.author || "Admin", - avatar: "" // Frontend đang tự xử lý hoặc dùng logo hệ thống + avatar: "", // Frontend đang tự xử lý hoặc dùng logo hệ thống }, comments: blog.commentsCount || 0, link: `/blog/${blog.slug}`, - thumbnail: blog.featuredImage + thumbnail: blog.featuredImage, })); data.blogPreview = blogPreview; @@ -189,4 +247,3 @@ exports.api = async (req, res) => { return res.status(500).json({ error: "Error loading home data" }); } }; - diff --git a/controllers/insuranceController.js b/controllers/insuranceController.js index fabb3c9..ba4a790 100644 --- a/controllers/insuranceController.js +++ b/controllers/insuranceController.js @@ -1,34 +1,37 @@ const Insurance = require("../models/insurance"); const { addBaseUrlToImages } = require("../utils/imageHelper"); +const writeAuditLog = require("../audit/writeAuditLog"); +const diffObject = require("../audit/diffObject"); +const AUDIT_ACTIONS = require("../constants/auditAction"); // API để lấy insurance data (cho frontend) exports.api = async (req, res) => { try { const language = req.query.lang || "en"; - + // Sử dụng getDefault để đảm bảo luôn có data const insurance = await Insurance.getDefault(language); - + // Trả về data với cấu trúc mới const insuranceData = insurance.toObject(); - + // Sử dụng helper để thêm base URL vào đường dẫn ảnh - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; const processedData = addBaseUrlToImages(insuranceData, baseUrl); - + // Trả về trực tiếp hero, page, content (không wrap trong object) res.json({ hero: processedData.hero, page: processedData.page, - content: processedData.content + content: processedData.content, }); - } catch (error) { console.error("API Error:", error); - res.status(500).json({ + res.status(500).json({ success: false, error: "Error loading insurance data", - message: error.message + message: error.message, }); } }; @@ -37,31 +40,34 @@ exports.api = async (req, res) => { exports.getInsuranceData = async (req, res) => { try { const language = req.query.lang || "en"; - const insurance = await Insurance.findOne({ name: "default", language: language }); - + const insurance = await Insurance.findOne({ + name: "default", + language: language, + }); + if (!insurance) { - return res.status(404).json({ - success: false, - error: "Insurance data not found" + return res.status(404).json({ + success: false, + error: "Insurance data not found", }); } - + const insuranceData = insurance.toObject(); - + // Thêm base URL vào đường dẫn ảnh - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; const processedData = addBaseUrlToImages(insuranceData, baseUrl); - + res.json({ success: true, - data: processedData + data: processedData, }); - } catch (error) { console.error("Error getting insurance data:", error); - res.status(500).json({ + res.status(500).json({ success: false, - error: "Error loading insurance data" + error: "Error loading insurance data", }); } }; @@ -70,36 +76,39 @@ exports.getInsuranceData = async (req, res) => { exports.getByLanguage = async (req, res) => { try { const language = req.params.lang || "en"; - - const insurance = await Insurance.findOne({ name: "default", language: language }); - + + const insurance = await Insurance.findOne({ + name: "default", + language: language, + }); + if (!insurance) { - return res.status(404).json({ - success: false, - error: "Insurance data not found" + return res.status(404).json({ + success: false, + error: "Insurance data not found", }); } - + const insuranceData = insurance.toObject(); - + // Thêm base URL vào đường dẫn ảnh - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; const processedData = addBaseUrlToImages(insuranceData, baseUrl); - + res.json({ success: true, data: { hero: processedData.hero, page: processedData.page, - content: processedData.content - } + content: processedData.content, + }, }); - } catch (error) { console.error("Error getting insurance by language:", error); res.status(500).json({ success: false, - error: "Error loading insurance data" + error: "Error loading insurance data", }); } }; @@ -110,18 +119,17 @@ exports.index = async (req, res) => { // Luôn đảm bảo có default data const insurance = await Insurance.getDefault("en"); const data = insurance.toObject(); - + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - + res.render("admin/insurance/index", { title: "Insurance Management", layout: "layouts/main", data, frontendUrl, currentPath: req.path, - user: req.session.user + user: req.session.user, }); - } catch (error) { console.error("Error in insurance index:", error); req.flash("error_msg", "An error occurred while loading the page"); @@ -132,18 +140,18 @@ exports.index = async (req, res) => { // Seed data từ JSON file (cấu trúc mới) exports.seed = async (req, res) => { try { - const fs = require('fs').promises; - const path = require('path'); - + const fs = require("fs").promises; + const path = require("path"); + // Đọc file JSON - const jsonPath = path.join(__dirname, '../data/insurance.json'); - const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8')); - - console.log('Seeding insurance from JSON...'); - + const jsonPath = path.join(__dirname, "../data/insurance.json"); + const jsonData = JSON.parse(await fs.readFile(jsonPath, "utf8")); + + console.log("Seeding insurance from JSON..."); + // Migrate từ cấu trúc cũ sang mới const insurance = await Insurance.migrateFromJson(jsonData, "en"); - + res.json({ success: true, message: "Insurance data seeded successfully", @@ -151,15 +159,14 @@ exports.seed = async (req, res) => { id: insurance._id, hero: insurance.hero, page: insurance.page, - content: insurance.content - } + content: insurance.content, + }, }); - } catch (error) { console.error("Error seeding insurance:", error); res.status(500).json({ success: false, - error: error.message || "Error seeding insurance data" + error: error.message || "Error seeding insurance data", }); } }; @@ -168,7 +175,7 @@ exports.seed = async (req, res) => { exports.preview = async (req, res) => { try { const { hero, page, content } = req.body; - + // Parse JSON strings const parseJson = (data) => { if (!data) return null; @@ -182,15 +189,16 @@ exports.preview = async (req, res) => { } return data; }; - + const heroData = parseJson(hero) || {}; const pageData = parseJson(page) || {}; const contentData = parseJson(content) || {}; - + // Thêm base URL vào đường dẫn ảnh cho preview - const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; const processedHeroData = addBaseUrlToImages(heroData, baseUrl); - + // Render preview HTML const html = ` @@ -198,13 +206,13 @@ exports.preview = async (req, res) => { - ${pageData.title || 'Insurance Preview'} + ${pageData.title || "Insurance Preview"} \ No newline at end of file diff --git a/views/admin/audit-log/show.ejs b/views/admin/audit-log/show.ejs new file mode 100644 index 0000000..75b200c --- /dev/null +++ b/views/admin/audit-log/show.ejs @@ -0,0 +1,314 @@ +
+
+
+

+ <%= title %> +

+

Detailed audit log information

+
+ +
+ +
+ +
+
+
+
+ Audit Information +
+
+
+
+
+
+ + <%= auditLog.model %> +
+
+ + <% + let actionStyle = 'background-color: var(--primary-color); color: white;'; + if (auditLog.action.includes('CREATE')) actionStyle = 'background-color: #28a745; color: white;'; + else if (auditLog.action.includes('UPDATE')) actionStyle = 'background-color: #ffc107; color: #212529;'; + else if (auditLog.action.includes('DELETE')) actionStyle = 'background-color: #dc3545; color: white;'; + %> + <%= auditLog.action %> +
+
+ + <%= auditLog.documentId %> +
+
+
+
+ +
+ <%= new Date(auditLog.createdAt).toLocaleDateString() %> + <%= new Date(auditLog.createdAt).toLocaleTimeString() %> +
+
+
+ +
+ <% if (auditLog.performedBy) { %> + <%= auditLog.performedBy.username %>
+ <%= auditLog.performedBy.email %> + <% } else { %> + System + <% } %> +
+
+
+ + <%= auditLog.ipAddress %> +
+
+
+ + <% if (auditLog.userAgent) { %> +
+ +
+ <%= auditLog.userAgent %> +
+
+ <% } %> +
+
+ + + <% if (auditLog.changes && auditLog.changes.length > 0) { %> +
+
+
+ Field Changes + <%= auditLog.changes.length %> +
+
+
+ <% if (user && (user.role === 'admin' || user.role === 'superadmin')) { %> +
+ + + + + + + + + + <% auditLog.changes.forEach((change, index) => { %> + + + + + + <% }); %> + +
FieldBeforeAfter
+ <%= change.field %> + +
+ <% if (change.before === null || change.before === undefined) { %> + null + <% } else if (typeof change.before === 'object') { %> +
<%= JSON.stringify(change.before, null, 2) %>
+ <% } else { %> + <%= change.before %> + <% } %> +
+
+
+ <% if (change.after === null || change.after === undefined) { %> + null + <% } else if (typeof change.after === 'object') { %> +
<%= JSON.stringify(change.after, null, 2) %>
+ <% } else { %> + <%= change.after %> + <% } %> +
+
+
+ <% } else { %> +
+ + Summary View: Detailed field values are restricted to administrators. +
+
+ + + + + + + + + <% auditLog.changes.forEach((change, index) => { %> + + + + + <% }); %> + +
FieldStatus
+ <%= change.field %> + + Modified +
+
+ <% } %> +
+
+ <% } %> + + +
+
+
+ Summary +
+
+
+
+
+
+
+ <%= auditLog.changes ? auditLog.changes.length : 0 %> +
+ Fields Changed +
+
+
+
+ <%= new Date(auditLog.createdAt).toLocaleDateString() === new Date().toLocaleDateString() ? 'Today' : 'Past' %> +
+ Timing +
+
+
+
+
+
+ + + +
+ + \ No newline at end of file diff --git a/views/layouts/main.ejs b/views/layouts/main.ejs index a2d7cd0..8c698e9 100644 --- a/views/layouts/main.ejs +++ b/views/layouts/main.ejs @@ -746,6 +746,11 @@ Pricing +
<% if (locals.user) { %>