Files
cms.uldp.edu.vn/controllers/aboutUsController.js
Tống Thành Đạt c6a2d4a55d 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
2026-04-10 22:32:51 +07:00

452 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;