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,6 +2,9 @@ const { addBaseUrlToImages } = require("../utils/imageHelper");
const AboutUs = require("../models/aboutUs"); const AboutUs = require("../models/aboutUs");
const Blog = require("../models/blog"); const Blog = require("../models/blog");
const jsonHelper = require("../utils/jsonHelper"); const jsonHelper = require("../utils/jsonHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/** /**
* GET /api/about * GET /api/about
@@ -30,13 +33,19 @@ exports.getAbout = async (req, res) => {
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => { blogs.sort((a, b) => {
return news.selectedBlogIds.indexOf(a._id.toString()) - news.selectedBlogIds.indexOf(b._id.toString()); 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 // 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) { if (blogs.length === 0) {
blogs = await Blog.find({ status: "published" }).sort({ createdAt: -1 }).limit(3).lean(); blogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.limit(3)
.lean();
} }
// Map dữ liệu blog sang format mà frontend mong đợi // Map dữ liệu blog sang format mà frontend mong đợi
@@ -62,7 +71,8 @@ exports.getAbout = async (req, res) => {
rawData.news = news; rawData.news = news;
// =============================== // ===============================
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(rawData, baseUrl); const processedData = addBaseUrlToImages(rawData, baseUrl);
res.json(processedData); res.json(processedData);
@@ -96,12 +106,40 @@ exports.updateAbout = async (req, res) => {
} }
const doc = await AboutUs.getSingle(); 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 // Use .set() for better handling of nested objects/arrays in Mongoose
doc.set(updateData); doc.set(updateData);
await doc.save(); 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 // Fetch fresh data for syncing and returning
const finalData = await AboutUs.findOne().select("-_id -__v -createdAt -updatedAt").lean(); const finalData = await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
// Update about.json file to keep it in sync // Update about.json file to keep it in sync
jsonHelper.writeJsonFile("about", finalData); jsonHelper.writeJsonFile("about", finalData);
@@ -129,7 +167,9 @@ exports.index = async (req, res) => {
const rawData = data.toObject(); const rawData = data.toObject();
// Lấy tất cả blog để chọn trong CMS // 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();
const activeTab = req.query.activeTab || "hero"; const activeTab = req.query.activeTab || "hero";
res.render("admin/aboutUs/index", { res.render("admin/aboutUs/index", {
@@ -166,10 +206,33 @@ exports.update = async (req, res) => {
} }
const doc = await AboutUs.getSingle(); const doc = await AboutUs.getSingle();
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
doc.set(updateData); doc.set(updateData);
await doc.save(); await doc.save();
const finalData = await AboutUs.findOne().select("-_id -__v -createdAt -updatedAt").lean(); // ✅ 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); jsonHelper.writeJsonFile("about", finalData);
req.flash("success_msg", "About Us updated successfully"); req.flash("success_msg", "About Us updated successfully");

View File

@@ -1,5 +1,8 @@
const AppointmentSubmission = require("../models/appointmentSubmission"); const AppointmentSubmission = require("../models/appointmentSubmission");
const Appointment = require("../models/appointment"); const Appointment = require("../models/appointment");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// ==================== CMS ADMIN FUNCTIONS ==================== // ==================== CMS ADMIN FUNCTIONS ====================
@@ -58,7 +61,9 @@ exports.index = async (req, res) => {
} }
} }
const submissions = await AppointmentSubmission.find(query).sort({ createdAt: -1 }).limit(50); const submissions = await AppointmentSubmission.find(query)
.sort({ createdAt: -1 })
.limit(50);
res.render("admin/appointment/index", { res.render("admin/appointment/index", {
layout: "layouts/main", layout: "layouts/main",
@@ -84,11 +89,17 @@ exports.update = async (req, res) => {
// Parse JSON strings if needed // Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero; const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const visaOptionsData = typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions; const visaOptionsData =
typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions;
const formData = typeof form === "string" ? JSON.parse(form) : form; const formData = typeof form === "string" ? JSON.parse(form) : form;
let appointment = await Appointment.findOne({ name: "default" }); let appointment = await Appointment.findOne({ name: "default" });
// Capture before state for audit logging
const beforeState = appointment
? JSON.parse(JSON.stringify(appointment.toObject()))
: null;
if (appointment) { if (appointment) {
appointment.hero = heroData; appointment.hero = heroData;
appointment.visaOptions = visaOptionsData; appointment.visaOptions = visaOptionsData;
@@ -103,6 +114,23 @@ exports.update = async (req, res) => {
}); });
} }
// 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"); req.flash("success", "Appointment data updated successfully");
res.redirect("/admin/appointment"); res.redirect("/admin/appointment");
} catch (err) { } catch (err) {
@@ -186,7 +214,8 @@ exports.api = async (req, res) => {
// API để submit appointment form (từ frontend) // API để submit appointment form (từ frontend)
exports.submitAppointment = async (req, res) => { exports.submitAppointment = async (req, res) => {
try { try {
const { name, email, phone, address, appointmentDate, message, visaTypes } = req.body; const { name, email, phone, address, appointmentDate, message, visaTypes } =
req.body;
// Validation // Validation
if (!name || !email) { if (!name || !email) {
@@ -213,7 +242,8 @@ exports.submitAppointment = async (req, res) => {
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: "Thank you! Your appointment request has been submitted. We will contact you soon.", message:
"Thank you! Your appointment request has been submitted. We will contact you soon.",
data: { data: {
id: submission._id, id: submission._id,
name: submission.name, name: submission.name,
@@ -246,7 +276,10 @@ exports.getAppointments = async (req, res) => {
const { status, page = 1, limit = 20 } = req.query; const { status, page = 1, limit = 20 } = req.query;
const query = {}; const query = {};
if (status && ["pending", "confirmed", "completed", "cancelled"].includes(status)) { if (
status &&
["pending", "confirmed", "completed", "cancelled"].includes(status)
) {
query.status = status; query.status = status;
} }
@@ -293,6 +326,19 @@ exports.updateAppointmentStatus = async (req, res) => {
}); });
} }
// 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 }; const updateData = { status };
if (notes !== undefined) updateData.notes = notes; if (notes !== undefined) updateData.notes = notes;
if (status === "confirmed") updateData.confirmedAt = new Date(); if (status === "confirmed") updateData.confirmedAt = new Date();
@@ -301,15 +347,25 @@ exports.updateAppointmentStatus = async (req, res) => {
const appointment = await AppointmentSubmission.findByIdAndUpdate( const appointment = await AppointmentSubmission.findByIdAndUpdate(
id, id,
updateData, updateData,
{ new: true } { new: true },
); );
if (!appointment) { // Capture after state for audit logging
return res.status(404).json({ const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
success: false,
error: "Appointment not found", // 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({ res.json({
success: true, success: true,
@@ -354,8 +410,9 @@ exports.getAppointmentById = async (req, res) => {
exports.deleteAppointment = async (req, res) => { exports.deleteAppointment = async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const appointment = await AppointmentSubmission.findByIdAndDelete(id);
// Get the appointment before deletion for audit logging
const appointment = await AppointmentSubmission.findById(id);
if (!appointment) { if (!appointment) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
@@ -363,6 +420,22 @@ exports.deleteAppointment = async (req, res) => {
}); });
} }
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({ res.json({
success: true, success: true,
message: "Appointment deleted successfully", message: "Appointment deleted successfully",

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");
}
};

View File

@@ -1,10 +1,13 @@
const Blog = require('../models/blog'); const Blog = require("../models/blog");
const BlogCategory = require('../models/blogCategory'); const BlogCategory = require("../models/blogCategory");
const BlogTag = require('../models/blogTag'); const BlogTag = require("../models/blogTag");
const BlogComment = require('../models/blogComment'); const BlogComment = require("../models/blogComment");
const RecentPost = require('../models/recentPost'); const RecentPost = require("../models/recentPost");
const { addBaseUrlToImages, getFullImageUrl } = require('../utils/imageHelper'); const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const slugify = require('slugify'); const slugify = require("slugify");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// -------------------- Helper Functions -------------------- // -------------------- Helper Functions --------------------
@@ -13,7 +16,7 @@ const generateSlug = (title) => {
return slugify(title, { return slugify(title, {
lower: true, lower: true,
strict: true, strict: true,
locale: 'vi' locale: "vi",
}); });
}; };
@@ -52,8 +55,8 @@ exports.index = async (req, res) => {
} }
if (req.query.search) { if (req.query.search) {
filter.$or = [ filter.$or = [
{ title: { $regex: req.query.search, $options: 'i' } }, { title: { $regex: req.query.search, $options: "i" } },
{ excerpt: { $regex: req.query.search, $options: 'i' } } { excerpt: { $regex: req.query.search, $options: "i" } },
]; ];
} }
@@ -70,12 +73,12 @@ exports.index = async (req, res) => {
// Get categories for filter // Get categories for filter
const categories = await BlogCategory.getActive(); const categories = await BlogCategory.getActive();
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001'; const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render('admin/blog/index', { res.render("admin/blog/index", {
layout: 'layouts/main', layout: "layouts/main",
title: 'Blog Management', title: "Blog Management",
blogs, blogs,
categories, categories,
frontendUrl, frontendUrl,
@@ -85,16 +88,16 @@ exports.index = async (req, res) => {
current: page, current: page,
total: totalPages, total: totalPages,
limit, limit,
totalItems: totalBlogs totalItems: totalBlogs,
}, },
query: req.query, query: req.query,
currentPath: req.path, currentPath: req.path,
user: req.session.user user: req.session.user,
}); });
} catch (err) { } catch (err) {
console.error('Blog index error:', err); console.error("Blog index error:", err);
req.flash('error_msg', 'Error loading blogs'); req.flash("error_msg", "Error loading blogs");
res.redirect('/admin/dashboard'); res.redirect("/admin/dashboard");
} }
}; };
@@ -104,24 +107,24 @@ exports.create = async (req, res) => {
const categories = await BlogCategory.getActive(); const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive(); const tags = await BlogTag.getActive();
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001'; const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render('admin/blog/create', { res.render("admin/blog/create", {
layout: 'layouts/main', layout: "layouts/main",
title: 'Create New Blog Post', title: "Create New Blog Post",
categories, categories,
tags, tags,
currentPath: req.path, currentPath: req.path,
user: req.session.user, user: req.session.user,
frontendUrl, frontendUrl,
backendUrl, backendUrl,
getFullImageUrl // Truyền helper function vào template getFullImageUrl, // Truyền helper function vào template
}); });
} catch (err) { } catch (err) {
console.error('Blog create form error:', err); console.error("Blog create form error:", err);
req.flash('error_msg', 'Error loading create form'); req.flash("error_msg", "Error loading create form");
res.redirect('/admin/blog'); res.redirect("/admin/blog");
} }
}; };
@@ -139,7 +142,7 @@ exports.store = async (req, res) => {
author, author,
galleryImages, galleryImages,
quote, quote,
contentAfterQuote contentAfterQuote,
} = req.body; } = req.body;
// Generate slug // Generate slug
@@ -148,8 +151,8 @@ exports.store = async (req, res) => {
// Check if slug exists // Check if slug exists
const existingBlog = await Blog.findOne({ slug }); const existingBlog = await Blog.findOne({ slug });
if (existingBlog) { if (existingBlog) {
req.flash('error_msg', 'A blog post with this title already exists'); req.flash("error_msg", "A blog post with this title already exists");
return res.redirect('/admin/blog/create'); return res.redirect("/admin/blog/create");
} }
// Create blog data // Create blog data
@@ -158,14 +161,22 @@ exports.store = async (req, res) => {
slug, slug,
excerpt, excerpt,
content, content,
category: category ? (Array.isArray(category) ? category : [category]) : [], // Array categories category: category
? Array.isArray(category)
? category
: [category]
: [], // Array categories
tags: tags ? (Array.isArray(tags) ? tags : [tags]) : [], tags: tags ? (Array.isArray(tags) ? tags : [tags]) : [],
status: status || 'published', status: status || "published",
isFeatured: isFeatured === 'on', isFeatured: isFeatured === "on",
author: author || 'Admin', author: author || "Admin",
galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [], galleryImages: galleryImages
quote: quote || '', ? Array.isArray(galleryImages)
contentAfterQuote: contentAfterQuote || '' ? galleryImages
: [galleryImages]
: [],
quote: quote || "",
contentAfterQuote: contentAfterQuote || "",
}; };
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX) // Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
@@ -177,17 +188,28 @@ exports.store = async (req, res) => {
const blog = new Blog(blogData); const blog = new Blog(blogData);
await blog.save(); await blog.save();
// AUDIT LOGGING - Blog Created
await writeAuditLog({
model: "Blog",
documentId: blog._id,
action: AUDIT_ACTIONS.CREATE_BLOG,
before: null, // No before state for CREATE
after: JSON.parse(JSON.stringify(blog.toObject())),
changes: [], // No changes for CREATE
req,
});
// Update counts // Update counts
await updateCategoryPostCounts(); await updateCategoryPostCounts();
await updateTagPostCounts(); await updateTagPostCounts();
await RecentPost.syncFromBlogs(); await RecentPost.syncFromBlogs();
req.flash('success_msg', 'Blog post created successfully'); req.flash("success_msg", "Blog post created successfully");
res.redirect('/admin/blog'); res.redirect("/admin/blog");
} catch (err) { } catch (err) {
console.error('Blog store error:', err); console.error("Blog store error:", err);
req.flash('error_msg', 'Error creating blog post'); req.flash("error_msg", "Error creating blog post");
res.redirect('/admin/blog/create'); res.redirect("/admin/blog/create");
} }
}; };
@@ -197,8 +219,8 @@ exports.edit = async (req, res) => {
const blog = await Blog.findById(req.params.id); const blog = await Blog.findById(req.params.id);
if (!blog) { if (!blog) {
req.flash('error_msg', 'Blog post not found'); req.flash("error_msg", "Blog post not found");
return res.redirect('/admin/blog'); return res.redirect("/admin/blog");
} }
const categories = await BlogCategory.getActive(); const categories = await BlogCategory.getActive();
@@ -210,21 +232,23 @@ exports.edit = async (req, res) => {
.lean(); .lean();
// Organize comments with replies // Organize comments with replies
const parentComments = allComments.filter(c => !c.parentId); const parentComments = allComments.filter((c) => !c.parentId);
const commentsWithReplies = parentComments.map(parent => { const commentsWithReplies = parentComments.map((parent) => {
const replies = allComments.filter(c => c.parentId && c.parentId.toString() === parent._id.toString()); const replies = allComments.filter(
(c) => c.parentId && c.parentId.toString() === parent._id.toString(),
);
return { return {
...parent, ...parent,
replies: replies replies: replies,
}; };
}); });
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || 'http://localhost:3001'; const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render('admin/blog/edit', { res.render("admin/blog/edit", {
layout: 'layouts/main', layout: "layouts/main",
title: 'Edit Blog Post', title: "Edit Blog Post",
blog, blog,
categories, categories,
tags, tags,
@@ -234,12 +258,12 @@ exports.edit = async (req, res) => {
user: req.session.user, user: req.session.user,
frontendUrl, frontendUrl,
backendUrl, backendUrl,
getFullImageUrl // Truyền helper function vào template getFullImageUrl, // Truyền helper function vào template
}); });
} catch (err) { } catch (err) {
console.error('Blog edit form error:', err); console.error("Blog edit form error:", err);
req.flash('error_msg', 'Error loading blog post'); req.flash("error_msg", "Error loading blog post");
res.redirect('/admin/blog'); res.redirect("/admin/blog");
} }
}; };
@@ -249,10 +273,13 @@ exports.update = async (req, res) => {
const blog = await Blog.findById(req.params.id); const blog = await Blog.findById(req.params.id);
if (!blog) { if (!blog) {
req.flash('error_msg', 'Blog post not found'); req.flash("error_msg", "Blog post not found");
return res.redirect('/admin/blog'); return res.redirect("/admin/blog");
} }
// Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(blog.toObject()));
const { const {
title, title,
excerpt, excerpt,
@@ -264,21 +291,29 @@ exports.update = async (req, res) => {
author, author,
galleryImages, galleryImages,
quote, quote,
contentAfterQuote contentAfterQuote,
} = req.body; } = req.body;
// Update blog data // Update blog data
blog.title = title; blog.title = title;
blog.excerpt = excerpt; blog.excerpt = excerpt;
blog.content = content; blog.content = content;
blog.category = category ? (Array.isArray(category) ? category : [category]) : []; // Array categories blog.category = category
? Array.isArray(category)
? category
: [category]
: []; // Array categories
blog.tags = tags ? (Array.isArray(tags) ? tags : [tags]) : []; blog.tags = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
blog.status = status || 'published'; blog.status = status || "published";
blog.isFeatured = isFeatured === 'on'; blog.isFeatured = isFeatured === "on";
blog.author = author || 'Admin'; blog.author = author || "Admin";
blog.galleryImages = galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : []; blog.galleryImages = galleryImages
blog.quote = quote || ''; ? Array.isArray(galleryImages)
blog.contentAfterQuote = contentAfterQuote || ''; ? galleryImages
: [galleryImages]
: [];
blog.quote = quote || "";
blog.contentAfterQuote = contentAfterQuote || "";
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX) // Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) { if (req.body.featuredImageUrl) {
@@ -288,9 +323,12 @@ exports.update = async (req, res) => {
// Generate new slug if title changed // Generate new slug if title changed
const newSlug = generateSlug(title); const newSlug = generateSlug(title);
if (newSlug !== blog.slug) { if (newSlug !== blog.slug) {
const existingBlog = await Blog.findOne({ slug: newSlug, _id: { $ne: blog._id } }); const existingBlog = await Blog.findOne({
slug: newSlug,
_id: { $ne: blog._id },
});
if (existingBlog) { if (existingBlog) {
req.flash('error_msg', 'A blog post with this title already exists'); req.flash("error_msg", "A blog post with this title already exists");
return res.redirect(`/admin/blog/${blog._id}/edit`); return res.redirect(`/admin/blog/${blog._id}/edit`);
} }
blog.slug = newSlug; blog.slug = newSlug;
@@ -298,16 +336,33 @@ exports.update = async (req, res) => {
await blog.save(); await blog.save();
// Capture AFTER state
const afterData = JSON.parse(JSON.stringify(blog.toObject()));
// AUDIT LOGGING - Blog Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Blog",
documentId: blog._id,
action: AUDIT_ACTIONS.UPDATE_BLOG,
before: beforeData,
after: afterData,
changes,
req,
});
}
// Update counts // Update counts
await updateCategoryPostCounts(); await updateCategoryPostCounts();
await updateTagPostCounts(); await updateTagPostCounts();
await RecentPost.syncFromBlogs(); await RecentPost.syncFromBlogs();
req.flash('success_msg', 'Blog post updated successfully'); req.flash("success_msg", "Blog post updated successfully");
res.redirect('/admin/blog'); res.redirect("/admin/blog");
} catch (err) { } catch (err) {
console.error('Blog update error:', err); console.error("Blog update error:", err);
req.flash('error_msg', 'Error updating blog post'); req.flash("error_msg", "Error updating blog post");
res.redirect(`/admin/blog/${req.params.id}/edit`); res.redirect(`/admin/blog/${req.params.id}/edit`);
} }
}; };
@@ -318,23 +373,37 @@ exports.destroy = async (req, res) => {
const blog = await Blog.findById(req.params.id); const blog = await Blog.findById(req.params.id);
if (!blog) { if (!blog) {
req.flash('error_msg', 'Blog post not found'); req.flash("error_msg", "Blog post not found");
return res.redirect('/admin/blog'); return res.redirect("/admin/blog");
} }
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(blog.toObject()));
await Blog.findByIdAndDelete(req.params.id); await Blog.findByIdAndDelete(req.params.id);
// ✅ AUDIT LOGGING - Blog Deleted
await writeAuditLog({
model: "Blog",
documentId: req.params.id,
action: AUDIT_ACTIONS.DELETE_BLOG,
before: beforeData,
after: null, // No after state for DELETE
changes: [],
req,
});
// Update counts // Update counts
await updateCategoryPostCounts(); await updateCategoryPostCounts();
await updateTagPostCounts(); await updateTagPostCounts();
await RecentPost.syncFromBlogs(); await RecentPost.syncFromBlogs();
req.flash('success_msg', 'Blog post deleted successfully'); req.flash("success_msg", "Blog post deleted successfully");
res.redirect('/admin/blog'); res.redirect("/admin/blog");
} catch (err) { } catch (err) {
console.error('Blog delete error:', err); console.error("Blog delete error:", err);
req.flash('error_msg', 'Error deleting blog post'); req.flash("error_msg", "Error deleting blog post");
res.redirect('/admin/blog'); res.redirect("/admin/blog");
} }
}; };
@@ -348,7 +417,7 @@ exports.api = async (req, res) => {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Build filter // Build filter
const filter = { status: 'published' }; const filter = { status: "published" };
if (req.query.category) { if (req.query.category) {
filter.category = { $in: [req.query.category] }; // Tìm trong array categories filter.category = { $in: [req.query.category] }; // Tìm trong array categories
@@ -360,8 +429,8 @@ exports.api = async (req, res) => {
if (req.query.search) { if (req.query.search) {
filter.$or = [ filter.$or = [
{ title: { $regex: req.query.search, $options: 'i' } }, { title: { $regex: req.query.search, $options: "i" } },
{ excerpt: { $regex: req.query.search, $options: 'i' } } { excerpt: { $regex: req.query.search, $options: "i" } },
]; ];
} }
@@ -375,28 +444,31 @@ exports.api = async (req, res) => {
const totalBlogs = await Blog.countDocuments(filter); const totalBlogs = await Blog.countDocuments(filter);
// Add base URL to images // Add base URL to images
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const baseUrl =
const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl)); process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedBlogs = blogs.map((blog) =>
addBaseUrlToImages(blog, baseUrl),
);
res.json({ res.json({
success: true, success: true,
message: 'Blogs fetched successfully', message: "Blogs fetched successfully",
data: { data: {
blogs: processedBlogs, blogs: processedBlogs,
pagination: { pagination: {
current: page, current: page,
total: Math.ceil(totalBlogs / limit), total: Math.ceil(totalBlogs / limit),
limit, limit,
totalItems: totalBlogs totalItems: totalBlogs,
} },
} },
}); });
} catch (err) { } catch (err) {
console.error('Blog API error:', err); console.error("Blog API error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error loading blogs', message: "Error loading blogs",
error: err.message || 'Error loading blogs' error: err.message || "Error loading blogs",
}); });
} }
}; };
@@ -406,13 +478,13 @@ exports.apiShow = async (req, res) => {
try { try {
const blog = await Blog.findOne({ const blog = await Blog.findOne({
slug: req.params.slug, slug: req.params.slug,
status: 'published' status: "published",
}).lean(); }).lean();
if (!blog) { if (!blog) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Blog post not found' message: "Blog post not found",
}); });
} }
@@ -425,15 +497,15 @@ exports.apiShow = async (req, res) => {
const replies = await BlogComment.getReplies(parentComment._id); const replies = await BlogComment.getReplies(parentComment._id);
return { return {
...parentComment.toObject(), ...parentComment.toObject(),
replies: replies.map(reply => reply.toObject()) replies: replies.map((reply) => reply.toObject()),
}; };
}) }),
); );
// Flatten comments array (parent + replies) // Flatten comments array (parent + replies)
const allComments = commentsWithReplies.flatMap(comment => [ const allComments = commentsWithReplies.flatMap((comment) => [
comment, comment,
...comment.replies ...comment.replies,
]); ]);
// Add comments to blog // Add comments to blog
@@ -442,20 +514,21 @@ exports.apiShow = async (req, res) => {
blog.commentsCount = allComments.length; blog.commentsCount = allComments.length;
// Add base URL to images // Add base URL to images
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedBlog = addBaseUrlToImages(blog, baseUrl); const processedBlog = addBaseUrlToImages(blog, baseUrl);
res.json({ res.json({
success: true, success: true,
message: 'Blog post fetched successfully', message: "Blog post fetched successfully",
data: processedBlog data: processedBlog,
}); });
} catch (err) { } catch (err) {
console.error('Blog show API error:', err); console.error("Blog show API error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error loading blog post', message: "Error loading blog post",
error: err.message || 'Error loading blog post' error: err.message || "Error loading blog post",
}); });
} }
}; };
@@ -463,7 +536,15 @@ exports.apiShow = async (req, res) => {
// Create a comment (no moderation for now: default approved) // Create a comment (no moderation for now: default approved)
exports.apiCreateComment = async (req, res) => { exports.apiCreateComment = async (req, res) => {
try { try {
const { authorName, authorEmail, authorPhone, authorAddress, authorDate, content, parentId } = req.body || {}; const {
authorName,
authorEmail,
authorPhone,
authorAddress,
authorDate,
content,
parentId,
} = req.body || {};
if (!authorName || !String(authorName).trim()) { if (!authorName || !String(authorName).trim()) {
return res.status(400).json({ return res.status(400).json({
@@ -479,7 +560,10 @@ exports.apiCreateComment = async (req, res) => {
}); });
} }
const blog = await Blog.findOne({ slug: req.params.slug, status: "published" }).lean(); const blog = await Blog.findOne({
slug: req.params.slug,
status: "published",
}).lean();
if (!blog) { if (!blog) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
@@ -490,7 +574,10 @@ exports.apiCreateComment = async (req, res) => {
// If replying, ensure parent exists and belongs to same post // If replying, ensure parent exists and belongs to same post
let parentObjectId = null; let parentObjectId = null;
if (parentId) { if (parentId) {
const parent = await BlogComment.findOne({ _id: parentId, postId: blog._id }).lean(); const parent = await BlogComment.findOne({
_id: parentId,
postId: blog._id,
}).lean();
if (!parent) { if (!parent) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -535,25 +622,26 @@ exports.apiFeatured = async (req, res) => {
try { try {
const limit = parseInt(req.query.limit) || 3; const limit = parseInt(req.query.limit) || 3;
const blogs = await Blog.getFeatured() const blogs = await Blog.getFeatured().limit(limit).lean();
.limit(limit)
.lean();
// Add base URL to images // Add base URL to images
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('port')}`; const baseUrl =
const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl)); process.env.BACKEND_URL || `${req.protocol}://${req.get("port")}`;
const processedBlogs = blogs.map((blog) =>
addBaseUrlToImages(blog, baseUrl),
);
res.json({ res.json({
success: true, success: true,
message: 'Featured blogs fetched successfully', message: "Featured blogs fetched successfully",
data: processedBlogs data: processedBlogs,
}); });
} catch (err) { } catch (err) {
console.error('Featured blogs API error:', err); console.error("Featured blogs API error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error loading featured blogs', message: "Error loading featured blogs",
error: err.message || 'Error loading featured blogs' error: err.message || "Error loading featured blogs",
}); });
} }
}; };
@@ -573,20 +661,23 @@ exports.apiRecent = async (req, res) => {
} }
// Add base URL to images // Add base URL to images
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const baseUrl =
const processedPosts = recentPosts.map(post => addBaseUrlToImages(post, baseUrl)); process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedPosts = recentPosts.map((post) =>
addBaseUrlToImages(post, baseUrl),
);
res.json({ res.json({
success: true, success: true,
message: 'Recent blogs fetched successfully', message: "Recent blogs fetched successfully",
data: processedPosts data: processedPosts,
}); });
} catch (err) { } catch (err) {
console.error('Recent blogs API error:', err); console.error("Recent blogs API error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error loading recent blogs', message: "Error loading recent blogs",
error: err.message || 'Error loading recent blogs' error: err.message || "Error loading recent blogs",
}); });
} }
}; };
@@ -594,7 +685,7 @@ exports.apiRecent = async (req, res) => {
// Get categories of a specific blog post // Get categories of a specific blog post
exports.apiCategories = async (req, res) => { exports.apiCategories = async (req, res) => {
try { try {
const mongoose = require('mongoose'); const mongoose = require("mongoose");
let query; let query;
// Check if it's a valid ObjectId // Check if it's a valid ObjectId
@@ -604,35 +695,35 @@ exports.apiCategories = async (req, res) => {
query = { slug: req.params.id }; query = { slug: req.params.id };
} }
query.status = 'published'; query.status = "published";
const blog = await Blog.findOne(query).lean(); const blog = await Blog.findOne(query).lean();
if (!blog) { if (!blog) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Blog post not found' message: "Blog post not found",
}); });
} }
// Get category details // Get category details
const BlogCategory = require('../models/blogCategory'); const BlogCategory = require("../models/blogCategory");
const categories = await BlogCategory.find({ const categories = await BlogCategory.find({
name: { $in: blog.category }, name: { $in: blog.category },
isActive: true isActive: true,
}).lean(); }).lean();
res.json({ res.json({
success: true, success: true,
message: 'Blog categories fetched successfully', message: "Blog categories fetched successfully",
data: categories data: categories,
}); });
} catch (err) { } catch (err) {
console.error('Blog categories API error:', err); console.error("Blog categories API error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error loading blog categories', message: "Error loading blog categories",
error: err.message || 'Error loading blog categories' error: err.message || "Error loading blog categories",
}); });
} }
}; };
@@ -640,7 +731,7 @@ exports.apiCategories = async (req, res) => {
// Get tags of a specific blog post // Get tags of a specific blog post
exports.apiTags = async (req, res) => { exports.apiTags = async (req, res) => {
try { try {
const mongoose = require('mongoose'); const mongoose = require("mongoose");
let query; let query;
// Check if it's a valid ObjectId // Check if it's a valid ObjectId
@@ -650,35 +741,35 @@ exports.apiTags = async (req, res) => {
query = { slug: req.params.id }; query = { slug: req.params.id };
} }
query.status = 'published'; query.status = "published";
const blog = await Blog.findOne(query).lean(); const blog = await Blog.findOne(query).lean();
if (!blog) { if (!blog) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Blog post not found' message: "Blog post not found",
}); });
} }
// Get tag details // Get tag details
const BlogTag = require('../models/blogTag'); const BlogTag = require("../models/blogTag");
const tags = await BlogTag.find({ const tags = await BlogTag.find({
name: { $in: blog.tags }, name: { $in: blog.tags },
isActive: true isActive: true,
}).lean(); }).lean();
res.json({ res.json({
success: true, success: true,
message: 'Blog tags fetched successfully', message: "Blog tags fetched successfully",
data: tags data: tags,
}); });
} catch (err) { } catch (err) {
console.error('Blog tags API error:', err); console.error("Blog tags API error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error loading blog tags', message: "Error loading blog tags",
error: err.message || 'Error loading blog tags' error: err.message || "Error loading blog tags",
}); });
} }
}; };
@@ -694,7 +785,7 @@ exports.approveComment = async (req, res) => {
if (!blog) { if (!blog) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Blog post not found' message: "Blog post not found",
}); });
} }
@@ -702,23 +793,23 @@ exports.approveComment = async (req, res) => {
if (!comment || comment.postId.toString() !== blogId) { if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Comment not found' message: "Comment not found",
}); });
} }
comment.status = 'approved'; comment.status = "approved";
await comment.save(); await comment.save();
res.json({ res.json({
success: true, success: true,
message: 'Comment approved successfully' message: "Comment approved successfully",
}); });
} catch (err) { } catch (err) {
console.error('Approve comment error:', err); console.error("Approve comment error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error approving comment', message: "Error approving comment",
error: err.message || 'Error approving comment' error: err.message || "Error approving comment",
}); });
} }
}; };
@@ -732,7 +823,7 @@ exports.rejectComment = async (req, res) => {
if (!blog) { if (!blog) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Blog post not found' message: "Blog post not found",
}); });
} }
@@ -740,23 +831,23 @@ exports.rejectComment = async (req, res) => {
if (!comment || comment.postId.toString() !== blogId) { if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Comment not found' message: "Comment not found",
}); });
} }
comment.status = 'rejected'; comment.status = "rejected";
await comment.save(); await comment.save();
res.json({ res.json({
success: true, success: true,
message: 'Comment rejected successfully' message: "Comment rejected successfully",
}); });
} catch (err) { } catch (err) {
console.error('Reject comment error:', err); console.error("Reject comment error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error rejecting comment', message: "Error rejecting comment",
error: err.message || 'Error rejecting comment' error: err.message || "Error rejecting comment",
}); });
} }
}; };
@@ -770,7 +861,7 @@ exports.deleteComment = async (req, res) => {
if (!blog) { if (!blog) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Blog post not found' message: "Blog post not found",
}); });
} }
@@ -778,32 +869,31 @@ exports.deleteComment = async (req, res) => {
if (!comment || comment.postId.toString() !== blogId) { if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Comment not found' message: "Comment not found",
}); });
} }
// Delete the comment and all its replies // Delete the comment and all its replies
await BlogComment.deleteMany({ await BlogComment.deleteMany({
$or: [ $or: [{ _id: commentId }, { parentId: commentId }],
{ _id: commentId },
{ parentId: commentId }
]
}); });
// Update blog comment count // Update blog comment count
const remainingComments = await BlogComment.countDocuments({ postId: blogId }); const remainingComments = await BlogComment.countDocuments({
postId: blogId,
});
await Blog.updateOne({ _id: blogId }, { commentsCount: remainingComments }); await Blog.updateOne({ _id: blogId }, { commentsCount: remainingComments });
res.json({ res.json({
success: true, success: true,
message: 'Comment deleted successfully' message: "Comment deleted successfully",
}); });
} catch (err) { } catch (err) {
console.error('Delete comment error:', err); console.error("Delete comment error:", err);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Error deleting comment', message: "Error deleting comment",
error: err.message || 'Error deleting comment' error: err.message || "Error deleting comment",
}); });
} }
}; };

