const mongoose = require("mongoose"); const { addBaseUrlToImages } = require("../utils/imageHelper"); const AboutUs = require("../models/aboutUs"); const Blog = require("../models/blog"); const jsonHelper = require("../utils/jsonHelper"); const { validateLengthRules, summarizeLengthErrors, } = require("../utils/lengthValidation"); const { ABOUT_US_LENGTH_RULES, } = require("../constants/contentLengthRules"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); const ABOUT_NEWS_FALLBACK_THUMBNAIL = "/uploads/blog/7281.jpg"; const ABOUT_NEWS_FALLBACK_AVATAR = "/assets/img/home-1/news/client.png"; const ABOUT_NEWS_PLACEHOLDER_THUMBNAIL = "img/inner-page/news-details/details-1.jpg"; const ABOUT_MISSION_ITEM_ICON = "/assets/img/home-1/icon/01.svg"; const normalizePath = (value) => { if (!value || typeof value !== "string") return ""; if (value.startsWith("http://") || value.startsWith("https://")) return value; if (value.startsWith("/")) return value; return `/${value}`; }; const isPlaceholderNewsThumbnail = (value) => { if (!value || typeof value !== "string") return true; const normalized = value.trim().toLowerCase(); return ( normalized === ABOUT_NEWS_PLACEHOLDER_THUMBNAIL || normalized === `/${ABOUT_NEWS_PLACEHOLDER_THUMBNAIL}` || normalized.endsWith("/inner-page/news-details/details-1.jpg") || normalized.endsWith("news-details/details-1.jpg") ); }; const sanitizeAboutSection = (section = {}, allowedKeys = []) => { const sanitized = {}; allowedKeys.forEach((key) => { if (section[key] !== undefined) { sanitized[key] = section[key]; } }); return sanitized; }; const sanitizeMissionItems = (items) => { if (!Array.isArray(items)) return []; return items .map((item = {}) => ({ icon: ABOUT_MISSION_ITEM_ICON, label: typeof item.label === "string" ? item.label : typeof item.title === "string" ? item.title : "", description: typeof item.description === "string" ? item.description : "", })) .filter((item) => item.label || item.description); }; const buildCanonicalAboutData = (data) => { const source = data || {}; return { hero: sanitizeAboutSection(source.hero, [ "title", "breadcrumb", "backgroundImage", ]), intro: sanitizeAboutSection(source.intro, [ "subheading", "heading", "description", "image", ]), mission: { ...sanitizeAboutSection(source.mission, [ "subheading", "heading", "description", "features", "ctaButton", ]), items: sanitizeMissionItems(source.mission?.items), images: sanitizeAboutSection(source.mission?.images, [ "main", "secondary", ]), }, features: { ...sanitizeAboutSection(source.features, [ "backgroundImage", "subheading", "heading", "description", "image", "items", "ctaButton", ]), }, news: { ...sanitizeAboutSection(source.news, [ "subheading", "heading", "ctaButton", "selectedBlogIds", ]), items: Array.isArray(source.news?.items) ? source.news.items : [], }, }; }; const resolveBlogImage = (value, fallback = ABOUT_NEWS_FALLBACK_THUMBNAIL) => { if (!value || isPlaceholderNewsThumbnail(value)) { return fallback; } return normalizePath(value); }; const toObjectId = (value) => { const stringValue = value && typeof value.toString === "function" ? value.toString() : ""; return mongoose.isValidObjectId(stringValue) ? new mongoose.Types.ObjectId(stringValue) : null; }; const resolveNewsItem = (blog, index = 0) => { const fallbackThumbs = [ ABOUT_NEWS_FALLBACK_THUMBNAIL, "/uploads/about/news-1.jpg", "/uploads/about/news-3.jpg", ]; return { 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: ABOUT_NEWS_FALLBACK_AVATAR, }, link: `/blog/${blog.slug}`, thumbnail: resolveBlogImage( blog.featuredImage, fallbackThumbs[index % fallbackThumbs.length], ), }; }; const handleLengthValidation = (validation, req, res, options = {}) => { const message = summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length."; if (options.json) { return res.status(400).json({ success: false, error: message, errors: validation.errors, }); } req.flash("error_msg", message); return res.redirect(options.redirectTo || "/admin/about-us"); }; /** * 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"); const data = await AboutUs.getSingle(); const rawData = buildCanonicalAboutData(data.toObject()); // === Dynamic Blog News Section === const news = rawData.news || {}; const selectedBlogIds = Array.isArray(news.selectedBlogIds) ? news.selectedBlogIds.map((id) => id.toString()) : []; const selectedObjectIds = selectedBlogIds .map((id) => toObjectId(id)) .filter(Boolean); let blogs = []; // Nếu có chọn blog cụ thể if (selectedBlogIds.length > 0) { const selectedQuery = await Blog.find({ _id: { $in: selectedObjectIds }, status: "published", }).lean(); // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds selectedQuery.sort((a, b) => { return ( selectedBlogIds.indexOf(a._id.toString()) - selectedBlogIds.indexOf(b._id.toString()) ); }); blogs = selectedQuery.slice(0, 3); if (blogs.length < 3) { const missingCount = 3 - blogs.length; const extraBlogs = await Blog.find({ status: "published", _id: { $nin: [...blogs.map((blog) => blog._id), ...selectedObjectIds], }, }) .sort({ createdAt: -1 }) .limit(missingCount) .lean(); blogs = [...blogs, ...extraBlogs]; } } // 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.slice(0, 3).map((blog, index) => resolveNewsItem(blog, index)); 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", }); } }; /** * PUT /api/about * 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; // 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())); updateData = buildCanonicalAboutData(updateData); const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES); if (!validation.valid) { return handleLengthValidation(validation, req, res, { json: true }); } // 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 = buildCanonicalAboutData( 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 page 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 = buildCanonicalAboutData(data.toObject()); // 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 Page 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 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(); updateData = buildCanonicalAboutData(updateData); // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(doc.toObject())); const validation = validateLengthRules(updateData, ABOUT_US_LENGTH_RULES); if (!validation.valid) { return handleLengthValidation(validation, req, res, { redirectTo: `/admin/about-us?activeTab=${req.query.activeTab || "hero"}`, }); } 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 = buildCanonicalAboutData( await AboutUs.findOne() .select("-_id -__v -createdAt -updatedAt") .lean(), ); jsonHelper.writeJsonFile("about", finalData); req.flash("success_msg", "About page 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 page: " + err.message); res.redirect("/admin/about-us"); } }; // Aliases for compatibility exports.api = exports.getAbout; exports.page = exports.getAbout; exports.updateAboutUs = exports.updateAbout;