forked from UKSOURCE/cms.hailearning.edu.vn
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:
40
audit/diffObject.js
Normal file
40
audit/diffObject.js
Normal 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
25
audit/writeAuditLog.js
Normal 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
72
constants/auditAction.js
Normal 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;
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
176
controllers/auditLogController.js
Normal file
176
controllers/auditLogController.js
Normal 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 === ' ') return false;
|
if (text === "" || text === "<br>" || text === " ") 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: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
64
models/AuditLog.js
Normal 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);
|
||||||
438
routes/admin.js
438
routes/admin.js
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
29
scripts/create-audit-log.js
Normal file
29
scripts/create-audit-log.js
Normal 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
18
utils/requestMeta.js
Normal 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,
|
||||||
|
};
|
||||||
699
views/admin/audit-log/index.ejs
Normal file
699
views/admin/audit-log/index.ejs
Normal 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"> </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>
|
||||||
314
views/admin/audit-log/show.ejs
Normal file
314
views/admin/audit-log/show.ejs
Normal 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>
|
||||||
@@ -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) { %>
|
||||||
|
|||||||
Reference in New Issue
Block a user