Files
cms.uldp.edu.vn/controllers/homeController.js
2026-04-09 22:03:00 +07:00

510 lines
15 KiB
JavaScript

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: "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?",
brand: {
imageSrc: "/assets/img/logo/black-logo.svg",
imageAlt: "HAI Learning",
},
trigger: {
imageSrc: "",
icon: "fa-comments",
},
actions: [
{
id: "facebook",
platform: "facebook",
enabled: true,
label: "Nhắn tin qua 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: "Nhắn tin qua 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 {
const docs = await getAllHomeDocs();
let data = docs[0]?.toObject() || {};
const baseUrl = `${req.protocol}://${req.get("host")}`;
if (docs.length > 1) {
data.hero = getPreferredHeroData(docs.map((doc) => doc.toObject()));
}
// === 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" });
}
};