feat: enhance about us and footer CMS admin panels

- Improve aboutUs controller with better field handling
- Update footer controller with expanded content management
- Refine about admin view templates
- Update appointment and footer admin views
- Add about contract repair migration script
- Update about.json seed data
This commit is contained in:
Tống Thành Đạt
2026-04-10 22:32:51 +07:00
parent 51c6303437
commit c6a2d4a55d
8 changed files with 534 additions and 114 deletions

View File

@@ -1,3 +1,4 @@
const mongoose = require("mongoose");
const { addBaseUrlToImages } = require("../utils/imageHelper");
const AboutUs = require("../models/aboutUs");
const Blog = require("../models/blog");
@@ -13,6 +14,157 @@ 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.";
@@ -41,26 +193,49 @@ exports.getAbout = async (req, res) => {
res.setHeader("Expires", "0");
const data = await AboutUs.getSingle();
const rawData = data.toObject();
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 (news.selectedBlogIds && news.selectedBlogIds.length > 0) {
blogs = await Blog.find({
_id: { $in: news.selectedBlogIds },
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
blogs.sort((a, b) => {
selectedQuery.sort((a, b) => {
return (
news.selectedBlogIds.indexOf(a._id.toString()) -
news.selectedBlogIds.indexOf(b._id.toString())
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
@@ -72,24 +247,7 @@ exports.getAbout = async (req, res) => {
}
// 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,
}));
news.items = blogs.slice(0, 3).map((blog, index) => resolveNewsItem(blog, index));
rawData.news = news;
// ===============================
@@ -133,6 +291,8 @@ exports.updateAbout = async (req, res) => {
// ✅ 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 });
@@ -165,16 +325,18 @@ exports.updateAbout = async (req, res) => {
}
// Fetch fresh data for syncing and returning
const finalData = await AboutUs.findOne()
const finalData = buildCanonicalAboutData(
await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
.lean(),
);
// Update about.json file to keep it in sync
jsonHelper.writeJsonFile("about", finalData);
res.json({
success: true,
message: "About Us updated successfully",
message: "About page updated successfully",
data: finalData,
});
} catch (error) {
@@ -192,7 +354,7 @@ exports.updateAbout = async (req, res) => {
exports.index = async (req, res) => {
try {
const data = await AboutUs.getSingle();
const rawData = data.toObject();
const rawData = buildCanonicalAboutData(data.toObject());
// Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" })
@@ -202,7 +364,7 @@ exports.index = async (req, res) => {
const activeTab = req.query.activeTab || "hero";
res.render("admin/aboutUs/index", {
layout: "layouts/main",
title: "About Us Management",
title: "About Page Management",
data: rawData,
allBlogs,
activeTab,
@@ -213,7 +375,7 @@ exports.index = async (req, res) => {
});
} catch (err) {
console.error("Error in about index:", err);
req.flash("error_msg", "Error loading About Us page");
req.flash("error_msg", "Error loading About page");
res.redirect("/admin/dashboard");
}
};
@@ -234,6 +396,7 @@ exports.update = async (req, res) => {
}
const doc = await AboutUs.getSingle();
updateData = buildCanonicalAboutData(updateData);
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
@@ -265,17 +428,19 @@ exports.update = async (req, res) => {
});
}
const finalData = await AboutUs.findOne()
const finalData = buildCanonicalAboutData(
await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
.lean(),
);
jsonHelper.writeJsonFile("about", finalData);
req.flash("success_msg", "About Us updated successfully");
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 Us: " + err.message);
req.flash("error_msg", "Error updating About page: " + err.message);
res.redirect("/admin/about-us");
}
};

View File

@@ -4,6 +4,77 @@ const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const isPlainObject = (value) =>
value && typeof value === "object" && !Array.isArray(value);
const normalizeFooterImagePath = (value) => {
if (!value || typeof value !== "string") return "";
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.startsWith("/uploads/footer/")) {
return trimmed;
}
if (trimmed.startsWith("uploads/footer/")) {
return `/${trimmed}`;
}
if (/^https?:\/\//i.test(trimmed)) {
try {
const parsed = new URL(trimmed);
if (parsed.pathname.startsWith("/uploads/footer/")) {
return parsed.pathname;
}
} catch (error) {
console.warn("Failed to parse footer image URL:", trimmed, error.message);
}
}
return trimmed;
};
const sanitizeFooterData = (data = {}) => {
const sanitized = JSON.parse(JSON.stringify(data || {}));
if (!sanitized.top) {
return sanitized;
}
if (typeof sanitized.top.bgImage === "string") {
sanitized.top.bgImage = normalizeFooterImagePath(sanitized.top.bgImage);
}
if (sanitized.top.logo && typeof sanitized.top.logo.src === "string") {
sanitized.top.logo.src = normalizeFooterImagePath(sanitized.top.logo.src);
}
return sanitized;
};
const mergeFooterData = (currentValue, incomingValue) => {
if (incomingValue === undefined) {
return currentValue;
}
if (Array.isArray(incomingValue)) {
return incomingValue;
}
if (isPlainObject(currentValue) && isPlainObject(incomingValue)) {
const merged = { ...currentValue };
Object.keys(incomingValue).forEach((key) => {
merged[key] = mergeFooterData(currentValue[key], incomingValue[key]);
});
return merged;
}
return incomingValue;
};
// GET /api/footer - Public API cho website và CMS load dữ liệu
exports.getFooter = async (req, res) => {
try {
@@ -42,6 +113,8 @@ exports.updateFooter = async (req, res) => {
}
}
updateData = sanitizeFooterData(updateData);
// Lấy footer hiện tại hoặc tạo mới (giống Header logic)
let footer = await Footer.findOne();
@@ -52,8 +125,9 @@ exports.updateFooter = async (req, res) => {
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);
const mergedData = mergeFooterData(footer.toObject(), updateData);
// Use set() so nested footer fields persist correctly in MongoDB
footer.set(mergedData);
await footer.save();
console.log("✓ Footer updated successfully");
}
@@ -80,11 +154,10 @@ exports.updateFooter = async (req, res) => {
exports.index = async (req, res) => {
try {
const data = await Footer.getSingle();
const processedData = addBaseUrlToImages(data.toObject());
res.render("admin/footer/index", {
title: "Footer Management",
data: processedData,
data: data.toObject(),
});
} catch (error) {
console.error("Error in footer index:", error);
@@ -114,6 +187,8 @@ exports.update = async (req, res) => {
}
}
updateData = sanitizeFooterData(updateData);
// Lấy footer hiện tại hoặc tạo mới (giống Header)
let footer = await Footer.findOne();
@@ -130,8 +205,9 @@ exports.update = async (req, res) => {
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);
const mergedData = mergeFooterData(footer.toObject(), updateData);
// Use set() so nested footer fields persist correctly in MongoDB
footer.set(mergedData);
await footer.save();
// ✅ Capture AFTER state
@@ -166,4 +242,14 @@ exports.update = async (req, res) => {
// Legacy API endpoints (giữ lại cho tương thích)
exports.api = exports.getFooter;
exports.getFooterData = exports.getFooter;
exports.getFooterData = async (req, res) => {
try {
const footer = await Footer.getSingle();
res.json(footer.toObject());
} catch (error) {
console.error("Error getting footer data for admin:", error);
res.status(500).json({
error: "Failed to get footer data",
});
}
};