Merge pull request 'feat: implement comprehensive audit logging system' (#37) from fea/bao-10022026-AuditLog into main

Reviewed-on: UKSOURCE/cms.hailearning.edu.vn#37
This commit is contained in:
2026-02-11 04:50:46 +00:00
28 changed files with 4796 additions and 2205 deletions

40
audit/diffObject.js Normal file
View File

@@ -0,0 +1,40 @@
function diffObject(before = {}, after = {}, parentPath = "") {
const changes = [];
const allKeys = new Set([
...Object.keys(before || {}),
...Object.keys(after || {}),
]);
for (const key of allKeys) {
const beforeValue = before?.[key];
const afterValue = after?.[key];
const currentPath = parentPath ? `${parentPath}.${key}` : key;
// Nếu cả hai đều là object (không phải array)
if (
typeof beforeValue === "object" &&
typeof afterValue === "object" &&
beforeValue !== null &&
afterValue !== null &&
!Array.isArray(beforeValue) &&
!Array.isArray(afterValue)
) {
changes.push(...diffObject(beforeValue, afterValue, currentPath));
continue;
}
// So sánh primitive hoặc array
if (JSON.stringify(beforeValue) !== JSON.stringify(afterValue)) {
changes.push({
field: currentPath,
before: beforeValue,
after: afterValue,
});
}
}
return changes;
}
module.exports = diffObject;

25
audit/writeAuditLog.js Normal file
View File

@@ -0,0 +1,25 @@
const AuditLog = require("../models/auditLog");
const RequestMeta = require("../utils/requestMeta");
async function writeAuditLog({
model,
documentId,
action,
before,
after,
changes = [],
req,
}) {
await AuditLog.create({
model,
documentId,
action,
before,
after,
changes,
ipAddress: RequestMeta.getClientIp(req),
userAgent: RequestMeta.getUserAgent(req),
performedBy: req.session?.user?.id || req.user?.id || null,
});
}
module.exports = writeAuditLog;

72
constants/auditAction.js Normal file
View File

@@ -0,0 +1,72 @@
const AUDIT_ACTIONS = Object.freeze({
CREATE: "CREATE",
UPDATE: "UPDATE",
DELETE: "DELETE",
// Service
UPDATE_SERVICE: "UPDATE_SERVICE",
UPDATE_SERVICE_DETAILS: "UPDATE_SERVICE_DETAILS",
// Blog
CREATE_BLOG: "CREATE_BLOG",
UPDATE_BLOG: "UPDATE_BLOG",
DELETE_BLOG: "DELETE_BLOG",
// Category
CREATE_CATEGORY: "CREATE_CATEGORY",
UPDATE_CATEGORY: "UPDATE_CATEGORY",
DELETE_CATEGORY: "DELETE_CATEGORY",
// Home
UPDATE_HOME: "UPDATE_HOME",
// About Us
UPDATE_ABOUT_US: "UPDATE_ABOUT_US",
// Header
UPDATE_HEADER: "UPDATE_HEADER",
// Footer
UPDATE_FOOTER: "UPDATE_FOOTER",
// Contact
UPDATE_CONTACT: "UPDATE_CONTACT",
// Pricing
UPDATE_PRICING: "UPDATE_PRICING",
// FAQ
UPDATE_FAQ: "UPDATE_FAQ",
// Terms
UPDATE_TERMS: "UPDATE_TERMS",
// Safety
UPDATE_SAFETY: "UPDATE_SAFETY",
// Insurance
UPDATE_INSURANCE: "UPDATE_INSURANCE",
// Travel
UPDATE_TRAVEL: "UPDATE_TRAVEL",
// Visa
UPDATE_VISA: "UPDATE_VISA",
// Appointment
UPDATE_APPOINTMENT: "UPDATE_APPOINTMENT",
UPDATE_APPOINTMENT_STATUS: "UPDATE_APPOINTMENT_STATUS",
DELETE_APPOINTMENT: "DELETE_APPOINTMENT",
// Testimonial
UPDATE_TESTIMONIAL: "UPDATE_TESTIMONIAL",
// Video Gallery
UPDATE_VIDEO_GALLERY: "UPDATE_VIDEO_GALLERY",
// Auth / System
LOGIN: "LOGIN",
LOGOUT: "LOGOUT",
});
module.exports = AUDIT_ACTIONS;

View File

@@ -2,77 +2,87 @@ const { addBaseUrlToImages } = require("../utils/imageHelper");
const AboutUs = require("../models/aboutUs");
const Blog = require("../models/blog");
const jsonHelper = require("../utils/jsonHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/**
* 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");
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 = data.toObject();
const data = await AboutUs.getSingle();
const rawData = data.toObject();
// === Dynamic Blog News Section ===
const news = rawData.news || {};
let blogs = [];
// === Dynamic Blog News Section ===
const news = rawData.news || {};
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 },
status: "published",
}).lean();
// Nếu có chọn blog cụ thể
if (news.selectedBlogIds && news.selectedBlogIds.length > 0) {
blogs = await Blog.find({
_id: { $in: news.selectedBlogIds },
status: "published",
}).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => {
return news.selectedBlogIds.indexOf(a._id.toString()) - news.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
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.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,
}));
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",
});
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => {
return (
news.selectedBlogIds.indexOf(a._id.toString()) -
news.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
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.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,
}));
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",
});
}
};
/**
@@ -80,106 +90,159 @@ exports.getAbout = async (req, res) => {
* 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;
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();
// Use .set() for better handling of nested objects/arrays in Mongoose
doc.set(updateData);
await doc.save();
// Fetch fresh data for syncing and returning
const finalData = 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 Us 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,
// 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()));
// 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 = 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 Us 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 = data.toObject();
try {
const data = await AboutUs.getSingle();
const rawData = data.toObject();
// Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).lean();
// 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 Us 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 Us page");
res.redirect("/admin/dashboard");
}
const activeTab = req.query.activeTab || "hero";
res.render("admin/aboutUs/index", {
layout: "layouts/main",
title: "About Us 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 Us 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();
doc.set(updateData);
await doc.save();
const finalData = await AboutUs.findOne().select("-_id -__v -createdAt -updatedAt").lean();
jsonHelper.writeJsonFile("about", finalData);
req.flash("success_msg", "About Us 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);
res.redirect("/admin/about-us");
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();
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
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 = await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
jsonHelper.writeJsonFile("about", finalData);
req.flash("success_msg", "About Us 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);
res.redirect("/admin/about-us");
}
};
// Aliases for compatibility

View File

@@ -1,377 +1,450 @@
const AppointmentSubmission = require("../models/appointmentSubmission");
const Appointment = require("../models/appointment");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// ==================== CMS ADMIN FUNCTIONS ====================
// Render admin page for appointment management
exports.index = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
try {
let appointment = await Appointment.findOne({ name: "default" });
// If no data in DB, try to load from JSON file
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
// If no data in DB, try to load from JSON file
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
} else {
// Create default appointment
appointment = await Appointment.create({
name: "default",
hero: {
title: "Make Appointment",
backgroundImage: "",
subtitle: "",
heading: "",
description: "",
},
visaOptions: [],
form: {
heading: "Request Appointment",
fields: [],
submitButton: {
text: "Request Appointment",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn",
},
},
});
}
}
const { startDate, endDate } = req.query;
const query = {};
if (startDate || endDate) {
query.createdAt = {};
if (startDate) {
query.createdAt.$gte = new Date(startDate);
}
if (endDate) {
// Set end date to end of day
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
query.createdAt.$lte = end;
}
}
const submissions = await AppointmentSubmission.find(query).sort({ createdAt: -1 }).limit(50);
res.render("admin/appointment/index", {
layout: "layouts/main",
title: "Appointment Management",
data: appointment,
submissions,
startDate,
endDate,
user: req.session.user,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
} else {
// Create default appointment
appointment = await Appointment.create({
name: "default",
hero: {
title: "Make Appointment",
backgroundImage: "",
subtitle: "",
heading: "",
description: "",
},
visaOptions: [],
form: {
heading: "Request Appointment",
fields: [],
submitButton: {
text: "Request Appointment",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn",
},
},
});
} catch (err) {
console.error("Error loading appointment admin page:", err);
req.flash("error", "Error loading appointment data");
res.redirect("/admin/dashboard");
}
}
const { startDate, endDate } = req.query;
const query = {};
if (startDate || endDate) {
query.createdAt = {};
if (startDate) {
query.createdAt.$gte = new Date(startDate);
}
if (endDate) {
// Set end date to end of day
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
query.createdAt.$lte = end;
}
}
const submissions = await AppointmentSubmission.find(query)
.sort({ createdAt: -1 })
.limit(50);
res.render("admin/appointment/index", {
layout: "layouts/main",
title: "Appointment Management",
data: appointment,
submissions,
startDate,
endDate,
user: req.session.user,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
});
} catch (err) {
console.error("Error loading appointment admin page:", err);
req.flash("error", "Error loading appointment data");
res.redirect("/admin/dashboard");
}
};
// Update appointment data
exports.update = async (req, res) => {
try {
const { hero, visaOptions, form } = req.body;
try {
const { hero, visaOptions, form } = req.body;
// Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const visaOptionsData = typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions;
const formData = typeof form === "string" ? JSON.parse(form) : form;
// Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const visaOptionsData =
typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions;
const formData = typeof form === "string" ? JSON.parse(form) : form;
let appointment = await Appointment.findOne({ name: "default" });
let appointment = await Appointment.findOne({ name: "default" });
if (appointment) {
appointment.hero = heroData;
appointment.visaOptions = visaOptionsData;
appointment.form = formData;
await appointment.save();
} else {
appointment = await Appointment.create({
name: "default",
hero: heroData,
visaOptions: visaOptionsData,
form: formData,
});
}
// Capture before state for audit logging
const beforeState = appointment
? JSON.parse(JSON.stringify(appointment.toObject()))
: null;
req.flash("success", "Appointment data updated successfully");
res.redirect("/admin/appointment");
} catch (err) {
console.error("Error updating appointment:", err);
req.flash("error", "Error updating appointment data");
res.redirect("/admin/appointment");
if (appointment) {
appointment.hero = heroData;
appointment.visaOptions = visaOptionsData;
appointment.form = formData;
await appointment.save();
} else {
appointment = await Appointment.create({
name: "default",
hero: heroData,
visaOptions: visaOptionsData,
form: formData,
});
}
// Capture after state for audit logging
const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
// Generate changes diff
const changes = beforeState ? diffObject(beforeState, afterState) : [];
// Write audit log
await writeAuditLog({
model: "Appointment",
documentId: appointment._id,
action: AUDIT_ACTIONS.UPDATE_APPOINTMENT,
before: beforeState,
after: afterState,
changes,
req,
});
req.flash("success", "Appointment data updated successfully");
res.redirect("/admin/appointment");
} catch (err) {
console.error("Error updating appointment:", err);
req.flash("error", "Error updating appointment data");
res.redirect("/admin/appointment");
}
};
// API to get appointment data
exports.getAppointmentData = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
try {
let appointment = await Appointment.findOne({ name: "default" });
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
}
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error getting appointment data:", err);
res.status(500).json({
success: false,
error: "Error loading appointment data",
});
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
}
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error getting appointment data:", err);
res.status(500).json({
success: false,
error: "Error loading appointment data",
});
}
};
// Public API to get appointment page data (for frontend)
exports.api = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
try {
let appointment = await Appointment.findOne({ name: "default" });
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
}
}
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment data not found",
});
}
res.json({
success: true,
data: {
hero: appointment.hero,
visaOptions: appointment.visaOptions,
form: appointment.form,
},
});
} catch (err) {
console.error("Error getting appointment API data:", err);
res.status(500).json({
success: false,
error: "Error loading appointment data",
});
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
}
}
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment data not found",
});
}
res.json({
success: true,
data: {
hero: appointment.hero,
visaOptions: appointment.visaOptions,
form: appointment.form,
},
});
} catch (err) {
console.error("Error getting appointment API data:", err);
res.status(500).json({
success: false,
error: "Error loading appointment data",
});
}
};
// ==================== APPOINTMENT SUBMISSIONS API ====================
// API để submit appointment form (từ frontend)
exports.submitAppointment = async (req, res) => {
try {
const { name, email, phone, address, appointmentDate, message, visaTypes } = req.body;
try {
const { name, email, phone, address, appointmentDate, message, visaTypes } =
req.body;
// Validation
if (!name || !email) {
return res.status(400).json({
success: false,
error: "Name and email are required",
});
}
// Create new submission
const submission = new AppointmentSubmission({
name: name.trim(),
email: email.trim().toLowerCase(),
phone: phone?.trim() || "",
address: address?.trim() || "",
appointmentDate: appointmentDate?.trim() || "",
message: message?.trim() || "",
visaTypes: Array.isArray(visaTypes) ? visaTypes : [],
ipAddress: req.ip || req.connection?.remoteAddress || "",
userAgent: req.get("User-Agent") || "",
});
await submission.save();
res.status(201).json({
success: true,
message: "Thank you! Your appointment request has been submitted. We will contact you soon.",
data: {
id: submission._id,
name: submission.name,
email: submission.email,
appointmentDate: submission.appointmentDate,
},
});
} catch (err) {
console.error("Error submitting appointment:", err);
// Handle validation errors
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({
success: false,
error: errors.join(", "),
});
}
res.status(500).json({
success: false,
error: "Error submitting appointment. Please try again later.",
});
// Validation
if (!name || !email) {
return res.status(400).json({
success: false,
error: "Name and email are required",
});
}
// Create new submission
const submission = new AppointmentSubmission({
name: name.trim(),
email: email.trim().toLowerCase(),
phone: phone?.trim() || "",
address: address?.trim() || "",
appointmentDate: appointmentDate?.trim() || "",
message: message?.trim() || "",
visaTypes: Array.isArray(visaTypes) ? visaTypes : [],
ipAddress: req.ip || req.connection?.remoteAddress || "",
userAgent: req.get("User-Agent") || "",
});
await submission.save();
res.status(201).json({
success: true,
message:
"Thank you! Your appointment request has been submitted. We will contact you soon.",
data: {
id: submission._id,
name: submission.name,
email: submission.email,
appointmentDate: submission.appointmentDate,
},
});
} catch (err) {
console.error("Error submitting appointment:", err);
// Handle validation errors
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({
success: false,
error: errors.join(", "),
});
}
res.status(500).json({
success: false,
error: "Error submitting appointment. Please try again later.",
});
}
};
// API để lấy danh sách appointments (cho admin)
exports.getAppointments = async (req, res) => {
try {
const { status, page = 1, limit = 20 } = req.query;
try {
const { status, page = 1, limit = 20 } = req.query;
const query = {};
if (status && ["pending", "confirmed", "completed", "cancelled"].includes(status)) {
query.status = status;
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [appointments, total] = await Promise.all([
AppointmentSubmission.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
AppointmentSubmission.countDocuments(query),
]);
res.json({
success: true,
data: appointments,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / parseInt(limit)),
},
});
} catch (err) {
console.error("Error getting appointments:", err);
res.status(500).json({
success: false,
error: "Error loading appointments",
});
const query = {};
if (
status &&
["pending", "confirmed", "completed", "cancelled"].includes(status)
) {
query.status = status;
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [appointments, total] = await Promise.all([
AppointmentSubmission.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
AppointmentSubmission.countDocuments(query),
]);
res.json({
success: true,
data: appointments,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / parseInt(limit)),
},
});
} catch (err) {
console.error("Error getting appointments:", err);
res.status(500).json({
success: false,
error: "Error loading appointments",
});
}
};
// API để cập nhật status của appointment
exports.updateAppointmentStatus = async (req, res) => {
try {
const { id } = req.params;
const { status, notes } = req.body;
try {
const { id } = req.params;
const { status, notes } = req.body;
const validStatuses = ["pending", "confirmed", "completed", "cancelled"];
if (!validStatuses.includes(status)) {
return res.status(400).json({
success: false,
error: "Invalid status",
});
}
const updateData = { status };
if (notes !== undefined) updateData.notes = notes;
if (status === "confirmed") updateData.confirmedAt = new Date();
if (status === "completed") updateData.completedAt = new Date();
const appointment = await AppointmentSubmission.findByIdAndUpdate(
id,
updateData,
{ new: true }
);
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error updating appointment:", err);
res.status(500).json({
success: false,
error: "Error updating appointment",
});
const validStatuses = ["pending", "confirmed", "completed", "cancelled"];
if (!validStatuses.includes(status)) {
return res.status(400).json({
success: false,
error: "Invalid status",
});
}
// Get the appointment before update for audit logging
const beforeAppointment = await AppointmentSubmission.findById(id);
if (!beforeAppointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
const beforeState = JSON.parse(
JSON.stringify(beforeAppointment.toObject()),
);
const updateData = { status };
if (notes !== undefined) updateData.notes = notes;
if (status === "confirmed") updateData.confirmedAt = new Date();
if (status === "completed") updateData.completedAt = new Date();
const appointment = await AppointmentSubmission.findByIdAndUpdate(
id,
updateData,
{ new: true },
);
// Capture after state for audit logging
const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
// Generate changes diff
const changes = diffObject(beforeState, afterState);
// Write audit log
await writeAuditLog({
model: "AppointmentSubmission",
documentId: appointment._id,
action: AUDIT_ACTIONS.UPDATE_APPOINTMENT_STATUS,
before: beforeState,
after: afterState,
changes,
req,
});
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error updating appointment:", err);
res.status(500).json({
success: false,
error: "Error updating appointment",
});
}
};
// API để lấy chi tiết một appointment
exports.getAppointmentById = async (req, res) => {
try {
const { id } = req.params;
const appointment = await AppointmentSubmission.findById(id);
try {
const { id } = req.params;
const appointment = await AppointmentSubmission.findById(id);
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error getting appointment:", err);
res.status(500).json({
success: false,
error: "Error loading appointment",
});
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error getting appointment:", err);
res.status(500).json({
success: false,
error: "Error loading appointment",
});
}
};
// API để xóa appointment
exports.deleteAppointment = async (req, res) => {
try {
const { id } = req.params;
const appointment = await AppointmentSubmission.findByIdAndDelete(id);
try {
const { id } = req.params;
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
res.json({
success: true,
message: "Appointment deleted successfully",
});
} catch (err) {
console.error("Error deleting appointment:", err);
res.status(500).json({
success: false,
error: "Error deleting appointment",
});
// Get the appointment before deletion for audit logging
const appointment = await AppointmentSubmission.findById(id);
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
const beforeState = JSON.parse(JSON.stringify(appointment.toObject()));
// Delete the appointment
await AppointmentSubmission.findByIdAndDelete(id);
// Write audit log
await writeAuditLog({
model: "AppointmentSubmission",
documentId: appointment._id,
action: AUDIT_ACTIONS.DELETE_APPOINTMENT,
before: beforeState,
after: null,
changes: [],
req,
});
res.json({
success: true,
message: "Appointment deleted successfully",
});
} catch (err) {
console.error("Error deleting appointment:", err);
res.status(500).json({
success: false,
error: "Error deleting appointment",
});
}
};

View File

@@ -0,0 +1,176 @@
const AuditLog = require("../models/auditLog");
const User = require("../models/User");
// Display audit logs with pagination and filtering
exports.index = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 8; // Default to 8, but allow override
const skip = (page - 1) * limit;
// Build filter query
const filter = {};
if (req.query.model) {
filter.model = req.query.model;
}
if (req.query.action) {
filter.action = req.query.action;
}
if (req.query.user) {
filter.performedBy = req.query.user;
}
if (req.query.dateFrom || req.query.dateTo) {
filter.createdAt = {};
if (req.query.dateFrom) {
if (req.query.dateTo) {
const dateTo = new Date(req.query.dateTo);
dateTo.setHours(23, 59, 59, 999); // End of day
filter.createdAt.$lte = dateTo;
}
}
// Get audit logs with user population
const auditLogs = await AuditLog.find(filter)
.populate("performedBy", "username email")
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit);
// Get total count for pagination
const totalCount = await AuditLog.countDocuments(filter);
const totalPages = Math.ceil(totalCount / limit);
// Get unique models and actions for filter dropdowns
const uniqueModels = await AuditLog.distinct("model");
const uniqueActions = await AuditLog.distinct("action");
const users = await User.find({}, "username email").sort({ username: 1 });
res.render("admin/audit-log/index", {
title: "Audit Logs",
layout: "layouts/main",
auditLogs,
pagination: {
current: page,
total: totalPages,
limit,
totalCount,
},
query: req.query,
uniqueModels,
uniqueActions,
users,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("Error loading audit logs:", err);
req.flash("error_msg", "Error loading audit logs");
res.redirect("/admin/dashboard");
}
};
// Display single audit log details
exports.show = async (req, res) => {
try {
const auditLog = await AuditLog.findById(req.params.id).populate(
"performedBy",
"username email",
);
if (!auditLog) {
req.flash("error_msg", "Audit log not found");
return res.redirect("/admin/audit-logs");
}
res.render("admin/audit-log/show", {
title: "Audit Log Details",
layout: "layouts/main",
auditLog,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("Error loading audit log:", err);
req.flash("error_msg", "Error loading audit log");
res.redirect("/admin/audit-logs");
}
};
// API endpoint to get audit logs (for AJAX requests)
exports.api = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 7; // Default to 7, but allow override
const skip = (page - 1) * limit;
const filter = {};
if (req.query.model) filter.model = req.query.model;
if (req.query.action) filter.action = req.query.action;
if (req.query.user) filter.performedBy = req.query.user;
if (req.query.dateFrom || req.query.dateTo) {
filter.createdAt = {};
if (req.query.dateFrom) {
filter.createdAt.$gte = new Date(req.query.dateFrom);
}
if (req.query.dateTo) {
const dateTo = new Date(req.query.dateTo);
dateTo.setHours(23, 59, 59, 999);
filter.createdAt.$lte = dateTo;
}
}
const auditLogs = await AuditLog.find(filter)
.populate("performedBy", "username email")
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit);
const totalCount = await AuditLog.countDocuments(filter);
res.json({
success: true,
data: auditLogs,
pagination: {
current: page,
total: Math.ceil(totalCount / limit),
limit,
totalCount,
},
});
} catch (err) {
console.error("API Error:", err);
res.status(500).json({
success: false,
error: "Error loading audit logs",
});
}
};
// Delete old audit logs (cleanup)
exports.cleanup = async (req, res) => {
try {
const daysToKeep = parseInt(req.body.days) || 90;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const result = await AuditLog.deleteMany({
createdAt: { $lt: cutoffDate },
});
req.flash(
"success_msg",
`Deleted ${result.deletedCount} old audit logs (older than ${daysToKeep} days)`,
);
res.redirect("/admin/audit-logs");
} catch (err) {
console.error("Error cleaning up audit logs:", err);
req.flash("error_msg", "Error cleaning up audit logs");
res.redirect("/admin/audit-logs");
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,9 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Contact = require("../models/contact");
const ContactSubmission = require("../models/contactSubmission");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Get contact data from MongoDB
const getContactData = async () => {
@@ -74,7 +77,11 @@ exports.index = async (req, res) => {
heading: "",
description: "",
fields: [],
submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" },
submitButton: {
text: "Send Message",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn style-2",
},
},
};
@@ -94,7 +101,9 @@ exports.index = async (req, res) => {
}
}
const submissions = await ContactSubmission.find(query).sort({ createdAt: -1 }).limit(50);
const submissions = await ContactSubmission.find(query)
.sort({ createdAt: -1 })
.limit(50);
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/contact/index", {
@@ -141,6 +150,11 @@ exports.update = async (req, res) => {
// Tìm hoặc tạo contact
let contact = await Contact.findOne({ name: "default" });
// ✅ Capture BEFORE state
const beforeData = contact
? JSON.parse(JSON.stringify(contact.toObject()))
: {};
if (!contact) {
// Tạo mới với default values
contact = new Contact({
@@ -157,7 +171,11 @@ exports.update = async (req, res) => {
contactCards: (contactCardsData || []).map((card) => ({
...card,
iconType: card.iconType || "",
iconSource: card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'),
iconSource:
card.iconSource ||
(card.iconType && card.iconType.startsWith("/uploads/")
? "image"
: "fontawesome"),
})),
map: mapData || {
coordinates: { lat: 0, lng: 0 },
@@ -177,7 +195,11 @@ exports.update = async (req, res) => {
heading: "",
description: "",
fields: [],
submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" },
submitButton: {
text: "Send Message",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn style-2",
},
},
});
} else {
@@ -188,7 +210,11 @@ exports.update = async (req, res) => {
contact.contactCards = contactCardsData.map((card) => ({
...card,
iconType: card.iconType || "",
iconSource: card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'),
iconSource:
card.iconSource ||
(card.iconType && card.iconType.startsWith("/uploads/")
? "image"
: "fontawesome"),
}));
}
if (mapData) contact.map = mapData;
@@ -197,6 +223,23 @@ exports.update = async (req, res) => {
await contact.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(contact.toObject()));
// ✅ AUDIT LOGGING - Contact Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Contact",
documentId: contact._id,
action: AUDIT_ACTIONS.UPDATE_CONTACT,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Contact updated successfully");
res.redirect("/admin/contact");
} catch (err) {
@@ -321,7 +364,7 @@ exports.updateSubmissionStatus = async (req, res) => {
const submission = await ContactSubmission.findByIdAndUpdate(
id,
updateData,
{ new: true }
{ new: true },
);
if (!submission) {

View File

@@ -1,4 +1,7 @@
const Home = require("../models/home");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Helper to get FAQ data from Home model
const getFaqData = async () => {
@@ -9,7 +12,7 @@ const getFaqData = async () => {
subheading: "",
description: "",
items: [],
ctaButton: { label: "", href: "" }
ctaButton: { label: "", href: "" },
};
}
return home.faq.toObject ? home.faq.toObject() : home.faq;
@@ -41,7 +44,7 @@ exports.index = async (req, res) => {
subheading: data.subheading || "",
description: data.description || "",
ctaButton: data.ctaButton || { label: "", href: "" },
items: data.items || []
items: data.items || [],
};
const frontendUrl = process.env.FRONTEND_URL;
@@ -64,12 +67,13 @@ exports.index = async (req, res) => {
// Update FAQ data
exports.update = async (req, res) => {
try {
const { heading, subheading, description, ctaLabel, ctaHref, items } = req.body;
const { heading, subheading, description, ctaLabel, ctaHref, items } =
req.body;
let parsedItems = [];
if (items) {
try {
parsedItems = typeof items === 'string' ? JSON.parse(items) : items;
parsedItems = typeof items === "string" ? JSON.parse(items) : items;
} catch (e) {
console.error("Error parsing items JSON:", e);
parsedItems = [];
@@ -81,22 +85,47 @@ exports.update = async (req, res) => {
home = new Home({});
}
home.faq = {
// ✅ Capture BEFORE state
const beforeData = home.faq
? JSON.parse(
JSON.stringify(home.faq.toObject ? home.faq.toObject() : home.faq),
)
: {};
const updatedFaqData = {
heading: heading || "",
subheading: subheading || "",
description: description || "",
ctaButton: {
label: ctaLabel || "",
href: ctaHref || ""
href: ctaHref || "",
},
items: parsedItems.map(item => ({
items: parsedItems.map((item) => ({
question: item.question || "",
answer: item.answer || ""
}))
answer: item.answer || "",
})),
};
home.faq = updatedFaqData;
await home.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedFaqData));
// ✅ AUDIT LOGGING - FAQ Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: home._id,
action: AUDIT_ACTIONS.UPDATE_FAQ,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "FAQ section updated successfully");
res.redirect("/admin/home/faq");
} catch (err) {
@@ -107,11 +136,19 @@ exports.update = async (req, res) => {
};
// Placeholder methods to prevent route crashes if routes are not cleaned up immediately
exports.addFAQ = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQItem = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQItem = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.addFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.reorderFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.updateSidebarNav = (req, res) => res.status(404).json({ error: "Endpoint deprecated" });
exports.addFAQ = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQItem = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQItem = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.addFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.reorderFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateSidebarNav = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });

View File

@@ -1,141 +1,167 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Footer = require("../models/footer");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// GET /api/footer - Public API cho website và CMS load dữ liệu
exports.getFooter = async (req, res) => {
try {
const footer = await Footer.getSingle();
const processedData = addBaseUrlToImages(footer.toObject());
try {
const footer = await Footer.getSingle();
const processedData = addBaseUrlToImages(footer.toObject());
res.json(processedData);
} catch (error) {
console.error("Error getting footer:", error);
res.status(500).json({
error: "Failed to get footer data",
});
}
res.json(processedData);
} catch (error) {
console.error("Error getting footer:", error);
res.status(500).json({
error: "Failed to get footer data",
});
}
};
// PUT /api/admin/footer - Update toàn bộ footer cho CMS
exports.updateFooter = async (req, res) => {
try {
let updateData = req.body;
try {
let updateData = req.body;
console.log("=== FOOTER UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
console.log("=== FOOTER UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
// Nếu có footerJson, parse nó (tương tự Header logic)
if (updateData.footerJson && typeof updateData.footerJson === "string") {
try {
const parsedData = JSON.parse(updateData.footerJson);
console.log("✓ Parsed footerJson successfully:", parsedData);
updateData = parsedData;
} catch (e) {
console.error("✗ Error parsing footerJson:", e.message);
return res.status(400).json({
success: false,
message: "Invalid JSON in footerJson: " + e.message,
});
}
}
// Lấy footer hiện tại hoặc tạo mới (giống Header logic)
let footer = await Footer.findOne();
if (!footer) {
console.log("No existing footer found, creating new one");
footer = new Footer(updateData);
await footer.save();
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);
await footer.save();
console.log("✓ Footer updated successfully");
}
const processedData = addBaseUrlToImages(footer.toObject());
console.log("Updated footer data:", JSON.stringify(processedData, null, 2));
res.json({
success: true,
message: "Footer updated successfully",
data: processedData,
});
} catch (error) {
console.error("✗ Error updating footer:", error);
res.status(500).json({
success: false,
error: "Failed to update footer: " + error.message,
// Nếu có footerJson, parse nó (tương tự Header logic)
if (updateData.footerJson && typeof updateData.footerJson === "string") {
try {
const parsedData = JSON.parse(updateData.footerJson);
console.log("✓ Parsed footerJson successfully:", parsedData);
updateData = parsedData;
} catch (e) {
console.error("✗ Error parsing footerJson:", e.message);
return res.status(400).json({
success: false,
message: "Invalid JSON in footerJson: " + e.message,
});
}
}
// Lấy footer hiện tại hoặc tạo mới (giống Header logic)
let footer = await Footer.findOne();
if (!footer) {
console.log("No existing footer found, creating new one");
footer = new Footer(updateData);
await footer.save();
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);
await footer.save();
console.log("✓ Footer updated successfully");
}
const processedData = addBaseUrlToImages(footer.toObject());
console.log("Updated footer data:", JSON.stringify(processedData, null, 2));
res.json({
success: true,
message: "Footer updated successfully",
data: processedData,
});
} catch (error) {
console.error("✗ Error updating footer:", error);
res.status(500).json({
success: false,
error: "Failed to update footer: " + error.message,
});
}
};
// Render admin view (giữ lại cho UI hiện tại)
exports.index = async (req, res) => {
try {
const data = await Footer.getSingle();
const processedData = addBaseUrlToImages(data.toObject());
try {
const data = await Footer.getSingle();
const processedData = addBaseUrlToImages(data.toObject());
res.render("admin/footer/index", {
title: "Footer Management",
data: processedData,
});
} catch (error) {
console.error("Error in footer index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
res.render("admin/footer/index", {
title: "Footer Management",
data: processedData,
});
} catch (error) {
console.error("Error in footer index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Update method cho form hiện tại (giống Header pattern)
exports.update = async (req, res) => {
try {
let updateData = req.body;
try {
let updateData = req.body;
console.log("=== FOOTER FORM UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
console.log("=== FOOTER FORM UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
// Nếu có footerJson, parse nó (giống Header logic)
if (updateData.footerJson && typeof updateData.footerJson === "string") {
try {
const parsedData = JSON.parse(updateData.footerJson);
console.log("✓ Parsed footerJson successfully:", parsedData);
updateData = parsedData;
} catch (e) {
console.error("✗ Error parsing footerJson:", e.message);
req.flash("error_msg", "Invalid JSON in footerJson: " + e.message);
return res.redirect("/admin/footer");
}
}
// Lấy footer hiện tại hoặc tạo mới (giống Header)
let footer = await Footer.findOne();
if (!footer) {
console.log("No existing footer found, creating new one");
footer = new Footer(updateData);
await footer.save();
console.log("✓ Footer created:", footer._id);
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);
await footer.save();
console.log("✓ Footer updated successfully");
req.flash("success_msg", "Footer updated successfully");
}
const activeTab = req.body.activeTab || "about";
res.redirect(`/admin/footer?activeTab=${activeTab}`);
} catch (err) {
console.error("✗ Error updating footer:", err);
req.flash("error_msg", err.message || "Error updating footer");
res.redirect("/admin/footer");
// Nếu có footerJson, parse nó (giống Header logic)
if (updateData.footerJson && typeof updateData.footerJson === "string") {
try {
const parsedData = JSON.parse(updateData.footerJson);
console.log("✓ Parsed footerJson successfully:", parsedData);
updateData = parsedData;
} catch (e) {
console.error("✗ Error parsing footerJson:", e.message);
req.flash("error_msg", "Invalid JSON in footerJson: " + e.message);
return res.redirect("/admin/footer");
}
}
// Lấy footer hiện tại hoặc tạo mới (giống Header)
let footer = await Footer.findOne();
// ✅ Capture BEFORE state
const beforeData = footer
? JSON.parse(JSON.stringify(footer.toObject()))
: {};
if (!footer) {
console.log("No existing footer found, creating new one");
footer = new Footer(updateData);
await footer.save();
console.log("✓ Footer created:", footer._id);
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);
await footer.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(footer.toObject()));
// ✅ AUDIT LOGGING - Footer Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Footer",
documentId: footer._id,
action: AUDIT_ACTIONS.UPDATE_FOOTER,
before: beforeData,
after: afterData,
changes,
req,
});
}
console.log("✓ Footer updated successfully");
req.flash("success_msg", "Footer updated successfully");
}
const activeTab = req.body.activeTab || "about";
res.redirect(`/admin/footer?activeTab=${activeTab}`);
} catch (err) {
console.error("✗ Error updating footer:", err);
req.flash("error_msg", err.message || "Error updating footer");
res.redirect("/admin/footer");
}
};
// Legacy API endpoints (giữ lại cho tương thích)

View File

@@ -1,363 +1,437 @@
const Header = require("../models/header");
const HeaderMenu = require("../models/HeaderMenu");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/**
* Helper function to build a tree structure (Mirroring logic in headerMenuController)
*/
const buildTree = (items, parentId = null) => {
const branch = [];
const children = items.filter(item =>
String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null)
);
const branch = [];
const children = items.filter(
(item) =>
String(item.parentId) === String(parentId) ||
(item.parentId === null && parentId === null),
);
for (const child of children) {
const item = child.toObject ? child.toObject() : { ...child };
const subChildren = buildTree(items, item._id);
item.children = subChildren.length > 0 ? subChildren : [];
branch.push(item);
}
for (const child of children) {
const item = child.toObject ? child.toObject() : { ...child };
const subChildren = buildTree(items, item._id);
item.children = subChildren.length > 0 ? subChildren : [];
branch.push(item);
}
return branch.sort((a, b) => a.order - b.order);
return branch.sort((a, b) => a.order - b.order);
};
// Admin: Render header management page
exports.index = async (req, res) => {
try {
const header = await Header.findOne().sort({ order: 1 });
try {
const header = await Header.findOne().sort({ order: 1 });
// Prepare data for view
const data = header
? {
topbar: {
contactInfo: {
phone: header.top?.phone || "",
email: header.top?.email || "",
location: header.top?.location || "",
},
socialLinks: header.top?.socialLinks || [],
},
logo: header.logo?.light || "",
}
: {
topbar: {
contactInfo: {
phone: "",
email: "",
location: "",
},
socialLinks: [],
},
logo: "",
};
const activeTab = req.query.tab || "topbar";
// Always fetch menu items to ensure they are available even if the user
// switches tabs client-side
const items = await HeaderMenu.find().sort({ order: 1 });
const menuData = {
flat: items,
tree: buildTree(items)
// Prepare data for view
const data = header
? {
topbar: {
contactInfo: {
phone: header.top?.phone || "",
email: header.top?.email || "",
location: header.top?.location || "",
},
socialLinks: header.top?.socialLinks || [],
},
logo: header.logo?.light || "",
}
: {
topbar: {
contactInfo: {
phone: "",
email: "",
location: "",
},
socialLinks: [],
},
logo: "",
};
res.render("admin/header/index", {
layout: "layouts/main",
title: "Header Management",
user: req.session.user || null,
data: data,
activeTab: activeTab,
menuData: menuData
});
} catch (error) {
console.error("Error loading header management:", error);
res.status(500).render("page/error", {
title: "Error",
message: "Failed to load header management page",
});
}
const activeTab = req.query.tab || "topbar";
// Always fetch menu items to ensure they are available even if the user
// switches tabs client-side
const items = await HeaderMenu.find().sort({ order: 1 });
const menuData = {
flat: items,
tree: buildTree(items),
};
res.render("admin/header/index", {
layout: "layouts/main",
title: "Header Management",
user: req.session.user || null,
data: data,
activeTab: activeTab,
menuData: menuData,
});
} catch (error) {
console.error("Error loading header management:", error);
res.status(500).render("page/error", {
title: "Error",
message: "Failed to load header management page",
});
}
};
// Admin: Get all headers (API)
exports.getAll = async (req, res) => {
try {
const headers = await Header.find().sort({ order: 1 });
res.json({
success: true,
data: headers,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
try {
const headers = await Header.find().sort({ order: 1 });
res.json({
success: true,
data: headers,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Admin: Get single header
exports.show = async (req, res) => {
try {
const header = await Header.findById(req.params.id);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
try {
const header = await Header.findById(req.params.id);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Admin: Create header
exports.store = async (req, res) => {
try {
const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body;
try {
const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body;
const header = new Header({
top,
offcanvas,
menu,
logo,
ctaButton,
status: status || "active",
order: order || 1,
});
const header = new Header({
top,
offcanvas,
menu,
logo,
ctaButton,
status: status || "active",
order: order || 1,
});
await header.save();
res.status(201).json({
success: true,
message: "Header created successfully",
data: header,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
await header.save();
res.status(201).json({
success: true,
message: "Header created successfully",
data: header,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Admin: Update header
exports.update = async (req, res) => {
try {
let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } = req.body;
try {
let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } =
req.body;
console.log("=== UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
console.log("topbarJson type:", typeof topbarJson);
console.log("topbarJson value:", topbarJson);
console.log("=== UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
console.log("topbarJson type:", typeof topbarJson);
console.log("topbarJson value:", topbarJson);
// Nếu có topbarJson, parse nó
if (topbarJson && typeof topbarJson === "string") {
try {
const parsedData = JSON.parse(topbarJson);
console.log("✓ Parsed topbarJson successfully:", parsedData);
// Chuyển đổi từ topbarData sang top format
top = {
phone: parsedData.contactInfo?.phone || "",
email: parsedData.contactInfo?.email || "",
location: parsedData.contactInfo?.location || "",
socialLinks: parsedData.socialLinks || [],
};
console.log("✓ Converted to top object:", top);
} catch (e) {
console.error("✗ Error parsing topbarJson:", e.message);
return res.status(400).json({
success: false,
message: "Invalid JSON in topbarJson: " + e.message,
});
}
}
// Nếu không có id, tìm header đầu tiên hoặc tạo mới
let headerId = req.params.id;
if (!headerId) {
// Tìm header đầu tiên
let header = await Header.findOne().sort({ order: 1 });
if (!header) {
console.log("No existing header found, creating new one");
// Tạo header mới nếu chưa có
header = new Header({
top,
offcanvas,
menu,
logo: logo ? { light: logo } : {},
ctaButton,
status: status || "active",
order: order || 1,
});
await header.save();
console.log("✓ Header created:", header._id);
return res.json({
success: true,
message: "Header created successfully",
data: header,
});
}
headerId = header._id;
console.log("✓ Found existing header:", headerId);
}
// Chuẩn bị dữ liệu logo - merge với dữ liệu cũ
let logoData = {};
if (logo) {
// Nếu có logo mới, lấy dữ liệu cũ và update light
const existingHeader = await Header.findById(headerId);
logoData = {
light: logo,
dark: existingHeader?.logo?.dark || "",
alt: existingHeader?.logo?.alt || "",
};
}
const updateData = {
top,
offcanvas,
menu,
ctaButton,
status,
order,
// Nếu có topbarJson, parse nó
if (topbarJson && typeof topbarJson === "string") {
try {
const parsedData = JSON.parse(topbarJson);
console.log("✓ Parsed topbarJson successfully:", parsedData);
// Chuyển đổi từ topbarData sang top format
top = {
phone: parsedData.contactInfo?.phone || "",
email: parsedData.contactInfo?.email || "",
location: parsedData.contactInfo?.location || "",
socialLinks: parsedData.socialLinks || [],
};
if (logo) {
updateData.logo = logoData;
updateData.logo = logoData;
}
console.log("Preparing to update header with data:", JSON.stringify(updateData, null, 2));
console.log(
"Preparing to update header with data:",
JSON.stringify(updateData, null, 2),
);
const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, { new: true, runValidators: true });
const updatedHeader = await Header.findByIdAndUpdate(
headerId,
updateData,
{ new: true, runValidators: true },
);
if (!updatedHeader) {
console.error("✗ Header not found with ID:", headerId);
return res.status(404).json({
success: false,
message: "Header not found",
});
console.error("✗ Header not found with ID:", headerId);
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header updated successfully",
data: updatedHeader,
success: true,
message: "Header updated successfully",
data: updatedHeader,
});
} catch (error) {
} catch (error) {
console.error("✗ Error updating header:", error);
res.status(400).json({
success: false,
message: error.message,
success: false,
message: error.message,
});
}
}
// Nếu không có id, tìm header đầu tiên hoặc tạo mới
let headerId = req.params.id;
if (!headerId) {
// Tìm header đầu tiên
let header = await Header.findOne().sort({ order: 1 });
if (!header) {
console.log("No existing header found, creating new one");
// Tạo header mới nếu chưa có
header = new Header({
top,
offcanvas,
menu,
logo: logo ? { light: logo } : {},
ctaButton,
status: status || "active",
order: order || 1,
});
await header.save();
console.log("✓ Header created:", header._id);
return res.json({
success: true,
message: "Header created successfully",
data: header,
});
}
headerId = header._id;
console.log("✓ Found existing header:", headerId);
}
// Chuẩn bị dữ liệu logo - merge với dữ liệu cũ
let logoData = {};
if (logo) {
// Nếu có logo mới, lấy dữ liệu cũ và update light
const existingHeader = await Header.findById(headerId);
logoData = {
light: logo,
dark: existingHeader?.logo?.dark || "",
alt: existingHeader?.logo?.alt || "",
};
}
const updateData = {
top,
offcanvas,
menu,
ctaButton,
status,
order,
};
if (logo) {
updateData.logo = logoData;
}
console.log(
"Preparing to update header with data:",
JSON.stringify(updateData, null, 2),
);
// ✅ Capture BEFORE state
const beforeHeader = await Header.findById(headerId);
const beforeData = beforeHeader
? JSON.parse(JSON.stringify(beforeHeader.toObject()))
: {};
const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, {
new: true,
runValidators: true,
});
if (!updatedHeader) {
console.error("✗ Header not found with ID:", headerId);
return res.status(404).json({
success: false,
message: "Header not found",
});
}
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedHeader.toObject()));
// ✅ AUDIT LOGGING - Header Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Header",
documentId: updatedHeader._id,
action: AUDIT_ACTIONS.UPDATE_HEADER,
before: beforeData,
after: afterData,
changes,
req,
});
}
console.log("✓ Header updated successfully:", updatedHeader._id);
console.log("Updated header data:", JSON.stringify(updatedHeader, null, 2));
res.json({
success: true,
message: "Header updated successfully",
data: updatedHeader,
});
} catch (error) {
console.error("✗ Error updating header:", error);
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Admin: Update status
exports.updateStatus = async (req, res) => {
try {
const { status } = req.body;
try {
const { status } = req.body;
if (!["active", "inactive"].includes(status)) {
return res.status(400).json({
success: false,
message: "Invalid status",
});
}
const header = await Header.findByIdAndUpdate(req.params.id, { status }, { new: true });
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header status updated",
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
if (!["active", "inactive"].includes(status)) {
return res.status(400).json({
success: false,
message: "Invalid status",
});
}
const header = await Header.findByIdAndUpdate(
req.params.id,
{ status },
{ new: true },
);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header status updated",
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Admin: Delete header
exports.destroy = async (req, res) => {
try {
const header = await Header.findByIdAndDelete(req.params.id);
try {
const header = await Header.findByIdAndDelete(req.params.id);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header deleted successfully",
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header deleted successfully",
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Public API: Get active header
exports.api = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
try {
const header = await Header.findOne({ status: "active" }).sort({
order: 1,
});
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
res.json({
success: true,
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
res.json({
success: true,
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Public API: Get menu tree structure
exports.getMenuTreeAPI = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
try {
const header = await Header.findOne({ status: "active" }).sort({
order: 1,
});
if (!header || !header.menu) {
return res.status(404).json({
success: false,
message: "No active menu found",
});
}
res.json({
success: true,
data: header.menu,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
if (!header || !header.menu) {
return res.status(404).json({
success: false,
message: "No active menu found",
});
}
res.json({
success: true,
data: header.menu,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};

View File

@@ -1,6 +1,9 @@
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 });
@@ -30,10 +33,28 @@ const getDefaultHomeData = () => ({
ctaButton: {},
},
visaSolutions: { heading: "", subheading: "", items: [] },
visaCountries: { heading: "", subheading: "", description: "", countries: [], ctaButton: {} },
testimonials: { heading: "", subheading: "", videoUrl: "", videoThumbnail: "", items: [] },
visaCountries: {
heading: "",
subheading: "",
description: "",
countries: [],
ctaButton: {},
},
testimonials: {
heading: "",
subheading: "",
videoUrl: "",
videoThumbnail: "",
items: [],
},
videoGallery: { heading: "", videoUrl: "", thumbnail: "" },
faq: { heading: "", subheading: "", description: "", ctaButton: {}, items: [] },
faq: {
heading: "",
subheading: "",
description: "",
ctaButton: {},
items: [],
},
achievements: { heading: "", subheading: "", items: [] },
partners: { visaConsultancy: { items: [] }, brands: { items: [] } },
blogPreview: {
@@ -41,7 +62,7 @@ const getDefaultHomeData = () => ({
subheading: "Visa Tips & Guides",
ctaButton: { label: "View All Articles", href: "/blog" },
items: [],
selectedBlogIds: [] // Array of manually selected blog IDs
selectedBlogIds: [], // Array of manually selected blog IDs
},
});
@@ -53,7 +74,7 @@ exports.index = async (req, res) => {
// Merge dữ liệu mặc định cho tất cả các phần
const sections = Object.keys(defaults);
sections.forEach(s => {
sections.forEach((s) => {
data[s] = data[s] || defaults[s];
});
@@ -61,7 +82,9 @@ exports.index = async (req, res) => {
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
// Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).lean();
const allBlogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.lean();
return res.render("admin/home/index", {
layout: "layouts/main",
@@ -85,17 +108,28 @@ exports.index = async (req, res) => {
exports.update = async (req, res) => {
try {
const sections = [
"hero", "whyChooseUs", "visaSolutions", "visaCountries",
"testimonials", "videoGallery", "faq", "achievements",
"partners", "blogPreview"
"hero",
"whyChooseUs",
"visaSolutions",
"visaCountries",
"testimonials",
"videoGallery",
"faq",
"achievements",
"partners",
"blogPreview",
];
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 {
@@ -104,6 +138,7 @@ exports.update = async (req, res) => {
doc[section] = payload;
doc.markModified(section);
hasChanges = true;
updatedSections.push(section);
} catch (e) {
console.error(`Invalid JSON for ${section}:`, e);
}
@@ -116,6 +151,22 @@ exports.update = async (req, res) => {
}
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) {
@@ -128,7 +179,10 @@ exports.update = async (req, res) => {
// 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();
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 });
@@ -137,7 +191,8 @@ exports.apiGetBlogs = async (req, res) => {
exports.api = async (req, res) => {
try {
let data = await getHomeData();
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// === Xử lý Blog Preview động ===
const blogPreview = data.blogPreview || {};
@@ -147,12 +202,15 @@ exports.api = async (req, res) => {
if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) {
blogs = await Blog.find({
_id: { $in: blogPreview.selectedBlogIds },
status: "published"
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());
return (
blogPreview.selectedBlogIds.indexOf(a._id.toString()) -
blogPreview.selectedBlogIds.indexOf(b._id.toString())
);
});
}
@@ -165,18 +223,18 @@ exports.api = async (req, res) => {
}
// Map dữ liệu blog sang format mà frontend mong đợi
blogPreview.items = blogs.map(blog => ({
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
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
thumbnail: blog.featuredImage,
}));
data.blogPreview = blogPreview;
@@ -189,4 +247,3 @@ exports.api = async (req, res) => {
return res.status(500).json({ error: "Error loading home data" });
}
};

View File

@@ -1,34 +1,37 @@
const Insurance = require("../models/insurance");
const { addBaseUrlToImages } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// API để lấy insurance data (cho frontend)
exports.api = async (req, res) => {
try {
const language = req.query.lang || "en";
// Sử dụng getDefault để đảm bảo luôn có data
const insurance = await Insurance.getDefault(language);
// Trả về data với cấu trúc mới
const insuranceData = insurance.toObject();
// Sử dụng helper để thêm base URL vào đường dẫn ảnh
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
// Trả về trực tiếp hero, page, content (không wrap trong object)
res.json({
hero: processedData.hero,
page: processedData.page,
content: processedData.content
content: processedData.content,
});
} catch (error) {
console.error("API Error:", error);
res.status(500).json({
res.status(500).json({
success: false,
error: "Error loading insurance data",
message: error.message
message: error.message,
});
}
};
@@ -37,31 +40,34 @@ exports.api = async (req, res) => {
exports.getInsuranceData = async (req, res) => {
try {
const language = req.query.lang || "en";
const insurance = await Insurance.findOne({ name: "default", language: language });
const insurance = await Insurance.findOne({
name: "default",
language: language,
});
if (!insurance) {
return res.status(404).json({
success: false,
error: "Insurance data not found"
return res.status(404).json({
success: false,
error: "Insurance data not found",
});
}
const insuranceData = insurance.toObject();
// Thêm base URL vào đường dẫn ảnh
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
res.json({
success: true,
data: processedData
data: processedData,
});
} catch (error) {
console.error("Error getting insurance data:", error);
res.status(500).json({
res.status(500).json({
success: false,
error: "Error loading insurance data"
error: "Error loading insurance data",
});
}
};
@@ -70,36 +76,39 @@ exports.getInsuranceData = async (req, res) => {
exports.getByLanguage = async (req, res) => {
try {
const language = req.params.lang || "en";
const insurance = await Insurance.findOne({ name: "default", language: language });
const insurance = await Insurance.findOne({
name: "default",
language: language,
});
if (!insurance) {
return res.status(404).json({
success: false,
error: "Insurance data not found"
return res.status(404).json({
success: false,
error: "Insurance data not found",
});
}
const insuranceData = insurance.toObject();
// Thêm base URL vào đường dẫn ảnh
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
res.json({
success: true,
data: {
hero: processedData.hero,
page: processedData.page,
content: processedData.content
}
content: processedData.content,
},
});
} catch (error) {
console.error("Error getting insurance by language:", error);
res.status(500).json({
success: false,
error: "Error loading insurance data"
error: "Error loading insurance data",
});
}
};
@@ -110,18 +119,17 @@ exports.index = async (req, res) => {
// Luôn đảm bảo có default data
const insurance = await Insurance.getDefault("en");
const data = insurance.toObject();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/insurance/index", {
title: "Insurance Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user
user: req.session.user,
});
} catch (error) {
console.error("Error in insurance index:", error);
req.flash("error_msg", "An error occurred while loading the page");
@@ -132,18 +140,18 @@ exports.index = async (req, res) => {
// Seed data từ JSON file (cấu trúc mới)
exports.seed = async (req, res) => {
try {
const fs = require('fs').promises;
const path = require('path');
const fs = require("fs").promises;
const path = require("path");
// Đọc file JSON
const jsonPath = path.join(__dirname, '../data/insurance.json');
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8'));
console.log('Seeding insurance from JSON...');
const jsonPath = path.join(__dirname, "../data/insurance.json");
const jsonData = JSON.parse(await fs.readFile(jsonPath, "utf8"));
console.log("Seeding insurance from JSON...");
// Migrate từ cấu trúc cũ sang mới
const insurance = await Insurance.migrateFromJson(jsonData, "en");
res.json({
success: true,
message: "Insurance data seeded successfully",
@@ -151,15 +159,14 @@ exports.seed = async (req, res) => {
id: insurance._id,
hero: insurance.hero,
page: insurance.page,
content: insurance.content
}
content: insurance.content,
},
});
} catch (error) {
console.error("Error seeding insurance:", error);
res.status(500).json({
success: false,
error: error.message || "Error seeding insurance data"
error: error.message || "Error seeding insurance data",
});
}
};
@@ -168,7 +175,7 @@ exports.seed = async (req, res) => {
exports.preview = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
@@ -182,15 +189,16 @@ exports.preview = async (req, res) => {
}
return data;
};
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
const contentData = parseJson(content) || {};
// Thêm base URL vào đường dẫn ảnh cho preview
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
// Render preview HTML
const html = `
<!DOCTYPE html>
@@ -198,13 +206,13 @@ exports.preview = async (req, res) => {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${pageData.title || 'Insurance Preview'}</title>
<title>${pageData.title || "Insurance Preview"}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { font-family: Arial, sans-serif; }
.hero-section {
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
url('${processedHeroData.backgroundImage || ''}');
url('${processedHeroData.backgroundImage || ""}');
background-size: cover;
background-position: center;
color: white;
@@ -226,15 +234,15 @@ exports.preview = async (req, res) => {
<body>
<!-- Hero Section -->
<div class="hero-section">
<h1>${heroData.title || 'Insurance'}</h1>
<p>${heroData.subtitle || ''}</p>
<h1>${heroData.title || "Insurance"}</h1>
<p>${heroData.subtitle || ""}</p>
</div>
<!-- Page Header -->
<div class="page-header">
<div class="container">
<h2>${pageData.title || 'Insurance Information'}</h2>
${pageData.divider !== false ? '<hr>' : ''}
<h2>${pageData.title || "Insurance Information"}</h2>
${pageData.divider !== false ? "<hr>" : ""}
</div>
</div>
@@ -247,9 +255,8 @@ exports.preview = async (req, res) => {
</body>
</html>
`;
res.send(html);
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
@@ -259,72 +266,79 @@ exports.preview = async (req, res) => {
// Helper function để render content items
function renderContentItems(contentItems) {
if (!Array.isArray(contentItems) || contentItems.length === 0) {
return '<p>No content available.</p>';
return "<p>No content available.</p>";
}
return contentItems.map(item => {
switch (item.type) {
case 'header':
return `<h${item.level || 2} class="content-item">${item.text}</h${item.level || 2}>`;
case 'paragraph':
return `<p class="content-item">${item.text}</p>`;
case 'section':
return `
return contentItems
.map((item) => {
switch (item.type) {
case "header":
return `<h${item.level || 2} class="content-item">${item.text}</h${item.level || 2}>`;
case "paragraph":
return `<p class="content-item">${item.text}</p>`;
case "section":
return `
<div class="content-item">
<h3>${item.title}</h3>
<p>${item.content}</p>
</div>
`;
case 'list':
const listItems = (item.items || []).map(li => `<li>${li}</li>`).join('');
return `<ul class="content-item">${listItems}</ul>`;
case 'note':
return `<div class="alert alert-info content-item">${item.text}</div>`;
case 'embed':
if (item.source === 'youtube') {
return `
case "list":
const listItems = (item.items || [])
.map((li) => `<li>${li}</li>`)
.join("");
return `<ul class="content-item">${listItems}</ul>`;
case "note":
return `<div class="alert alert-info content-item">${item.text}</div>`;
case "embed":
if (item.source === "youtube") {
return `
<div class="content-item">
<iframe width="${item.width || 560}" height="${item.height || 315}"
src="${item.url || item.embed}"
frameborder="0" allowfullscreen></iframe>
${item.caption ? `<p class="text-muted mt-2">${item.caption}</p>` : ''}
${item.caption ? `<p class="text-muted mt-2">${item.caption}</p>` : ""}
</div>
`;
}
return '';
default:
return '';
}
}).join('');
}
return "";
default:
return "";
}
})
.join("");
}
// API để tạo insurance mới (cho các ngôn ngữ khác)
exports.create = async (req, res) => {
try {
const { hero, page, content, language } = req.body;
if (!language) {
return res.status(400).json({
success: false,
error: "Language is required"
error: "Language is required",
});
}
// Kiểm tra đã tồn tại chưa
const existing = await Insurance.findOne({ name: "default", language: language });
const existing = await Insurance.findOne({
name: "default",
language: language,
});
if (existing) {
return res.status(400).json({
success: false,
error: "Insurance already exists for this language"
error: "Insurance already exists for this language",
});
}
// Parse JSON nếu cần
const parseJson = (data) => {
if (!data) return null;
@@ -338,7 +352,7 @@ exports.create = async (req, res) => {
}
return data;
};
const insurance = new Insurance({
name: "default",
language: language,
@@ -347,22 +361,21 @@ exports.create = async (req, res) => {
content: parseJson(content) || {},
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false
migratedFromOldStructure: false,
});
await insurance.save();
res.json({
success: true,
message: "Insurance created successfully for language: " + language,
data: insurance
data: insurance,
});
} catch (error) {
console.error("Error creating insurance:", error);
res.status(500).json({
success: false,
error: error.message || "Error creating insurance"
error: error.message || "Error creating insurance",
});
}
};
@@ -371,7 +384,7 @@ exports.create = async (req, res) => {
exports.update = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
@@ -385,7 +398,7 @@ exports.update = async (req, res) => {
}
return data;
};
// Parse all data với cấu trúc mới
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
@@ -393,22 +406,23 @@ exports.update = async (req, res) => {
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
function extractYouTubeId(url) {
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/;
const regex =
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
}
if (contentData && Array.isArray(contentData.content)) {
contentData.content.forEach(item => {
if (item.type === 'embed' && item.source === 'youtube') {
if (item.url && item.url.includes('watch?v=')) {
contentData.content.forEach((item) => {
if (item.type === "embed" && item.source === "youtube") {
if (item.url && item.url.includes("watch?v=")) {
const videoId = extractYouTubeId(item.url);
if (videoId) {
item.url = `https://www.youtube.com/embed/${videoId}`;
item.videoId = videoId;
}
}
if (item.embed && item.embed.includes('watch?v=')) {
if (item.embed && item.embed.includes("watch?v=")) {
const videoId = extractYouTubeId(item.embed);
if (videoId) {
item.embed = `https://www.youtube.com/embed/${videoId}`;
@@ -418,10 +432,20 @@ exports.update = async (req, res) => {
}
});
}
// Tìm hoặc tạo insurance
let insurance = await Insurance.findOne({ name: "default", language: "en" });
let insurance = await Insurance.findOne({
name: "default",
language: "en",
});
// ✅ Capture BEFORE state
const beforeData = insurance
? JSON.parse(
JSON.stringify(insurance.toObject ? insurance.toObject() : insurance),
)
: {};
if (!insurance) {
insurance = new Insurance({
name: "default",
@@ -430,7 +454,7 @@ exports.update = async (req, res) => {
page: pageData,
content: contentData,
version: "2.0.0",
isActive: true
isActive: true,
});
} else {
insurance.hero = heroData;
@@ -438,12 +462,30 @@ exports.update = async (req, res) => {
insurance.content = contentData;
insurance.version = "2.0.0";
}
await insurance.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(insurance.toObject ? insurance.toObject() : insurance),
);
// ✅ AUDIT LOGGING - Insurance Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Insurance",
documentId: insurance._id,
action: AUDIT_ACTIONS.UPDATE_INSURANCE,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Insurance updated successfully");
res.redirect("/admin/insurance");
} catch (err) {
console.error("Error updating insurance:", err);
req.flash("error_msg", err.message || "Error updating insurance");
@@ -455,41 +497,43 @@ exports.update = async (req, res) => {
exports.delete = async (req, res) => {
try {
const language = req.params.lang;
if (!language) {
return res.status(400).json({
success: false,
error: "Language parameter is required"
error: "Language parameter is required",
});
}
// Không cho phép xóa tiếng Anh mặc định
if (language === "en") {
return res.status(400).json({
success: false,
error: "Cannot delete default English insurance data"
error: "Cannot delete default English insurance data",
});
}
const result = await Insurance.deleteOne({ name: "default", language: language });
const result = await Insurance.deleteOne({
name: "default",
language: language,
});
if (result.deletedCount === 0) {
return res.status(404).json({
success: false,
error: "Insurance not found for this language"
error: "Insurance not found for this language",
});
}
res.json({
success: true,
message: "Insurance deleted successfully for language: " + language
message: "Insurance deleted successfully for language: " + language,
});
} catch (error) {
console.error("Error deleting insurance:", error);
res.status(500).json({
success: false,
error: error.message || "Error deleting insurance"
error: error.message || "Error deleting insurance",
});
}
};

View File

@@ -1,193 +1,229 @@
const Pricing = require("../models/pricing");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// ==================== CMS ADMIN FUNCTIONS ====================
// Render admin page for pricing management
exports.index = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
try {
let pricing = await Pricing.findOne({ name: "default" });
// If no data in DB, try to load from JSON file
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
// If no data in DB, try to load from JSON file
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
} else {
// Create default pricing
pricing = await Pricing.create({
name: "default",
hero: {
title: "Pricing Plan",
backgroundImage: "/assets/img/inner-page/breadcrumb.jpg",
shapeImage: "/assets/img/inner-page/shape.png",
breadcrumb: [
{ text: "Home", link: "/" },
{ text: "Pricing Plan", link: "" },
],
},
pricingSection: {
subtitle: "pricing plan",
heading: "Flexible Plans to Suit Every Traveler",
description: "Choose the plan that fits your visa needs and enjoy expert guidance every step of the way.",
},
plans: {
monthly: [],
yearly: [],
},
testimonials: {
subtitle: "What Our Clients Say",
heading: "Immigration Success Stories",
buttonText: "View All Review",
buttonLink: "/contact",
buttonIcon: "fa-solid fa-arrow-right",
image: "",
items: [],
},
});
}
}
res.render("admin/pricing/index", {
layout: "layouts/main",
title: "Pricing Management",
data: pricing,
user: req.session.user,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
} else {
// Create default pricing
pricing = await Pricing.create({
name: "default",
hero: {
title: "Pricing Plan",
backgroundImage: "/assets/img/inner-page/breadcrumb.jpg",
shapeImage: "/assets/img/inner-page/shape.png",
breadcrumb: [
{ text: "Home", link: "/" },
{ text: "Pricing Plan", link: "" },
],
},
pricingSection: {
subtitle: "pricing plan",
heading: "Flexible Plans to Suit Every Traveler",
description:
"Choose the plan that fits your visa needs and enjoy expert guidance every step of the way.",
},
plans: {
monthly: [],
yearly: [],
},
testimonials: {
subtitle: "What Our Clients Say",
heading: "Immigration Success Stories",
buttonText: "View All Review",
buttonLink: "/contact",
buttonIcon: "fa-solid fa-arrow-right",
image: "",
items: [],
},
});
} catch (err) {
console.error("Error loading pricing admin page:", err);
req.flash("error", "Error loading pricing data");
res.redirect("/admin/dashboard");
}
}
res.render("admin/pricing/index", {
layout: "layouts/main",
title: "Pricing Management",
data: pricing,
user: req.session.user,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
});
} catch (err) {
console.error("Error loading pricing admin page:", err);
req.flash("error", "Error loading pricing data");
res.redirect("/admin/dashboard");
}
};
// Update pricing data
exports.update = async (req, res) => {
try {
const { hero, pricingSection, plans, testimonials } = req.body;
try {
const { hero, pricingSection, plans, testimonials } = req.body;
// Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const pricingSectionData = typeof pricingSection === "string" ? JSON.parse(pricingSection) : pricingSection;
const plansData = typeof plans === "string" ? JSON.parse(plans) : plans;
const testimonialsData = typeof testimonials === "string" ? JSON.parse(testimonials) : testimonials;
// Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const pricingSectionData =
typeof pricingSection === "string"
? JSON.parse(pricingSection)
: pricingSection;
const plansData = typeof plans === "string" ? JSON.parse(plans) : plans;
const testimonialsData =
typeof testimonials === "string"
? JSON.parse(testimonials)
: testimonials;
let pricing = await Pricing.findOne({ name: "default" });
let pricing = await Pricing.findOne({ name: "default" });
if (pricing) {
pricing.hero = heroData;
pricing.pricingSection = pricingSectionData;
pricing.plans = plansData;
pricing.testimonials = testimonialsData;
await pricing.save();
} else {
pricing = await Pricing.create({
name: "default",
hero: heroData,
pricingSection: pricingSectionData,
plans: plansData,
testimonials: testimonialsData,
});
}
// ✅ Capture BEFORE state
const beforeData = pricing
? JSON.parse(JSON.stringify(pricing.toObject()))
: {};
req.flash("success", "Pricing data updated successfully");
res.redirect("/admin/pricing");
} catch (err) {
console.error("Error updating pricing:", err);
req.flash("error", "Error updating pricing data");
res.redirect("/admin/pricing");
if (pricing) {
pricing.hero = heroData;
pricing.pricingSection = pricingSectionData;
pricing.plans = plansData;
pricing.testimonials = testimonialsData;
await pricing.save();
} else {
pricing = await Pricing.create({
name: "default",
hero: heroData,
pricingSection: pricingSectionData,
plans: plansData,
testimonials: testimonialsData,
});
}
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(pricing.toObject()));
// ✅ AUDIT LOGGING - Pricing Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Pricing",
documentId: pricing._id,
action: AUDIT_ACTIONS.UPDATE_PRICING,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success", "Pricing data updated successfully");
res.redirect("/admin/pricing");
} catch (err) {
console.error("Error updating pricing:", err);
req.flash("error", "Error updating pricing data");
res.redirect("/admin/pricing");
}
};
// API to get pricing data (admin)
exports.getPricingData = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
try {
let pricing = await Pricing.findOne({ name: "default" });
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
}
}
res.json({
success: true,
data: pricing,
});
} catch (err) {
console.error("Error getting pricing data:", err);
res.status(500).json({
success: false,
error: "Error loading pricing data",
});
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
}
}
res.json({
success: true,
data: pricing,
});
} catch (err) {
console.error("Error getting pricing data:", err);
res.status(500).json({
success: false,
error: "Error loading pricing data",
});
}
};
// Public API to get pricing page data (for frontend)
exports.api = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
try {
let pricing = await Pricing.findOne({ name: "default" });
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
}
}
if (!pricing) {
return res.status(404).json({
success: false,
error: "Pricing data not found",
});
}
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
const getFullUrl = (path) => {
if (!path || path.startsWith("http")) return path;
return `${backendUrl}${path.startsWith("/") ? "" : "/"}${path}`;
};
// Convert to plain object to modify properties safely
const pricingData = pricing.toObject ? pricing.toObject() : pricing;
if (pricingData.hero) {
pricingData.hero.backgroundImage = getFullUrl(pricingData.hero.backgroundImage);
pricingData.hero.shapeImage = getFullUrl(pricingData.hero.shapeImage);
}
if (pricingData.testimonials) {
pricingData.testimonials.image = getFullUrl(pricingData.testimonials.image);
}
res.json({
success: true,
data: {
hero: pricingData.hero,
pricingSection: pricingData.pricingSection,
plans: pricingData.plans,
testimonials: pricingData.testimonials,
},
});
} catch (err) {
console.error("Error getting pricing API data:", err);
res.status(500).json({
success: false,
error: "Error loading pricing data",
});
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
}
}
if (!pricing) {
return res.status(404).json({
success: false,
error: "Pricing data not found",
});
}
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
const getFullUrl = (path) => {
if (!path || path.startsWith("http")) return path;
return `${backendUrl}${path.startsWith("/") ? "" : "/"}${path}`;
};
// Convert to plain object to modify properties safely
const pricingData = pricing.toObject ? pricing.toObject() : pricing;
if (pricingData.hero) {
pricingData.hero.backgroundImage = getFullUrl(
pricingData.hero.backgroundImage,
);
pricingData.hero.shapeImage = getFullUrl(pricingData.hero.shapeImage);
}
if (pricingData.testimonials) {
pricingData.testimonials.image = getFullUrl(
pricingData.testimonials.image,
);
}
res.json({
success: true,
data: {
hero: pricingData.hero,
pricingSection: pricingData.pricingSection,
plans: pricingData.plans,
testimonials: pricingData.testimonials,
},
});
} catch (err) {
console.error("Error getting pricing API data:", err);
res.status(500).json({
success: false,
error: "Error loading pricing data",
});
}
};

View File

@@ -1,164 +1,197 @@
const Safety = require("../models/safety");
const { addBaseUrlToImages } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Lấy dữ liệu Safety từ MongoDB
const getSafetyData = async () => {
const safety = await Safety.findOne().sort({ updatedAt: -1 });
if (!safety) {
return null;
}
return safety.toObject();
const safety = await Safety.findOne().sort({ updatedAt: -1 });
if (!safety) {
return null;
}
return safety.toObject();
};
// API endpoint cho frontend
exports.api = async (req, res) => {
try {
const safety = await getSafetyData();
if (!safety) {
return res.status(404).json({ error: "Safety data not found" });
}
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(safety, baseUrl);
res.json(processedData);
} catch (err) {
console.error("Safety API error:", err);
res.status(500).json({ error: "Error loading safety data" });
}
try {
const safety = await getSafetyData();
if (!safety) {
return res.status(404).json({ error: "Safety data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(safety, baseUrl);
res.json(processedData);
} catch (err) {
console.error("Safety API error:", err);
res.status(500).json({ error: "Error loading safety data" });
}
};
// Hiển thị danh sách Safety cho admin
exports.index = async (req, res) => {
try {
const items = await Safety.find().sort({ updatedAt: -1 }).limit(10);
// Lấy bản ghi mới nhất hoặc object rỗng nếu chưa có dữ liệu
const latest = items && items.length > 0 ? items[0] : null;
const data = latest ? (latest.toObject ? latest.toObject() : latest) : {
hero: { title: "", banner: "" },
approach: {},
approachImgs: [],
approachStats: [],
approachFeatures: [],
approachCards: [],
philosophy: {},
philosophyCards: [],
security: {},
securityCards: []
};
res.render("admin/safety/index", {
layout: "layouts/main",
title: "Safety Management",
items,
data,
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading Safety data");
res.redirect("/admin/dashboard");
}
try {
const items = await Safety.find().sort({ updatedAt: -1 }).limit(10);
// Lấy bản ghi mới nhất hoặc object rỗng nếu chưa có dữ liệu
const latest = items && items.length > 0 ? items[0] : null;
const data = latest
? latest.toObject
? latest.toObject()
: latest
: {
hero: { title: "", banner: "" },
approach: {},
approachImgs: [],
approachStats: [],
approachFeatures: [],
approachCards: [],
philosophy: {},
philosophyCards: [],
security: {},
securityCards: [],
};
res.render("admin/safety/index", {
layout: "layouts/main",
title: "Safety Management",
items,
data,
frontendUrl:
process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading Safety data");
res.redirect("/admin/dashboard");
}
};
// Hiển thị form tạo mới Safety
exports.createForm = async (req, res) => {
try {
res.render("admin/safety/create", {
layout: "layouts/main",
title: "Create Safety",
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading create form");
res.redirect("/admin/safety");
}
try {
res.render("admin/safety/create", {
layout: "layouts/main",
title: "Create Safety",
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading create form");
res.redirect("/admin/safety");
}
};
// Tạo mới Safety
exports.create = async (req, res) => {
try {
const safetyData = req.body; // Tùy chỉnh parse nếu cần
const newSafety = new Safety(safetyData);
await newSafety.save();
req.flash("success_msg", "Safety created successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Create error:", err);
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety/create");
}
try {
const safetyData = req.body; // Tùy chỉnh parse nếu cần
const newSafety = new Safety(safetyData);
await newSafety.save();
req.flash("success_msg", "Safety created successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Create error:", err);
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety/create");
}
};
// Cập nhật Safety
exports.update = async (req, res) => {
try {
const { hero, approach, philosophy, security } = req.body;
try {
const { hero, approach, philosophy, security } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
const heroData = parseJson(hero);
const approachData = parseJson(approach);
const philosophyData = parseJson(philosophy);
const securityData = parseJson(security);
const heroData = parseJson(hero);
const approachData = parseJson(approach);
const philosophyData = parseJson(philosophy);
const securityData = parseJson(security);
// Tìm hoặc tạo safety record
const items = await Safety.find().sort({ updatedAt: -1 }).limit(1);
let safety = items && items.length > 0 ? items[0] : null;
// Tìm hoặc tạo safety record
const items = await Safety.find().sort({ updatedAt: -1 }).limit(1);
let safety = items && items.length > 0 ? items[0] : null;
if (!safety) {
// Tạo mới
safety = new Safety({
hero: heroData || { title: "", banner: "" },
approach: approachData || {},
philosophy: philosophyData || {},
security: securityData || {}
});
} else {
// Cập nhật
if (heroData) safety.hero = heroData;
if (approachData) safety.approach = approachData;
if (philosophyData) safety.philosophy = philosophyData;
if (securityData) safety.security = securityData;
}
// ✅ Capture BEFORE state
const beforeData = safety
? JSON.parse(JSON.stringify(safety.toObject ? safety.toObject() : safety))
: {};
await safety.save();
if (!safety) {
// Tạo mới
safety = new Safety({
hero: heroData || { title: "", banner: "" },
approach: approachData || {},
philosophy: philosophyData || {},
security: securityData || {},
});
} else {
// Cập nhật
if (heroData) safety.hero = heroData;
if (approachData) safety.approach = approachData;
if (philosophyData) safety.philosophy = philosophyData;
if (securityData) safety.security = securityData;
}
req.flash("success_msg", "Safety updated successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety");
}
await safety.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(safety.toObject ? safety.toObject() : safety),
);
// ✅ AUDIT LOGGING - Safety Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Safety",
documentId: safety._id,
action: AUDIT_ACTIONS.UPDATE_SAFETY,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Safety updated successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety");
}
};
// Xóa Safety
exports.delete = async (req, res) => {
try {
const safety = await Safety.findById(req.params.id);
if (!safety) {
req.flash("error_msg", "Safety record not found");
return res.redirect("/admin/safety");
}
await Safety.findByIdAndDelete(req.params.id);
req.flash("success_msg", "Safety record deleted successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Delete error:", err);
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety");
}
try {
const safety = await Safety.findById(req.params.id);
if (!safety) {
req.flash("error_msg", "Safety record not found");
return res.redirect("/admin/safety");
}
await Safety.findByIdAndDelete(req.params.id);
req.flash("success_msg", "Safety record deleted successfully");
res.redirect("/admin/safety");
} catch (err) {
console.error("Delete error:", err);
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`);
res.redirect("/admin/safety");
}
};

View File

@@ -1,6 +1,10 @@
const { getServiceData } = require("../services/service.service");
const Service = require("../models/service");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const slugify = require("slugify");
// Admin page - Service list
@@ -60,6 +64,10 @@ exports.updateService = async (req, res) => {
return res.redirect("/admin/service");
}
const oldItem = JSON.parse(
JSON.stringify(currentData.services.items[serviceIndex]),
);
// Update service data
const updatedData = { ...currentData.toObject?.() };
updatedData.services.items[serviceIndex] = {
@@ -76,7 +84,20 @@ exports.updateService = async (req, res) => {
} else {
await Service.create(updatedData);
}
const newItem = updatedData.services.items[serviceIndex];
const changes = diffObject(oldItem, newItem);
console.log("USER:", req.session?.user || req.user || "No user found");
await writeAuditLog({
model: "Service",
documentId: currentData._id,
action: AUDIT_ACTIONS.UPDATE_SERVICE,
before: oldItem,
after: newItem,
changes,
req,
});
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
@@ -169,14 +190,16 @@ exports.updateDetails = async (req, res) => {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
const beforeDetails = JSON.parse(
JSON.stringify(currentData.services.items[serviceIndex].details || {}),
);
// Parse features and FAQ from JSON strings
const features = req.body.features ? JSON.parse(req.body.features) : [];
const faq = req.body.faq ? JSON.parse(req.body.faq) : [];
// Update service details
const updatedData = { ...currentData.toObject?.() };
updatedData.services.items[serviceIndex].details = {
const updatedDetails = {
title: req.body.title,
description: req.body.description,
mainImage: req.body.mainImage,
@@ -185,17 +208,30 @@ exports.updateDetails = async (req, res) => {
additionalDescription: req.body.additionalDescription,
keyFeaturesTitle: req.body.keyFeaturesTitle,
keyFeaturesImage: req.body.keyFeaturesImage,
features: features,
features,
faqTitle: req.body.faqTitle,
faqImage: req.body.faqImage,
faq: faq,
faq,
};
updatedData.services.items[serviceIndex].details = updatedDetails;
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
const changes = diffObject(beforeDetails, updatedDetails);
if (changes.length > 0) {
await writeAuditLog({
model: "Service",
documentId: currentData._id,
action: AUDIT_ACTIONS.UPDATE_SERVICE_DETAILS,
before: beforeDetails,
after: updatedDetails,
changes,
req,
});
}
req.flash("success_msg", "Service details updated successfully");
res.redirect(`/admin/service/${slug}/details`);

View File

@@ -1,38 +1,41 @@
// controllers/termsController.js
const Terms = require("../models/terms");
const { addBaseUrlToImages } = require("../utils/imageHelper"); // Import helper
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// API để lấy terms data (cho frontend)
exports.api = async (req, res) => {
try {
const language = req.query.lang || "en";
// Sử dụng getDefault để đảm bảo luôn có data
const terms = await Terms.getDefault(language);
// Trả về data với cấu trúc mới
const termsData = terms.toObject();
// Sử dụng helper để thêm base URL vào đường dẫn ảnh
// Truyền baseUrl từ request hoặc từ environment
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: {
hero: processedData.hero,
page: processedData.page,
content: processedData.content
}
content: processedData.content,
},
});
} catch (error) {
console.error("API Error:", error);
res.status(500).json({
res.status(500).json({
success: false,
error: "Error loading terms data",
message: error.message
message: error.message,
});
}
};
@@ -42,30 +45,30 @@ exports.getTermsData = async (req, res) => {
try {
const language = req.query.lang || "en";
const terms = await Terms.findOne({ name: "default", language: language });
if (!terms) {
return res.status(404).json({
success: false,
error: "Terms data not found"
return res.status(404).json({
success: false,
error: "Terms data not found",
});
}
const termsData = terms.toObject();
// Thêm base URL vào đường dẫn ảnh
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: processedData
data: processedData,
});
} catch (error) {
console.error("Error getting terms data:", error);
res.status(500).json({
res.status(500).json({
success: false,
error: "Error loading terms data"
error: "Error loading terms data",
});
}
};
@@ -74,36 +77,36 @@ exports.getTermsData = async (req, res) => {
exports.getByLanguage = async (req, res) => {
try {
const language = req.params.lang || "en";
const terms = await Terms.findOne({ name: "default", language: language });
if (!terms) {
return res.status(404).json({
success: false,
error: "Terms data not found for language: " + language
error: "Terms data not found for language: " + language,
});
}
const termsData = terms.toObject();
// Thêm base URL vào đường dẫn ảnh
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: {
hero: processedData.hero,
page: processedData.page,
content: processedData.content
}
content: processedData.content,
},
});
} catch (error) {
console.error("Error getting terms by language:", error);
res.status(500).json({
success: false,
error: "Error loading terms data"
error: "Error loading terms data",
});
}
};
@@ -114,18 +117,17 @@ exports.index = async (req, res) => {
// Luôn đảm bảo có default data
const terms = await Terms.getDefault("en");
const data = terms.toObject();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/terms/index", {
title: "Terms & Conditions Management",
layout: "layouts/main",
data, // Không cần addBaseUrlToImages cho admin view
frontendUrl,
currentPath: req.path,
user: req.session.user
user: req.session.user,
});
} catch (error) {
console.error("Error in terms index:", error);
req.flash("error_msg", "An error occurred while loading the page");
@@ -137,7 +139,7 @@ exports.index = async (req, res) => {
exports.update = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
@@ -151,7 +153,7 @@ exports.update = async (req, res) => {
}
return data;
};
// Parse all data với cấu trúc mới
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
@@ -159,41 +161,48 @@ exports.update = async (req, res) => {
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
function extractYouTubeId(url) {
if (!url || typeof url !== 'string') return null;
if (!url || typeof url !== "string") return null;
// common YouTube URL patterns
const m = url.match(/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/);
const m = url.match(
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/,
);
return m ? m[1] : null;
}
// Trong exports.update
if (contentData && Array.isArray(contentData.content)) {
contentData.content = contentData.content.map(item => {
if (item && item.type === 'embed') {
let embedUrl = item.embed || item.url || item.source || '';
// Luôn chuyển đổi sang embed URL nếu là watch URL
if (embedUrl.includes('youtube.com/watch')) {
const videoId = extractYouTubeId(embedUrl);
if (videoId) {
item.embed = `https://www.youtube.com/embed/${videoId}`;
item.videoId = videoId;
if (contentData && Array.isArray(contentData.content)) {
contentData.content = contentData.content.map((item) => {
if (item && item.type === "embed") {
let embedUrl = item.embed || item.url || item.source || "";
// Luôn chuyển đổi sang embed URL nếu là watch URL
if (embedUrl.includes("youtube.com/watch")) {
const videoId = extractYouTubeId(embedUrl);
if (videoId) {
item.embed = `https://www.youtube.com/embed/${videoId}`;
item.videoId = videoId;
}
}
// Đảm bảo có videoId
else if (embedUrl && !item.videoId) {
const videoId = extractYouTubeId(embedUrl);
if (videoId) {
item.videoId = videoId;
}
}
}
}
// Đảm bảo có videoId
else if (embedUrl && !item.videoId) {
const videoId = extractYouTubeId(embedUrl);
if (videoId) {
item.videoId = videoId;
}
}
return item;
});
}
return item;
});
}
// Tìm hoặc tạo terms
let terms = await Terms.findOne({ name: "default", language: "en" });
// ✅ Capture BEFORE state
const beforeData = terms
? JSON.parse(JSON.stringify(terms.toObject ? terms.toObject() : terms))
: {};
if (!terms) {
// Tạo mới với cấu trúc mới
terms = new Terms({
@@ -204,7 +213,7 @@ if (contentData && Array.isArray(contentData.content)) {
content: contentData,
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false
migratedFromOldStructure: false,
});
} else {
// Update existing với cấu trúc mới
@@ -215,12 +224,30 @@ if (contentData && Array.isArray(contentData.content)) {
terms.migratedFromOldStructure = false;
terms.updatedAt = new Date();
}
await terms.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(terms.toObject ? terms.toObject() : terms),
);
// ✅ AUDIT LOGGING - Terms Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Terms",
documentId: terms._id,
action: AUDIT_ACTIONS.UPDATE_TERMS,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Terms & Conditions updated successfully");
res.redirect("/admin/terms-conditions");
} catch (err) {
console.error("Error updating terms:", err);
req.flash("error_msg", err.message || "Error updating terms");
@@ -231,30 +258,30 @@ if (contentData && Array.isArray(contentData.content)) {
// Seed data từ JSON file mới (cấu trúc mới)
exports.seed = async (req, res) => {
try {
const fs = require('fs').promises;
const path = require('path');
const fs = require("fs").promises;
const path = require("path");
// Đọc file JSON
const jsonPath = path.join(__dirname, '../data/terms-conditions.json');
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8'));
console.log('Seeding from JSON...');
console.log('JSON structure keys:', Object.keys(jsonData));
const jsonPath = path.join(__dirname, "../data/terms-conditions.json");
const jsonData = JSON.parse(await fs.readFile(jsonPath, "utf8"));
console.log("Seeding from JSON...");
console.log("JSON structure keys:", Object.keys(jsonData));
// Kiểm tra cấu trúc JSON
let terms;
if (jsonData.hero && jsonData.page && jsonData.content) {
// Cấu trúc mới
console.log('Using new structure (hero, page, content)');
console.log("Using new structure (hero, page, content)");
terms = await Terms.migrateFromNewJson(jsonData, "en");
} else if (jsonData.hero && jsonData.termsHeader && jsonData.sections) {
// Cấu trúc cũ
console.log('Using old structure, converting to new...');
console.log("Using old structure, converting to new...");
terms = await Terms.migrateFromJson(jsonData, "en");
} else {
throw new Error("Unknown JSON structure");
}
res.json({
success: true,
message: "Terms data seeded successfully",
@@ -262,15 +289,14 @@ exports.seed = async (req, res) => {
id: terms._id,
hero: terms.hero,
page: terms.page,
content: terms.content
}
content: terms.content,
},
});
} catch (error) {
console.error("Error seeding terms:", error);
res.status(500).json({
success: false,
error: error.message || "Error seeding terms data"
error: error.message || "Error seeding terms data",
});
}
};
@@ -279,7 +305,7 @@ exports.seed = async (req, res) => {
exports.preview = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
@@ -293,15 +319,16 @@ exports.preview = async (req, res) => {
}
return data;
};
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
const contentData = parseJson(content) || {};
// Thêm base URL vào đường dẫn ảnh cho preview
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
// Render preview HTML
const html = `
<!DOCTYPE html>
@@ -309,13 +336,13 @@ exports.preview = async (req, res) => {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${pageData.title || 'Terms & Conditions Preview'}</title>
<title>${pageData.title || "Terms & Conditions Preview"}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { font-family: Arial, sans-serif; }
.hero-section {
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
url('${processedHeroData.backgroundImage || ''}');
url('${processedHeroData.backgroundImage || ""}');
background-size: cover;
background-position: center;
color: white;
@@ -337,14 +364,14 @@ exports.preview = async (req, res) => {
<body>
<!-- Hero Section -->
<div class="hero-section">
<h1>${heroData.title || 'Terms & Conditions'}</h1>
<h1>${heroData.title || "Terms & Conditions"}</h1>
</div>
<!-- Page Header -->
<div class="page-header">
<div class="container">
<h2>${pageData.title || 'Terms & Conditions'}</h2>
${pageData.divider !== false ? '<hr>' : ''}
<h2>${pageData.title || "Terms & Conditions"}</h2>
${pageData.divider !== false ? "<hr>" : ""}
</div>
</div>
@@ -357,9 +384,8 @@ exports.preview = async (req, res) => {
</body>
</html>
`;
res.send(html);
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
@@ -369,87 +395,98 @@ exports.preview = async (req, res) => {
// Helper function để render content items
function renderContentItems(contentItems) {
if (!Array.isArray(contentItems) || contentItems.length === 0) {
return '<p>No content available.</p>';
return "<p>No content available.</p>";
}
return contentItems.map(item => {
switch (item.type) {
case 'paragraph':
return `<div class="content-item"><p>${item.text || ''}</p></div>`;
case 'section':
let html = `<div class="content-item">`;
html += `<h3>${item.title || ''}</h3>`;
html += `<p>${item.content || ''}</p>`;
if (item.subsections && item.subsections.length > 0) {
item.subsections.forEach(subsection => {
if (subsection.type === 'cancellation_table') {
html += `<h4>${subsection.title || ''}</h4>`;
if (subsection.items && subsection.items.length > 0) {
html += '<ul>';
subsection.items.forEach(listItem => {
html += `<li>${listItem}</li>`;
});
html += '</ul>';
return contentItems
.map((item) => {
switch (item.type) {
case "paragraph":
return `<div class="content-item"><p>${item.text || ""}</p></div>`;
case "section":
let html = `<div class="content-item">`;
html += `<h3>${item.title || ""}</h3>`;
html += `<p>${item.content || ""}</p>`;
if (item.subsections && item.subsections.length > 0) {
item.subsections.forEach((subsection) => {
if (subsection.type === "cancellation_table") {
html += `<h4>${subsection.title || ""}</h4>`;
if (subsection.items && subsection.items.length > 0) {
html += "<ul>";
subsection.items.forEach((listItem) => {
html += `<li>${listItem}</li>`;
});
html += "</ul>";
}
} else if (subsection.type === "cancellation_section") {
html += `<h4>${subsection.title || ""}</h4>`;
if (subsection.items && subsection.items.length > 0) {
html += "<ul>";
subsection.items.forEach((listItem) => {
html += `<li>${listItem}</li>`;
});
html += "</ul>";
}
} else if (subsection.type === "note") {
html += `<div class="alert alert-info">${subsection.text || ""}</div>`;
}
} else if (subsection.type === 'cancellation_section') {
html += `<h4>${subsection.title || ''}</h4>`;
if (subsection.items && subsection.items.length > 0) {
html += '<ul>';
subsection.items.forEach(listItem => {
html += `<li>${listItem}</li>`;
});
html += '</ul>';
}
} else if (subsection.type === 'note') {
html += `<div class="alert alert-info">${subsection.text || ''}</div>`;
}
});
}
html += `</div>`;
return html;
case 'note':
return `<div class="content-item alert alert-info">${item.text || ''}</div>`;
case 'embed':
// Support several embed shapes: { embed }, { url }, { source }, { videoId }
const embedSrc = (item.embed || item.url || item.source || (item.videoId ? `https://www.youtube.com/embed/${item.videoId}` : ''));
if (!embedSrc) return `<div class="content-item">Invalid embed</div>`;
return `<div class="content-item embed-item" style="margin-bottom:20px;">
});
}
html += `</div>`;
return html;
case "note":
return `<div class="content-item alert alert-info">${item.text || ""}</div>`;
case "embed":
// Support several embed shapes: { embed }, { url }, { source }, { videoId }
const embedSrc =
item.embed ||
item.url ||
item.source ||
(item.videoId
? `https://www.youtube.com/embed/${item.videoId}`
: "");
if (!embedSrc) return `<div class="content-item">Invalid embed</div>`;
return `<div class="content-item embed-item" style="margin-bottom:20px;">
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;">
<iframe src="${embedSrc}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen loading="lazy" referrerpolicy="no-referrer"></iframe>
</div>
</div>`;
default:
return `<div class="content-item">Unknown content type: ${item.type}</div>`;
}
}).join('');
default:
return `<div class="content-item">Unknown content type: ${item.type}</div>`;
}
})
.join("");
}
// API để tạo terms mới (cho các ngôn ngữ khác)
exports.create = async (req, res) => {
try {
const { hero, page, content, language } = req.body;
if (!language) {
return res.status(400).json({
success: false,
error: "Language is required"
error: "Language is required",
});
}
// Kiểm tra đã tồn tại chưa
const existing = await Terms.findOne({ name: "default", language: language });
const existing = await Terms.findOne({
name: "default",
language: language,
});
if (existing) {
return res.status(400).json({
success: false,
error: "Terms already exists for language: " + language
error: "Terms already exists for language: " + language,
});
}
// Parse JSON nếu cần
const parseJson = (data) => {
if (!data) return null;
@@ -463,7 +500,7 @@ exports.create = async (req, res) => {
}
return data;
};
const terms = new Terms({
name: "default",
language: language,
@@ -472,22 +509,21 @@ exports.create = async (req, res) => {
content: parseJson(content) || {},
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false
migratedFromOldStructure: false,
});
await terms.save();
res.json({
success: true,
message: "Terms created successfully for language: " + language,
data: terms
data: terms,
});
} catch (error) {
console.error("Error creating terms:", error);
res.status(500).json({
success: false,
error: error.message || "Error creating terms"
error: error.message || "Error creating terms",
});
}
};
@@ -496,41 +532,43 @@ exports.create = async (req, res) => {
exports.delete = async (req, res) => {
try {
const language = req.params.lang;
if (!language) {
return res.status(400).json({
success: false,
error: "Language is required"
error: "Language is required",
});
}
// Không cho phép xóa tiếng Anh mặc định
if (language === "en") {
return res.status(400).json({
success: false,
error: "Cannot delete default English terms"
error: "Cannot delete default English terms",
});
}
const result = await Terms.deleteOne({ name: "default", language: language });
const result = await Terms.deleteOne({
name: "default",
language: language,
});
if (result.deletedCount === 0) {
return res.status(404).json({
success: false,
error: "Terms not found for language: " + language
error: "Terms not found for language: " + language,
});
}
res.json({
success: true,
message: "Terms deleted successfully for language: " + language
message: "Terms deleted successfully for language: " + language,
});
} catch (error) {
console.error("Error deleting terms:", error);
res.status(500).json({
success: false,
error: error.message || "Error deleting terms"
error: error.message || "Error deleting terms",
});
}
};
};

View File

@@ -1,103 +1,138 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Home = require("../models/home");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Get testimonial data from Home model
const getTestimonialData = async () => {
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.testimonials) {
return null;
}
return home.testimonials.toObject ? home.testimonials.toObject() : home.testimonials;
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.testimonials) {
return null;
}
return home.testimonials.toObject
? home.testimonials.toObject()
: home.testimonials;
};
// API to get testimonial data
exports.api = async (req, res) => {
try {
const testimonial = await getTestimonialData();
if (!testimonial) {
return res.status(404).json({ error: "Testimonial data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(testimonial, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading testimonial data" });
try {
const testimonial = await getTestimonialData();
if (!testimonial) {
return res.status(404).json({ error: "Testimonial data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(testimonial, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading testimonial data" });
}
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = (await getTestimonialData()) || {
heading: "Student Reviews & Testimonials",
subheading: "What Our Students Say",
videoUrl: "",
videoThumbnail: "",
items: [],
};
try {
const data = (await getTestimonialData()) || {
heading: "Student Reviews & Testimonials",
subheading: "What Our Students Say",
videoUrl: "",
videoThumbnail: "",
items: [],
};
const frontendUrl = process.env.FRONTEND_URL;
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/home/testimonial/index", {
title: "Testimonials Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in testimonial index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
res.render("admin/home/testimonial/index", {
title: "Testimonials Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in testimonial index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu testimonial (chỉ update phần testimonials của Home)
exports.update = async (req, res) => {
try {
const { heading, subheading, videoUrl, videoThumbnail, items } = req.body;
try {
const { heading, subheading, videoUrl, videoThumbnail, items } = req.body;
// Parse JSON strings nếu cần
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
const itemsData = parseJson(items);
// Tìm hoặc tạo Home document
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
// Parse JSON strings nếu cần
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
// Cập nhật chỉ phần testimonials
home.testimonials = {
heading: heading || "Student Reviews & Testimonials",
subheading: subheading || "What Our Students Say",
videoUrl: videoUrl || "",
videoThumbnail: videoThumbnail || "",
items: itemsData || [],
};
const itemsData = parseJson(items);
await home.save();
// Tìm hoặc tạo Home document
let home = await Home.findOne().sort({ updatedAt: -1 });
req.flash("success_msg", "Testimonials updated successfully");
res.redirect("/admin/home/testimonials");
} catch (err) {
console.error("Error updating testimonials:", err);
req.flash("error_msg", err.message || "Error updating testimonials");
res.redirect("/admin/home/testimonials");
if (!home) {
home = new Home({});
}
// ✅ Capture BEFORE state
const beforeData = home.testimonials
? JSON.parse(
JSON.stringify(
home.testimonials.toObject
? home.testimonials.toObject()
: home.testimonials,
),
)
: {};
const updatedTestimonialData = {
heading: heading || "Student Reviews & Testimonials",
subheading: subheading || "What Our Students Say",
videoUrl: videoUrl || "",
videoThumbnail: videoThumbnail || "",
items: itemsData || [],
};
// Cập nhật chỉ phần testimonials
home.testimonials = updatedTestimonialData;
await home.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedTestimonialData));
// ✅ AUDIT LOGGING - Testimonial Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: home._id,
action: AUDIT_ACTIONS.UPDATE_TESTIMONIAL,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Testimonials updated successfully");
res.redirect("/admin/home/testimonials");
} catch (err) {
console.error("Error updating testimonials:", err);
req.flash("error_msg", err.message || "Error updating testimonials");
res.redirect("/admin/home/testimonials");
}
};

View File

@@ -2,16 +2,20 @@ const Travel = require("../models/travel");
const { addBaseUrlToImages } = require("../utils/imageHelper");
const fs = require("fs").promises;
const path = require("path");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/**
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
*/
function extractYouTubeId(url) {
if (!url || typeof url !== 'string') return null;
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
const regex = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
if (!url || typeof url !== "string") return null;
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
const regex =
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
}
/**
@@ -19,214 +23,268 @@ function extractYouTubeId(url) {
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
*/
function sanitizeContentBlocks(blocks) {
if (!blocks || !Array.isArray(blocks)) return [];
if (!blocks || !Array.isArray(blocks)) return [];
const seenVideoIds = new Set();
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
const processedBlocks = blocks.map(block => {
if (block.type === 'embed') {
const url = block.data.source || block.data.embed || '';
const videoId = extractYouTubeId(url);
if (videoId) {
seenVideoIds.add(videoId);
// Cập nhật lại data chuẩn cho Editor.js
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
block.data.source = url;
block.data.videoId = videoId;
block.data.service = 'youtube';
}
}
return block;
});
const seenVideoIds = new Set();
// Bước 2: Lọc bỏ paragraph rác
return processedBlocks.filter(block => {
if (block.type === 'paragraph') {
const text = (block.data?.text || '').trim();
// Xóa paragraph rỗng
if (text === '' || text === '<br>' || text === '&nbsp;') return false;
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
const processedBlocks = blocks.map((block) => {
if (block.type === "embed") {
const url = block.data.source || block.data.embed || "";
const videoId = extractYouTubeId(url);
if (videoId) {
seenVideoIds.add(videoId);
// Cập nhật lại data chuẩn cho Editor.js
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
block.data.source = url;
block.data.videoId = videoId;
block.data.service = "youtube";
}
}
return block;
});
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
const videoIdInText = extractYouTubeId(text);
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
console.log(`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`);
return false;
}
}
return true;
});
// Bước 2: Lọc bỏ paragraph rác
return processedBlocks.filter((block) => {
if (block.type === "paragraph") {
const text = (block.data?.text || "").trim();
// Xóa paragraph rỗng
if (text === "" || text === "<br>" || text === "&nbsp;") return false;
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
const videoIdInText = extractYouTubeId(text);
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
console.log(
`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`,
);
return false;
}
}
return true;
});
}
// GET: Show travel editor
exports.index = async (req, res) => {
try {
const travel = await Travel.findOne();
try {
const travel = await Travel.findOne();
if (!travel) {
return res.render("admin/travel/index", {
title: "Travel Management",
data: {
page: { title: "Travel Information", description: "", metadata: { title: "", description: "" } },
hero: { title: "Travel Information", backgroundImage: "" },
content: { blocks: [] },
enableScrollspy: false,
},
message: "No travel data found. Please run migration first.",
});
}
res.render("admin/travel/index", {
title: "Travel Management",
data: travel,
});
} catch (error) {
console.error("Error loading travel page:", error);
res.status(500).send("Error loading travel page");
if (!travel) {
return res.render("admin/travel/index", {
title: "Travel Management",
data: {
page: {
title: "Travel Information",
description: "",
metadata: { title: "", description: "" },
},
hero: { title: "Travel Information", backgroundImage: "" },
content: { blocks: [] },
enableScrollspy: false,
},
message: "No travel data found. Please run migration first.",
});
}
res.render("admin/travel/index", {
title: "Travel Management",
data: travel,
});
} catch (error) {
console.error("Error loading travel page:", error);
res.status(500).send("Error loading travel page");
}
};
// POST: Update travel information
exports.update = async (req, res) => {
try {
const { page, hero, content, enableScrollspy } = req.body;
const updateData = {};
try {
const { page, hero, content, enableScrollspy } = req.body;
if (page) updateData.page = JSON.parse(page);
if (hero) updateData.hero = JSON.parse(hero);
if (content) {
let contentObj = JSON.parse(content);
// Áp dụng bộ lọc dọn dẹp nội dung
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
updateData.content = contentObj;
}
// Get current data for before state
const currentTravel = await Travel.findOne();
if (enableScrollspy !== undefined) {
updateData.enableScrollspy = (enableScrollspy === "true" || enableScrollspy === true);
}
// ✅ Capture BEFORE state
const beforeData = currentTravel
? JSON.parse(
JSON.stringify(
currentTravel.toObject ? currentTravel.toObject() : currentTravel,
),
)
: {};
await Travel.findOneAndUpdate({}, updateData, {
upsert: true,
new: true,
});
const updateData = {};
req.flash("success", "Travel information updated and sanitized successfully");
res.redirect("/admin/travel");
} catch (error) {
console.error("Error updating travel:", error);
req.flash("error", "Error updating travel information");
res.redirect("/admin/travel");
if (page) updateData.page = JSON.parse(page);
if (hero) updateData.hero = JSON.parse(hero);
if (content) {
let contentObj = JSON.parse(content);
// Áp dụng bộ lọc dọn dẹp nội dung
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
updateData.content = contentObj;
}
if (enableScrollspy !== undefined) {
updateData.enableScrollspy =
enableScrollspy === "true" || enableScrollspy === true;
}
const updatedTravel = await Travel.findOneAndUpdate({}, updateData, {
upsert: true,
new: true,
});
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(
updatedTravel.toObject ? updatedTravel.toObject() : updatedTravel,
),
);
// ✅ AUDIT LOGGING - Travel Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Travel",
documentId: updatedTravel._id,
action: AUDIT_ACTIONS.UPDATE_TRAVEL,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash(
"success",
"Travel information updated and sanitized successfully",
);
res.redirect("/admin/travel");
} catch (error) {
console.error("Error updating travel:", error);
req.flash("error", "Error updating travel information");
res.redirect("/admin/travel");
}
};
// GET: Travel data API (Sử dụng cho Frontend/Public)
exports.api = exports.getTravelData = async (req, res) => {
try {
const travel = await Travel.findOne();
if (!travel) {
return res.status(404).json({ error: "Travel data not found" });
}
const travelObj = travel.toObject();
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(travelObj, baseUrl);
return res.json({
success: true,
data: {
hero: processed.hero,
page: processed.page,
content: processed.content,
enableScrollspy: processed.enableScrollspy
},
});
} catch (error) {
console.error("Error fetching travel data:", error);
res.status(500).json({ error: "Internal server error" });
try {
const travel = await Travel.findOne();
if (!travel) {
return res.status(404).json({ error: "Travel data not found" });
}
const travelObj = travel.toObject();
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processed = addBaseUrlToImages(travelObj, baseUrl);
return res.json({
success: true,
data: {
hero: processed.hero,
page: processed.page,
content: processed.content,
enableScrollspy: processed.enableScrollspy,
},
});
} catch (error) {
console.error("Error fetching travel data:", error);
res.status(500).json({ error: "Internal server error" });
}
};
// POST: Preview travel
exports.preview = async (req, res) => {
try {
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } = req.body;
try {
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } =
req.body;
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
let contentObj = JSON.parse(content);
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
let contentObj = JSON.parse(content);
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
const previewData = {
page: {
title: pageTitle || "Travel Information",
year: pageYear || ""
},
hero: {
title: heroTitle || "Travel Information",
backgroundImage: heroBackgroundImage || "",
},
content: contentObj,
enableScrollspy: false,
};
const previewData = {
page: {
title: pageTitle || "Travel Information",
year: pageYear || "",
},
hero: {
title: heroTitle || "Travel Information",
backgroundImage: heroBackgroundImage || "",
},
content: contentObj,
enableScrollspy: false,
};
res.render("page/travel", {
title: "Travel Preview",
data: previewData,
});
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
}
res.render("page/travel", {
title: "Travel Preview",
data: previewData,
});
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
}
};
// GET: Seed/Import from JSON
exports.seed = async (req, res) => {
try {
const jsonPath = path.join(__dirname, "../data/travel.json");
const jsonData = await fs.readFile(jsonPath, "utf-8");
const jsonTravelData = JSON.parse(jsonData);
try {
const jsonPath = path.join(__dirname, "../data/travel.json");
const jsonData = await fs.readFile(jsonPath, "utf-8");
const jsonTravelData = JSON.parse(jsonData);
let contentBlocks = [];
let contentBlocks = [];
// Trường hợp JSON đã có định dạng bài viết (blog format)
if (Array.isArray(jsonTravelData.posts) && jsonTravelData.posts.length > 0) {
const firstPost = jsonTravelData.posts[0];
contentBlocks = (firstPost.content && firstPost.content.blocks) ? firstPost.content.blocks : [];
}
// Trường hợp format cũ (legacy)
else {
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
}
// Luôn làm sạch dữ liệu trước khi seed vào DB
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
const travelData = {
page: {
title: jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
year: jsonTravelData.page?.year || "",
metadata: {
title: "Travel Guide - Go and Grow Camp",
description: "Everything you need to know about traveling to our camps",
},
},
hero: {
title: jsonTravelData.hero?.title || "Travel Information",
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
},
content: { blocks: cleanedBlocks },
enableScrollspy: true,
};
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
req.flash("success", "Travel data seeded and sanitized successfully");
res.redirect("/admin/travel");
} catch (error) {
console.error("Error seeding travel data:", error);
req.flash("error", "Failed to seed travel data");
res.redirect("/admin/travel");
// Trường hợp JSON đã có định dạng bài viết (blog format)
if (
Array.isArray(jsonTravelData.posts) &&
jsonTravelData.posts.length > 0
) {
const firstPost = jsonTravelData.posts[0];
contentBlocks =
firstPost.content && firstPost.content.blocks
? firstPost.content.blocks
: [];
}
};
// Trường hợp format cũ (legacy)
else {
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
}
// Luôn làm sạch dữ liệu trước khi seed vào DB
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
const travelData = {
page: {
title:
jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
year: jsonTravelData.page?.year || "",
metadata: {
title: "Travel Guide - Go and Grow Camp",
description:
"Everything you need to know about traveling to our camps",
},
},
hero: {
title: jsonTravelData.hero?.title || "Travel Information",
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
},
content: { blocks: cleanedBlocks },
enableScrollspy: true,
};
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
req.flash("success", "Travel data seeded and sanitized successfully");
res.redirect("/admin/travel");
} catch (error) {
console.error("Error seeding travel data:", error);
req.flash("error", "Failed to seed travel data");
res.redirect("/admin/travel");
}
};

View File

@@ -1,84 +1,119 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Home = require("../models/home");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Get videoGallery data from Home model
const getVideoGalleryData = async () => {
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.videoGallery) {
return null;
}
return home.videoGallery.toObject ? home.videoGallery.toObject() : home.videoGallery;
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.videoGallery) {
return null;
}
return home.videoGallery.toObject
? home.videoGallery.toObject()
: home.videoGallery;
};
// API to get videoGallery data
exports.api = async (req, res) => {
try {
const videoGallery = await getVideoGalleryData();
if (!videoGallery) {
return res.status(404).json({ error: "Video Gallery data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(videoGallery, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading video gallery data" });
try {
const videoGallery = await getVideoGalleryData();
if (!videoGallery) {
return res.status(404).json({ error: "Video Gallery data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(videoGallery, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading video gallery data" });
}
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = (await getVideoGalleryData()) || {
heading: "",
videoUrl: "",
thumbnail: "",
};
try {
const data = (await getVideoGalleryData()) || {
heading: "",
videoUrl: "",
thumbnail: "",
};
const frontendUrl = process.env.FRONTEND_URL;
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/home/videoGallery/index", {
title: "Video Gallery Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in videoGallery index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
res.render("admin/home/videoGallery/index", {
title: "Video Gallery Management",
layout: "layouts/main",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in videoGallery index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu videoGallery
exports.update = async (req, res) => {
try {
const { heading, videoUrl, thumbnail } = req.body;
try {
const { heading, videoUrl, thumbnail } = req.body;
// Tìm hoặc tạo Home document
let home = await Home.findOne().sort({ updatedAt: -1 });
// Tìm hoặc tạo Home document
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
}
// Cập nhật chỉ phần videoGallery
home.videoGallery = {
heading: heading || "",
videoUrl: videoUrl || "",
thumbnail: thumbnail || "",
};
await home.save();
req.flash("success_msg", "Video Gallery updated successfully");
res.redirect("/admin/home/video-gallery");
} catch (err) {
console.error("Error updating video gallery:", err);
req.flash("error_msg", err.message || "Error updating video gallery");
res.redirect("/admin/home/video-gallery");
if (!home) {
home = new Home({});
}
// ✅ Capture BEFORE state
const beforeData = home.videoGallery
? JSON.parse(
JSON.stringify(
home.videoGallery.toObject
? home.videoGallery.toObject()
: home.videoGallery,
),
)
: {};
const updatedVideoGalleryData = {
heading: heading || "",
videoUrl: videoUrl || "",
thumbnail: thumbnail || "",
};
// Cập nhật chỉ phần videoGallery
home.videoGallery = updatedVideoGalleryData;
await home.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedVideoGalleryData));
// ✅ AUDIT LOGGING - Video Gallery Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: home._id,
action: AUDIT_ACTIONS.UPDATE_VIDEO_GALLERY,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Video Gallery updated successfully");
res.redirect("/admin/home/video-gallery");
} catch (err) {
console.error("Error updating video gallery:", err);
req.flash("error_msg", err.message || "Error updating video gallery");
res.redirect("/admin/home/video-gallery");
}
};

View File

@@ -45,6 +45,9 @@ const addBaseUrlToImages = (data, baseUrl) => {
};
const Visa = require("../models/visa");
const slugify = require("slugify");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const createSlug = (text) => {
return slugify(text, {
lower: true,
@@ -184,6 +187,15 @@ exports.update = async (req, res) => {
// Get current data
const currentData = await getVisaData();
// ✅ Capture BEFORE state
const beforeData = currentData
? JSON.parse(
JSON.stringify(
currentData.toObject ? currentData.toObject() : currentData,
),
)
: {};
// Create updated data object
const updatedData = {
...(currentData.toObject ? currentData.toObject() : currentData),
@@ -200,23 +212,37 @@ exports.update = async (req, res) => {
updatedData.hero.title = req.body.heroTitle;
}
// Check if there are changes
const hasChanges =
JSON.stringify(updatedData) !== JSON.stringify(currentData);
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return req.session.save(() => res.redirect("/admin/visa"));
}
// Update or create document
try {
let savedData;
if (currentData._id) {
await Visa.findByIdAndUpdate(currentData._id, updatedData, {
savedData = await Visa.findByIdAndUpdate(currentData._id, updatedData, {
new: true,
});
} else {
await Visa.create(updatedData);
savedData = await Visa.create(updatedData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(savedData.toObject()));
// ✅ AUDIT LOGGING - Visa Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa update: ${changes.length} changes`,
);
} else {
console.log(" No changes detected for Visa update");
}
req.flash("success_msg", "Visa data updated successfully");
@@ -243,6 +269,11 @@ exports.addCountry = async (req, res) => {
visaData = getDefaultVisaData();
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// Validate required fields
if (!req.body.name) {
return res.status(400).json({ error: "Name is required" });
@@ -305,6 +336,28 @@ exports.addCountry = async (req, res) => {
savedData = await Visa.create(updatedData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
);
// ✅ AUDIT LOGGING - Visa Country Added
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa country addition: ${changes.length} changes`,
);
}
console.log(`✅ Country "${newCountry.name}" added successfully`);
res.json({
success: true,
@@ -330,6 +383,11 @@ exports.updateCountry = async (req, res) => {
.json({ error: "Cấu trúc dữ liệu Visa không hợp lệ" });
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.id === parseInt(id),
@@ -390,10 +448,33 @@ exports.updateCountry = async (req, res) => {
visaData.markModified("hero.summaryList");
}
let savedData;
if (visaData._id) {
await visaData.save(); // Sử dụng save() trực tiếp nếu visaData là Mongoose Document
savedData = await visaData.save(); // Sử dụng save() trực tiếp nếu visaData là Mongoose Document
} else {
await Visa.create(visaData);
savedData = await Visa.create(visaData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
);
// ✅ AUDIT LOGGING - Visa Country Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa country update: ${changes.length} changes`,
);
}
console.log(

64
models/AuditLog.js Normal file
View File

@@ -0,0 +1,64 @@
const mongoose = require("mongoose");
const AUDIT_ACTIONS = require("../constants/auditAction");
const auditLogSchema = new mongoose.Schema({
model: {
type: String,
required: true,
},
documentId: {
type: mongoose.Schema.Types.ObjectId,
required: true,
},
action: {
type: String,
enum: Object.values(AUDIT_ACTIONS),
required: true,
},
before: {
type: mongoose.Schema.Types.Mixed,
default: null,
},
after: {
type: mongoose.Schema.Types.Mixed,
default: null,
},
changes: {
type: [
{
field: String,
before: mongoose.Schema.Types.Mixed,
after: mongoose.Schema.Types.Mixed,
},
],
default: [],
},
ipAddress: {
type: String,
required: true,
},
userAgent: {
type: String,
default: "",
},
performedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
default: null,
},
createdAt: {
type: Date,
default: Date.now,
},
});
module.exports = mongoose.model("AuditLog", auditLogSchema);

View File

@@ -18,6 +18,7 @@ const visaController = require("../controllers/visaController");
const { upload, uploadVideo, convertToWebp } = require("../middleware/upload");
const safetyController = require("../controllers/safetyController");
const insuranceController = require("../controllers/insuranceController");
const auditLogController = require("../controllers/auditLogController");
const activityController = require("../controllers/activityController");
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
@@ -32,7 +33,6 @@ const socialLinkController = require("../controllers/socialLinkController");
const testimonialController = require("../controllers/testimonialController");
const videoGalleryController = require("../controllers/videoGalleryController");
// Dashboard
router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard);
@@ -43,8 +43,8 @@ router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs);
// Middleware chuẩn hóa code
router.param("code", (req, res, next, code) => {
req.params.code = code.toUpperCase();
next();
req.params.code = code.toUpperCase();
next();
});
// About Us
@@ -55,40 +55,94 @@ router.post("/about-us/update", ensureAuthenticated, aboutUsController.update);
// Form Management
router.get("/form", ensureAuthenticated, formController.index);
router.post("/form/update", ensureAuthenticated, formController.updateDefaultForm);
router.post(
"/form/update",
ensureAuthenticated,
formController.updateDefaultForm,
);
// Upload routes
router.get("/upload", ensureAuthenticated, (req, res) => {
res.render("admin/upload/index", {
layout: "layouts/admin",
title: "Quản lý Upload Ảnh",
user: req.session.user,
});
res.render("admin/upload/index", {
layout: "layouts/admin",
title: "Quản lý Upload Ảnh",
user: req.session.user,
});
});
router.post("/upload/image", ensureAuthenticated, upload.single("image"), uploadController.uploadImage);
router.post("/upload/video", ensureAuthenticated, uploadVideo.single("video"), uploadController.uploadVideo);
router.post("/upload/update-path", ensureAuthenticated, uploadController.updateImagePath);
router.post("/upload/delete", ensureAuthenticated, uploadController.deleteImage);
router.post(
"/upload/image",
ensureAuthenticated,
upload.single("image"),
uploadController.uploadImage,
);
router.post(
"/upload/video",
ensureAuthenticated,
uploadVideo.single("video"),
uploadController.uploadVideo,
);
router.post(
"/upload/update-path",
ensureAuthenticated,
uploadController.updateImagePath,
);
router.post(
"/upload/delete",
ensureAuthenticated,
uploadController.deleteImage,
);
// Header routes
router.get("/header", ensureAuthenticated, headerController.index);
router.post("/header/update", ensureAuthenticated, headerController.update);
router.get("/header/data", ensureAuthenticated, headerController.api); // Normalized from getHeaderData
router.patch("/header/:id/status", ensureAuthenticated, headerController.updateStatus);
router.patch(
"/header/:id/status",
ensureAuthenticated,
headerController.updateStatus,
);
router.delete("/header/:id", ensureAuthenticated, headerController.destroy);
// Header Menu INTEGRATED routes
router.post("/header/menu/create", ensureAuthenticated, headerMenuController.store);
router.post("/header/menu/update/:id", ensureAuthenticated, headerMenuController.update);
router.post("/header/menu/delete", ensureAuthenticated, headerMenuController.destroy);
router.post("/header/menu/reorder", ensureAuthenticated, headerMenuController.reorder);
router.post(
"/header/menu/create",
ensureAuthenticated,
headerMenuController.store,
);
router.post(
"/header/menu/update/:id",
ensureAuthenticated,
headerMenuController.update,
);
router.post(
"/header/menu/delete",
ensureAuthenticated,
headerMenuController.destroy,
);
router.post(
"/header/menu/reorder",
ensureAuthenticated,
headerMenuController.reorder,
);
// Social Links routes
router.get("/social-links", ensureAuthenticated, socialLinkController.index);
router.post("/social-links", ensureAuthenticated, socialLinkController.store);
router.put("/social-links/:platform", ensureAuthenticated, socialLinkController.update);
router.delete("/social-links/:platform", ensureAuthenticated, socialLinkController.destroy);
router.post("/social-links/reorder", ensureAuthenticated, socialLinkController.reorder);
router.put(
"/social-links/:platform",
ensureAuthenticated,
socialLinkController.update,
);
router.delete(
"/social-links/:platform",
ensureAuthenticated,
socialLinkController.destroy,
);
router.post(
"/social-links/reorder",
ensureAuthenticated,
socialLinkController.reorder,
);
// Footer routes
router.get("/footer", ensureAuthenticated, footerController.index);
@@ -98,60 +152,160 @@ router.get("/footer/data", ensureAuthenticated, footerController.getFooterData);
// Contact routes
router.get("/contact", ensureAuthenticated, contactController.index);
router.post("/contact/update", ensureAuthenticated, contactController.update);
router.get("/contact/data", ensureAuthenticated, contactController.getContactData);
router.get(
"/contact/data",
ensureAuthenticated,
contactController.getContactData,
);
// Contact submissions management
router.get("/contact/submissions", ensureAuthenticated, contactController.getSubmissions);
router.put("/contact/submissions/:id", ensureAuthenticated, contactController.updateSubmissionStatus);
router.get(
"/contact/submissions",
ensureAuthenticated,
contactController.getSubmissions,
);
router.put(
"/contact/submissions/:id",
ensureAuthenticated,
contactController.updateSubmissionStatus,
);
// Appointment management
const appointmentController = require("../controllers/appointmentController");
router.get("/appointments", ensureAuthenticated, appointmentController.getAppointments);
router.get("/appointments/:id", ensureAuthenticated, appointmentController.getAppointmentById);
router.put("/appointments/:id", ensureAuthenticated, appointmentController.updateAppointmentStatus);
router.delete("/appointments/:id", ensureAuthenticated, appointmentController.deleteAppointment);
router.get(
"/appointments",
ensureAuthenticated,
appointmentController.getAppointments,
);
router.get(
"/appointments/:id",
ensureAuthenticated,
appointmentController.getAppointmentById,
);
router.put(
"/appointments/:id",
ensureAuthenticated,
appointmentController.updateAppointmentStatus,
);
router.delete(
"/appointments/:id",
ensureAuthenticated,
appointmentController.deleteAppointment,
);
// Appointment CMS page management
router.get("/appointment", ensureAuthenticated, appointmentController.index);
router.post("/appointment/update", ensureAuthenticated, appointmentController.update);
router.get("/appointment/data", ensureAuthenticated, appointmentController.getAppointmentData);
router.post(
"/appointment/update",
ensureAuthenticated,
appointmentController.update,
);
router.get(
"/appointment/data",
ensureAuthenticated,
appointmentController.getAppointmentData,
);
// Pricing CMS page management
const pricingController = require("../controllers/pricingController");
router.get("/pricing", ensureAuthenticated, pricingController.index);
router.post("/pricing/update", ensureAuthenticated, pricingController.update);
router.get("/pricing/data", ensureAuthenticated, pricingController.getPricingData);
router.get(
"/pricing/data",
ensureAuthenticated,
pricingController.getPricingData,
);
// Activity CRUD routes
router.get("/activity", ensureAuthenticated, activityController.index);
router.get("/activity/create", ensureAuthenticated, activityController.createForm);
router.get(
"/activity/create",
ensureAuthenticated,
activityController.createForm,
);
router.post("/activity/create", ensureAuthenticated, activityController.create);
// Update filters (place before any parameterized /activity/:id routes to avoid route collision)
router.post("/activity/filters/update", ensureAuthenticated, activityController.updateFilters);
router.post(
"/activity/filters/update",
ensureAuthenticated,
activityController.updateFilters,
);
// Update hero (global hero section for activities)
router.post("/activity/hero/update", ensureAuthenticated, activityController.updateHero);
router.get("/activity/:id/edit", ensureAuthenticated, activityController.editForm);
router.post("/activity/:id/update", ensureAuthenticated, activityController.update);
router.post("/activity/:id/delete", ensureAuthenticated, activityController.delete);
router.post("/activity/:id/toggle-status", ensureAuthenticated, activityController.toggleStatus);
router.post(
"/activity/hero/update",
ensureAuthenticated,
activityController.updateHero,
);
router.get(
"/activity/:id/edit",
ensureAuthenticated,
activityController.editForm,
);
router.post(
"/activity/:id/update",
ensureAuthenticated,
activityController.update,
);
router.post(
"/activity/:id/delete",
ensureAuthenticated,
activityController.delete,
);
router.post(
"/activity/:id/toggle-status",
ensureAuthenticated,
activityController.toggleStatus,
);
// Update display order
router.post("/activity/update-order", ensureAuthenticated, activityController.updateOrder);
router.post(
"/activity/update-order",
ensureAuthenticated,
activityController.updateOrder,
);
// Booking submissions routes
router.get("/activity/:id/bookings/count", ensureAuthenticated, activityController.getBookingCount);
router.get("/activity/:id/bookings", ensureAuthenticated, activityController.getBookingSubmissions);
router.get("/activity/:id/bookings/export", ensureAuthenticated, activityController.exportBookingData);
router.get(
"/activity/:id/bookings/count",
ensureAuthenticated,
activityController.getBookingCount,
);
router.get(
"/activity/:id/bookings",
ensureAuthenticated,
activityController.getBookingSubmissions,
);
router.get(
"/activity/:id/bookings/export",
ensureAuthenticated,
activityController.exportBookingData,
);
// Export all bookings (across all activities)
router.get("/bookings/export-all", ensureAuthenticated, activityController.exportAllBookingsData);
router.get(
"/bookings/export-all",
ensureAuthenticated,
activityController.exportAllBookingsData,
);
// Update booking submission
router.put("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.updateBookingSubmission);
router.put(
"/bookings/:bookingId",
ensureAuthenticated,
bookingSubmissionController.updateBookingSubmission,
);
// Delete booking submission
router.delete("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.deleteBookingSubmission);
router.delete(
"/bookings/:bookingId",
ensureAuthenticated,
bookingSubmissionController.deleteBookingSubmission,
);
// Update filters
// Preview activity
router.get("/activity/:id/preview", ensureAuthenticated, activityController.preview);
router.get(
"/activity/:id/preview",
ensureAuthenticated,
activityController.preview,
);
// FAQ routes
router.get("/home/faq", ensureAuthenticated, faqController.index);
@@ -163,8 +317,16 @@ router.get("/home/faq/api", faqController.api);
// API routes cho quản lý FAQ items (AJAX calls)
router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ);
router.put("/faq/api/update-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.updateFAQItem);
router.delete("/faq/api/delete-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.deleteFAQItem);
router.put(
"/faq/api/update-faq-item/:sectionId/:faqId",
ensureAuthenticated,
faqController.updateFAQItem,
);
router.delete(
"/faq/api/delete-faq-item/:sectionId/:faqId",
ensureAuthenticated,
faqController.deleteFAQItem,
);
router.get("/terms-conditions", ensureAuthenticated, termsController.index);
router.post("/terms/update", ensureAuthenticated, termsController.update);
router.get("/terms/data", ensureAuthenticated, termsController.getTermsData);
@@ -182,13 +344,33 @@ router.get("/travel/seed", ensureAuthenticated, travelController.seed);
// Deprecated FAQ API routes removed
// API routes cho quản lý FAQ sections (AJAX calls)
router.post("/faq/api/add-section", ensureAuthenticated, faqController.addFAQSection);
router.put("/faq/api/update-section/:sectionId", ensureAuthenticated, faqController.updateFAQSection);
router.delete("/faq/api/delete-section/:sectionId", ensureAuthenticated, faqController.deleteFAQSection);
router.post("/faq/api/reorder-sections", ensureAuthenticated, faqController.reorderFAQSection);
router.post(
"/faq/api/add-section",
ensureAuthenticated,
faqController.addFAQSection,
);
router.put(
"/faq/api/update-section/:sectionId",
ensureAuthenticated,
faqController.updateFAQSection,
);
router.delete(
"/faq/api/delete-section/:sectionId",
ensureAuthenticated,
faqController.deleteFAQSection,
);
router.post(
"/faq/api/reorder-sections",
ensureAuthenticated,
faqController.reorderFAQSection,
);
// API routes cho sidebar navigation (AJAX calls)
router.put("/faq/api/update-sidebar", ensureAuthenticated, faqController.updateSidebarNav);
router.put(
"/faq/api/update-sidebar",
ensureAuthenticated,
faqController.updateSidebarNav,
);
// Safety routes
router.get("/safety", ensureAuthenticated, safetyController.index);
@@ -196,74 +378,98 @@ router.post("/safety/update", ensureAuthenticated, safetyController.update);
//Insurance routes
router.get("/insurance", ensureAuthenticated, insuranceController.index);
router.post("/insurance/update", ensureAuthenticated, insuranceController.update);
router.post(
"/insurance/update",
ensureAuthenticated,
insuranceController.update,
);
// Service routes
router.get("/service", ensureAuthenticated, serviceController.index);
router.post("/service/update", ensureAuthenticated, serviceController.update);
router.post("/service/generate-slug", ensureAuthenticated, serviceController.generateSlug);
router.post(
"/service/generate-slug",
ensureAuthenticated,
serviceController.generateSlug,
);
router.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit);
router.post("/service/:slug/edit", ensureAuthenticated, serviceController.updateService);
router.get("/service/:slug/details", ensureAuthenticated, serviceController.details);
router.post("/service/:slug/details/update", ensureAuthenticated, serviceController.updateDetails);
router.post(
"/service/:slug/edit",
ensureAuthenticated,
serviceController.updateService,
);
router.get(
"/service/:slug/details",
ensureAuthenticated,
serviceController.details,
);
router.post(
"/service/:slug/details/update",
ensureAuthenticated,
serviceController.updateDetails,
);
// Test Image Paths route
router.get("/test-images", ensureAuthenticated, (req, res) => {
const fs = require("fs");
const path = require("path");
const campLocationData = require("../data/camp-location.json");
const fs = require("fs");
const path = require("path");
const campLocationData = require("../data/camp-location.json");
// Collect all image paths
const imagePaths = [];
// Collect all image paths
const imagePaths = [];
// Camps images
if (campLocationData.camps) {
campLocationData.camps.forEach((camp) => {
if (camp.image) {
imagePaths.push({
type: "Camp",
name: camp.title,
path: camp.image,
exists: fs.existsSync(path.join(__dirname, "../public", camp.image)),
});
}
// Camps images
if (campLocationData.camps) {
campLocationData.camps.forEach((camp) => {
if (camp.image) {
imagePaths.push({
type: "Camp",
name: camp.title,
path: camp.image,
exists: fs.existsSync(path.join(__dirname, "../public", camp.image)),
});
}
// Locations images
if (campLocationData.locations) {
campLocationData.locations.forEach((location) => {
if (location.imageSrc) {
imagePaths.push({
type: "Location",
name: location.title,
path: location.imageSrc,
exists: fs.existsSync(path.join(__dirname, "../public", location.imageSrc)),
});
}
// Program images
if (location.programOptions) {
location.programOptions.forEach((program) => {
if (program.imageSrc) {
imagePaths.push({
type: "Program",
name: program.title,
path: program.imageSrc,
exists: fs.existsSync(path.join(__dirname, "../public", program.imageSrc)),
});
}
});
}
});
}
res.render("admin/test-images", {
layout: "layouts/admin",
title: "Test Image Paths",
images: imagePaths,
user: req.session.user,
}
});
}
// Locations images
if (campLocationData.locations) {
campLocationData.locations.forEach((location) => {
if (location.imageSrc) {
imagePaths.push({
type: "Location",
name: location.title,
path: location.imageSrc,
exists: fs.existsSync(
path.join(__dirname, "../public", location.imageSrc),
),
});
}
// Program images
if (location.programOptions) {
location.programOptions.forEach((program) => {
if (program.imageSrc) {
imagePaths.push({
type: "Program",
name: program.title,
path: program.imageSrc,
exists: fs.existsSync(
path.join(__dirname, "../public", program.imageSrc),
),
});
}
});
}
});
}
res.render("admin/test-images", {
layout: "layouts/admin",
title: "Test Image Paths",
images: imagePaths,
user: req.session.user,
});
});
// Display visa management page
@@ -279,10 +485,18 @@ router.post("/visa/update", ensureAuthenticated, visaController.updateCountry);
router.post("/visa/add", ensureAuthenticated, visaController.addCountry);
// Update single country
router.put("/visa/update/:id", ensureAuthenticated, visaController.updateCountry);
router.put(
"/visa/update/:id",
ensureAuthenticated,
visaController.updateCountry,
);
// Delete country
router.delete("/visa/delete/:id", ensureAuthenticated, visaController.deleteCountry);
router.delete(
"/visa/delete/:id",
ensureAuthenticated,
visaController.deleteCountry,
);
// Blog routes
// Blog Management Routes
router.get("/blog", ensureAuthenticated, blogController.index);
@@ -293,36 +507,112 @@ router.post("/blog/:id/edit", ensureAuthenticated, blogController.update);
router.post("/blog/:id/delete", ensureAuthenticated, blogController.destroy);
// Comment management routes
router.post("/blog/:blogId/comments/:commentId/approve", ensureAuthenticated, blogController.approveComment);
router.post("/blog/:blogId/comments/:commentId/reject", ensureAuthenticated, blogController.rejectComment);
router.post("/blog/:blogId/comments/:commentId/delete", ensureAuthenticated, blogController.deleteComment);
router.post(
"/blog/:blogId/comments/:commentId/approve",
ensureAuthenticated,
blogController.approveComment,
);
router.post(
"/blog/:blogId/comments/:commentId/reject",
ensureAuthenticated,
blogController.rejectComment,
);
router.post(
"/blog/:blogId/comments/:commentId/delete",
ensureAuthenticated,
blogController.deleteComment,
);
// Blog Categories Management
router.get("/blog/categories", ensureAuthenticated, blogCategoryController.index);
router.get("/blog/categories/create", ensureAuthenticated, blogCategoryController.create);
router.post("/blog/categories/create", ensureAuthenticated, blogCategoryController.store);
router.get("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.edit);
router.post("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.update);
router.post("/blog/categories/:id/delete", ensureAuthenticated, blogCategoryController.destroy);
router.post("/blog/categories/quick-create", ensureAuthenticated, blogCategoryController.quickCreate);
router.get(
"/blog/categories",
ensureAuthenticated,
blogCategoryController.index,
);
router.get(
"/blog/categories/create",
ensureAuthenticated,
blogCategoryController.create,
);
router.post(
"/blog/categories/create",
ensureAuthenticated,
blogCategoryController.store,
);
router.get(
"/blog/categories/:id/edit",
ensureAuthenticated,
blogCategoryController.edit,
);
router.post(
"/blog/categories/:id/edit",
ensureAuthenticated,
blogCategoryController.update,
);
router.post(
"/blog/categories/:id/delete",
ensureAuthenticated,
blogCategoryController.destroy,
);
router.post(
"/blog/categories/quick-create",
ensureAuthenticated,
blogCategoryController.quickCreate,
);
// Blog Tags Management
router.get("/blog/tags", ensureAuthenticated, blogTagController.index);
router.get("/blog/tags/create", ensureAuthenticated, blogTagController.create);
router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store);
router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit);
router.post("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.update);
router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy);
router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate);
router.post(
"/blog/tags/:id/edit",
ensureAuthenticated,
blogTagController.update,
);
router.post(
"/blog/tags/:id/delete",
ensureAuthenticated,
blogTagController.destroy,
);
router.post(
"/blog/tags/quick-create",
ensureAuthenticated,
blogTagController.quickCreate,
);
// Testimonials management
router.get("/home/testimonials", ensureAuthenticated, testimonialController.index);
router.post("/home/testimonials/update", ensureAuthenticated, testimonialController.update);
router.get(
"/home/testimonials",
ensureAuthenticated,
testimonialController.index,
);
router.post(
"/home/testimonials/update",
ensureAuthenticated,
testimonialController.update,
);
// Video Gallery management
router.get("/home/video-gallery", ensureAuthenticated, videoGalleryController.index);
router.post("/home/video-gallery/update", ensureAuthenticated, videoGalleryController.update);
router.get(
"/home/video-gallery",
ensureAuthenticated,
videoGalleryController.index,
);
router.post(
"/home/video-gallery/update",
ensureAuthenticated,
videoGalleryController.update,
);
// Audit Log routes
router.get("/audit-logs", ensureAuthenticated, auditLogController.index);
router.get("/audit-logs/:id", ensureAuthenticated, auditLogController.show);
router.get("/audit-logs-api", ensureAuthenticated, auditLogController.api);
router.post(
"/audit-logs/cleanup",
ensureAuthenticated,
auditLogController.cleanup,
);
module.exports = router;

View File

@@ -0,0 +1,29 @@
require("dotenv").config();
const mongoose = require("mongoose");
async function run() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log("Connected DB");
const collections = await mongoose.connection.db
.listCollections({ name: "auditlogs" })
.toArray();
if (collections.length > 0) {
console.log("AuditLog collection already exists");
process.exit(0);
}
await mongoose.connection.createCollection("auditlogs");
console.log("AuditLog collection created");
process.exit(0);
} catch (err) {
console.error(err);
process.exit(1);
}
}
run();

18
utils/requestMeta.js Normal file
View File

@@ -0,0 +1,18 @@
function getClientIp(req) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) {
return forwarded.split(",")[0].trim();
}
return req.socket?.remoteAddress || req.connection?.remoteAddress || null;
}
function getUserAgent(req) {
return req.headers["user-agent"] || "";
}
module.exports = {
getClientIp,
getUserAgent,
};

View File

@@ -0,0 +1,699 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
<%= title %>
</h1>
<p class="text-muted mb-0">System activity and change tracking</p>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-warning" onclick="openCleanupModal()">
<i class="fas fa-broom me-2"></i>Cleanup Old Logs
</button>
</div>
</div>
<!-- Filters -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="GET" action="/admin/audit-logs" class="row g-3">
<div class="col-md-2">
<label class="form-label">Model</label>
<select class="form-select" name="model">
<option value="">All Models</option>
<% uniqueModels.forEach(model => { %>
<option value="<%= model %>" <%= query.model === model ? 'selected' : '' %>>
<%= model %>
</option>
<% }); %>
</select>
</div>
<div class="col-md-2">
<label class="form-label">Action</label>
<select class="form-select" name="action">
<option value="">All Actions</option>
<% uniqueActions.forEach(action => { %>
<option value="<%= action %>" <%= query.action === action ? 'selected' : '' %>>
<%= action %>
</option>
<% }); %>
</select>
</div>
<div class="col-md-2">
<label class="form-label">User</label>
<select class="form-select" name="user">
<option value="">All Users</option>
<% users.forEach(user => { %>
<option value="<%= user._id %>" <%= query.user === user._id.toString() ? 'selected' : '' %>>
<%= user.username %>
</option>
<% }); %>
</select>
</div>
<div class="col-md-2">
<label class="form-label">From Date</label>
<input type="date" class="form-control" name="dateFrom" value="<%= query.dateFrom || '' %>">
</div>
<div class="col-md-2">
<label class="form-label">To Date</label>
<input type="date" class="form-control" name="dateTo" value="<%= query.dateTo || '' %>">
</div>
<div class="col-md-2">
<label class="form-label">Items per page</label>
<select class="form-select" name="limit">
<option value="5" <%= (query.limit && query.limit == '5') ? 'selected' : '' %>>5 items</option>
<option value="7" <%= (!query.limit || query.limit == '7') ? 'selected' : '' %>>7 items</option>
<option value="10" <%= (query.limit && query.limit == '10') ? 'selected' : '' %>>10 items</option>
<option value="15" <%= (query.limit && query.limit == '15') ? 'selected' : '' %>>15 items</option>
<option value="20" <%= (query.limit && query.limit == '20') ? 'selected' : '' %>>20 items</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<div class="d-flex gap-1">
<button type="submit" class="btn flex-fill" style="background-color: var(--primary-color); color: white;">
<i class="fas fa-search me-1"></i>Filter
</button>
<a href="/admin/audit-logs" class="btn btn-outline-secondary">
<i class="fas fa-times"></i>
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Audit Logs List -->
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<% if (auditLogs && auditLogs.length > 0) { %>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">
Showing <%= ((pagination.current - 1) * pagination.limit) + 1 %> -
<%= Math.min(pagination.current * pagination.limit, pagination.totalCount) %>
of <%= pagination.totalCount %> audit logs
<% if (pagination.totalCount > pagination.limit) { %>
<span class="badge bg-info ms-2">
<%= pagination.total %> pages
</span>
<% } %>
</span>
</div>
</div>
<div class="col-md-4">
<div class="d-flex justify-content-end">
<div class="btn-group btn-group-sm me-3" role="group">
<input type="radio" class="btn-check" name="viewMode" id="tableView" autocomplete="off" checked>
<label class="btn btn-outline-secondary" for="tableView">
<i class="fas fa-table"></i> Table
</label>
<input type="radio" class="btn-check" name="viewMode" id="cardView" autocomplete="off">
<label class="btn btn-outline-secondary" for="cardView">
<i class="fas fa-th-large"></i> Cards
</label>
</div>
</div>
</div>
</div>
<!-- Table View -->
<div id="tableViewContent" class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th scope="col" style="width: 140px; color: var(--primary-dark);">Date/Time</th>
<th scope="col" style="color: var(--primary-dark);">Model</th>
<th scope="col" style="color: var(--primary-dark);">Action</th>
<th scope="col" style="color: var(--primary-dark);">User</th>
<th scope="col" style="color: var(--primary-dark);">Changes</th>
<th scope="col" style="color: var(--primary-dark);">IP Address</th>
<th scope="col" style="width: 120px; color: var(--primary-dark);">Actions</th>
</tr>
</thead>
<tbody>
<% auditLogs.forEach((log, index) => { %>
<tr>
<td>
<small class="text-muted">
<%= new Date(log.createdAt).toLocaleDateString() %><br>
<%= new Date(log.createdAt).toLocaleTimeString() %>
</small>
</td>
<td>
<span class="badge" style="background-color: var(--primary-color); color: white;">
<%= log.model %>
</span>
</td>
<td>
<%
let actionStyle = 'background-color: var(--primary-color); color: white;';
if (log.action.includes('CREATE')) actionStyle = 'background-color: #28a745; color: white;';
else if (log.action.includes('UPDATE')) actionStyle = 'background-color: #ffc107; color: #212529;';
else if (log.action.includes('DELETE')) actionStyle = 'background-color: #dc3545; color: white;';
%>
<span class="badge" style="<%= actionStyle %>">
<%= log.action %>
</span>
</td>
<td>
<% if (log.performedBy) { %>
<div>
<strong style="color: var(--primary-dark);"><%= log.performedBy.username %></strong><br>
<small class="text-muted"><%= log.performedBy.email %></small>
</div>
<% } else { %>
<span class="text-muted">System</span>
<% } %>
</td>
<td>
<% if (log.changes && log.changes.length > 0) { %>
<span class="badge" style="background-color: var(--primary-color); color: white;">
<%= log.changes.length %> field<%= log.changes.length > 1 ? 's' : '' %>
</span>
<div class="mt-1">
<% log.changes.slice(0, 3).forEach(change => { %>
<small class="text-muted d-block">
<strong><%= change.field %></strong>
</small>
<% }); %>
<% if (log.changes.length > 3) { %>
<small class="text-muted">
+<%= log.changes.length - 3 %> more...
</small>
<% } %>
</div>
<% } else { %>
<span class="text-muted">-</span>
<% } %>
</td>
<td>
<small class="text-muted">
<%= log.ipAddress %>
</small>
</td>
<td>
<div class="btn-group" role="group">
<a href="/admin/audit-logs/<%= log._id %>" class="btn btn-sm" style="background-color: var(--primary-color); color: white;">
<i class="fas fa-eye me-1"></i>View
</a>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<!-- Card View (Hidden by default) -->
<div id="cardViewContent" style="display: none;">
<% auditLogs.forEach((log, index) => { %>
<div class="audit-card">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span class="badge" style="background-color: var(--primary-color); color: white; font-size: 0.7rem;"><%= log.model %></span>
<small class="text-muted" style="font-size: 0.7rem;">
<%= new Date(log.createdAt).toLocaleDateString() %>
</small>
</div>
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<%
let actionStyle = 'background-color: var(--primary-color); color: white;';
if (log.action.includes('CREATE')) actionStyle = 'background-color: #28a745; color: white;';
else if (log.action.includes('UPDATE')) actionStyle = 'background-color: #ffc107; color: #212529;';
else if (log.action.includes('DELETE')) actionStyle = 'background-color: #dc3545; color: white;';
%>
<span class="badge" style="<%= actionStyle %>; font-size: 0.65rem;">
<%= log.action %>
</span>
<small class="text-muted" style="font-size: 0.7rem;">
<%= new Date(log.createdAt).toLocaleTimeString() %>
</small>
</div>
<div class="mb-2">
<strong style="font-size: 0.8rem;">User:</strong>
<% if (log.performedBy) { %>
<div style="color: var(--primary-dark); font-size: 0.8rem;"><%= log.performedBy.username %></div>
<small class="text-muted" style="font-size: 0.7rem;"><%= log.performedBy.email %></small>
<% } else { %>
<span class="text-muted" style="font-size: 0.8rem;">System</span>
<% } %>
</div>
<% if (log.changes && log.changes.length > 0) { %>
<div class="mb-2">
<strong style="font-size: 0.8rem;">Changes:</strong>
<span class="badge ms-1" style="background-color: var(--primary-color); color: white; font-size: 0.65rem;">
<%= log.changes.length %> field<%= log.changes.length > 1 ? 's' : '' %>
</span>
<div class="mt-1">
<% log.changes.slice(0, 2).forEach(change => { %>
<small class="text-muted d-block" style="font-size: 0.7rem;">
• <%= change.field %>
</small>
<% }); %>
<% if (log.changes.length > 2) { %>
<small class="text-muted" style="font-size: 0.7rem;">
+<%= log.changes.length - 2 %> more...
</small>
<% } %>
</div>
</div>
<% } %>
<div class="mb-2">
<small class="text-muted" style="font-size: 0.7rem;">
IP: <%= log.ipAddress %>
</small>
</div>
</div>
<div class="card-footer p-2">
<a href="/admin/audit-logs/<%= log._id %>" class="btn btn-sm w-100" style="background-color: var(--primary-color); color: white; font-size: 0.8rem;">
<i class="fas fa-eye me-1"></i>View Details
</a>
</div>
</div>
</div>
<% }); %>
</div>
<!-- Pagination -->
<% if (pagination && pagination.total > 1) { %>
<nav aria-label="Audit log pagination" class="mt-4">
<ul class="pagination justify-content-center">
<% if (pagination.current > 1) { %>
<li class="page-item">
<a class="page-link" href="?page=<%= pagination.current - 1 %><%= Object.keys(query).map(key => key !== 'page' ? '&' + key + '=' + encodeURIComponent(query[key]) : '').join('') %>" style="color: var(--primary-dark);">Previous</a>
</li>
<% } %>
<% for (let i = 1; i <= pagination.total; i++) { %>
<% if (i === pagination.current) { %>
<li class="page-item active">
<span class="page-link" style="background-color: var(--primary-color); border-color: var(--primary-color);"><%= i %></span>
</li>
<% } else if (i === 1 || i === pagination.total || (i >= pagination.current - 2 && i <= pagination.current + 2)) { %>
<li class="page-item">
<a class="page-link" href="?page=<%= i %><%= Object.keys(query).map(key => key !== 'page' ? '&' + key + '=' + encodeURIComponent(query[key]) : '').join('') %>" style="color: var(--primary-dark);">
<%= i %>
</a>
</li>
<% } else if (i === pagination.current - 3 || i === pagination.current + 3) { %>
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
<% } %>
<% } %>
<% if (pagination.current < pagination.total) { %>
<li class="page-item">
<a class="page-link" href="?page=<%= pagination.current + 1 %><%= Object.keys(query).map(key => key !== 'page' ? '&' + key + '=' + encodeURIComponent(query[key]) : '').join('') %>" style="color: var(--primary-dark);">Next</a>
</li>
<% } %>
</ul>
<!-- Pagination Info -->
<div class="text-center mt-2">
<small class="text-muted">
Page <%= pagination.current %> of <%= pagination.total %>
(showing <%= ((pagination.current - 1) * pagination.limit) + 1 %> -
<%= Math.min(pagination.current * pagination.limit, pagination.totalCount) %>
of <%= pagination.totalCount %> total items)
</small>
</div>
</nav>
<% } %>
<% } else { %>
<div class="text-center py-5">
<i class="fas fa-clipboard-list text-muted mb-3" style="font-size: 3rem;"></i>
<h5 class="text-muted mb-3">No Audit Logs Found</h5>
<p class="text-muted">No audit logs match your current filters.</p>
<a href="/admin/audit-logs" class="btn" style="background-color: var(--primary-color); color: white;">
<i class="fas fa-refresh me-1"></i>Clear Filters
</a>
</div>
<% } %>
</div>
</div>
</div>
<!-- Custom Cleanup Modal -->
<div id="cleanupModal" class="custom-modal" style="display: none;">
<div class="custom-modal-overlay"></div>
<div class="custom-modal-content">
<div class="custom-modal-header">
<h5 class="custom-modal-title">
<i class="fas fa-broom me-2"></i>Cleanup Old Audit Logs
</h5>
<button type="button" class="custom-modal-close" onclick="closeCleanupModal()">
<i class="fas fa-times"></i>
</button>
</div>
<form method="POST" action="/admin/audit-logs/cleanup">
<div class="custom-modal-body">
<p>Delete audit logs older than the specified number of days.</p>
<div class="mb-3">
<label for="days" class="form-label">Keep logs for (days):</label>
<input type="number" class="form-control" id="days" name="days" value="90" min="1" max="365" required>
<div class="form-text">Recommended: 90 days for compliance</div>
</div>
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Warning:</strong> This action cannot be undone. Deleted audit logs will be permanently removed.
</div>
</div>
<div class="custom-modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeCleanupModal()">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="fas fa-broom me-1"></i>Cleanup Logs
</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// View mode toggle
const tableViewRadio = document.getElementById('tableView');
const cardViewRadio = document.getElementById('cardView');
const tableViewContent = document.getElementById('tableViewContent');
const cardViewContent = document.getElementById('cardViewContent');
// Debug: Check if elements exist
console.log('Elements found:', {
tableViewRadio: !!tableViewRadio,
cardViewRadio: !!cardViewRadio,
tableViewContent: !!tableViewContent,
cardViewContent: !!cardViewContent
});
if (!tableViewRadio || !cardViewRadio || !tableViewContent || !cardViewContent) {
console.error('Some view toggle elements are missing!');
return;
}
tableViewRadio.addEventListener('change', function() {
if (this.checked) {
tableViewContent.classList.remove('hidden');
cardViewContent.classList.remove('active');
console.log('Table view activated');
}
});
cardViewRadio.addEventListener('change', function() {
if (this.checked) {
tableViewContent.classList.add('hidden');
cardViewContent.classList.add('active');
console.log('Card view activated with CSS Grid');
}
});
// Auto-submit form when changing items per page
const limitSelect = document.querySelector('select[name="limit"]');
if (limitSelect) {
limitSelect.addEventListener('change', function() {
// Reset to page 1 when changing limit
const pageInput = document.querySelector('input[name="page"]');
if (pageInput) {
pageInput.value = 1;
} else {
// Create hidden input for page
const hiddenPageInput = document.createElement('input');
hiddenPageInput.type = 'hidden';
hiddenPageInput.name = 'page';
hiddenPageInput.value = 1;
this.form.appendChild(hiddenPageInput);
}
// Submit the form
this.form.submit();
});
}
});
function openCleanupModal() {
document.getElementById('cleanupModal').style.display = 'block';
document.body.style.overflow = 'hidden';
}
function closeCleanupModal() {
document.getElementById('cleanupModal').style.display = 'none';
document.body.style.overflow = 'auto';
}
// Close modal when clicking overlay
document.addEventListener('click', function(e) {
if (e.target.classList.contains('custom-modal-overlay')) {
closeCleanupModal();
}
});
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCleanupModal();
}
});
function exportLogs() {
// Get current filter parameters
const params = new URLSearchParams(window.location.search);
params.set('export', 'csv');
// Create download link
const exportUrl = '/admin/audit-logs/export?' + params.toString();
window.open(exportUrl, '_blank');
}
</script>
<style>
.badge {
font-size: 0.75em;
}
.card-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.table th {
border-top: none;
font-weight: 600;
color: var(--primary-dark);
}
.btn-group-sm .btn {
font-size: 0.75rem;
}
.pagination .page-link {
color: var(--primary-dark) !important;
background-color: #fff !important;
border: 1px solid #dee2e6 !important;
padding: 0.5rem 0.75rem;
text-decoration: none;
position: relative;
}
.pagination .page-item.active .page-link {
background-color: var(--primary-color) !important;
border-color: var(--primary-color) !important;
color: #fff !important;
z-index: 3;
font-weight: 600;
}
.pagination .page-link:hover {
color: var(--primary-color) !important;
background-color: #f8f9fa !important;
border-color: var(--primary-color) !important;
text-decoration: none;
}
.pagination .page-link:focus {
color: var(--primary-color) !important;
background-color: #f8f9fa !important;
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 0.2rem rgba(var(--primary-color-rgb), 0.25);
outline: 0;
}
.pagination .page-item.disabled .page-link {
color: #6c757d !important;
background-color: #fff !important;
border-color: #dee2e6 !important;
}
/* Ensure pagination text is always visible */
.pagination .page-link span,
.pagination .page-link {
display: inline-block;
line-height: 1.25;
}
/* Custom Modal Styles */
.custom-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1050;
}
.custom-modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.custom-modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.custom-modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #dee2e6;
background-color: #fff3cd;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: between;
align-items: center;
}
.custom-modal-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #856404;
flex: 1;
}
.custom-modal-close {
background: none;
border: none;
font-size: 1.2rem;
color: #856404;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}
.custom-modal-close:hover {
background-color: rgba(133, 100, 4, 0.1);
}
.custom-modal-body {
padding: 1.5rem;
}
.custom-modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #dee2e6;
background-color: #f8f9fa;
border-radius: 0 0 8px 8px;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* Card View Improvements - Using CSS Grid for better control */
#cardViewContent {
display: none !important;
visibility: hidden !important;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
padding: 0;
margin: 0;
}
#cardViewContent.active {
display: grid !important;
visibility: visible !important;
}
#cardViewContent .card {
transition: transform 0.2s, box-shadow 0.2s;
width: 100% !important;
margin: 0 !important;
}
#cardViewContent .card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
}
#cardViewContent .card-header {
background-color: #f8f9fa !important;
border-bottom: 1px solid #dee2e6;
}
#cardViewContent .card-footer {
background-color: #f8f9fa !important;
border-top: 1px solid #dee2e6;
}
/* Table View */
#tableViewContent {
display: block;
}
#tableViewContent.hidden {
display: none !important;
}
/* Responsive grid adjustments */
@media (max-width: 1400px) {
#cardViewContent {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)) !important;
}
}
@media (max-width: 1200px) {
#cardViewContent {
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)) !important;
}
}
@media (max-width: 992px) {
#cardViewContent {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) !important;
}
}
@media (max-width: 768px) {
#cardViewContent {
grid-template-columns: 1fr !important;
gap: 1rem !important;
}
.custom-modal-content {
width: 95%;
margin: 1rem;
}
}
</style>

View File

@@ -0,0 +1,314 @@
<div class="container">
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
<div>
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
<%= title %>
</h1>
<p class="text-muted mb-0">Detailed audit log information</p>
</div>
<div class="d-flex gap-2">
<a href="/admin/audit-logs" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Back to Audit Logs
</a>
</div>
</div>
<div class="row">
<!-- Main Information -->
<div class="col-12">
<div class="card border-0 shadow-sm mb-4">
<div class="card-header">
<h5 class="card-title mb-0" style="color: var(--primary-dark);">
<i class="fas fa-info-circle me-2"></i>Audit Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label fw-bold">Model:</label>
<span class="badge ms-2" style="background-color: var(--primary-color); color: white;"><%= auditLog.model %></span>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Action:</label>
<%
let actionStyle = 'background-color: var(--primary-color); color: white;';
if (auditLog.action.includes('CREATE')) actionStyle = 'background-color: #28a745; color: white;';
else if (auditLog.action.includes('UPDATE')) actionStyle = 'background-color: #ffc107; color: #212529;';
else if (auditLog.action.includes('DELETE')) actionStyle = 'background-color: #dc3545; color: white;';
%>
<span class="badge ms-2" style="<%= actionStyle %>"><%= auditLog.action %></span>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Document ID:</label>
<code class="ms-2" style="background-color: #f8f9fa; color: var(--primary-dark);"><%= auditLog.documentId %></code>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label fw-bold">Date & Time:</label>
<div class="ms-2">
<%= new Date(auditLog.createdAt).toLocaleDateString() %>
<%= new Date(auditLog.createdAt).toLocaleTimeString() %>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">User:</label>
<div class="ms-2">
<% if (auditLog.performedBy) { %>
<strong style="color: var(--primary-dark);"><%= auditLog.performedBy.username %></strong><br>
<small class="text-muted"><%= auditLog.performedBy.email %></small>
<% } else { %>
<span class="text-muted">System</span>
<% } %>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">IP Address:</label>
<code class="ms-2" style="background-color: #f8f9fa; color: var(--primary-dark);"><%= auditLog.ipAddress %></code>
</div>
</div>
</div>
<% if (auditLog.userAgent) { %>
<div class="mb-3">
<label class="form-label fw-bold">User Agent:</label>
<div class="ms-2">
<small class="text-muted font-monospace"><%= auditLog.userAgent %></small>
</div>
</div>
<% } %>
</div>
</div>
<!-- Changes Details -->
<% if (auditLog.changes && auditLog.changes.length > 0) { %>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header">
<h5 class="card-title mb-0" style="color: var(--primary-dark);">
<i class="fas fa-edit me-2"></i>Field Changes
<span class="badge ms-2" style="background-color: var(--primary-color); color: white;"><%= auditLog.changes.length %></span>
</h5>
</div>
<div class="card-body">
<% if (user && (user.role === 'admin' || user.role === 'superadmin')) { %>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th style="width: 200px; color: var(--primary-dark);">Field</th>
<th style="color: var(--primary-dark);">Before</th>
<th style="color: var(--primary-dark);">After</th>
</tr>
</thead>
<tbody>
<% auditLog.changes.forEach((change, index) => { %>
<tr>
<td>
<strong style="color: var(--primary-dark);"><%= change.field %></strong>
</td>
<td>
<div class="change-value before">
<% if (change.before === null || change.before === undefined) { %>
<span class="text-muted fst-italic">null</span>
<% } else if (typeof change.before === 'object') { %>
<pre class="mb-0"><%= JSON.stringify(change.before, null, 2) %></pre>
<% } else { %>
<%= change.before %>
<% } %>
</div>
</td>
<td>
<div class="change-value after">
<% if (change.after === null || change.after === undefined) { %>
<span class="text-muted fst-italic">null</span>
<% } else if (typeof change.after === 'object') { %>
<pre class="mb-0"><%= JSON.stringify(change.after, null, 2) %></pre>
<% } else { %>
<%= change.after %>
<% } %>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } else { %>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Summary View:</strong> Detailed field values are restricted to administrators.
</div>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th style="color: var(--primary-dark);">Field</th>
<th style="color: var(--primary-dark);">Status</th>
</tr>
</thead>
<tbody>
<% auditLog.changes.forEach((change, index) => { %>
<tr>
<td>
<strong style="color: var(--primary-dark);"><%= change.field %></strong>
</td>
<td>
<span class="badge bg-info">Modified</span>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
<% } %>
<!-- Summary Stats -->
<div class="card border-0 shadow-sm">
<div class="card-header">
<h6 class="card-title mb-0" style="color: var(--primary-dark);">
<i class="fas fa-chart-bar me-2"></i>Summary
</h6>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<div class="border-end">
<div class="h5 mb-0" style="color: var(--primary-color);">
<%= auditLog.changes ? auditLog.changes.length : 0 %>
</div>
<small class="text-muted">Fields Changed</small>
</div>
</div>
<div class="col-6">
<div class="h5 mb-0" style="color: var(--primary-color);">
<%= new Date(auditLog.createdAt).toLocaleDateString() === new Date().toLocaleDateString() ? 'Today' : 'Past' %>
</div>
<small class="text-muted">Timing</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Raw Data (Admin Only - Collapsible) -->
<!-- <% if (user && (user.role === 'admin' || user.role === 'superadmin')) { %>
<div class="card border-0 shadow-sm mt-4">
<div class="card-header">
<h5 class="card-title mb-0">
<button class="btn btn-link p-0 text-decoration-none" type="button" data-bs-toggle="collapse" data-bs-target="#rawDataCollapse" aria-expanded="false" style="color: var(--primary-dark);">
<i class="fas fa-code me-2"></i>Raw Data <span class="badge bg-warning text-dark ms-2">Admin Only</span>
<i class="fas fa-chevron-down ms-2"></i>
</button>
</h5>
</div>
<div class="collapse" id="rawDataCollapse">
<div class="card-body">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Security Notice:</strong> Raw data contains sensitive information and is only visible to administrators.
</div>
<div class="row">
<% if (auditLog.before) { %>
<div class="col-md-6">
<h6 class="text-danger">Before:</h6>
<pre class="p-3 rounded" style="background-color: #f8f9fa; border: 1px solid #dee2e6; max-height: 400px; overflow-y: auto;"><%= JSON.stringify(auditLog.before, null, 2) %></pre>
</div>
<% } %>
<% if (auditLog.after) { %>
<div class="col-md-6">
<h6 class="text-success">After:</h6>
<pre class="p-3 rounded" style="background-color: #f8f9fa; border: 1px solid #dee2e6; max-height: 400px; overflow-y: auto;"><%= JSON.stringify(auditLog.after, null, 2) %></pre>
</div>
<% } %>
</div>
<% if (!auditLog.before && !auditLog.after) { %>
<div class="text-center text-muted py-3">
<i class="fas fa-info-circle me-2"></i>No raw data available for this audit log.
</div>
<% } %>
</div>
</div>
</div>
<% } else { %>
<div class="card border-0 shadow-sm mt-4">
<div class="card-header">
<h5 class="card-title mb-0" style="color: var(--primary-dark);">
<i class="fas fa-lock me-2"></i>Raw Data
</h5>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="fas fa-shield-alt me-2"></i>
<strong>Access Restricted:</strong> Raw data access is limited to administrators for security reasons.
</div>
<div class="text-center text-muted py-3">
<i class="fas fa-user-shield" style="font-size: 3rem; opacity: 0.3;"></i>
<p class="mt-3">Contact your administrator if you need access to detailed raw data.</p>
</div>
</div>
</div>
<% } %> -->
</div>
<style>
.change-value {
max-width: 300px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.change-value pre {
max-height: 200px;
overflow-y: auto;
font-size: 0.8rem;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
color: var(--primary-dark);
}
.change-value.before {
background-color: #fff5f5;
border-left: 3px solid #dc3545;
padding-left: 8px;
}
.change-value.after {
background-color: #f0fff4;
border-left: 3px solid #28a745;
padding-left: 8px;
}
.font-monospace {
font-family: 'Courier New', Courier, monospace;
font-size: 0.8rem;
}
.btn-link {
color: var(--primary-dark) !important;
}
.btn-link:hover {
color: var(--primary-color) !important;
}
.table th {
border-top: none;
font-weight: 600;
color: var(--primary-dark);
}
.card-header {
background-color: #f8f9fa !important;
border-bottom: 1px solid #dee2e6;
}
.badge {
font-size: 0.75em;
}
</style>

View File

@@ -746,6 +746,11 @@
<a class="nav-link <%= currentPath === '/admin/pricing' ? 'active' : '' %>"
href="/admin/pricing">Pricing</a>
</li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/audit-logs' ? 'active' : '' %>"
href="/admin/audit-logs">Audit Log
</a>
</li>
</ul>
<div class="d-flex align-items-center">
<% if (locals.user) { %>