feat: implement comprehensive audit logging system

This commit is contained in:
nguyenvanbao
2026-02-10 16:42:35 +07:00
parent d440a04618
commit 970fcbac7d
28 changed files with 4783 additions and 2221 deletions

View File

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