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 }); const getAllHomeDocs = async () => Home.find().sort({ updatedAt: -1 }); const getHomeData = async () => (await getHomeDoc())?.toObject() || {}; const getOrCreateHomeDoc = async () => { let doc = await getHomeDoc(); if (!doc) { doc = new Home(getDefaultHomeData()); } return doc; }; const hasMeaningfulValue = (value) => typeof value === "string" && value.trim().length > 0; const getMeaningfulHeroSlides = (hero = {}) => { if (!Array.isArray(hero.slides)) return []; return hero.slides.filter((slide = {}) => { return ( hasMeaningfulValue(slide.title) || hasMeaningfulValue(slide.subtitle) || hasMeaningfulValue(slide.description) || hasMeaningfulValue(slide.heroImage) || hasMeaningfulValue(slide.videoUrl) || hasMeaningfulValue(slide.primaryButton?.label) || hasMeaningfulValue(slide.primaryButton?.href) || hasMeaningfulValue(slide.secondaryButton?.label) || hasMeaningfulValue(slide.secondaryButton?.href) ); }); }; const scoreHeroSlides = (slides = []) => slides.reduce((score, slide = {}) => { return ( score + (slide.title || "").trim().length * 3 + (slide.subtitle || "").trim().length + (slide.description || "").trim().length * 2 + (slide.primaryButton?.label || "").trim().length + (slide.primaryButton?.href || "").trim().length + (slide.secondaryButton?.label || "").trim().length + (slide.secondaryButton?.href || "").trim().length + (hasMeaningfulValue(slide.heroImage) ? 20 : 0) + (hasMeaningfulValue(slide.videoUrl) ? 12 : 0) ); }, 0); const scoreHeroData = (hero = {}) => { const slides = getMeaningfulHeroSlides(hero); if (slides.length > 0) { return scoreHeroSlides(slides) + slides.length * 30; } return ( (hero.title || "").trim().length * 3 + (hero.subtitle || "").trim().length + (hero.description || "").trim().length * 2 + (hero.primaryButton?.label || "").trim().length + (hero.primaryButton?.href || "").trim().length + (hero.secondaryButton?.label || "").trim().length + (hero.secondaryButton?.href || "").trim().length + (hasMeaningfulValue(hero.heroImage) ? 20 : 0) + (hasMeaningfulValue(hero.videoUrl) ? 12 : 0) ); }; const getPreferredHeroData = (docs = []) => { const heroes = docs .map((doc) => doc?.hero) .filter(Boolean); if (!heroes.length) return {}; return heroes.reduce((bestHero, currentHero) => { return scoreHeroData(currentHero) > scoreHeroData(bestHero) ? currentHero : bestHero; }, heroes[0]); }; const normalizeStoredImagePath = (imagePath) => { if (!imagePath || typeof imagePath !== "string") return ""; const raw = imagePath.trim(); if (!raw) return ""; const knownPrefixes = ["/uploads/", "/assets/", "/img/"]; for (const prefix of knownPrefixes) { const index = raw.indexOf(prefix); if (index >= 0) { return raw.slice(index); } } if (raw.startsWith("/")) { return raw; } return `/${raw}`; }; const getDefaultFloatingContactData = () => ({ enabled: true, position: "bottom-right", panelTitle: "Do you need any additional advice or support?", brand: { imageSrc: "/assets/img/logo/black-logo.svg", imageAlt: "HAI Learning", }, trigger: { imageSrc: "", icon: "fa-comments", }, actions: [ { id: "facebook", platform: "facebook", enabled: true, label: "Message via Facebook", subtitle: "facebook.com/hailearning.edu.vn", href: "https://www.facebook.com/hailearning.edu.vn/", iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp", iconType: "iconClass", iconClass: "fa-brands fa-facebook-messenger", iconText: "", order: 1, }, { id: "zalo", platform: "zalo", enabled: true, label: "Message via Zalo", subtitle: "zalo.me/84961834040", href: "https://zalo.me/84961834040", iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp", iconType: "iconText", iconClass: "", iconText: "Zalo", order: 2, }, ], }); const normalizeFloatingContactData = (payload = {}) => { const defaults = getDefaultFloatingContactData(); const brand = payload.brand || {}; const trigger = payload.trigger || {}; const hasProvidedActions = Array.isArray(payload.actions); const rawActions = hasProvidedActions ? payload.actions : []; const actions = rawActions .map((action, index) => ({ id: String(action.id || `${action.platform || "action"}-${index + 1}`), platform: String(action.platform || "").trim(), enabled: action.enabled !== false, label: String(action.label || "").trim(), subtitle: String(action.subtitle || "").trim(), href: String(action.href || "").trim(), iconImage: normalizeStoredImagePath(String(action.iconImage || "").trim()), iconType: action.iconType === "iconText" ? "iconText" : "iconClass", iconClass: String(action.iconClass || "").trim(), iconText: String(action.iconText || "").trim(), order: Number.isFinite(Number(action.order)) ? Number(action.order) : index + 1, })) .filter((action) => { return ( action.platform || action.label || action.subtitle || action.href || action.iconImage || action.iconClass || action.iconText ); }) .sort((a, b) => a.order - b.order) .map((action, index) => ({ ...action, order: index + 1, })); return { enabled: payload.enabled !== false, position: payload.position || defaults.position, panelTitle: String(payload.panelTitle || defaults.panelTitle).trim(), brand: { imageSrc: normalizeStoredImagePath( String(brand.imageSrc || defaults.brand.imageSrc).trim(), ), imageAlt: String(brand.imageAlt || defaults.brand.imageAlt).trim(), }, trigger: { imageSrc: normalizeStoredImagePath( String(trigger.imageSrc || "").trim(), ), icon: String(trigger.icon || defaults.trigger.icon).trim() || defaults.trigger.icon, }, actions: hasProvidedActions ? actions : defaults.actions, }; }; const getDefaultHomeData = () => ({ hero: { backgroundImage: "", slides: [], title: "", subtitle: "", description: "", heroImage: "", videoUrl: "", primaryButton: {}, secondaryButton: {}, }, whyChooseUs: { heading: "", subheading: "", description: "", highlightWord: "", mainImage: "", secondaryImage: "", items: [], features: [], ctaButton: {}, }, visaSolutions: { heading: "", subheading: "", items: [] }, visaCountries: { heading: "", subheading: "", description: "", countries: [], ctaButton: {}, }, testimonials: { heading: "", subheading: "", videoUrl: "", videoThumbnail: "", items: [], }, videoGallery: { heading: "", videoUrl: "", thumbnail: "" }, faq: { heading: "", subheading: "", description: "", ctaButton: {}, items: [], }, achievements: { heading: "", subheading: "", items: [] }, partners: { visaConsultancy: { items: [] }, brands: { items: [] } }, blogPreview: { heading: "Latest Insights & Updates", subheading: "Visa Tips & Guides", ctaButton: { label: "View All Articles", href: "/blog" }, items: [], selectedBlogIds: [], // Array of manually selected blog IDs }, floatingContact: getDefaultFloatingContactData(), }); // Admin: Xem trang quản lý exports.index = async (req, res) => { try { let data = await getHomeData(); const defaults = getDefaultHomeData(); // Merge dữ liệu mặc định cho tất cả các phần const sections = Object.keys(defaults); sections.forEach((s) => { data[s] = data[s] || defaults[s]; }); data.floatingContact = normalizeFloatingContactData(data.floatingContact); const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const backendUrl = `${req.protocol}://${req.get("host")}`; // Lấy tất cả blog để chọn trong CMS const allBlogs = await Blog.find({ status: "published" }) .sort({ createdAt: -1 }) .lean(); return res.render("admin/home/index", { layout: "layouts/main", title: "Home Management", data, allBlogs, frontendUrl, backendUrl, getFullImageUrl, currentPath: req.path, user: req.session.user, }); } catch (err) { console.error("Home index error:", err); req.flash("error_msg", "Error loading home data"); return req.session.save(() => res.redirect("/admin/dashboard")); } }; // Admin: Cập nhật dữ liệu (tập trung vào achievements, partners; các phần khác giữ nguyên nếu không có dữ liệu mới) exports.update = async (req, res) => { try { const sections = [ "hero", "whyChooseUs", "visaSolutions", "visaCountries", "testimonials", "videoGallery", "faq", "achievements", "partners", "blogPreview", "floatingContact", ]; 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 { const payload = JSON.parse(req.body[section]); // Gán trực tiếp vào doc, Mongoose sẽ tự check schema doc[section] = section === "floatingContact" ? normalizeFloatingContactData(payload) : payload; doc.markModified(section); hasChanges = true; updatedSections.push(section); } catch (e) { console.error(`Invalid JSON for ${section}:`, e); } } } if (!hasChanges) { req.flash("info_msg", "No changes were made"); return req.session.save(() => res.redirect("/admin/home")); } 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) { console.error("Home update error:", err); req.flash("error_msg", `Update error: ${err.message}`); return req.session.save(() => res.redirect("/admin/home")); } }; exports.updateFloatingContact = async (req, res) => { try { const payload = typeof req.body?.floatingContact === "string" ? JSON.parse(req.body.floatingContact) : req.body?.floatingContact || req.body; const doc = await getOrCreateHomeDoc(); const beforeData = JSON.parse(JSON.stringify(doc.toObject())); doc.floatingContact = normalizeFloatingContactData(payload); doc.markModified("floatingContact"); await doc.save(); const afterData = JSON.parse(JSON.stringify(doc.toObject())); 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, }); } return res.status(200).json({ success: true, message: "Floating contact updated successfully", floatingContact: doc.floatingContact, }); } catch (err) { console.error("Floating contact update error:", err); return res.status(500).json({ success: false, error: err.message || "Failed to update floating contact", }); } }; // 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(); res.json(blogs); } catch (err) { res.status(500).json({ error: err.message }); } }; exports.api = async (req, res) => { try { // Chỉ dùng doc mới nhất, không merge nhiều docs const doc = await getHomeDoc(); let data = doc?.toObject() || {}; const baseUrl = `${req.protocol}://${req.get("host")}`; // === Xử lý Blog Preview động === const blogPreview = data.blogPreview || {}; let blogs = []; // Nếu có chọn blog cụ thể if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) { blogs = await Blog.find({ _id: { $in: blogPreview.selectedBlogIds }, 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()) ); }); } // 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 (hoặc bù vào) 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 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 }, comments: blog.commentsCount || 0, link: `/blog/${blog.slug}`, thumbnail: blog.featuredImage, })); data.blogPreview = blogPreview; data.floatingContact = normalizeFloatingContactData(data.floatingContact); // =============================== const processed = addBaseUrlToImages(data, baseUrl); return res.json(processed); } catch (err) { console.error("Home API error:", err); return res.status(500).json({ error: "Error loading home data" }); } };