View File

@@ -1,6 +1,9 @@
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const Contact = require("../models/contact"); const Contact = require("../models/contact");
const ContactSubmission = require("../models/contactSubmission"); 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 // Get contact data from MongoDB
const getContactData = async () => { const getContactData = async () => {
@@ -74,7 +77,11 @@ exports.index = async (req, res) => {
heading: "", heading: "",
description: "", description: "",
fields: [], 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; const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/contact/index", { res.render("admin/contact/index", {
@@ -141,6 +150,11 @@ exports.update = async (req, res) => {
// Tìm hoặc tạo contact // Tìm hoặc tạo contact
let contact = await Contact.findOne({ name: "default" }); let contact = await Contact.findOne({ name: "default" });
// ✅ Capture BEFORE state
const beforeData = contact
? JSON.parse(JSON.stringify(contact.toObject()))
: {};
if (!contact) { if (!contact) {
// Tạo mới với default values // Tạo mới với default values
contact = new Contact({ contact = new Contact({
@@ -157,7 +171,11 @@ exports.update = async (req, res) => {
contactCards: (contactCardsData || []).map((card) => ({ contactCards: (contactCardsData || []).map((card) => ({
...card, ...card,
iconType: card.iconType || "", 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 || { map: mapData || {
coordinates: { lat: 0, lng: 0 }, coordinates: { lat: 0, lng: 0 },
@@ -177,7 +195,11 @@ exports.update = async (req, res) => {
heading: "", heading: "",
description: "", description: "",
fields: [], 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 { } else {
@@ -188,7 +210,11 @@ exports.update = async (req, res) => {
contact.contactCards = contactCardsData.map((card) => ({ contact.contactCards = contactCardsData.map((card) => ({
...card, ...card,
iconType: card.iconType || "", 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; if (mapData) contact.map = mapData;
@@ -197,6 +223,23 @@ exports.update = async (req, res) => {
await contact.save(); 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"); req.flash("success_msg", "Contact updated successfully");
res.redirect("/admin/contact"); res.redirect("/admin/contact");
} catch (err) { } catch (err) {
@@ -321,7 +364,7 @@ exports.updateSubmissionStatus = async (req, res) => {
const submission = await ContactSubmission.findByIdAndUpdate( const submission = await ContactSubmission.findByIdAndUpdate(
id, id,
updateData, updateData,
{ new: true } { new: true },
); );
if (!submission) { if (!submission) {

View File

@@ -1,4 +1,7 @@
const Home = require("../models/home"); 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 // Helper to get FAQ data from Home model
const getFaqData = async () => { const getFaqData = async () => {
@@ -9,7 +12,7 @@ const getFaqData = async () => {
subheading: "", subheading: "",
description: "", description: "",
items: [], items: [],
ctaButton: { label: "", href: "" } ctaButton: { label: "", href: "" },
}; };
} }
return home.faq.toObject ? home.faq.toObject() : home.faq; return home.faq.toObject ? home.faq.toObject() : home.faq;
@@ -41,7 +44,7 @@ exports.index = async (req, res) => {
subheading: data.subheading || "", subheading: data.subheading || "",
description: data.description || "", description: data.description || "",
ctaButton: data.ctaButton || { label: "", href: "" }, ctaButton: data.ctaButton || { label: "", href: "" },
items: data.items || [] items: data.items || [],
}; };
const frontendUrl = process.env.FRONTEND_URL; const frontendUrl = process.env.FRONTEND_URL;
@@ -64,12 +67,13 @@ exports.index = async (req, res) => {
// Update FAQ data // Update FAQ data
exports.update = async (req, res) => { exports.update = async (req, res) => {
try { try {
const { heading, subheading, description, ctaLabel, ctaHref, items } = req.body; const { heading, subheading, description, ctaLabel, ctaHref, items } =
req.body;
let parsedItems = []; let parsedItems = [];
if (items) { if (items) {
try { try {
parsedItems = typeof items === 'string' ? JSON.parse(items) : items; parsedItems = typeof items === "string" ? JSON.parse(items) : items;
} catch (e) { } catch (e) {
console.error("Error parsing items JSON:", e); console.error("Error parsing items JSON:", e);
parsedItems = []; parsedItems = [];
@@ -81,22 +85,47 @@ exports.update = async (req, res) => {
home = new Home({}); 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 || "", heading: heading || "",
subheading: subheading || "", subheading: subheading || "",
description: description || "", description: description || "",
ctaButton: { ctaButton: {
label: ctaLabel || "", label: ctaLabel || "",
href: ctaHref || "" href: ctaHref || "",
}, },
items: parsedItems.map(item => ({ items: parsedItems.map((item) => ({
question: item.question || "", question: item.question || "",
answer: item.answer || "" answer: item.answer || "",
})) })),
}; };
home.faq = updatedFaqData;
await home.save(); 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"); req.flash("success_msg", "FAQ section updated successfully");
res.redirect("/admin/home/faq"); res.redirect("/admin/home/faq");
} catch (err) { } catch (err) {
@@ -107,11 +136,19 @@ exports.update = async (req, res) => {
}; };
// Placeholder methods to prevent route crashes if routes are not cleaned up immediately // 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.addFAQ = (req, res) =>
exports.updateFAQItem = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQItem = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); exports.updateFAQItem = (req, res) =>
exports.addFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); exports.deleteFAQItem = (req, res) =>
exports.deleteFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); res.status(404).json({ error: "Endpoint deprecated" });
exports.reorderFAQSection = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); exports.addFAQSection = (req, res) =>
exports.updateSidebarNav = (req, res) => res.status(404).json({ error: "Endpoint deprecated" }); 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,5 +1,8 @@
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const Footer = require("../models/footer"); 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 // GET /api/footer - Public API cho website và CMS load dữ liệu
exports.getFooter = async (req, res) => { exports.getFooter = async (req, res) => {
@@ -114,6 +117,11 @@ exports.update = async (req, res) => {
// Lấy footer hiện tại hoặc tạo mới (giống Header) // Lấy footer hiện tại hoặc tạo mới (giống Header)
let footer = await Footer.findOne(); let footer = await Footer.findOne();
// ✅ Capture BEFORE state
const beforeData = footer
? JSON.parse(JSON.stringify(footer.toObject()))
: {};
if (!footer) { if (!footer) {
console.log("No existing footer found, creating new one"); console.log("No existing footer found, creating new one");
footer = new Footer(updateData); footer = new Footer(updateData);
@@ -125,6 +133,24 @@ exports.update = async (req, res) => {
// Merge với dữ liệu cũ (giống Header) // Merge với dữ liệu cũ (giống Header)
Object.assign(footer, updateData); Object.assign(footer, updateData);
await footer.save(); 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"); console.log("✓ Footer updated successfully");
req.flash("success_msg", "Footer updated successfully"); req.flash("success_msg", "Footer updated successfully");
} }

View File

@@ -1,13 +1,18 @@
const Header = require("../models/header"); const Header = require("../models/header");
const HeaderMenu = require("../models/HeaderMenu"); 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) * Helper function to build a tree structure (Mirroring logic in headerMenuController)
*/ */
const buildTree = (items, parentId = null) => { const buildTree = (items, parentId = null) => {
const branch = []; const branch = [];
const children = items.filter(item => const children = items.filter(
String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null) (item) =>
String(item.parentId) === String(parentId) ||
(item.parentId === null && parentId === null),
); );
for (const child of children) { for (const child of children) {
@@ -57,7 +62,7 @@ exports.index = async (req, res) => {
const items = await HeaderMenu.find().sort({ order: 1 }); const items = await HeaderMenu.find().sort({ order: 1 });
const menuData = { const menuData = {
flat: items, flat: items,
tree: buildTree(items) tree: buildTree(items),
}; };
res.render("admin/header/index", { res.render("admin/header/index", {
@@ -66,7 +71,7 @@ exports.index = async (req, res) => {
user: req.session.user || null, user: req.session.user || null,
data: data, data: data,
activeTab: activeTab, activeTab: activeTab,
menuData: menuData menuData: menuData,
}); });
} catch (error) { } catch (error) {
console.error("Error loading header management:", error); console.error("Error loading header management:", error);
@@ -147,7 +152,8 @@ exports.store = async (req, res) => {
// Admin: Update header // Admin: Update header
exports.update = async (req, res) => { exports.update = async (req, res) => {
try { try {
let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } = req.body; let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } =
req.body;
console.log("=== UPDATE REQUEST RECEIVED ==="); console.log("=== UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2)); console.log("Raw body:", JSON.stringify(req.body, null, 2));
@@ -166,12 +172,39 @@ exports.update = async (req, res) => {
location: parsedData.contactInfo?.location || "", location: parsedData.contactInfo?.location || "",
socialLinks: parsedData.socialLinks || [], socialLinks: parsedData.socialLinks || [],
}; };
console.log("✓ Converted to top object:", top);
} catch (e) { if (logo) {
console.error("✗ Error parsing topbarJson:", e.message); updateData.logo = logoData;
return res.status(400).json({ }
console.log(
"Preparing to update header with data:",
JSON.stringify(updateData, null, 2),
);
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, success: false,
message: "Invalid JSON in topbarJson: " + e.message, message: "Header not found",
});
}
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,
}); });
} }
} }
@@ -231,9 +264,21 @@ exports.update = async (req, res) => {
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 }); // ✅ 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) { if (!updatedHeader) {
console.error("✗ Header not found with ID:", headerId); console.error("✗ Header not found with ID:", headerId);
@@ -242,6 +287,27 @@ exports.update = async (req, res) => {
message: "Header not found", 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({ res.json({
success: true, success: true,
message: "Header updated successfully", message: "Header updated successfully",
@@ -268,7 +334,11 @@ exports.updateStatus = async (req, res) => {
}); });
} }
const header = await Header.findByIdAndUpdate(req.params.id, { status }, { new: true }); const header = await Header.findByIdAndUpdate(
req.params.id,
{ status },
{ new: true },
);
if (!header) { if (!header) {
return res.status(404).json({ return res.status(404).json({
@@ -317,7 +387,9 @@ exports.destroy = async (req, res) => {
// Public API: Get active header // Public API: Get active header
exports.api = async (req, res) => { exports.api = async (req, res) => {
try { try {
const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); const header = await Header.findOne({ status: "active" }).sort({
order: 1,
});
if (!header) { if (!header) {
return res.status(404).json({ return res.status(404).json({
@@ -341,7 +413,9 @@ exports.api = async (req, res) => {
// Public API: Get menu tree structure // Public API: Get menu tree structure
exports.getMenuTreeAPI = async (req, res) => { exports.getMenuTreeAPI = async (req, res) => {
try { try {
const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); const header = await Header.findOne({ status: "active" }).sort({
order: 1,
});
if (!header || !header.menu) { if (!header || !header.menu) {
return res.status(404).json({ return res.status(404).json({

View File

@@ -1,6 +1,9 @@
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const Home = require("../models/home"); const Home = require("../models/home");
const Blog = require("../models/blog"); 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ợ // Các hàm hỗ trợ
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 }); const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
@@ -30,10 +33,28 @@ const getDefaultHomeData = () => ({
ctaButton: {}, ctaButton: {},
}, },
visaSolutions: { heading: "", subheading: "", items: [] }, visaSolutions: { heading: "", subheading: "", items: [] },
visaCountries: { heading: "", subheading: "", description: "", countries: [], ctaButton: {} }, visaCountries: {
testimonials: { heading: "", subheading: "", videoUrl: "", videoThumbnail: "", items: [] }, heading: "",
subheading: "",
description: "",
countries: [],
ctaButton: {},
},
testimonials: {
heading: "",
subheading: "",
videoUrl: "",
videoThumbnail: "",
items: [],
},
videoGallery: { heading: "", videoUrl: "", thumbnail: "" }, videoGallery: { heading: "", videoUrl: "", thumbnail: "" },
faq: { heading: "", subheading: "", description: "", ctaButton: {}, items: [] }, faq: {
heading: "",
subheading: "",
description: "",
ctaButton: {},
items: [],
},
achievements: { heading: "", subheading: "", items: [] }, achievements: { heading: "", subheading: "", items: [] },
partners: { visaConsultancy: { items: [] }, brands: { items: [] } }, partners: { visaConsultancy: { items: [] }, brands: { items: [] } },
blogPreview: { blogPreview: {
@@ -41,7 +62,7 @@ const getDefaultHomeData = () => ({
subheading: "Visa Tips & Guides", subheading: "Visa Tips & Guides",
ctaButton: { label: "View All Articles", href: "/blog" }, ctaButton: { label: "View All Articles", href: "/blog" },
items: [], 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 // Merge dữ liệu mặc định cho tất cả các phần
const sections = Object.keys(defaults); const sections = Object.keys(defaults);
sections.forEach(s => { sections.forEach((s) => {
data[s] = data[s] || defaults[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"; const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
// Lấy tất cả blog để chọn trong CMS // 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", { return res.render("admin/home/index", {
layout: "layouts/main", layout: "layouts/main",
@@ -85,17 +108,28 @@ exports.index = async (req, res) => {
exports.update = async (req, res) => { exports.update = async (req, res) => {
try { try {
const sections = [ const sections = [
"hero", "whyChooseUs", "visaSolutions", "visaCountries", "hero",
"testimonials", "videoGallery", "faq", "achievements", "whyChooseUs",
"partners", "blogPreview" "visaSolutions",
"visaCountries",
"testimonials",
"videoGallery",
"faq",
"achievements",
"partners",
"blogPreview",
]; ];
let doc = await getHomeDoc(); let doc = await getHomeDoc();
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
if (!doc) { if (!doc) {
doc = new Home({}); doc = new Home({});
} }
let hasChanges = false; let hasChanges = false;
const updatedSections = [];
for (const section of sections) { for (const section of sections) {
if (req.body[section]) { if (req.body[section]) {
try { try {
@@ -104,6 +138,7 @@ exports.update = async (req, res) => {
doc[section] = payload; doc[section] = payload;
doc.markModified(section); doc.markModified(section);
hasChanges = true; hasChanges = true;
updatedSections.push(section);
} catch (e) { } catch (e) {
console.error(`Invalid JSON for ${section}:`, e); console.error(`Invalid JSON for ${section}:`, e);
} }
@@ -116,6 +151,22 @@ exports.update = async (req, res) => {
} }
await doc.save(); 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!"); req.flash("success_msg", "Home page configuration has been updated!");
return req.session.save(() => res.redirect("/admin/home")); return req.session.save(() => res.redirect("/admin/home"));
} catch (err) { } catch (err) {
@@ -128,7 +179,10 @@ exports.update = async (req, res) => {
// Public API// API lấy danh sách blog cho CMS // Public API// API lấy danh sách blog cho CMS
exports.apiGetBlogs = async (req, res) => { exports.apiGetBlogs = async (req, res) => {
try { 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); res.json(blogs);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -137,7 +191,8 @@ exports.apiGetBlogs = async (req, res) => {
exports.api = async (req, res) => { exports.api = async (req, res) => {
try { try {
let data = await getHomeData(); 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 === // === Xử lý Blog Preview động ===
const blogPreview = data.blogPreview || {}; const blogPreview = data.blogPreview || {};
@@ -147,12 +202,15 @@ exports.api = async (req, res) => {
if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) { if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) {
blogs = await Blog.find({ blogs = await Blog.find({
_id: { $in: blogPreview.selectedBlogIds }, _id: { $in: blogPreview.selectedBlogIds },
status: "published" status: "published",
}).lean(); }).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds // Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => { 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 // Map dữ liệu blog sang format mà frontend mong đợi
blogPreview.items = blogs.map(blog => ({ blogPreview.items = blogs.map((blog) => ({
title: blog.title, title: blog.title,
excerpt: blog.excerpt, excerpt: blog.excerpt,
category: blog.category && blog.category[0] ? blog.category[0] : "Visa", category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
date: blog.publishedAt || blog.createdAt, date: blog.publishedAt || blog.createdAt,
author: { author: {
name: blog.author || "Admin", 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, comments: blog.commentsCount || 0,
link: `/blog/${blog.slug}`, link: `/blog/${blog.slug}`,
thumbnail: blog.featuredImage thumbnail: blog.featuredImage,
})); }));
data.blogPreview = blogPreview; data.blogPreview = blogPreview;
@@ -189,4 +247,3 @@ exports.api = async (req, res) => {
return res.status(500).json({ error: "Error loading home data" }); return res.status(500).json({ error: "Error loading home data" });
} }
}; };

View File

@@ -1,5 +1,8 @@
const Insurance = require("../models/insurance"); const Insurance = require("../models/insurance");
const { addBaseUrlToImages } = require("../utils/imageHelper"); 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) // API để lấy insurance data (cho frontend)
exports.api = async (req, res) => { exports.api = async (req, res) => {
@@ -13,22 +16,22 @@ exports.api = async (req, res) => {
const insuranceData = insurance.toObject(); const insuranceData = insurance.toObject();
// Sử dụng helper để thêm base URL vào đường dẫn ảnh // 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); const processedData = addBaseUrlToImages(insuranceData, baseUrl);
// Trả về trực tiếp hero, page, content (không wrap trong object) // Trả về trực tiếp hero, page, content (không wrap trong object)
res.json({ res.json({
hero: processedData.hero, hero: processedData.hero,
page: processedData.page, page: processedData.page,
content: processedData.content content: processedData.content,
}); });
} catch (error) { } catch (error) {
console.error("API Error:", error); console.error("API Error:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Error loading insurance data", 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) => { exports.getInsuranceData = async (req, res) => {
try { try {
const language = req.query.lang || "en"; 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) { if (!insurance) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: "Insurance data not found" error: "Insurance data not found",
}); });
} }
const insuranceData = insurance.toObject(); const insuranceData = insurance.toObject();
// Thêm base URL vào đường dẫn ảnh // 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); const processedData = addBaseUrlToImages(insuranceData, baseUrl);
res.json({ res.json({
success: true, success: true,
data: processedData data: processedData,
}); });
} catch (error) { } catch (error) {
console.error("Error getting insurance data:", error); console.error("Error getting insurance data:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Error loading insurance data" error: "Error loading insurance data",
}); });
} }
}; };
@@ -71,19 +77,23 @@ exports.getByLanguage = async (req, res) => {
try { try {
const language = req.params.lang || "en"; 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) { if (!insurance) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: "Insurance data not found" error: "Insurance data not found",
}); });
} }
const insuranceData = insurance.toObject(); const insuranceData = insurance.toObject();
// Thêm base URL vào đường dẫn ảnh // 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); const processedData = addBaseUrlToImages(insuranceData, baseUrl);
res.json({ res.json({
@@ -91,15 +101,14 @@ exports.getByLanguage = async (req, res) => {
data: { data: {
hero: processedData.hero, hero: processedData.hero,
page: processedData.page, page: processedData.page,
content: processedData.content content: processedData.content,
} },
}); });
} catch (error) { } catch (error) {
console.error("Error getting insurance by language:", error); console.error("Error getting insurance by language:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Error loading insurance data" error: "Error loading insurance data",
}); });
} }
}; };
@@ -119,9 +128,8 @@ exports.index = async (req, res) => {
data, data,
frontendUrl, frontendUrl,
currentPath: req.path, currentPath: req.path,
user: req.session.user user: req.session.user,
}); });
} catch (error) { } catch (error) {
console.error("Error in insurance index:", error); console.error("Error in insurance index:", error);
req.flash("error_msg", "An error occurred while loading the page"); req.flash("error_msg", "An error occurred while loading the page");
@@ -132,14 +140,14 @@ exports.index = async (req, res) => {
// Seed data từ JSON file (cấu trúc mới) // Seed data từ JSON file (cấu trúc mới)
exports.seed = async (req, res) => { exports.seed = async (req, res) => {
try { try {
const fs = require('fs').promises; const fs = require("fs").promises;
const path = require('path'); const path = require("path");
// Đọc file JSON // Đọc file JSON
const jsonPath = path.join(__dirname, '../data/insurance.json'); const jsonPath = path.join(__dirname, "../data/insurance.json");
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8')); const jsonData = JSON.parse(await fs.readFile(jsonPath, "utf8"));
console.log('Seeding insurance from JSON...'); console.log("Seeding insurance from JSON...");
// Migrate từ cấu trúc cũ sang mới // Migrate từ cấu trúc cũ sang mới
const insurance = await Insurance.migrateFromJson(jsonData, "en"); const insurance = await Insurance.migrateFromJson(jsonData, "en");
@@ -151,15 +159,14 @@ exports.seed = async (req, res) => {
id: insurance._id, id: insurance._id,
hero: insurance.hero, hero: insurance.hero,
page: insurance.page, page: insurance.page,
content: insurance.content content: insurance.content,
} },
}); });
} catch (error) { } catch (error) {
console.error("Error seeding insurance:", error); console.error("Error seeding insurance:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message || "Error seeding insurance data" error: error.message || "Error seeding insurance data",
}); });
} }
}; };
@@ -188,7 +195,8 @@ exports.preview = async (req, res) => {
const contentData = parseJson(content) || {}; const contentData = parseJson(content) || {};
// Thêm base URL vào đường dẫn ảnh cho preview // 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); const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
// Render preview HTML // Render preview HTML
@@ -198,13 +206,13 @@ exports.preview = async (req, res) => {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style> <style>
body { font-family: Arial, sans-serif; } body { font-family: Arial, sans-serif; }
.hero-section { .hero-section {
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)), 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-size: cover;
background-position: center; background-position: center;
color: white; color: white;
@@ -226,15 +234,15 @@ exports.preview = async (req, res) => {
<body> <body>
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero-section"> <div class="hero-section">
<h1>${heroData.title || 'Insurance'}</h1> <h1>${heroData.title || "Insurance"}</h1>
<p>${heroData.subtitle || ''}</p> <p>${heroData.subtitle || ""}</p>
</div> </div>
<!-- Page Header --> <!-- Page Header -->
<div class="page-header"> <div class="page-header">
<div class="container"> <div class="container">
<h2>${pageData.title || 'Insurance Information'}</h2> <h2>${pageData.title || "Insurance Information"}</h2>
${pageData.divider !== false ? '<hr>' : ''} ${pageData.divider !== false ? "<hr>" : ""}
</div> </div>
</div> </div>
@@ -249,7 +257,6 @@ exports.preview = async (req, res) => {
`; `;
res.send(html); res.send(html);
} catch (error) { } catch (error) {
console.error("Error generating preview:", error); console.error("Error generating preview:", error);
res.status(500).send("Error generating preview"); res.status(500).send("Error generating preview");
@@ -259,18 +266,19 @@ exports.preview = async (req, res) => {
// Helper function để render content items // Helper function để render content items
function renderContentItems(contentItems) { function renderContentItems(contentItems) {
if (!Array.isArray(contentItems) || contentItems.length === 0) { if (!Array.isArray(contentItems) || contentItems.length === 0) {
return '<p>No content available.</p>'; return "<p>No content available.</p>";
} }
return contentItems.map(item => { return contentItems
.map((item) => {
switch (item.type) { switch (item.type) {
case 'header': case "header":
return `<h${item.level || 2} class="content-item">${item.text}</h${item.level || 2}>`; return `<h${item.level || 2} class="content-item">${item.text}</h${item.level || 2}>`;
case 'paragraph': case "paragraph":
return `<p class="content-item">${item.text}</p>`; return `<p class="content-item">${item.text}</p>`;
case 'section': case "section":
return ` return `
<div class="content-item"> <div class="content-item">
<h3>${item.title}</h3> <h3>${item.title}</h3>
@@ -278,30 +286,33 @@ function renderContentItems(contentItems) {
</div> </div>
`; `;
case 'list': case "list":
const listItems = (item.items || []).map(li => `<li>${li}</li>`).join(''); const listItems = (item.items || [])
.map((li) => `<li>${li}</li>`)
.join("");
return `<ul class="content-item">${listItems}</ul>`; return `<ul class="content-item">${listItems}</ul>`;
case 'note': case "note":
return `<div class="alert alert-info content-item">${item.text}</div>`; return `<div class="alert alert-info content-item">${item.text}</div>`;
case 'embed': case "embed":
if (item.source === 'youtube') { if (item.source === "youtube") {
return ` return `
<div class="content-item"> <div class="content-item">
<iframe width="${item.width || 560}" height="${item.height || 315}" <iframe width="${item.width || 560}" height="${item.height || 315}"
src="${item.url || item.embed}" src="${item.url || item.embed}"
frameborder="0" allowfullscreen></iframe> 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> </div>
`; `;
} }
return ''; return "";
default: default:
return ''; return "";
} }
}).join(''); })
.join("");
} }
// API để tạo insurance mới (cho các ngôn ngữ khác) // API để tạo insurance mới (cho các ngôn ngữ khác)
@@ -312,16 +323,19 @@ exports.create = async (req, res) => {
if (!language) { if (!language) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Language is required" error: "Language is required",
}); });
} }
// Kiểm tra đã tồn tại chưa // 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) { if (existing) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Insurance already exists for this language" error: "Insurance already exists for this language",
}); });
} }
@@ -347,7 +361,7 @@ exports.create = async (req, res) => {
content: parseJson(content) || {}, content: parseJson(content) || {},
version: "2.0.0", version: "2.0.0",
isActive: true, isActive: true,
migratedFromOldStructure: false migratedFromOldStructure: false,
}); });
await insurance.save(); await insurance.save();
@@ -355,14 +369,13 @@ exports.create = async (req, res) => {
res.json({ res.json({
success: true, success: true,
message: "Insurance created successfully for language: " + language, message: "Insurance created successfully for language: " + language,
data: insurance data: insurance,
}); });
} catch (error) { } catch (error) {
console.error("Error creating insurance:", error); console.error("Error creating insurance:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message || "Error creating insurance" error: error.message || "Error creating insurance",
}); });
} }
}; };
@@ -393,22 +406,23 @@ exports.update = async (req, res) => {
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs) // Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
function extractYouTubeId(url) { 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); const match = url.match(regex);
return match ? match[1] : null; return match ? match[1] : null;
} }
if (contentData && Array.isArray(contentData.content)) { if (contentData && Array.isArray(contentData.content)) {
contentData.content.forEach(item => { contentData.content.forEach((item) => {
if (item.type === 'embed' && item.source === 'youtube') { if (item.type === "embed" && item.source === "youtube") {
if (item.url && item.url.includes('watch?v=')) { if (item.url && item.url.includes("watch?v=")) {
const videoId = extractYouTubeId(item.url); const videoId = extractYouTubeId(item.url);
if (videoId) { if (videoId) {
item.url = `https://www.youtube.com/embed/${videoId}`; item.url = `https://www.youtube.com/embed/${videoId}`;
item.videoId = 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); const videoId = extractYouTubeId(item.embed);
if (videoId) { if (videoId) {
item.embed = `https://www.youtube.com/embed/${videoId}`; item.embed = `https://www.youtube.com/embed/${videoId}`;
@@ -420,7 +434,17 @@ exports.update = async (req, res) => {
} }
// Tìm hoặc tạo insurance // 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) { if (!insurance) {
insurance = new Insurance({ insurance = new Insurance({
@@ -430,7 +454,7 @@ exports.update = async (req, res) => {
page: pageData, page: pageData,
content: contentData, content: contentData,
version: "2.0.0", version: "2.0.0",
isActive: true isActive: true,
}); });
} else { } else {
insurance.hero = heroData; insurance.hero = heroData;
@@ -441,9 +465,27 @@ exports.update = async (req, res) => {
await insurance.save(); 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"); req.flash("success_msg", "Insurance updated successfully");
res.redirect("/admin/insurance"); res.redirect("/admin/insurance");
} catch (err) { } catch (err) {
console.error("Error updating insurance:", err); console.error("Error updating insurance:", err);
req.flash("error_msg", err.message || "Error updating insurance"); req.flash("error_msg", err.message || "Error updating insurance");
@@ -459,7 +501,7 @@ exports.delete = async (req, res) => {
if (!language) { if (!language) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Language parameter is required" error: "Language parameter is required",
}); });
} }
@@ -467,29 +509,31 @@ exports.delete = async (req, res) => {
if (language === "en") { if (language === "en") {
return res.status(400).json({ return res.status(400).json({
success: false, 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) { if (result.deletedCount === 0) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: "Insurance not found for this language" error: "Insurance not found for this language",
}); });
} }
res.json({ res.json({
success: true, success: true,
message: "Insurance deleted successfully for language: " + language message: "Insurance deleted successfully for language: " + language,
}); });
} catch (error) { } catch (error) {
console.error("Error deleting insurance:", error); console.error("Error deleting insurance:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message || "Error deleting insurance" error: error.message || "Error deleting insurance",
}); });
} }
}; };

View File

@@ -1,4 +1,7 @@
const Pricing = require("../models/pricing"); const Pricing = require("../models/pricing");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// ==================== CMS ADMIN FUNCTIONS ==================== // ==================== CMS ADMIN FUNCTIONS ====================
@@ -32,7 +35,8 @@ exports.index = async (req, res) => {
pricingSection: { pricingSection: {
subtitle: "pricing plan", subtitle: "pricing plan",
heading: "Flexible Plans to Suit Every Traveler", 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.", description:
"Choose the plan that fits your visa needs and enjoy expert guidance every step of the way.",
}, },
plans: { plans: {
monthly: [], monthly: [],
@@ -72,12 +76,23 @@ exports.update = async (req, res) => {
// Parse JSON strings if needed // Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero; const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const pricingSectionData = typeof pricingSection === "string" ? JSON.parse(pricingSection) : pricingSection; const pricingSectionData =
typeof pricingSection === "string"
? JSON.parse(pricingSection)
: pricingSection;
const plansData = typeof plans === "string" ? JSON.parse(plans) : plans; const plansData = typeof plans === "string" ? JSON.parse(plans) : plans;
const testimonialsData = typeof testimonials === "string" ? JSON.parse(testimonials) : testimonials; const testimonialsData =
typeof testimonials === "string"
? JSON.parse(testimonials)
: testimonials;
let pricing = await Pricing.findOne({ name: "default" }); let pricing = await Pricing.findOne({ name: "default" });
// ✅ Capture BEFORE state
const beforeData = pricing
? JSON.parse(JSON.stringify(pricing.toObject()))
: {};
if (pricing) { if (pricing) {
pricing.hero = heroData; pricing.hero = heroData;
pricing.pricingSection = pricingSectionData; pricing.pricingSection = pricingSectionData;
@@ -94,6 +109,23 @@ exports.update = async (req, res) => {
}); });
} }
// ✅ 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"); req.flash("success", "Pricing data updated successfully");
res.redirect("/admin/pricing"); res.redirect("/admin/pricing");
} catch (err) { } catch (err) {
@@ -166,12 +198,16 @@ exports.api = async (req, res) => {
const pricingData = pricing.toObject ? pricing.toObject() : pricing; const pricingData = pricing.toObject ? pricing.toObject() : pricing;
if (pricingData.hero) { if (pricingData.hero) {
pricingData.hero.backgroundImage = getFullUrl(pricingData.hero.backgroundImage); pricingData.hero.backgroundImage = getFullUrl(
pricingData.hero.backgroundImage,
);
pricingData.hero.shapeImage = getFullUrl(pricingData.hero.shapeImage); pricingData.hero.shapeImage = getFullUrl(pricingData.hero.shapeImage);
} }
if (pricingData.testimonials) { if (pricingData.testimonials) {
pricingData.testimonials.image = getFullUrl(pricingData.testimonials.image); pricingData.testimonials.image = getFullUrl(
pricingData.testimonials.image,
);
} }
res.json({ res.json({

View File

@@ -1,5 +1,8 @@
const Safety = require("../models/safety"); const Safety = require("../models/safety");
const { addBaseUrlToImages } = require("../utils/imageHelper"); 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 // Lấy dữ liệu Safety từ MongoDB
const getSafetyData = async () => { const getSafetyData = async () => {
@@ -17,7 +20,8 @@ exports.api = async (req, res) => {
if (!safety) { if (!safety) {
return res.status(404).json({ error: "Safety data not found" }); return res.status(404).json({ error: "Safety data not found" });
} }
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(safety, baseUrl); const processedData = addBaseUrlToImages(safety, baseUrl);
res.json(processedData); res.json(processedData);
} catch (err) { } catch (err) {
@@ -32,7 +36,11 @@ exports.index = async (req, res) => {
const items = await Safety.find().sort({ updatedAt: -1 }).limit(10); 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 // 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 latest = items && items.length > 0 ? items[0] : null;
const data = latest ? (latest.toObject ? latest.toObject() : latest) : { const data = latest
? latest.toObject
? latest.toObject()
: latest
: {
hero: { title: "", banner: "" }, hero: { title: "", banner: "" },
approach: {}, approach: {},
approachImgs: [], approachImgs: [],
@@ -42,14 +50,15 @@ exports.index = async (req, res) => {
philosophy: {}, philosophy: {},
philosophyCards: [], philosophyCards: [],
security: {}, security: {},
securityCards: [] securityCards: [],
}; };
res.render("admin/safety/index", { res.render("admin/safety/index", {
layout: "layouts/main", layout: "layouts/main",
title: "Safety Management", title: "Safety Management",
items, items,
data, data,
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"), frontendUrl:
process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
currentPath: req.path, currentPath: req.path,
user: req.session.user, user: req.session.user,
}); });
@@ -118,13 +127,18 @@ exports.update = async (req, res) => {
const items = await Safety.find().sort({ updatedAt: -1 }).limit(1); const items = await Safety.find().sort({ updatedAt: -1 }).limit(1);
let safety = items && items.length > 0 ? items[0] : null; let safety = items && items.length > 0 ? items[0] : null;
// ✅ Capture BEFORE state
const beforeData = safety
? JSON.parse(JSON.stringify(safety.toObject ? safety.toObject() : safety))
: {};
if (!safety) { if (!safety) {
// Tạo mới // Tạo mới
safety = new Safety({ safety = new Safety({
hero: heroData || { title: "", banner: "" }, hero: heroData || { title: "", banner: "" },
approach: approachData || {}, approach: approachData || {},
philosophy: philosophyData || {}, philosophy: philosophyData || {},
security: securityData || {} security: securityData || {},
}); });
} else { } else {
// Cập nhật // Cập nhật
@@ -136,6 +150,25 @@ exports.update = async (req, res) => {
await safety.save(); 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"); req.flash("success_msg", "Safety updated successfully");
res.redirect("/admin/safety"); res.redirect("/admin/safety");
} catch (err) { } catch (err) {

View File

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

View File

@@ -1,6 +1,9 @@
// controllers/termsController.js // controllers/termsController.js
const Terms = require("../models/terms"); const Terms = require("../models/terms");
const { addBaseUrlToImages } = require("../utils/imageHelper"); // Import helper 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) // API để lấy terms data (cho frontend)
exports.api = async (req, res) => { exports.api = async (req, res) => {
@@ -15,7 +18,8 @@ exports.api = async (req, res) => {
// Sử dụng helper để thêm base URL vào đường dẫn ảnh // Sử dụng helper để thêm base URL vào đường dẫn ảnh
// Truyền baseUrl từ request hoặc từ environment // 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); const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({ res.json({
@@ -23,16 +27,15 @@ exports.api = async (req, res) => {
data: { data: {
hero: processedData.hero, hero: processedData.hero,
page: processedData.page, page: processedData.page,
content: processedData.content content: processedData.content,
} },
}); });
} catch (error) { } catch (error) {
console.error("API Error:", error); console.error("API Error:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Error loading terms data", error: "Error loading terms data",
message: error.message message: error.message,
}); });
} }
}; };
@@ -46,26 +49,26 @@ exports.getTermsData = async (req, res) => {
if (!terms) { if (!terms) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: "Terms data not found" error: "Terms data not found",
}); });
} }
const termsData = terms.toObject(); const termsData = terms.toObject();
// Thêm base URL vào đường dẫn ảnh // 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); const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({ res.json({
success: true, success: true,
data: processedData data: processedData,
}); });
} catch (error) { } catch (error) {
console.error("Error getting terms data:", error); console.error("Error getting terms data:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Error loading terms data" error: "Error loading terms data",
}); });
} }
}; };
@@ -80,14 +83,15 @@ exports.getByLanguage = async (req, res) => {
if (!terms) { if (!terms) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: "Terms data not found for language: " + language error: "Terms data not found for language: " + language,
}); });
} }
const termsData = terms.toObject(); const termsData = terms.toObject();
// Thêm base URL vào đường dẫn ảnh // 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); const processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({ res.json({
@@ -95,15 +99,14 @@ exports.getByLanguage = async (req, res) => {
data: { data: {
hero: processedData.hero, hero: processedData.hero,
page: processedData.page, page: processedData.page,
content: processedData.content content: processedData.content,
} },
}); });
} catch (error) { } catch (error) {
console.error("Error getting terms by language:", error); console.error("Error getting terms by language:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Error loading terms data" error: "Error loading terms data",
}); });
} }
}; };
@@ -123,9 +126,8 @@ exports.index = async (req, res) => {
data, // Không cần addBaseUrlToImages cho admin view data, // Không cần addBaseUrlToImages cho admin view
frontendUrl, frontendUrl,
currentPath: req.path, currentPath: req.path,
user: req.session.user user: req.session.user,
}); });
} catch (error) { } catch (error) {
console.error("Error in terms index:", error); console.error("Error in terms index:", error);
req.flash("error_msg", "An error occurred while loading the page"); req.flash("error_msg", "An error occurred while loading the page");
@@ -159,20 +161,22 @@ exports.update = async (req, res) => {
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs) // Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
function extractYouTubeId(url) { function extractYouTubeId(url) {
if (!url || typeof url !== 'string') return null; if (!url || typeof url !== "string") return null;
// common YouTube URL patterns // 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; return m ? m[1] : null;
} }
// Trong exports.update // Trong exports.update
if (contentData && Array.isArray(contentData.content)) { if (contentData && Array.isArray(contentData.content)) {
contentData.content = contentData.content.map(item => { contentData.content = contentData.content.map((item) => {
if (item && item.type === 'embed') { if (item && item.type === "embed") {
let embedUrl = item.embed || item.url || item.source || ''; let embedUrl = item.embed || item.url || item.source || "";
// Luôn chuyển đổi sang embed URL nếu là watch URL // Luôn chuyển đổi sang embed URL nếu là watch URL
if (embedUrl.includes('youtube.com/watch')) { if (embedUrl.includes("youtube.com/watch")) {
const videoId = extractYouTubeId(embedUrl); const videoId = extractYouTubeId(embedUrl);
if (videoId) { if (videoId) {
item.embed = `https://www.youtube.com/embed/${videoId}`; item.embed = `https://www.youtube.com/embed/${videoId}`;
@@ -189,11 +193,16 @@ if (contentData && Array.isArray(contentData.content)) {
} }
return item; return item;
}); });
} }
// Tìm hoặc tạo terms // Tìm hoặc tạo terms
let terms = await Terms.findOne({ name: "default", language: "en" }); 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) { if (!terms) {
// Tạo mới với cấu trúc mới // Tạo mới với cấu trúc mới
terms = new Terms({ terms = new Terms({
@@ -204,7 +213,7 @@ if (contentData && Array.isArray(contentData.content)) {
content: contentData, content: contentData,
version: "2.0.0", version: "2.0.0",
isActive: true, isActive: true,
migratedFromOldStructure: false migratedFromOldStructure: false,
}); });
} else { } else {
// Update existing với cấu trúc mới // Update existing với cấu trúc mới
@@ -218,9 +227,27 @@ if (contentData && Array.isArray(contentData.content)) {
await terms.save(); 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"); req.flash("success_msg", "Terms & Conditions updated successfully");
res.redirect("/admin/terms-conditions"); res.redirect("/admin/terms-conditions");
} catch (err) { } catch (err) {
console.error("Error updating terms:", err); console.error("Error updating terms:", err);
req.flash("error_msg", err.message || "Error updating terms"); req.flash("error_msg", err.message || "Error updating terms");
@@ -231,25 +258,25 @@ if (contentData && Array.isArray(contentData.content)) {
// Seed data từ JSON file mới (cấu trúc mới) // Seed data từ JSON file mới (cấu trúc mới)
exports.seed = async (req, res) => { exports.seed = async (req, res) => {
try { try {
const fs = require('fs').promises; const fs = require("fs").promises;
const path = require('path'); const path = require("path");
// Đọc file JSON // Đọc file JSON
const jsonPath = path.join(__dirname, '../data/terms-conditions.json'); const jsonPath = path.join(__dirname, "../data/terms-conditions.json");
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8')); const jsonData = JSON.parse(await fs.readFile(jsonPath, "utf8"));
console.log('Seeding from JSON...'); console.log("Seeding from JSON...");
console.log('JSON structure keys:', Object.keys(jsonData)); console.log("JSON structure keys:", Object.keys(jsonData));
// Kiểm tra cấu trúc JSON // Kiểm tra cấu trúc JSON
let terms; let terms;
if (jsonData.hero && jsonData.page && jsonData.content) { if (jsonData.hero && jsonData.page && jsonData.content) {
// Cấu trúc mới // 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"); terms = await Terms.migrateFromNewJson(jsonData, "en");
} else if (jsonData.hero && jsonData.termsHeader && jsonData.sections) { } else if (jsonData.hero && jsonData.termsHeader && jsonData.sections) {
// Cấu trúc cũ // 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"); terms = await Terms.migrateFromJson(jsonData, "en");
} else { } else {
throw new Error("Unknown JSON structure"); throw new Error("Unknown JSON structure");
@@ -262,15 +289,14 @@ exports.seed = async (req, res) => {
id: terms._id, id: terms._id,
hero: terms.hero, hero: terms.hero,
page: terms.page, page: terms.page,
content: terms.content content: terms.content,
} },
}); });
} catch (error) { } catch (error) {
console.error("Error seeding terms:", error); console.error("Error seeding terms:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message || "Error seeding terms data" error: error.message || "Error seeding terms data",
}); });
} }
}; };
@@ -299,7 +325,8 @@ exports.preview = async (req, res) => {
const contentData = parseJson(content) || {}; const contentData = parseJson(content) || {};
// Thêm base URL vào đường dẫn ảnh cho preview // 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); const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
// Render preview HTML // Render preview HTML
@@ -309,13 +336,13 @@ exports.preview = async (req, res) => {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style> <style>
body { font-family: Arial, sans-serif; } body { font-family: Arial, sans-serif; }
.hero-section { .hero-section {
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)), 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-size: cover;
background-position: center; background-position: center;
color: white; color: white;
@@ -337,14 +364,14 @@ exports.preview = async (req, res) => {
<body> <body>
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero-section"> <div class="hero-section">
<h1>${heroData.title || 'Terms & Conditions'}</h1> <h1>${heroData.title || "Terms & Conditions"}</h1>
</div> </div>
<!-- Page Header --> <!-- Page Header -->
<div class="page-header"> <div class="page-header">
<div class="container"> <div class="container">
<h2>${pageData.title || 'Terms & Conditions'}</h2> <h2>${pageData.title || "Terms & Conditions"}</h2>
${pageData.divider !== false ? '<hr>' : ''} ${pageData.divider !== false ? "<hr>" : ""}
</div> </div>
</div> </div>
@@ -359,7 +386,6 @@ exports.preview = async (req, res) => {
`; `;
res.send(html); res.send(html);
} catch (error) { } catch (error) {
console.error("Error generating preview:", error); console.error("Error generating preview:", error);
res.status(500).send("Error generating preview"); res.status(500).send("Error generating preview");
@@ -369,41 +395,42 @@ exports.preview = async (req, res) => {
// Helper function để render content items // Helper function để render content items
function renderContentItems(contentItems) { function renderContentItems(contentItems) {
if (!Array.isArray(contentItems) || contentItems.length === 0) { if (!Array.isArray(contentItems) || contentItems.length === 0) {
return '<p>No content available.</p>'; return "<p>No content available.</p>";
} }
return contentItems.map(item => { return contentItems
.map((item) => {
switch (item.type) { switch (item.type) {
case 'paragraph': case "paragraph":
return `<div class="content-item"><p>${item.text || ''}</p></div>`; return `<div class="content-item"><p>${item.text || ""}</p></div>`;
case 'section': case "section":
let html = `<div class="content-item">`; let html = `<div class="content-item">`;
html += `<h3>${item.title || ''}</h3>`; html += `<h3>${item.title || ""}</h3>`;
html += `<p>${item.content || ''}</p>`; html += `<p>${item.content || ""}</p>`;
if (item.subsections && item.subsections.length > 0) { if (item.subsections && item.subsections.length > 0) {
item.subsections.forEach(subsection => { item.subsections.forEach((subsection) => {
if (subsection.type === 'cancellation_table') { if (subsection.type === "cancellation_table") {
html += `<h4>${subsection.title || ''}</h4>`; html += `<h4>${subsection.title || ""}</h4>`;
if (subsection.items && subsection.items.length > 0) { if (subsection.items && subsection.items.length > 0) {
html += '<ul>'; html += "<ul>";
subsection.items.forEach(listItem => { subsection.items.forEach((listItem) => {
html += `<li>${listItem}</li>`; html += `<li>${listItem}</li>`;
}); });
html += '</ul>'; html += "</ul>";
} }
} else if (subsection.type === 'cancellation_section') { } else if (subsection.type === "cancellation_section") {
html += `<h4>${subsection.title || ''}</h4>`; html += `<h4>${subsection.title || ""}</h4>`;
if (subsection.items && subsection.items.length > 0) { if (subsection.items && subsection.items.length > 0) {
html += '<ul>'; html += "<ul>";
subsection.items.forEach(listItem => { subsection.items.forEach((listItem) => {
html += `<li>${listItem}</li>`; html += `<li>${listItem}</li>`;
}); });
html += '</ul>'; html += "</ul>";
} }
} else if (subsection.type === 'note') { } else if (subsection.type === "note") {
html += `<div class="alert alert-info">${subsection.text || ''}</div>`; html += `<div class="alert alert-info">${subsection.text || ""}</div>`;
} }
}); });
} }
@@ -411,11 +438,17 @@ function renderContentItems(contentItems) {
html += `</div>`; html += `</div>`;
return html; return html;
case 'note': case "note":
return `<div class="content-item alert alert-info">${item.text || ''}</div>`; return `<div class="content-item alert alert-info">${item.text || ""}</div>`;
case 'embed': case "embed":
// Support several embed shapes: { embed }, { url }, { source }, { videoId } // 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}` : '')); 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>`; if (!embedSrc) return `<div class="content-item">Invalid embed</div>`;
return `<div class="content-item embed-item" style="margin-bottom:20px;"> return `<div class="content-item embed-item" style="margin-bottom:20px;">
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;"> <div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;">
@@ -426,7 +459,8 @@ function renderContentItems(contentItems) {
default: default:
return `<div class="content-item">Unknown content type: ${item.type}</div>`; return `<div class="content-item">Unknown content type: ${item.type}</div>`;
} }
}).join(''); })
.join("");
} }
// API để tạo terms mới (cho các ngôn ngữ khác) // API để tạo terms mới (cho các ngôn ngữ khác)
@@ -437,16 +471,19 @@ exports.create = async (req, res) => {
if (!language) { if (!language) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Language is required" error: "Language is required",
}); });
} }
// Kiểm tra đã tồn tại chưa // 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) { if (existing) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Terms already exists for language: " + language error: "Terms already exists for language: " + language,
}); });
} }
@@ -472,7 +509,7 @@ exports.create = async (req, res) => {
content: parseJson(content) || {}, content: parseJson(content) || {},
version: "2.0.0", version: "2.0.0",
isActive: true, isActive: true,
migratedFromOldStructure: false migratedFromOldStructure: false,
}); });
await terms.save(); await terms.save();
@@ -480,14 +517,13 @@ exports.create = async (req, res) => {
res.json({ res.json({
success: true, success: true,
message: "Terms created successfully for language: " + language, message: "Terms created successfully for language: " + language,
data: terms data: terms,
}); });
} catch (error) { } catch (error) {
console.error("Error creating terms:", error); console.error("Error creating terms:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message || "Error creating terms" error: error.message || "Error creating terms",
}); });
} }
}; };
@@ -500,7 +536,7 @@ exports.delete = async (req, res) => {
if (!language) { if (!language) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: "Language is required" error: "Language is required",
}); });
} }
@@ -508,29 +544,31 @@ exports.delete = async (req, res) => {
if (language === "en") { if (language === "en") {
return res.status(400).json({ return res.status(400).json({
success: false, 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) { if (result.deletedCount === 0) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: "Terms not found for language: " + language error: "Terms not found for language: " + language,
}); });
} }
res.json({ res.json({
success: true, success: true,
message: "Terms deleted successfully for language: " + language message: "Terms deleted successfully for language: " + language,
}); });
} catch (error) { } catch (error) {
console.error("Error deleting terms:", error); console.error("Error deleting terms:", error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message || "Error deleting terms" error: error.message || "Error deleting terms",
}); });
} }
}; };

View File

@@ -1,5 +1,8 @@
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const Home = require("../models/home"); 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 // Get testimonial data from Home model
const getTestimonialData = async () => { const getTestimonialData = async () => {
@@ -7,7 +10,9 @@ const getTestimonialData = async () => {
if (!home || !home.testimonials) { if (!home || !home.testimonials) {
return null; return null;
} }
return home.testimonials.toObject ? home.testimonials.toObject() : home.testimonials; return home.testimonials.toObject
? home.testimonials.toObject()
: home.testimonials;
}; };
// API to get testimonial data // API to get testimonial data
@@ -82,8 +87,18 @@ exports.update = async (req, res) => {
home = new Home({}); home = new Home({});
} }
// Cập nhật chỉ phần testimonials // ✅ Capture BEFORE state
home.testimonials = { const beforeData = home.testimonials
? JSON.parse(
JSON.stringify(
home.testimonials.toObject
? home.testimonials.toObject()
: home.testimonials,
),
)
: {};
const updatedTestimonialData = {
heading: heading || "Student Reviews & Testimonials", heading: heading || "Student Reviews & Testimonials",
subheading: subheading || "What Our Students Say", subheading: subheading || "What Our Students Say",
videoUrl: videoUrl || "", videoUrl: videoUrl || "",
@@ -91,8 +106,28 @@ exports.update = async (req, res) => {
items: itemsData || [], items: itemsData || [],
}; };
// Cập nhật chỉ phần testimonials
home.testimonials = updatedTestimonialData;
await home.save(); 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"); req.flash("success_msg", "Testimonials updated successfully");
res.redirect("/admin/home/testimonials"); res.redirect("/admin/home/testimonials");
} catch (err) { } catch (err) {

View File

@@ -2,14 +2,18 @@ const Travel = require("../models/travel");
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const fs = require("fs").promises; const fs = require("fs").promises;
const path = require("path"); 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 * Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
*/ */
function extractYouTubeId(url) { function extractYouTubeId(url) {
if (!url || typeof url !== 'string') return null; if (!url || typeof url !== "string") return null;
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/ // 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 regex =
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
const match = url.match(regex); const match = url.match(regex);
return match ? match[1] : null; return match ? match[1] : null;
} }
@@ -24,9 +28,9 @@ function sanitizeContentBlocks(blocks) {
const seenVideoIds = new Set(); const seenVideoIds = new Set();
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video // 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 => { const processedBlocks = blocks.map((block) => {
if (block.type === 'embed') { if (block.type === "embed") {
const url = block.data.source || block.data.embed || ''; const url = block.data.source || block.data.embed || "";
const videoId = extractYouTubeId(url); const videoId = extractYouTubeId(url);
if (videoId) { if (videoId) {
seenVideoIds.add(videoId); seenVideoIds.add(videoId);
@@ -34,24 +38,26 @@ function sanitizeContentBlocks(blocks) {
block.data.embed = `https://www.youtube.com/embed/${videoId}`; block.data.embed = `https://www.youtube.com/embed/${videoId}`;
block.data.source = url; block.data.source = url;
block.data.videoId = videoId; block.data.videoId = videoId;
block.data.service = 'youtube'; block.data.service = "youtube";
} }
} }
return block; return block;
}); });
// Bước 2: Lọc bỏ paragraph rác // Bước 2: Lọc bỏ paragraph rác
return processedBlocks.filter(block => { return processedBlocks.filter((block) => {
if (block.type === 'paragraph') { if (block.type === "paragraph") {
const text = (block.data?.text || '').trim(); const text = (block.data?.text || "").trim();
// Xóa paragraph rỗng // Xóa paragraph rỗng
if (text === '' || text === '<br>' || text === '&nbsp;') return false; if (text === "" || text === "<br>" || text === "&nbsp;") return false;
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên // Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
const videoIdInText = extractYouTubeId(text); const videoIdInText = extractYouTubeId(text);
if (videoIdInText && seenVideoIds.has(videoIdInText)) { if (videoIdInText && seenVideoIds.has(videoIdInText)) {
console.log(`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`); console.log(
`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`,
);
return false; return false;
} }
} }
@@ -68,7 +74,11 @@ exports.index = async (req, res) => {
return res.render("admin/travel/index", { return res.render("admin/travel/index", {
title: "Travel Management", title: "Travel Management",
data: { data: {
page: { title: "Travel Information", description: "", metadata: { title: "", description: "" } }, page: {
title: "Travel Information",
description: "",
metadata: { title: "", description: "" },
},
hero: { title: "Travel Information", backgroundImage: "" }, hero: { title: "Travel Information", backgroundImage: "" },
content: { blocks: [] }, content: { blocks: [] },
enableScrollspy: false, enableScrollspy: false,
@@ -91,6 +101,19 @@ exports.index = async (req, res) => {
exports.update = async (req, res) => { exports.update = async (req, res) => {
try { try {
const { page, hero, content, enableScrollspy } = req.body; const { page, hero, content, enableScrollspy } = req.body;
// Get current data for before state
const currentTravel = await Travel.findOne();
// ✅ Capture BEFORE state
const beforeData = currentTravel
? JSON.parse(
JSON.stringify(
currentTravel.toObject ? currentTravel.toObject() : currentTravel,
),
)
: {};
const updateData = {}; const updateData = {};
if (page) updateData.page = JSON.parse(page); if (page) updateData.page = JSON.parse(page);
@@ -104,15 +127,40 @@ exports.update = async (req, res) => {
} }
if (enableScrollspy !== undefined) { if (enableScrollspy !== undefined) {
updateData.enableScrollspy = (enableScrollspy === "true" || enableScrollspy === true); updateData.enableScrollspy =
enableScrollspy === "true" || enableScrollspy === true;
} }
await Travel.findOneAndUpdate({}, updateData, { const updatedTravel = await Travel.findOneAndUpdate({}, updateData, {
upsert: true, upsert: true,
new: true, new: true,
}); });
req.flash("success", "Travel information updated and sanitized successfully"); // ✅ 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"); res.redirect("/admin/travel");
} catch (error) { } catch (error) {
console.error("Error updating travel:", error); console.error("Error updating travel:", error);
@@ -130,7 +178,8 @@ exports.api = exports.getTravelData = async (req, res) => {
} }
const travelObj = travel.toObject(); const travelObj = travel.toObject();
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processed = addBaseUrlToImages(travelObj, baseUrl); const processed = addBaseUrlToImages(travelObj, baseUrl);
return res.json({ return res.json({
@@ -139,7 +188,7 @@ exports.api = exports.getTravelData = async (req, res) => {
hero: processed.hero, hero: processed.hero,
page: processed.page, page: processed.page,
content: processed.content, content: processed.content,
enableScrollspy: processed.enableScrollspy enableScrollspy: processed.enableScrollspy,
}, },
}); });
} catch (error) { } catch (error) {
@@ -151,7 +200,8 @@ exports.api = exports.getTravelData = async (req, res) => {
// POST: Preview travel // POST: Preview travel
exports.preview = async (req, res) => { exports.preview = async (req, res) => {
try { try {
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } = req.body; 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 // Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
let contentObj = JSON.parse(content); let contentObj = JSON.parse(content);
@@ -160,7 +210,7 @@ exports.preview = async (req, res) => {
const previewData = { const previewData = {
page: { page: {
title: pageTitle || "Travel Information", title: pageTitle || "Travel Information",
year: pageYear || "" year: pageYear || "",
}, },
hero: { hero: {
title: heroTitle || "Travel Information", title: heroTitle || "Travel Information",
@@ -190,9 +240,15 @@ exports.seed = async (req, res) => {
let contentBlocks = []; let contentBlocks = [];
// Trường hợp JSON đã có định dạng bài viết (blog format) // Trường hợp JSON đã có định dạng bài viết (blog format)
if (Array.isArray(jsonTravelData.posts) && jsonTravelData.posts.length > 0) { if (
Array.isArray(jsonTravelData.posts) &&
jsonTravelData.posts.length > 0
) {
const firstPost = jsonTravelData.posts[0]; const firstPost = jsonTravelData.posts[0];
contentBlocks = (firstPost.content && firstPost.content.blocks) ? firstPost.content.blocks : []; contentBlocks =
firstPost.content && firstPost.content.blocks
? firstPost.content.blocks
: [];
} }
// Trường hợp format cũ (legacy) // Trường hợp format cũ (legacy)
else { else {
@@ -205,11 +261,13 @@ exports.seed = async (req, res) => {
const travelData = { const travelData = {
page: { page: {
title: jsonTravelData.page?.title || "Go and Grow Camp Travel Information", title:
jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
year: jsonTravelData.page?.year || "", year: jsonTravelData.page?.year || "",
metadata: { metadata: {
title: "Travel Guide - Go and Grow Camp", title: "Travel Guide - Go and Grow Camp",
description: "Everything you need to know about traveling to our camps", description:
"Everything you need to know about traveling to our camps",
}, },
}, },
hero: { hero: {

View File

@@ -1,5 +1,8 @@
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const Home = require("../models/home"); 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 // Get videoGallery data from Home model
const getVideoGalleryData = async () => { const getVideoGalleryData = async () => {
@@ -7,7 +10,9 @@ const getVideoGalleryData = async () => {
if (!home || !home.videoGallery) { if (!home || !home.videoGallery) {
return null; return null;
} }
return home.videoGallery.toObject ? home.videoGallery.toObject() : home.videoGallery; return home.videoGallery.toObject
? home.videoGallery.toObject()
: home.videoGallery;
}; };
// API to get videoGallery data // API to get videoGallery data
@@ -65,15 +70,45 @@ exports.update = async (req, res) => {
home = new Home({}); home = new Home({});
} }
// Cập nhật chỉ phần videoGallery // ✅ Capture BEFORE state
home.videoGallery = { const beforeData = home.videoGallery
? JSON.parse(
JSON.stringify(
home.videoGallery.toObject
? home.videoGallery.toObject()
: home.videoGallery,
),
)
: {};
const updatedVideoGalleryData = {
heading: heading || "", heading: heading || "",
videoUrl: videoUrl || "", videoUrl: videoUrl || "",
thumbnail: thumbnail || "", thumbnail: thumbnail || "",
}; };
// Cập nhật chỉ phần videoGallery
home.videoGallery = updatedVideoGalleryData;
await home.save(); 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"); req.flash("success_msg", "Video Gallery updated successfully");
res.redirect("/admin/home/video-gallery"); res.redirect("/admin/home/video-gallery");
} catch (err) { } catch (err) {

View File

@@ -45,6 +45,9 @@ const addBaseUrlToImages = (data, baseUrl) => {
}; };
const Visa = require("../models/visa"); const Visa = require("../models/visa");
const slugify = require("slugify"); const slugify = require("slugify");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const createSlug = (text) => { const createSlug = (text) => {
return slugify(text, { return slugify(text, {
lower: true, lower: true,
@@ -184,6 +187,15 @@ exports.update = async (req, res) => {
// Get current data // Get current data
const currentData = await getVisaData(); const currentData = await getVisaData();
// ✅ Capture BEFORE state
const beforeData = currentData
? JSON.parse(
JSON.stringify(
currentData.toObject ? currentData.toObject() : currentData,
),
)
: {};
// Create updated data object // Create updated data object
const updatedData = { const updatedData = {
...(currentData.toObject ? currentData.toObject() : currentData), ...(currentData.toObject ? currentData.toObject() : currentData),
@@ -200,23 +212,37 @@ exports.update = async (req, res) => {
updatedData.hero.title = req.body.heroTitle; 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 // Update or create document
try { try {
let savedData;
if (currentData._id) { if (currentData._id) {
await Visa.findByIdAndUpdate(currentData._id, updatedData, { savedData = await Visa.findByIdAndUpdate(currentData._id, updatedData, {
new: true, new: true,
}); });
} else { } 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"); req.flash("success_msg", "Visa data updated successfully");
@@ -243,6 +269,11 @@ exports.addCountry = async (req, res) => {
visaData = getDefaultVisaData(); visaData = getDefaultVisaData();
} }
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// Validate required fields // Validate required fields
if (!req.body.name) { if (!req.body.name) {
return res.status(400).json({ error: "Name is required" }); return res.status(400).json({ error: "Name is required" });
@@ -305,6 +336,28 @@ exports.addCountry = async (req, res) => {
savedData = await Visa.create(updatedData); 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`); console.log(`✅ Country "${newCountry.name}" added successfully`);
res.json({ res.json({
success: true, 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ệ" }); .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) // 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
const countryIndex = visaData.hero.summaryList.findIndex( const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.id === parseInt(id), (c) => c.id === parseInt(id),
@@ -390,10 +448,33 @@ exports.updateCountry = async (req, res) => {
visaData.markModified("hero.summaryList"); visaData.markModified("hero.summaryList");
} }
let savedData;
if (visaData._id) { 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 { } 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( 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 { upload, uploadVideo, convertToWebp } = require("../middleware/upload");
const safetyController = require("../controllers/safetyController"); const safetyController = require("../controllers/safetyController");
const insuranceController = require("../controllers/insuranceController"); const insuranceController = require("../controllers/insuranceController");
const auditLogController = require("../controllers/auditLogController");
const activityController = require("../controllers/activityController"); const activityController = require("../controllers/activityController");
const bookingSubmissionController = require("../controllers/bookingSubmissionController"); const bookingSubmissionController = require("../controllers/bookingSubmissionController");
@@ -32,7 +33,6 @@ const socialLinkController = require("../controllers/socialLinkController");
const testimonialController = require("../controllers/testimonialController"); const testimonialController = require("../controllers/testimonialController");
const videoGalleryController = require("../controllers/videoGalleryController"); const videoGalleryController = require("../controllers/videoGalleryController");
// Dashboard // Dashboard
router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard); router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard);
@@ -55,7 +55,11 @@ router.post("/about-us/update", ensureAuthenticated, aboutUsController.update);
// Form Management // Form Management
router.get("/form", ensureAuthenticated, formController.index); router.get("/form", ensureAuthenticated, formController.index);
router.post("/form/update", ensureAuthenticated, formController.updateDefaultForm); router.post(
"/form/update",
ensureAuthenticated,
formController.updateDefaultForm,
);
// Upload routes // Upload routes
router.get("/upload", ensureAuthenticated, (req, res) => { router.get("/upload", ensureAuthenticated, (req, res) => {
@@ -65,30 +69,80 @@ router.get("/upload", ensureAuthenticated, (req, res) => {
user: req.session.user, user: req.session.user,
}); });
}); });
router.post("/upload/image", ensureAuthenticated, upload.single("image"), uploadController.uploadImage); router.post(
router.post("/upload/video", ensureAuthenticated, uploadVideo.single("video"), uploadController.uploadVideo); "/upload/image",
router.post("/upload/update-path", ensureAuthenticated, uploadController.updateImagePath); ensureAuthenticated,
router.post("/upload/delete", ensureAuthenticated, uploadController.deleteImage); 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 // Header routes
router.get("/header", ensureAuthenticated, headerController.index); router.get("/header", ensureAuthenticated, headerController.index);
router.post("/header/update", ensureAuthenticated, headerController.update); router.post("/header/update", ensureAuthenticated, headerController.update);
router.get("/header/data", ensureAuthenticated, headerController.api); // Normalized from getHeaderData 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); router.delete("/header/:id", ensureAuthenticated, headerController.destroy);
// Header Menu INTEGRATED routes // Header Menu INTEGRATED routes
router.post("/header/menu/create", ensureAuthenticated, headerMenuController.store); router.post(
router.post("/header/menu/update/:id", ensureAuthenticated, headerMenuController.update); "/header/menu/create",
router.post("/header/menu/delete", ensureAuthenticated, headerMenuController.destroy); ensureAuthenticated,
router.post("/header/menu/reorder", ensureAuthenticated, headerMenuController.reorder); 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 // Social Links routes
router.get("/social-links", ensureAuthenticated, socialLinkController.index); router.get("/social-links", ensureAuthenticated, socialLinkController.index);
router.post("/social-links", ensureAuthenticated, socialLinkController.store); router.post("/social-links", ensureAuthenticated, socialLinkController.store);
router.put("/social-links/:platform", ensureAuthenticated, socialLinkController.update); router.put(
router.delete("/social-links/:platform", ensureAuthenticated, socialLinkController.destroy); "/social-links/:platform",
router.post("/social-links/reorder", ensureAuthenticated, socialLinkController.reorder); ensureAuthenticated,
socialLinkController.update,
);
router.delete(
"/social-links/:platform",
ensureAuthenticated,
socialLinkController.destroy,
);
router.post(
"/social-links/reorder",
ensureAuthenticated,
socialLinkController.reorder,
);
// Footer routes // Footer routes
router.get("/footer", ensureAuthenticated, footerController.index); router.get("/footer", ensureAuthenticated, footerController.index);
@@ -98,60 +152,160 @@ router.get("/footer/data", ensureAuthenticated, footerController.getFooterData);
// Contact routes // Contact routes
router.get("/contact", ensureAuthenticated, contactController.index); router.get("/contact", ensureAuthenticated, contactController.index);
router.post("/contact/update", ensureAuthenticated, contactController.update); router.post("/contact/update", ensureAuthenticated, contactController.update);
router.get("/contact/data", ensureAuthenticated, contactController.getContactData); router.get(
"/contact/data",
ensureAuthenticated,
contactController.getContactData,
);
// Contact submissions management // Contact submissions management
router.get("/contact/submissions", ensureAuthenticated, contactController.getSubmissions); router.get(
router.put("/contact/submissions/:id", ensureAuthenticated, contactController.updateSubmissionStatus); "/contact/submissions",
ensureAuthenticated,
contactController.getSubmissions,
);
router.put(
"/contact/submissions/:id",
ensureAuthenticated,
contactController.updateSubmissionStatus,
);
// Appointment management // Appointment management
const appointmentController = require("../controllers/appointmentController"); const appointmentController = require("../controllers/appointmentController");
router.get("/appointments", ensureAuthenticated, appointmentController.getAppointments); router.get(
router.get("/appointments/:id", ensureAuthenticated, appointmentController.getAppointmentById); "/appointments",
router.put("/appointments/:id", ensureAuthenticated, appointmentController.updateAppointmentStatus); ensureAuthenticated,
router.delete("/appointments/:id", ensureAuthenticated, appointmentController.deleteAppointment); 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 // Appointment CMS page management
router.get("/appointment", ensureAuthenticated, appointmentController.index); router.get("/appointment", ensureAuthenticated, appointmentController.index);
router.post("/appointment/update", ensureAuthenticated, appointmentController.update); router.post(
router.get("/appointment/data", ensureAuthenticated, appointmentController.getAppointmentData); "/appointment/update",
ensureAuthenticated,
appointmentController.update,
);
router.get(
"/appointment/data",
ensureAuthenticated,
appointmentController.getAppointmentData,
);
// Pricing CMS page management // Pricing CMS page management
const pricingController = require("../controllers/pricingController"); const pricingController = require("../controllers/pricingController");
router.get("/pricing", ensureAuthenticated, pricingController.index); router.get("/pricing", ensureAuthenticated, pricingController.index);
router.post("/pricing/update", ensureAuthenticated, pricingController.update); router.post("/pricing/update", ensureAuthenticated, pricingController.update);
router.get("/pricing/data", ensureAuthenticated, pricingController.getPricingData); router.get(
"/pricing/data",
ensureAuthenticated,
pricingController.getPricingData,
);
// Activity CRUD routes // Activity CRUD routes
router.get("/activity", ensureAuthenticated, activityController.index); 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); router.post("/activity/create", ensureAuthenticated, activityController.create);
// Update filters (place before any parameterized /activity/:id routes to avoid route collision) // 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) // Update hero (global hero section for activities)
router.post("/activity/hero/update", ensureAuthenticated, activityController.updateHero); router.post(
router.get("/activity/:id/edit", ensureAuthenticated, activityController.editForm); "/activity/hero/update",
router.post("/activity/:id/update", ensureAuthenticated, activityController.update); ensureAuthenticated,
router.post("/activity/:id/delete", ensureAuthenticated, activityController.delete); activityController.updateHero,
router.post("/activity/:id/toggle-status", ensureAuthenticated, activityController.toggleStatus); );
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 // Update display order
router.post("/activity/update-order", ensureAuthenticated, activityController.updateOrder); router.post(
"/activity/update-order",
ensureAuthenticated,
activityController.updateOrder,
);
// Booking submissions routes // Booking submissions routes
router.get("/activity/:id/bookings/count", ensureAuthenticated, activityController.getBookingCount); router.get(
router.get("/activity/:id/bookings", ensureAuthenticated, activityController.getBookingSubmissions); "/activity/:id/bookings/count",
router.get("/activity/:id/bookings/export", ensureAuthenticated, activityController.exportBookingData); 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) // 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 // Update booking submission
router.put("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.updateBookingSubmission); router.put(
"/bookings/:bookingId",
ensureAuthenticated,
bookingSubmissionController.updateBookingSubmission,
);
// Delete booking submission // Delete booking submission
router.delete("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.deleteBookingSubmission); router.delete(
"/bookings/:bookingId",
ensureAuthenticated,
bookingSubmissionController.deleteBookingSubmission,
);
// Update filters // Update filters
// Preview activity // Preview activity
router.get("/activity/:id/preview", ensureAuthenticated, activityController.preview); router.get(
"/activity/:id/preview",
ensureAuthenticated,
activityController.preview,
);
// FAQ routes // FAQ routes
router.get("/home/faq", ensureAuthenticated, faqController.index); 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) // API routes cho quản lý FAQ items (AJAX calls)
router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ); router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ);
router.put("/faq/api/update-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.updateFAQItem); router.put(
router.delete("/faq/api/delete-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.deleteFAQItem); "/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.get("/terms-conditions", ensureAuthenticated, termsController.index);
router.post("/terms/update", ensureAuthenticated, termsController.update); router.post("/terms/update", ensureAuthenticated, termsController.update);
router.get("/terms/data", ensureAuthenticated, termsController.getTermsData); router.get("/terms/data", ensureAuthenticated, termsController.getTermsData);
@@ -182,13 +344,33 @@ router.get("/travel/seed", ensureAuthenticated, travelController.seed);
// Deprecated FAQ API routes removed // Deprecated FAQ API routes removed
// API routes cho quản lý FAQ sections (AJAX calls) // API routes cho quản lý FAQ sections (AJAX calls)
router.post("/faq/api/add-section", ensureAuthenticated, faqController.addFAQSection); router.post(
router.put("/faq/api/update-section/:sectionId", ensureAuthenticated, faqController.updateFAQSection); "/faq/api/add-section",
router.delete("/faq/api/delete-section/:sectionId", ensureAuthenticated, faqController.deleteFAQSection); ensureAuthenticated,
router.post("/faq/api/reorder-sections", ensureAuthenticated, faqController.reorderFAQSection); 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) // 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 // Safety routes
router.get("/safety", ensureAuthenticated, safetyController.index); router.get("/safety", ensureAuthenticated, safetyController.index);
@@ -196,16 +378,36 @@ router.post("/safety/update", ensureAuthenticated, safetyController.update);
//Insurance routes //Insurance routes
router.get("/insurance", ensureAuthenticated, insuranceController.index); router.get("/insurance", ensureAuthenticated, insuranceController.index);
router.post("/insurance/update", ensureAuthenticated, insuranceController.update); router.post(
"/insurance/update",
ensureAuthenticated,
insuranceController.update,
);
// Service routes // Service routes
router.get("/service", ensureAuthenticated, serviceController.index); router.get("/service", ensureAuthenticated, serviceController.index);
router.post("/service/update", ensureAuthenticated, serviceController.update); 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.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit);
router.post("/service/:slug/edit", ensureAuthenticated, serviceController.updateService); router.post(
router.get("/service/:slug/details", ensureAuthenticated, serviceController.details); "/service/:slug/edit",
router.post("/service/:slug/details/update", ensureAuthenticated, serviceController.updateDetails); ensureAuthenticated,
serviceController.updateService,
);
router.get(
"/service/:slug/details",
ensureAuthenticated,
serviceController.details,
);
router.post(
"/service/:slug/details/update",
ensureAuthenticated,
serviceController.updateDetails,
);
// Test Image Paths route // Test Image Paths route
router.get("/test-images", ensureAuthenticated, (req, res) => { router.get("/test-images", ensureAuthenticated, (req, res) => {
@@ -238,7 +440,9 @@ router.get("/test-images", ensureAuthenticated, (req, res) => {
type: "Location", type: "Location",
name: location.title, name: location.title,
path: location.imageSrc, path: location.imageSrc,
exists: fs.existsSync(path.join(__dirname, "../public", location.imageSrc)), exists: fs.existsSync(
path.join(__dirname, "../public", location.imageSrc),
),
}); });
} }
@@ -250,7 +454,9 @@ router.get("/test-images", ensureAuthenticated, (req, res) => {
type: "Program", type: "Program",
name: program.title, name: program.title,
path: program.imageSrc, path: program.imageSrc,
exists: fs.existsSync(path.join(__dirname, "../public", program.imageSrc)), exists: fs.existsSync(
path.join(__dirname, "../public", program.imageSrc),
),
}); });
} }
}); });
@@ -279,10 +485,18 @@ router.post("/visa/update", ensureAuthenticated, visaController.updateCountry);
router.post("/visa/add", ensureAuthenticated, visaController.addCountry); router.post("/visa/add", ensureAuthenticated, visaController.addCountry);
// Update single country // Update single country
router.put("/visa/update/:id", ensureAuthenticated, visaController.updateCountry); router.put(
"/visa/update/:id",
ensureAuthenticated,
visaController.updateCountry,
);
// Delete country // Delete country
router.delete("/visa/delete/:id", ensureAuthenticated, visaController.deleteCountry); router.delete(
"/visa/delete/:id",
ensureAuthenticated,
visaController.deleteCountry,
);
// Blog routes // Blog routes
// Blog Management Routes // Blog Management Routes
router.get("/blog", ensureAuthenticated, blogController.index); 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); router.post("/blog/:id/delete", ensureAuthenticated, blogController.destroy);
// Comment management routes // Comment management routes
router.post("/blog/:blogId/comments/:commentId/approve", ensureAuthenticated, blogController.approveComment); router.post(
router.post("/blog/:blogId/comments/:commentId/reject", ensureAuthenticated, blogController.rejectComment); "/blog/:blogId/comments/:commentId/approve",
router.post("/blog/:blogId/comments/:commentId/delete", ensureAuthenticated, blogController.deleteComment); 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 // Blog Categories Management
router.get("/blog/categories", ensureAuthenticated, blogCategoryController.index); router.get(
router.get("/blog/categories/create", ensureAuthenticated, blogCategoryController.create); "/blog/categories",
router.post("/blog/categories/create", ensureAuthenticated, blogCategoryController.store); ensureAuthenticated,
router.get("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.edit); blogCategoryController.index,
router.post("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.update); );
router.post("/blog/categories/:id/delete", ensureAuthenticated, blogCategoryController.destroy); router.get(
router.post("/blog/categories/quick-create", ensureAuthenticated, blogCategoryController.quickCreate); "/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 // Blog Tags Management
router.get("/blog/tags", ensureAuthenticated, blogTagController.index); router.get("/blog/tags", ensureAuthenticated, blogTagController.index);
router.get("/blog/tags/create", ensureAuthenticated, blogTagController.create); router.get("/blog/tags/create", ensureAuthenticated, blogTagController.create);
router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store); router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store);
router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit); router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit);
router.post("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.update); router.post(
router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy); "/blog/tags/:id/edit",
router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate); ensureAuthenticated,
blogTagController.update,
);
router.post(
"/blog/tags/:id/delete",
ensureAuthenticated,
blogTagController.destroy,
);
router.post(
"/blog/tags/quick-create",
ensureAuthenticated,
blogTagController.quickCreate,
);
// Testimonials management // Testimonials management
router.get("/home/testimonials", ensureAuthenticated, testimonialController.index); router.get(
router.post("/home/testimonials/update", ensureAuthenticated, testimonialController.update); "/home/testimonials",
ensureAuthenticated,
testimonialController.index,
);
router.post(
"/home/testimonials/update",
ensureAuthenticated,
testimonialController.update,
);
// Video Gallery management // Video Gallery management
router.get("/home/video-gallery", ensureAuthenticated, videoGalleryController.index); router.get(
router.post("/home/video-gallery/update", ensureAuthenticated, videoGalleryController.update); "/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; 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' : '' %>" <a class="nav-link <%= currentPath === '/admin/pricing' ? 'active' : '' %>"
href="/admin/pricing">Pricing</a> href="/admin/pricing">Pricing</a>
</li> </li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/audit-logs' ? 'active' : '' %>"
href="/admin/audit-logs">Audit Log
</a>
</li>
</ul> </ul>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<% if (locals.user) { %> <% if (locals.user) { %>