first commit

This commit is contained in:
2026-04-11 14:08:27 +07:00
parent e86e5d2c46
commit 6b7655aa16
389 changed files with 5387 additions and 60861 deletions

View File

@@ -1,251 +0,0 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const AboutUs = require("../models/aboutUs");
const Blog = require("../models/blog");
const jsonHelper = require("../utils/jsonHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/**
* GET /api/about
* Lấy dữ liệu About Us (Public API cho website và CMS load dữ liệu)
*/
exports.getAbout = async (req, res) => {
try {
// Force no-cache headers
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
const data = await AboutUs.getSingle();
const rawData = data.toObject();
// === Dynamic Blog News Section ===
const news = rawData.news || {};
let blogs = [];
// Nếu có chọn blog cụ thể
if (news.selectedBlogIds && news.selectedBlogIds.length > 0) {
blogs = await Blog.find({
_id: { $in: news.selectedBlogIds },
status: "published",
}).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => {
return (
news.selectedBlogIds.indexOf(a._id.toString()) -
news.selectedBlogIds.indexOf(b._id.toString())
);
});
}
// Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất
if (blogs.length === 0) {
blogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.limit(3)
.lean();
}
// Map dữ liệu blog sang format mà frontend mong đợi
news.items = blogs.map((blog) => ({
title: blog.title,
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
date:
blog.publishedAt ||
new Date(blog.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
}),
comments: blog.commentsCount || 0,
author: {
name: blog.author || "Admin",
avatar: "/assets/img/home-1/news/client.png", // Default avatar
},
link: `/blog/${blog.slug}`,
thumbnail: blog.featuredImage,
}));
rawData.news = news;
// ===============================
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(rawData, baseUrl);
res.json(processedData);
} catch (error) {
console.error("Error getting about data:", error);
res.status(500).json({
success: false,
error: "Failed to get about data",
});
}
};
/**
* PUT /api/about
* Cập nhật dữ liệu About Us (Dùng cho AJAX từ CMS)
*/
exports.updateAbout = async (req, res) => {
try {
let updateData = req.body;
// Nếu dữ liệu gửi qua trường aboutJson (dạng string JSON)
if (updateData.aboutJson && typeof updateData.aboutJson === "string") {
try {
updateData = JSON.parse(updateData.aboutJson);
} catch (e) {
return res.status(400).json({
success: false,
message: "Invalid JSON in aboutJson",
});
}
}
const doc = await AboutUs.getSingle();
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
// Use .set() for better handling of nested objects/arrays in Mongoose
doc.set(updateData);
await doc.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
// ✅ AUDIT LOGGING - About Us Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "AboutUs",
documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_ABOUT_US,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for About Us update: ${changes.length} changes`,
);
} else {
console.log(" No changes detected for About Us update");
}
// Fetch fresh data for syncing and returning
const finalData = await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
// Update about.json file to keep it in sync
jsonHelper.writeJsonFile("about", finalData);
res.json({
success: true,
message: "About Us updated successfully",
data: finalData,
});
} catch (error) {
console.error("Error updating about data:", error);
res.status(500).json({
success: false,
error: "Failed to update about data: " + error.message,
});
}
};
/**
* Render admin page (Dùng cho Admin UI)
*/
exports.index = async (req, res) => {
try {
const data = await AboutUs.getSingle();
const rawData = data.toObject();
// Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.lean();
const activeTab = req.query.activeTab || "hero";
res.render("admin/aboutUs/index", {
layout: "layouts/main",
title: "About Us Management",
data: rawData,
allBlogs,
activeTab,
user: req.session.user,
currentPath: req.path,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
backendUrl: process.env.BACKEND_URL || "http://localhost:3001",
});
} catch (err) {
console.error("Error in about index:", err);
req.flash("error_msg", "Error loading About Us page");
res.redirect("/admin/dashboard");
}
};
/**
* Update method cho form-based submission (Admin UI - Post fallback)
*/
exports.update = async (req, res) => {
try {
let updateData = req.body;
if (updateData.aboutJson && typeof updateData.aboutJson === "string") {
try {
updateData = JSON.parse(updateData.aboutJson);
} catch (e) {
req.flash("error_msg", "Invalid JSON data");
return res.redirect("/admin/about-us");
}
}
const doc = await AboutUs.getSingle();
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
doc.set(updateData);
await doc.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
// ✅ AUDIT LOGGING - About Us Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "AboutUs",
documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_ABOUT_US,
before: beforeData,
after: afterData,
changes,
req,
});
}
const finalData = await AboutUs.findOne()
.select("-_id -__v -createdAt -updatedAt")
.lean();
jsonHelper.writeJsonFile("about", finalData);
req.flash("success_msg", "About Us updated successfully");
const activeTab = req.query.activeTab || "hero";
res.redirect(`/admin/about-us?activeTab=${activeTab}`);
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", "Error updating About Us: " + err.message);
res.redirect("/admin/about-us");
}
};
// Aliases for compatibility
exports.api = exports.getAbout;
exports.page = exports.getAbout;
exports.updateAboutUs = exports.updateAbout;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -54,7 +54,7 @@ exports.index = async (req, res) => {
res.render("admin/audit-log/index", {
title: "Audit Logs",
layout: "layouts/main",
layout: "layouts/admin",
auditLogs,
pagination: {
current: page,
@@ -91,7 +91,7 @@ exports.show = async (req, res) => {
res.render("admin/audit-log/show", {
title: "Audit Log Details",
layout: "layouts/main",
layout: "layouts/admin",
auditLog,
currentPath: req.path,
user: req.session.user,

View File

@@ -1,342 +0,0 @@
const BlogCategory = require('../models/blogCategory');
const slugify = require('slugify');
// -------------------- Admin Controllers --------------------
// Display category management page
exports.index = async (req, res) => {
try {
const categories = await BlogCategory.find()
.sort({ name: 1 })
.lean();
res.render('admin/blog/categories/index', {
layout: 'layouts/main',
title: 'Blog Categories',
categories,
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Category index error:', err);
req.flash('error_msg', 'Error loading categories');
res.redirect('/admin/dashboard');
}
};
// Show create category form
exports.create = async (req, res) => {
try {
res.render('admin/blog/categories/create', {
layout: 'layouts/main',
title: 'Create New Category',
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Category create form error:', err);
req.flash('error_msg', 'Error loading create form');
res.redirect('/admin/blog/categories');
}
};
// Store new category
exports.store = async (req, res) => {
try {
const {
name,
description,
isActive
} = req.body;
// Generate slug
const slug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if slug exists
const existingCategory = await BlogCategory.findOne({ slug });
if (existingCategory) {
req.flash('error_msg', 'A category with this name already exists');
return res.redirect('/admin/blog/categories/create');
}
// Create category data
const categoryData = {
name,
slug,
description,
isActive: isActive === 'on'
};
// Create category
const category = new BlogCategory(categoryData);
await category.save();
req.flash('success_msg', 'Category created successfully');
res.redirect('/admin/blog/categories');
} catch (err) {
console.error('Category store error:', err);
req.flash('error_msg', 'Error creating category');
res.redirect('/admin/blog/categories/create');
}
};
// Show edit category form
exports.edit = async (req, res) => {
try {
const category = await BlogCategory.findById(req.params.id);
if (!category) {
req.flash('error_msg', 'Category not found');
return res.redirect('/admin/blog/categories');
}
res.render('admin/blog/categories/edit', {
layout: 'layouts/main',
title: 'Edit Category',
category,
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Category edit form error:', err);
req.flash('error_msg', 'Error loading category');
res.redirect('/admin/blog/categories');
}
};
// Update category
exports.update = async (req, res) => {
try {
const category = await BlogCategory.findById(req.params.id);
if (!category) {
req.flash('error_msg', 'Category not found');
return res.redirect('/admin/blog/categories');
}
const {
name,
description,
isActive
} = req.body;
// Update category data
category.name = name;
category.description = description;
category.isActive = isActive === 'on';
// Generate new slug if name changed
const newSlug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
if (newSlug !== category.slug) {
const existingCategory = await BlogCategory.findOne({
slug: newSlug,
_id: { $ne: category._id }
});
if (existingCategory) {
req.flash('error_msg', 'A category with this name already exists');
return res.redirect(`/admin/blog/categories/${category._id}/edit`);
}
category.slug = newSlug;
}
await category.save();
req.flash('success_msg', 'Category updated successfully');
res.redirect('/admin/blog/categories');
} catch (err) {
console.error('Category update error:', err);
req.flash('error_msg', 'Error updating category');
res.redirect(`/admin/blog/categories/${req.params.id}/edit`);
}
};
// Delete category
exports.destroy = async (req, res) => {
try {
const category = await BlogCategory.findById(req.params.id);
if (!category) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
req.flash('error_msg', 'Category not found');
return res.redirect('/admin/blog/categories');
}
// Check if category has posts
const Blog = require('../models/blog');
const postCount = await Blog.countDocuments({ category: { $in: [category.name] } });
if (postCount > 0) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(400).json({
success: false,
message: 'Cannot delete category that has blog posts'
});
}
req.flash('error_msg', 'Cannot delete category that has blog posts');
return res.redirect('/admin/blog/categories');
}
await BlogCategory.findByIdAndDelete(req.params.id);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.json({
success: true,
message: 'Category deleted successfully'
});
}
req.flash('success_msg', 'Category deleted successfully');
res.redirect('/admin/blog/categories');
} catch (err) {
console.error('Category delete error:', err);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(500).json({
success: false,
message: 'Error deleting category',
error: err.message || 'Error deleting category'
});
}
req.flash('error_msg', 'Error deleting category');
res.redirect('/admin/blog/categories');
}
};
// -------------------- Public API Controllers --------------------
// Get all active categories
exports.api = async (req, res) => {
try {
const categories = await BlogCategory.getActive();
// Update post counts
for (const category of categories) {
await category.updatePostCount();
}
res.json({
success: true,
message: 'Categories fetched successfully',
data: categories
});
} catch (err) {
console.error('Categories API error:', err);
res.status(500).json({
success: false,
message: 'Error loading categories',
error: err.message || 'Error loading categories'
});
}
};
// Get category by slug
exports.apiShow = async (req, res) => {
try {
const category = await BlogCategory.findOne({
slug: req.params.slug,
isActive: true
}).lean();
if (!category) {
return res.status(404).json({
success: false,
message: 'Category not found'
});
}
res.json({
success: true,
message: 'Category fetched successfully',
data: category
});
} catch (err) {
console.error('Category show API error:', err);
res.status(500).json({
success: false,
message: 'Error loading category',
error: err.message || 'Error loading category'
});
}
};
// Quick create category (for inline creation in blog form)
exports.quickCreate = async (req, res) => {
try {
const { name, description } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({
success: false,
message: 'Category name is required'
});
}
const categoryName = name.trim();
// Generate slug
const slug = slugify(categoryName, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if category already exists
let category = await BlogCategory.findOne({ slug });
if (category) {
return res.json({
success: true,
message: 'Category already exists',
data: category.toObject()
});
}
// Create new category
category = new BlogCategory({
name: categoryName,
slug,
description: description || '',
isActive: true
});
await category.save();
res.json({
success: true,
message: 'Category created successfully',
data: category.toObject()
});
} catch (err) {
console.error('Quick create category error:', err);
res.status(500).json({
success: false,
message: 'Error creating category',
error: err.message || 'Error creating category'
});
}
};
module.exports = exports;

View File

@@ -1,901 +0,0 @@
const Blog = require("../models/blog");
const BlogCategory = require("../models/blogCategory");
const BlogTag = require("../models/blogTag");
const BlogComment = require("../models/blogComment");
const RecentPost = require("../models/recentPost");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const slugify = require("slugify");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// -------------------- Helper Functions --------------------
// Generate slug from title
const generateSlug = (title) => {
return slugify(title, {
lower: true,
strict: true,
locale: "vi",
});
};
// Update category post counts
const updateCategoryPostCounts = async () => {
const categories = await BlogCategory.find();
for (const category of categories) {
await category.updatePostCount();
}
};
// Update tag post counts
const updateTagPostCounts = async () => {
const tags = await BlogTag.find();
for (const tag of tags) {
await tag.updatePostCount();
}
};
// -------------------- Admin Controllers --------------------
// Display blog management page
exports.index = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Build filter
const filter = {};
if (req.query.status) {
filter.status = req.query.status;
}
if (req.query.category) {
filter.category = req.query.category;
}
if (req.query.search) {
filter.$or = [
{ title: { $regex: req.query.search, $options: "i" } },
{ excerpt: { $regex: req.query.search, $options: "i" } },
];
}
// Get blogs with pagination
const blogs = await Blog.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean();
const totalBlogs = await Blog.countDocuments(filter);
const totalPages = Math.ceil(totalBlogs / limit);
// Get categories for filter
const categories = await BlogCategory.getActive();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render("admin/blog/index", {
layout: "layouts/main",
title: "Blog Management",
blogs,
categories,
frontendUrl,
backendUrl,
getFullImageUrl, // Truyền helper function vào template
pagination: {
current: page,
total: totalPages,
limit,
totalItems: totalBlogs,
},
query: req.query,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("Blog index error:", err);
req.flash("error_msg", "Error loading blogs");
res.redirect("/admin/dashboard");
}
};
// Show create blog form
exports.create = async (req, res) => {
try {
const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render("admin/blog/create", {
layout: "layouts/main",
title: "Create New Blog Post",
categories,
tags,
currentPath: req.path,
user: req.session.user,
frontendUrl,
backendUrl,
getFullImageUrl, // Truyền helper function vào template
});
} catch (err) {
console.error("Blog create form error:", err);
req.flash("error_msg", "Error loading create form");
res.redirect("/admin/blog");
}
};
// Store new blog
exports.store = async (req, res) => {
try {
const {
title,
excerpt,
content,
category,
tags,
status,
isFeatured,
author,
galleryImages,
quote,
contentAfterQuote,
} = req.body;
// Generate slug
const slug = generateSlug(title);
// Check if slug exists
const existingBlog = await Blog.findOne({ slug });
if (existingBlog) {
req.flash("error_msg", "A blog post with this title already exists");
return res.redirect("/admin/blog/create");
}
// Create blog data
const blogData = {
title,
slug,
excerpt,
content,
category: category
? Array.isArray(category)
? category
: [category]
: [], // Array categories
tags: tags ? (Array.isArray(tags) ? tags : [tags]) : [],
status: status || "published",
isFeatured: isFeatured === "on",
author: author || "Admin",
galleryImages: galleryImages
? Array.isArray(galleryImages)
? galleryImages
: [galleryImages]
: [],
quote: quote || "",
contentAfterQuote: contentAfterQuote || "",
};
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) {
blogData.featuredImage = req.body.featuredImageUrl;
}
// Create blog
const blog = new Blog(blogData);
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
await updateCategoryPostCounts();
await updateTagPostCounts();
await RecentPost.syncFromBlogs();
req.flash("success_msg", "Blog post created successfully");
res.redirect("/admin/blog");
} catch (err) {
console.error("Blog store error:", err);
req.flash("error_msg", "Error creating blog post");
res.redirect("/admin/blog/create");
}
};
// Show edit blog form
exports.edit = async (req, res) => {
try {
const blog = await Blog.findById(req.params.id);
if (!blog) {
req.flash("error_msg", "Blog post not found");
return res.redirect("/admin/blog");
}
const categories = await BlogCategory.getActive();
const tags = await BlogTag.getActive();
// Get all comments for this blog post (including pending, approved, rejected)
const allComments = await BlogComment.find({ postId: blog._id })
.sort({ createdAt: -1 })
.lean();
// Organize comments with replies
const parentComments = allComments.filter((c) => !c.parentId);
const commentsWithReplies = parentComments.map((parent) => {
const replies = allComments.filter(
(c) => c.parentId && c.parentId.toString() === parent._id.toString(),
);
return {
...parent,
replies: replies,
};
});
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
res.render("admin/blog/edit", {
layout: "layouts/main",
title: "Edit Blog Post",
blog,
categories,
tags,
comments: commentsWithReplies,
commentsCount: allComments.length,
currentPath: req.path,
user: req.session.user,
frontendUrl,
backendUrl,
getFullImageUrl, // Truyền helper function vào template
});
} catch (err) {
console.error("Blog edit form error:", err);
req.flash("error_msg", "Error loading blog post");
res.redirect("/admin/blog");
}
};
// Update blog
exports.update = async (req, res) => {
try {
const blog = await Blog.findById(req.params.id);
if (!blog) {
req.flash("error_msg", "Blog post not found");
return res.redirect("/admin/blog");
}
// Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(blog.toObject()));
const {
title,
excerpt,
content,
category,
tags,
status,
isFeatured,
author,
galleryImages,
quote,
contentAfterQuote,
} = req.body;
// Update blog data
blog.title = title;
blog.excerpt = excerpt;
blog.content = content;
blog.category = category
? Array.isArray(category)
? category
: [category]
: []; // Array categories
blog.tags = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
blog.status = status || "published";
blog.isFeatured = isFeatured === "on";
blog.author = author || "Admin";
blog.galleryImages = galleryImages
? Array.isArray(galleryImages)
? galleryImages
: [galleryImages]
: [];
blog.quote = quote || "";
blog.contentAfterQuote = contentAfterQuote || "";
// Handle featured image - using featuredImageUrl from form (uploaded via AJAX)
if (req.body.featuredImageUrl) {
blog.featuredImage = req.body.featuredImageUrl;
}
// Generate new slug if title changed
const newSlug = generateSlug(title);
if (newSlug !== blog.slug) {
const existingBlog = await Blog.findOne({
slug: newSlug,
_id: { $ne: blog._id },
});
if (existingBlog) {
req.flash("error_msg", "A blog post with this title already exists");
return res.redirect(`/admin/blog/${blog._id}/edit`);
}
blog.slug = newSlug;
}
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
await updateCategoryPostCounts();
await updateTagPostCounts();
await RecentPost.syncFromBlogs();
req.flash("success_msg", "Blog post updated successfully");
res.redirect("/admin/blog");
} catch (err) {
console.error("Blog update error:", err);
req.flash("error_msg", "Error updating blog post");
res.redirect(`/admin/blog/${req.params.id}/edit`);
}
};
// Delete blog
exports.destroy = async (req, res) => {
try {
const blog = await Blog.findById(req.params.id);
if (!blog) {
req.flash("error_msg", "Blog post not found");
return res.redirect("/admin/blog");
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(JSON.stringify(blog.toObject()));
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
await updateCategoryPostCounts();
await updateTagPostCounts();
await RecentPost.syncFromBlogs();
req.flash("success_msg", "Blog post deleted successfully");
res.redirect("/admin/blog");
} catch (err) {
console.error("Blog delete error:", err);
req.flash("error_msg", "Error deleting blog post");
res.redirect("/admin/blog");
}
};
// -------------------- Public API Controllers --------------------
// Get all published blogs for frontend
exports.api = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Build filter
const filter = { status: "published" };
if (req.query.category) {
filter.category = { $in: [req.query.category] }; // Tìm trong array categories
}
if (req.query.tag) {
filter.tags = { $in: [req.query.tag] }; // Tìm trong array tags
}
if (req.query.search) {
filter.$or = [
{ title: { $regex: req.query.search, $options: "i" } },
{ excerpt: { $regex: req.query.search, $options: "i" } },
];
}
// Get blogs
const blogs = await Blog.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean();
const totalBlogs = await Blog.countDocuments(filter);
// Add base URL to images
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedBlogs = blogs.map((blog) =>
addBaseUrlToImages(blog, baseUrl),
);
res.json({
success: true,
message: "Blogs fetched successfully",
data: {
blogs: processedBlogs,
pagination: {
current: page,
total: Math.ceil(totalBlogs / limit),
limit,
totalItems: totalBlogs,
},
},
});
} catch (err) {
console.error("Blog API error:", err);
res.status(500).json({
success: false,
message: "Error loading blogs",
error: err.message || "Error loading blogs",
});
}
};
// Get single blog by slug
exports.apiShow = async (req, res) => {
try {
const blog = await Blog.findOne({
slug: req.params.slug,
status: "published",
}).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// Get comments for this post (parent comments only)
const parentComments = await BlogComment.getApprovedByPost(blog._id);
// Get replies for each parent comment
const commentsWithReplies = await Promise.all(
parentComments.map(async (parentComment) => {
const replies = await BlogComment.getReplies(parentComment._id);
return {
...parentComment.toObject(),
replies: replies.map((reply) => reply.toObject()),
};
}),
);
// Flatten comments array (parent + replies)
const allComments = commentsWithReplies.flatMap((comment) => [
comment,
...comment.replies,
]);
// Add comments to blog
blog.comments = allComments;
// Keep commentsCount in sync for frontend
blog.commentsCount = allComments.length;
// Add base URL to images
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedBlog = addBaseUrlToImages(blog, baseUrl);
res.json({
success: true,
message: "Blog post fetched successfully",
data: processedBlog,
});
} catch (err) {
console.error("Blog show API error:", err);
res.status(500).json({
success: false,
message: "Error loading blog post",
error: err.message || "Error loading blog post",
});
}
};
// Create a comment (no moderation for now: default approved)
exports.apiCreateComment = async (req, res) => {
try {
const {
authorName,
authorEmail,
authorPhone,
authorAddress,
authorDate,
content,
parentId,
} = req.body || {};
if (!authorName || !String(authorName).trim()) {
return res.status(400).json({
success: false,
message: "authorName is required",
});
}
if (!content || !String(content).trim()) {
return res.status(400).json({
success: false,
message: "content is required",
});
}
const blog = await Blog.findOne({
slug: req.params.slug,
status: "published",
}).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// If replying, ensure parent exists and belongs to same post
let parentObjectId = null;
if (parentId) {
const parent = await BlogComment.findOne({
_id: parentId,
postId: blog._id,
}).lean();
if (!parent) {
return res.status(400).json({
success: false,
message: "Invalid parentId",
});
}
parentObjectId = parentId;
}
const newComment = await BlogComment.create({
postId: blog._id,
authorName: String(authorName).trim(),
...(authorEmail ? { authorEmail: String(authorEmail).trim() } : {}),
...(authorPhone ? { authorPhone: String(authorPhone).trim() } : {}),
...(authorAddress ? { authorAddress: String(authorAddress).trim() } : {}),
...(authorDate ? { authorDate: String(authorDate).trim() } : {}),
content: String(content).trim(),
parentId: parentObjectId,
status: "approved",
});
// Keep counter roughly correct (also counts replies)
await Blog.updateOne({ _id: blog._id }, { $inc: { commentsCount: 1 } });
return res.json({
success: true,
message: "Comment created successfully",
data: newComment.toJSON(),
});
} catch (err) {
console.error("Create comment API error:", err);
return res.status(500).json({
success: false,
message: "Error creating comment",
error: err.message || "Error creating comment",
});
}
};
// Get featured blogs
exports.apiFeatured = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 3;
const blogs = await Blog.getFeatured().limit(limit).lean();
// Add base URL to images
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("port")}`;
const processedBlogs = blogs.map((blog) =>
addBaseUrlToImages(blog, baseUrl),
);
res.json({
success: true,
message: "Featured blogs fetched successfully",
data: processedBlogs,
});
} catch (err) {
console.error("Featured blogs API error:", err);
res.status(500).json({
success: false,
message: "Error loading featured blogs",
error: err.message || "Error loading featured blogs",
});
}
};
// Get recent blogs
exports.apiRecent = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 5;
// Try to get from RecentPost first
let recentPosts = await RecentPost.getRecent(limit);
// If no recent posts, sync from blogs
if (recentPosts.length === 0) {
await RecentPost.syncFromBlogs(limit);
recentPosts = await RecentPost.getRecent(limit);
}
// Add base URL to images
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedPosts = recentPosts.map((post) =>
addBaseUrlToImages(post, baseUrl),
);
res.json({
success: true,
message: "Recent blogs fetched successfully",
data: processedPosts,
});
} catch (err) {
console.error("Recent blogs API error:", err);
res.status(500).json({
success: false,
message: "Error loading recent blogs",
error: err.message || "Error loading recent blogs",
});
}
};
// Get categories of a specific blog post
exports.apiCategories = async (req, res) => {
try {
const mongoose = require("mongoose");
let query;
// Check if it's a valid ObjectId
if (mongoose.Types.ObjectId.isValid(req.params.id)) {
query = { _id: req.params.id };
} else {
query = { slug: req.params.id };
}
query.status = "published";
const blog = await Blog.findOne(query).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// Get category details
const BlogCategory = require("../models/blogCategory");
const categories = await BlogCategory.find({
name: { $in: blog.category },
isActive: true,
}).lean();
res.json({
success: true,
message: "Blog categories fetched successfully",
data: categories,
});
} catch (err) {
console.error("Blog categories API error:", err);
res.status(500).json({
success: false,
message: "Error loading blog categories",
error: err.message || "Error loading blog categories",
});
}
};
// Get tags of a specific blog post
exports.apiTags = async (req, res) => {
try {
const mongoose = require("mongoose");
let query;
// Check if it's a valid ObjectId
if (mongoose.Types.ObjectId.isValid(req.params.id)) {
query = { _id: req.params.id };
} else {
query = { slug: req.params.id };
}
query.status = "published";
const blog = await Blog.findOne(query).lean();
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
// Get tag details
const BlogTag = require("../models/blogTag");
const tags = await BlogTag.find({
name: { $in: blog.tags },
isActive: true,
}).lean();
res.json({
success: true,
message: "Blog tags fetched successfully",
data: tags,
});
} catch (err) {
console.error("Blog tags API error:", err);
res.status(500).json({
success: false,
message: "Error loading blog tags",
error: err.message || "Error loading blog tags",
});
}
};
// -------------------- Comment Management Controllers --------------------
// Approve a comment
exports.approveComment = async (req, res) => {
try {
const { blogId, commentId } = req.params;
const blog = await Blog.findById(blogId);
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
const comment = await BlogComment.findById(commentId);
if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({
success: false,
message: "Comment not found",
});
}
comment.status = "approved";
await comment.save();
res.json({
success: true,
message: "Comment approved successfully",
});
} catch (err) {
console.error("Approve comment error:", err);
res.status(500).json({
success: false,
message: "Error approving comment",
error: err.message || "Error approving comment",
});
}
};
// Reject a comment
exports.rejectComment = async (req, res) => {
try {
const { blogId, commentId } = req.params;
const blog = await Blog.findById(blogId);
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
const comment = await BlogComment.findById(commentId);
if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({
success: false,
message: "Comment not found",
});
}
comment.status = "rejected";
await comment.save();
res.json({
success: true,
message: "Comment rejected successfully",
});
} catch (err) {
console.error("Reject comment error:", err);
res.status(500).json({
success: false,
message: "Error rejecting comment",
error: err.message || "Error rejecting comment",
});
}
};
// Delete a comment
exports.deleteComment = async (req, res) => {
try {
const { blogId, commentId } = req.params;
const blog = await Blog.findById(blogId);
if (!blog) {
return res.status(404).json({
success: false,
message: "Blog post not found",
});
}
const comment = await BlogComment.findById(commentId);
if (!comment || comment.postId.toString() !== blogId) {
return res.status(404).json({
success: false,
message: "Comment not found",
});
}
// Delete the comment and all its replies
await BlogComment.deleteMany({
$or: [{ _id: commentId }, { parentId: commentId }],
});
// Update blog comment count
const remainingComments = await BlogComment.countDocuments({
postId: blogId,
});
await Blog.updateOne({ _id: blogId }, { commentsCount: remainingComments });
res.json({
success: true,
message: "Comment deleted successfully",
});
} catch (err) {
console.error("Delete comment error:", err);
res.status(500).json({
success: false,
message: "Error deleting comment",
error: err.message || "Error deleting comment",
});
}
};
module.exports = exports;

View File

@@ -1,358 +0,0 @@
const BlogTag = require('../models/blogTag');
const slugify = require('slugify');
// -------------------- Admin Controllers --------------------
// Display tag management page
exports.index = async (req, res) => {
try {
const tags = await BlogTag.find()
.sort({ name: 1 })
.lean();
res.render('admin/blog/tags/index', {
layout: 'layouts/main',
title: 'Blog Tags',
tags,
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Tag index error:', err);
req.flash('error_msg', 'Error loading tags');
res.redirect('/admin/dashboard');
}
};
// Show create tag form
exports.create = async (req, res) => {
try {
res.render('admin/blog/tags/create', {
layout: 'layouts/main',
title: 'Create New Tag',
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Tag create form error:', err);
req.flash('error_msg', 'Error loading create form');
res.redirect('/admin/blog/tags');
}
};
// Store new tag
exports.store = async (req, res) => {
try {
const {
name,
isActive
} = req.body;
// Generate slug
const slug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if slug exists
const existingTag = await BlogTag.findOne({ slug });
if (existingTag) {
req.flash('error_msg', 'A tag with this name already exists');
return res.redirect('/admin/blog/tags/create');
}
// Create tag data
const tagData = {
name,
slug,
isActive: isActive === 'on'
};
// Create tag
const tag = new BlogTag(tagData);
await tag.save();
req.flash('success_msg', 'Tag created successfully');
res.redirect('/admin/blog/tags');
} catch (err) {
console.error('Tag store error:', err);
req.flash('error_msg', 'Error creating tag');
res.redirect('/admin/blog/tags/create');
}
};
// Show edit tag form
exports.edit = async (req, res) => {
try {
const tag = await BlogTag.findById(req.params.id);
if (!tag) {
req.flash('error_msg', 'Tag not found');
return res.redirect('/admin/blog/tags');
}
res.render('admin/blog/tags/edit', {
layout: 'layouts/main',
title: 'Edit Tag',
tag,
currentPath: req.path,
user: req.session.user
});
} catch (err) {
console.error('Tag edit form error:', err);
req.flash('error_msg', 'Error loading tag');
res.redirect('/admin/blog/tags');
}
};
// Update tag
exports.update = async (req, res) => {
try {
const tag = await BlogTag.findById(req.params.id);
if (!tag) {
req.flash('error_msg', 'Tag not found');
return res.redirect('/admin/blog/tags');
}
const {
name,
isActive
} = req.body;
// Update tag data
tag.name = name;
tag.isActive = isActive === 'on';
// Generate new slug if name changed
const newSlug = slugify(name, {
lower: true,
strict: true,
locale: 'vi'
});
if (newSlug !== tag.slug) {
const existingTag = await BlogTag.findOne({
slug: newSlug,
_id: { $ne: tag._id }
});
if (existingTag) {
req.flash('error_msg', 'A tag with this name already exists');
return res.redirect(`/admin/blog/tags/${tag._id}/edit`);
}
tag.slug = newSlug;
}
await tag.save();
req.flash('success_msg', 'Tag updated successfully');
res.redirect('/admin/blog/tags');
} catch (err) {
console.error('Tag update error:', err);
req.flash('error_msg', 'Error updating tag');
res.redirect(`/admin/blog/tags/${req.params.id}/edit`);
}
};
// Delete tag
exports.destroy = async (req, res) => {
try {
const tag = await BlogTag.findById(req.params.id);
if (!tag) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(404).json({
success: false,
message: 'Tag not found'
});
}
req.flash('error_msg', 'Tag not found');
return res.redirect('/admin/blog/tags');
}
// Check if tag has posts
const Blog = require('../models/blog');
const postCount = await Blog.countDocuments({ tags: { $in: [tag.name] } });
if (postCount > 0) {
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(400).json({
success: false,
message: 'Cannot delete tag that is used in blog posts'
});
}
req.flash('error_msg', 'Cannot delete tag that is used in blog posts');
return res.redirect('/admin/blog/tags');
}
await BlogTag.findByIdAndDelete(req.params.id);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.json({
success: true,
message: 'Tag deleted successfully'
});
}
req.flash('success_msg', 'Tag deleted successfully');
res.redirect('/admin/blog/tags');
} catch (err) {
console.error('Tag delete error:', err);
// Check if it's an AJAX request
const isAjax = req.xhr || req.headers['accept']?.includes('application/json') || req.headers['content-type']?.includes('application/json');
if (isAjax) {
return res.status(500).json({
success: false,
message: 'Error deleting tag',
error: err.message || 'Error deleting tag'
});
}
req.flash('error_msg', 'Error deleting tag');
res.redirect('/admin/blog/tags');
}
};
// -------------------- Public API Controllers --------------------
// Get all active tags
exports.api = async (req, res) => {
try {
const tags = await BlogTag.getActive();
// Update post counts
for (const tag of tags) {
await tag.updatePostCount();
}
res.json({
success: true,
message: 'Tags fetched successfully',
data: tags
});
} catch (err) {
console.error('Tags API error:', err);
res.status(500).json({
success: false,
message: 'Error loading tags',
error: err.message || 'Error loading tags'
});
}
};
// Get popular tags
exports.apiPopular = async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const tags = await BlogTag.getPopular(limit);
res.json({
success: true,
message: 'Popular tags fetched successfully',
data: tags
});
} catch (err) {
console.error('Popular tags API error:', err);
res.status(500).json({
success: false,
message: 'Error loading popular tags',
error: err.message || 'Error loading popular tags'
});
}
};
// Get tag by slug
exports.apiShow = async (req, res) => {
try {
const tag = await BlogTag.findOne({
slug: req.params.slug,
isActive: true
}).lean();
if (!tag) {
return res.status(404).json({
success: false,
message: 'Tag not found'
});
}
res.json({
success: true,
message: 'Tag fetched successfully',
data: tag
});
} catch (err) {
console.error('Tag show API error:', err);
res.status(500).json({
success: false,
message: 'Error loading tag',
error: err.message || 'Error loading tag'
});
}
};
// Quick create tag (for inline creation in blog form)
exports.quickCreate = async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({
success: false,
message: 'Tag name is required'
});
}
const tagName = name.trim();
// Generate slug
const slug = slugify(tagName, {
lower: true,
strict: true,
locale: 'vi'
});
// Check if tag already exists
let tag = await BlogTag.findOne({ slug });
if (tag) {
return res.json({
success: true,
message: 'Tag already exists',
data: tag.toObject()
});
}
// Create new tag
tag = new BlogTag({
name: tagName,
slug,
isActive: true
});
await tag.save();
res.json({
success: true,
message: 'Tag created successfully',
data: tag.toObject()
});
} catch (err) {
console.error('Quick create tag error:', err);
res.status(500).json({
success: false,
message: 'Error creating tag',
error: err.message || 'Error creating tag'
});
}
};
module.exports = exports;

View File

@@ -1,549 +0,0 @@
const fs = require('fs');
const path = require('path');
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Booking = require("../models/booking");
// -------------------- Public helpers --------------------
const getBookingData = async () => {
const booking = await Booking.findOne().sort({ updatedAt: -1 });
return booking ? (booking.toObject ? booking.toObject() : booking) : null;
};
// Load static booking JSON from `data/booking.json` (if present)
const loadStaticBooking = () => {
try {
const p = path.join(__dirname, '..', 'data', 'booking.json');
if (!fs.existsSync(p)) return null;
const raw = fs.readFileSync(p, 'utf8');
return JSON.parse(raw);
} catch (e) {
console.error('booking.loadStaticBooking error:', e && e.message);
return null;
}
};
// Normalize booking shape: ensure configuration exists with discounts/vouchers
const normalizeBookingShape = (booking) => {
if (!booking || typeof booking !== 'object') return booking;
const b = JSON.parse(JSON.stringify(booking));
if (!b.configuration || typeof b.configuration !== 'object') {
b.configuration = { currency: 'USD', discounts: [], vouchers: [] };
}
// Ensure configuration.discounts and configuration.vouchers exist
if (!Array.isArray(b.configuration.discounts)) {
b.configuration.discounts = [];
}
if (!Array.isArray(b.configuration.vouchers)) {
b.configuration.vouchers = [];
}
return b;
};
// Deep merge: properties from `overrides` replace / merge into `base`.
const deepMerge = (base, overrides) => {
if (overrides === undefined) return base;
if (base === undefined || base === null) return overrides;
if (Array.isArray(overrides)) return overrides;
if (typeof overrides !== 'object' || overrides === null) return overrides;
const out = Object.assign({}, base);
Object.keys(overrides).forEach((k) => {
if (Array.isArray(overrides[k]) || typeof overrides[k] !== 'object' || overrides[k] === null) {
out[k] = overrides[k];
} else {
out[k] = deepMerge(base[k], overrides[k]);
}
});
return out;
};
// Ensure booking data fields have the expected shapes to avoid runtime errors
const sanitizeBookingData = (raw) => {
const defaults = {
hero: { title: '', backgroundImage: '' },
searchBar: { locationLabel: '', holidaySeasonLabel: '', searchButtonText: '' },
filterPanel: {
title: '',
priceTitle: '',
priceLabel: '',
pricePlaceholder: '',
priceMin: 0,
priceMax: 0,
ageTitle: '',
ageMin: 0,
ageMax: 0,
ageSelectPlaceholder: '',
activitiesTitle: '',
ratingTitle: '',
ratingOptions: [],
resetButtonText: ''
},
programs: [],
holidays: [],
locations: [],
camps: [],
configuration: { currency: 'USD', discounts: [], vouchers: [] },
formSteps: [],
validation: {}
};
if (!raw || typeof raw !== 'object') return defaults;
// Use raw data first, then fill in missing fields with defaults
const safe = Object.assign({}, raw);
// Ensure nested objects/arrays have correct types (use raw data if valid, otherwise defaults)
safe.hero = (safe.hero && typeof safe.hero === 'object') ? safe.hero : defaults.hero;
safe.searchBar = (safe.searchBar && typeof safe.searchBar === 'object') ? safe.searchBar : defaults.searchBar;
safe.filterPanel = (safe.filterPanel && typeof safe.filterPanel === 'object') ? safe.filterPanel : defaults.filterPanel;
if (!Array.isArray(safe.filterPanel.ratingOptions)) safe.filterPanel.ratingOptions = defaults.filterPanel.ratingOptions;
safe.programs = Array.isArray(safe.programs) ? safe.programs : defaults.programs;
safe.holidays = Array.isArray(safe.holidays) ? safe.holidays : defaults.holidays;
safe.locations = Array.isArray(safe.locations) ? safe.locations : defaults.locations;
safe.camps = Array.isArray(safe.camps) ? safe.camps : defaults.camps;
// Ensure configuration has proper structure
if (!safe.configuration || typeof safe.configuration !== 'object') {
safe.configuration = defaults.configuration;
}
if (!Array.isArray(safe.configuration.discounts)) {
safe.configuration.discounts = defaults.configuration.discounts;
}
if (!Array.isArray(safe.configuration.vouchers)) {
safe.configuration.vouchers = defaults.configuration.vouchers;
}
// Ensure formSteps and validation have correct types
safe.formSteps = Array.isArray(safe.formSteps) ? safe.formSteps : defaults.formSteps;
safe.validation = (safe.validation && typeof safe.validation === 'object' && !Array.isArray(safe.validation)) ? safe.validation : defaults.validation;
return safe;
};
// Safe JSON parse with better error handling - handles double-encoded JSON and JS object notation
const safeParse = (value, fieldName = 'unknown') => {
// If already an object or array, return as-is
if (typeof value === 'object' && value !== null) {
return value;
}
// If string, try to parse
if (typeof value === 'string') {
try {
let cleaned = value.trim();
// Check if it looks like JavaScript object notation (has single quotes or unquoted keys)
if (cleaned.includes("'") || /\{\s*\w+:/.test(cleaned)) {
console.warn(`safeParse: Converting JS notation to JSON for "${fieldName}"`);
// Aggressive conversion approach
cleaned = cleaned
.replace(/'/g, '"') // Replace ALL single quotes with double quotes
.replace(/\r?\n|\r/g, ' ') // Remove all newlines
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas before } or ]
.replace(/([{,]\s*)(\w+):/g, '$1"$2":'); // Quote unquoted object keys
}
// Try parsing
let parsed = JSON.parse(cleaned);
// If result is still a string, try parsing again (double-encoded)
if (typeof parsed === 'string') {
console.warn(`safeParse: Double-encoded JSON detected for "${fieldName}"`);
parsed = JSON.parse(parsed);
}
return parsed;
} catch (e) {
console.error(`safeParse: Failed to parse field "${fieldName}"`, {
error: e.message,
valuePreview: value.substring(0, 200)
});
throw new Error(`Invalid JSON format for field: ${fieldName}. Error: ${e.message}`);
}
}
// For other types, return empty array or object
console.warn(`safeParse: Unexpected type for field "${fieldName}": ${typeof value}`);
return Array.isArray(value) ? [] : {};
};
// Validate booking data structure
const validateBookingData = (data) => {
const errors = [];
// Check required fields
if (!data.hero || typeof data.hero !== 'object') {
errors.push('Hero data is required and must be an object');
}
if (!data.searchBar || typeof data.searchBar !== 'object') {
errors.push('SearchBar data is required and must be an object');
}
if (!data.filterPanel || typeof data.filterPanel !== 'object') {
errors.push('FilterPanel data is required and must be an object');
}
// Validate arrays
if (data.programs && !Array.isArray(data.programs)) {
errors.push('Programs must be an array');
}
if (data.holidays && !Array.isArray(data.holidays)) {
errors.push('Holidays must be an array');
}
if (data.locations && !Array.isArray(data.locations)) {
errors.push('Locations must be an array');
}
if (data.camps && !Array.isArray(data.camps)) {
errors.push('Camps must be an array');
}
// Validate configuration structure
if (data.configuration) {
if (typeof data.configuration !== 'object') {
errors.push('Configuration must be an object');
} else {
if (data.configuration.discounts && !Array.isArray(data.configuration.discounts)) {
errors.push('Configuration.discounts must be an array');
}
if (data.configuration.vouchers && !Array.isArray(data.configuration.vouchers)) {
errors.push('Configuration.vouchers must be an array');
}
}
}
// Validate formSteps and validation structure if provided
if (data.formSteps && !Array.isArray(data.formSteps)) {
errors.push('formSteps must be an array');
}
if (data.validation && (typeof data.validation !== 'object' || Array.isArray(data.validation))) {
errors.push('validation must be an object');
}
return {
isValid: errors.length === 0,
errors
};
};
// -------------------- Public endpoints --------------------
// Public endpoint: return Booking JSON
exports.page = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
// Build final payload according to BOOKING_MODE env var
const finalBooking = getFinalBooking(normStatic, normDb);
if (!finalBooking) {
return res.status(404).json({
error: "No booking data found",
message: "Please configure booking data in admin panel"
});
}
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(finalBooking, baseUrl);
return res.json(processed);
} catch (err) {
console.error("booking.page error:", err);
return res.status(500).json({
error: "Error loading booking data",
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
};
// API endpoint to return booking JSON
exports.api = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
const finalBooking = getFinalBooking(normStatic, normDb);
if (!finalBooking) {
return res.status(404).json({
error: "No booking data found",
message: "Please configure booking data in admin panel"
});
}
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(finalBooking, baseUrl);
return res.json(processed);
} catch (err) {
console.error("booking.api error:", err);
return res.status(500).json({
error: "Error loading booking data",
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
};
// -------------------- Admin endpoints --------------------
// Display Booking management page
exports.index = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Merge static booking with DB data (use same merge logic as public endpoints)
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
const mergedData = getFinalBooking(normStatic, normDb);
// Normalize again after merge to ensure discounts/vouchers are synced to top-level
const data = normalizeBookingShape(mergedData);
// Sanitize data to ensure nested fields are objects/arrays (fixes malformed DB entries)
const safeData = sanitizeBookingData(data);
res.render("admin/booking/index", {
layout: "layouts/main",
title: "Booking Management",
data: safeData,
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("booking.index error:", err);
req.flash("error_msg", "Error loading booking page");
res.redirect("/admin/dashboard");
}
};
// Update booking data
exports.update = async (req, res) => {
try {
const { id } = req.params;
// ADD THIS DEBUG LOG
console.log('=== RAW REQUEST BODY ===');
console.log('Discounts type:', typeof req.body.discounts);
console.log('Discounts first 500 chars:', req.body.discounts?.substring(0, 500));
console.log('Vouchers type:', typeof req.body.vouchers);
console.log('Vouchers first 500 chars:', req.body.vouchers?.substring(0, 500));
console.log('========================');
const {
hero,
searchBar,
filterPanel,
programs,
holidays,
locations,
camps,
discounts,
vouchers,
formSteps,
validation: validationRaw
} = req.body;
// Parse JSON strings
const errors = [];
let updateData = {};
try {
console.log('Raw discounts from req.body:', typeof discounts, discounts);
console.log('Raw vouchers from req.body:', typeof vouchers, vouchers);
const parsedDiscounts = safeParse(discounts, 'discounts');
const parsedVouchers = safeParse(vouchers, 'vouchers');
console.log('Parsed discounts:', typeof parsedDiscounts, Array.isArray(parsedDiscounts), parsedDiscounts);
console.log('Parsed vouchers:', typeof parsedVouchers, Array.isArray(parsedVouchers), parsedVouchers);
updateData = {
hero: safeParse(hero, 'hero'),
searchBar: safeParse(searchBar, 'searchBar'),
filterPanel: safeParse(filterPanel, 'filterPanel'),
programs: safeParse(programs, 'programs'),
holidays: safeParse(holidays, 'holidays'),
locations: safeParse(locations, 'locations'),
camps: safeParse(camps, 'camps'),
formSteps: safeParse(formSteps, 'formSteps'),
validation: safeParse(validationRaw, 'validation'),
configuration: {
currency: 'USD',
discounts: parsedDiscounts,
vouchers: parsedVouchers
}
};
} catch (parseError) {
console.error('booking.update: Parse error', parseError);
req.flash("error_msg", `Data processing error: ${parseError.message}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
// Validate data structure
const validation = validateBookingData(updateData);
if (!validation.isValid) {
console.error('booking.update: Validation failed', validation.errors);
req.flash("error_msg", `Validation failed: ${validation.errors[0]}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
console.log('Final updateData keys:', Object.keys(updateData));
console.log('updateData.discounts:', updateData.discounts);
console.log('updateData.configuration:', updateData.configuration);
// CRITICAL: Remove any top-level discounts/vouchers to prevent schema conflicts
// These should ONLY exist in configuration object
delete updateData.discounts;
delete updateData.vouchers;
// Update or create booking document
let result;
try {
if (id && id !== 'undefined') {
result = await Booking.findByIdAndUpdate(
id,
{
...updateData,
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
},
{
new: true,
runValidators: false, // TẮT validator để tránh lỗi cast
strict: false // TẮT strict mode
}
);
if (!result) {
req.flash("error_msg", "Booking document not found");
return req.session.save(() => res.redirect("/admin/booking"));
}
} else {
// Upsert: update existing or create new
result = await Booking.findOneAndUpdate(
{},
{
...updateData,
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
},
{
upsert: true,
new: true,
runValidators: false, // TẮT validator
strict: false // TẮT strict mode
}
);
}
req.flash("success_msg", "Booking data updated successfully");
return req.session.save(() => res.redirect("/admin/booking"));
} catch (dbError) {
console.error("booking.update: Database error", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
} catch (err) {
console.error("booking.update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
};
// Booking selection mode: 'merge' (default) = static base, DB overrides;
// 'static' = use `data/booking.json` only; 'db' = use DB only.
const getFinalBooking = (staticBooking, dbBooking) => {
const mode = (process.env.BOOKING_MODE || 'merge').toLowerCase();
if (mode === 'static') return staticBooking || dbBooking || null;
if (mode === 'db') return dbBooking || staticBooking || null;
// default: merge static (base) with DB overrides
// If both static and db present, attempt to map DB primitive lists (e.g. ["915"]) to
// full objects from the static file (e.g. {id:"915", name:..., type:...}).
const mapDbPrimitivesToObjects = (db, stat) => {
if (!db || !stat) return db;
const dbCfg = db.configuration || {};
const statCfg = stat.configuration || {};
console.log('DB discounts/vouchers:', db.discounts, db.vouchers);
console.log('DB config:', dbCfg.discounts, dbCfg.vouchers);
console.log('Static config:', statCfg.discounts, statCfg.vouchers);
// Handle legacy: if top-level discounts/vouchers exist as strings, migrate to configuration
if (Array.isArray(db.discounts) && db.discounts.length > 0 && (!dbCfg.discounts || dbCfg.discounts.length === 0)) {
const statDiscountById = {};
if (statCfg.discounts) {
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
}
if (typeof db.discounts[0] === 'string') {
dbCfg.discounts = db.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
} else {
dbCfg.discounts = db.discounts;
}
}
if (Array.isArray(db.vouchers) && db.vouchers.length > 0 && (!dbCfg.vouchers || dbCfg.vouchers.length === 0)) {
const statVouchByCode = {};
if (statCfg.vouchers) {
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
}
if (typeof db.vouchers[0] === 'string') {
dbCfg.vouchers = db.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
} else {
dbCfg.vouchers = db.vouchers;
}
}
// If DB configuration still empty, use static data
if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length === 0 && statCfg.discounts && statCfg.discounts.length > 0) {
dbCfg.discounts = statCfg.discounts;
} else if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length > 0 && typeof dbCfg.discounts[0] === 'string') {
// Map string IDs to full objects from static
const statDiscountById = {};
if (statCfg.discounts) {
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
}
dbCfg.discounts = dbCfg.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
}
if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length === 0 && statCfg.vouchers && statCfg.vouchers.length > 0) {
dbCfg.vouchers = statCfg.vouchers;
} else if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length > 0 && typeof dbCfg.vouchers[0] === 'string') {
// Map string codes to full objects from static
const statVouchByCode = {};
if (statCfg.vouchers) {
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
}
dbCfg.vouchers = dbCfg.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
}
return Object.assign({}, db, { configuration: dbCfg });
};
const mappedDb = mapDbPrimitivesToObjects(dbBooking, staticBooking);
const merged = staticBooking ? deepMerge(staticBooking, mappedDb || {}) : (mappedDb || null);
// Clean up: remove top-level discounts/vouchers after migrating to configuration
if (merged) {
delete merged.discounts;
delete merged.vouchers;
}
return merged;
};

View File

@@ -1,558 +0,0 @@
const BookingSubmission = require('../models/bookingSubmission');
const Activity = require('../models/activity');
// API endpoint để tạo booking submission mới
exports.submitBooking = async (req, res) => {
try {
const {
activityId,
sessionId,
parentFirstName,
parentLastName,
email,
phone,
address,
city,
country,
postalCode,
participantFirstName,
participantLastName,
participantBirthDate,
participantGender,
numberOfParticipants,
medicalConditions,
dietaryRestrictions,
specialRequests,
emergencyContact,
emergencyPhone,
agreeTerms,
agreeNewsletter
} = req.body;
// Validate required fields
if (!activityId || !sessionId || !parentFirstName || !parentLastName ||
!email || !phone || !address || !city || !country || !postalCode ||
!participantFirstName || !participantLastName || !participantBirthDate ||
!participantGender || !emergencyContact || !emergencyPhone || !agreeTerms) {
return res.status(400).json({
error: 'Missing required fields',
message: 'Please fill in all required fields'
});
}
// Verify activity exists
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({
error: 'Activity not found',
message: 'The selected activity does not exist'
});
}
// Verify session exists and is active
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
if (!session) {
return res.status(404).json({
error: 'Session not found',
message: 'The selected session does not exist'
});
}
if (!session.isActive) {
return res.status(400).json({
error: 'Session not available',
message: 'The selected session is no longer available for booking'
});
}
// Check availability based on participant gender
const currentBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender,
status: { $in: ['pending', 'confirmed'] }
});
const availableSpots = participantGender === 'male'
? session.totalMaleSpots - session.bookedMaleSpots
: session.totalFemaleSpots - session.bookedFemaleSpots;
if (currentBookings >= availableSpots) {
return res.status(400).json({
error: 'Session full',
message: `No more spots available for ${participantGender} participants in this session`
});
}
// Calculate total amount based on activity price and number of participants
const totalAmount = (activity.price || 0) * (parseInt(numberOfParticipants) || 1);
// Create booking submission
const bookingSubmission = new BookingSubmission({
activityId,
sessionId,
parentFirstName: parentFirstName.trim(),
parentLastName: parentLastName.trim(),
email: email.toLowerCase().trim(),
phone: phone.trim(),
address: address.trim(),
city: city.trim(),
country: country.trim(),
postalCode: postalCode.trim(),
participantFirstName: participantFirstName.trim(),
participantLastName: participantLastName.trim(),
participantBirthDate: new Date(participantBirthDate),
participantGender,
numberOfParticipants: parseInt(numberOfParticipants) || 1,
medicalConditions: (medicalConditions || '').trim(),
dietaryRestrictions: dietaryRestrictions || 'none',
specialRequests: (specialRequests || '').trim(),
emergencyContact: emergencyContact.trim(),
emergencyPhone: emergencyPhone.trim(),
agreeTerms: Boolean(agreeTerms),
agreeNewsletter: Boolean(agreeNewsletter),
totalAmount,
status: 'pending',
paymentStatus: 'pending'
});
await bookingSubmission.save();
// Update session booked spots
const updateField = participantGender === 'male' ? 'bookingSessions.$.bookedMaleSpots' : 'bookingSessions.$.bookedFemaleSpots';
await Activity.updateOne(
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
{ $inc: { [updateField]: 1 } }
);
// Populate activity info for response
await bookingSubmission.populate('activityId', 'name price');
return res.status(201).json({
success: true,
message: 'Booking submitted successfully',
booking: {
id: bookingSubmission._id,
activityName: bookingSubmission.activityId.name,
sessionId: bookingSubmission.sessionId,
participantName: `${bookingSubmission.participantFirstName} ${bookingSubmission.participantLastName}`,
totalAmount: bookingSubmission.totalAmount,
status: bookingSubmission.status
}
});
} catch (error) {
console.error('submitBooking error:', error);
// Handle validation errors
if (error.name === 'ValidationError') {
const validationErrors = Object.values(error.errors).map(err => err.message);
return res.status(400).json({
error: 'Validation failed',
message: validationErrors.join(', ')
});
}
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while processing your booking. Please try again.'
});
}
};
// API endpoint để lấy thông tin session availability
exports.getSessionAvailability = async (req, res) => {
try {
const { activityId, sessionId } = req.params;
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({ error: 'Activity not found' });
}
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Get current booking counts
const maleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender: 'male',
status: { $in: ['pending', 'confirmed'] }
});
const femaleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender: 'female',
status: { $in: ['pending', 'confirmed'] }
});
return res.json({
sessionId,
isActive: session.isActive,
startDate: session.startDate,
endDate: session.endDate,
overnightStays: session.overnightStays,
price: session.price || activity.price,
availability: {
male: {
total: session.totalMaleSpots,
booked: maleBookings,
available: Math.max(0, session.totalMaleSpots - maleBookings)
},
female: {
total: session.totalFemaleSpots,
booked: femaleBookings,
available: Math.max(0, session.totalFemaleSpots - femaleBookings)
}
}
});
} catch (error) {
console.error('getSessionAvailability error:', error);
return res.status(500).json({ error: 'Error loading session availability' });
}
};
// API endpoint để lấy tất cả sessions có sẵn cho một activity
exports.getAvailableSessions = async (req, res) => {
try {
const { activityId } = req.params;
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({ error: 'Activity not found' });
}
const sessions = activity.bookingSessions || [];
const availableSessions = [];
for (const session of sessions) {
if (!session.isActive) continue;
// Get current booking counts
const maleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId: session.sessionId,
participantGender: 'male',
status: { $in: ['pending', 'confirmed'] }
});
const femaleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId: session.sessionId,
participantGender: 'female',
status: { $in: ['pending', 'confirmed'] }
});
const maleAvailable = Math.max(0, session.totalMaleSpots - maleBookings);
const femaleAvailable = Math.max(0, session.totalFemaleSpots - femaleBookings);
// Only include sessions that have available spots
if (maleAvailable > 0 || femaleAvailable > 0) {
availableSessions.push({
sessionId: session.sessionId,
startDate: session.startDate,
endDate: session.endDate,
overnightStays: session.overnightStays,
price: session.price || activity.price,
availability: {
male: {
total: session.totalMaleSpots,
booked: maleBookings,
available: maleAvailable
},
female: {
total: session.totalFemaleSpots,
booked: femaleBookings,
available: femaleAvailable
}
}
});
}
}
return res.json({
activityId,
activityName: activity.name,
sessions: availableSessions
});
} catch (error) {
console.error('getAvailableSessions error:', error);
return res.status(500).json({ error: 'Error loading available sessions' });
}
};
// API endpoint để cập nhật booking submission
exports.updateBookingSubmission = async (req, res) => {
try {
const { bookingId } = req.params;
const updateData = req.body;
// Find the booking
let booking = await BookingSubmission.findById(bookingId);
// If not found as a separate document, try to find it as an embedded booking in Activity.bookingSessions
let activityContaining = null;
let sessionIndex = -1;
let bookingIndex = -1;
if (!booking) {
activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
if (!activityContaining) {
return res.status(404).json({
error: 'Booking not found',
message: 'The booking submission does not exist'
});
}
// locate the exact session and booking positions
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
const bl = activityContaining.bookingSessions[si].bookingList || [];
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
if (bi !== -1) {
sessionIndex = si;
bookingIndex = bi;
break;
}
}
if (sessionIndex === -1 || bookingIndex === -1) {
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
}
booking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
}
// Define allowed fields to update
const allowedUpdates = [
'status',
'paymentStatus',
'paidAmount',
'totalAmount',
'adminNotes',
'emergencyContact',
'emergencyPhone',
'medicalConditions',
'dietaryRestrictions',
'specialRequests'
];
// Build update object with only allowed fields
const updateFields = {};
for (const field of allowedUpdates) {
if (updateData[field] !== undefined) {
updateFields[field] = updateData[field];
}
}
if (Object.keys(updateFields).length === 0) {
return res.status(400).json({
error: 'No valid fields to update',
message: 'Please provide at least one valid field to update'
});
}
// If booking is a separate document, update the BookingSubmission collection
if (activityContaining === null) {
const updatedBooking = await BookingSubmission.findByIdAndUpdate(
bookingId,
updateFields,
{ new: true, runValidators: true }
).populate('activityId', 'name price');
return res.json({
success: true,
message: 'Booking updated successfully',
booking: updatedBooking
});
}
// Otherwise update the embedded booking in the Activity document
const currentBooking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
// Handle status updates and spot adjustments
const newStatus = updateData.status || updateData.bookingStatus;
const currentStatus = currentBooking.status || currentBooking.bookingStatus;
// Apply allowed updates to the embedded booking
const allowedEmbeddedUpdates = [
'status', 'bookingStatus', 'paymentStatus', 'paidAmount', 'totalAmount', 'adminNotes',
'emergencyContact', 'emergencyPhone', 'medicalConditions', 'dietaryRestrictions', 'specialRequests'
];
for (const field of allowedEmbeddedUpdates) {
if (updateData[field] !== undefined) {
if (field === 'status') {
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].status = updateData.status;
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].bookingStatus = updateData.status;
} else {
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex][field] = updateData[field];
}
}
}
// If status change affects spots, adjust counts
if (newStatus && newStatus !== currentStatus) {
const numberOfParticipants = currentBooking.numberOfParticipants || 1;
const participantGender = currentBooking.participantGender;
// If booking is being cancelled, free up spots
if (newStatus === 'cancelled' && currentStatus !== 'cancelled') {
if (participantGender === 'male') {
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
} else if (participantGender === 'female') {
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
}
}
// If restoring from cancelled, ensure capacity then book spots
if (currentStatus === 'cancelled' && newStatus !== 'cancelled') {
if (participantGender === 'male') {
const totalMale = activityContaining.bookingSessions[sessionIndex].totalMaleSpots;
const currentMale = activityContaining.bookingSessions[sessionIndex].bookedMaleSpots;
if (currentMale + numberOfParticipants > totalMale) {
return res.status(400).json({ error: "Not enough male spots available to restore this booking" });
}
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
} else if (participantGender === 'female') {
const totalFemale = activityContaining.bookingSessions[sessionIndex].totalFemaleSpots;
const currentFemale = activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots;
if (currentFemale + numberOfParticipants > totalFemale) {
return res.status(400).json({ error: "Not enough female spots available to restore this booking" });
}
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
}
}
}
await activityContaining.save();
return res.json({
success: true,
message: 'Embedded booking updated successfully',
booking: activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex]
});
} catch (error) {
console.error('updateBookingSubmission error:', error);
// Handle validation errors
if (error.name === 'ValidationError') {
const validationErrors = Object.values(error.errors).map(err => err.message);
return res.status(400).json({
error: 'Validation failed',
message: validationErrors.join(', ')
});
}
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while updating the booking'
});
}
};
// API endpoint để xóa booking submission
exports.deleteBookingSubmission = async (req, res) => {
try {
const { bookingId } = req.params;
// Find and delete the booking
let booking = await BookingSubmission.findById(bookingId);
// If not found in separate collection, try to delete embedded booking in Activity
if (!booking) {
const activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
if (!activityContaining) {
return res.status(404).json({
error: 'Booking not found',
message: 'The booking submission does not exist'
});
}
// locate session and booking
let sessionIndex = -1;
let bookingIndex = -1;
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
const bl = activityContaining.bookingSessions[si].bookingList || [];
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
if (bi !== -1) {
sessionIndex = si;
bookingIndex = bi;
break;
}
}
if (sessionIndex === -1 || bookingIndex === -1) {
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
}
const bookingToDelete = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
// Free up spots if booking is not cancelled
if ((bookingToDelete.bookingStatus || bookingToDelete.status) !== 'cancelled') {
const numberOfParticipants = bookingToDelete.numberOfParticipants || 1;
const participantGender = bookingToDelete.participantGender;
if (participantGender === 'male') {
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
} else if (participantGender === 'female') {
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
}
}
// Remove booking and save
activityContaining.bookingSessions[sessionIndex].bookingList.splice(bookingIndex, 1);
await activityContaining.save();
return res.json({
success: true,
message: 'Embedded booking deleted successfully',
booking: {
id: bookingId,
participantName: `${bookingToDelete.participantFirstName} ${bookingToDelete.participantLastName}`,
email: bookingToDelete.email
}
});
}
// Store info for session spot adjustment
const { activityId, sessionId, participantGender, numberOfParticipants } = booking;
// Delete the booking
await BookingSubmission.findByIdAndDelete(bookingId);
// Update session booked spots (decrease the count)
if (booking.status !== 'cancelled') {
const updateField = participantGender === 'male'
? 'bookingSessions.$.bookedMaleSpots'
: 'bookingSessions.$.bookedFemaleSpots';
await Activity.updateOne(
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
{ $inc: { [updateField]: -numberOfParticipants } }
);
}
return res.json({
success: true,
message: 'Booking deleted successfully',
booking: {
id: bookingId,
participantName: `${booking.participantFirstName} ${booking.participantLastName}`,
email: booking.email
}
});
} catch (error) {
console.error('deleteBookingSubmission error:', error);
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while deleting the booking'
});
}
};

View File

@@ -0,0 +1,161 @@
const path = require('path');
const Certificate = require('../models/certificate');
const Department = require('../models/department');
const Level = require('../models/level');
const writeAuditLog = require('../audit/writeAuditLog');
const AUDIT_ACTIONS = require('../constants/auditAction');
function normalizePath(filePath) {
if (!filePath) return undefined;
return path.basename(filePath.replace(/\\/g, '/'));
}
// GET /admin/certificate
exports.index = async (req, res) => {
try {
const { search, status } = req.query;
const filter = {};
if (search) {
filter.$or = [
{ certification_number: { $regex: search, $options: 'i' } },
{ student_name: { $regex: search, $options: 'i' } }
];
}
if (status) filter.status = status;
const [certificates, departments, levels] = await Promise.all([
Certificate.find(filter).populate('department level').sort({ createdAt: -1 }),
Department.find(), Level.find()
]);
res.render('admin/certificate/index', {
certificates, departments, levels, query: req.query,
user: req.session.user, layout: 'layouts/admin', title: 'Certificates'
});
} catch (err) {
console.error(err);
req.flash('error', 'Error loading certificates');
res.redirect('/admin/dashboard');
}
};
// GET /admin/certificate/create
exports.createForm = async (req, res) => {
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/certificate/create', {
departments, levels, user: req.session.user,
layout: 'layouts/admin', title: 'Create Certificate'
});
} catch (err) {
req.flash('error', 'Error'); res.redirect('/admin/certificate');
}
};
// POST /admin/certificate/create
exports.create = async (req, res) => {
try {
const data = { ...req.body };
const imgPath = req.files?.certificate_image?.[0]?.path;
if (imgPath) data.certificate_image = normalizePath(imgPath);
const cert = new Certificate(data);
await cert.save();
await writeAuditLog({ model: 'Certificate', documentId: cert._id, action: AUDIT_ACTIONS.CREATE_CERTIFICATE, before: null, after: cert.toObject(), req });
req.flash('success', 'Certificate created');
res.redirect('/admin/certificate');
} catch (err) {
console.error(err);
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/certificate/create', {
error: err.message, formData: req.body, departments, levels,
user: req.session.user, layout: 'layouts/admin', title: 'Create Certificate'
});
} catch { req.flash('error', err.message); res.redirect('/admin/certificate'); }
}
};
// GET /admin/certificate/:id/edit
exports.editForm = async (req, res) => {
try {
const cert = await Certificate.findById(req.params.id).populate('department level');
if (!cert) { req.flash('error', 'Not found'); return res.redirect('/admin/certificate'); }
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/certificate/edit', {
cert, departments, levels, user: req.session.user,
layout: 'layouts/admin', title: 'Edit Certificate'
});
} catch (err) {
req.flash('error', 'Error'); res.redirect('/admin/certificate');
}
};
// POST /admin/certificate/:id/edit
exports.update = async (req, res) => {
try {
const cert = await Certificate.findById(req.params.id);
if (!cert) { req.flash('error', 'Not found'); return res.redirect('/admin/certificate'); }
const before = cert.toObject();
const fields = ['certification_number','student_name','program_name','department','level',
'issued_date','status','passport_number','address'];
fields.forEach(f => { if (req.body[f] !== undefined) cert[f] = req.body[f]; });
const imgPath = req.files?.certificate_image?.[0]?.path;
if (imgPath) cert.certificate_image = normalizePath(imgPath);
await cert.save();
await writeAuditLog({ model: 'Certificate', documentId: cert._id, action: AUDIT_ACTIONS.UPDATE_CERTIFICATE, before, after: cert.toObject(), req });
req.flash('success', 'Certificate updated');
res.redirect('/admin/certificate');
} catch (err) {
req.flash('error', err.message); res.redirect('back');
}
};
// POST /admin/certificate/:id/delete
exports.destroy = async (req, res) => {
try {
const cert = await Certificate.findById(req.params.id);
if (!cert) { req.flash('error', 'Not found'); return res.redirect('/admin/certificate'); }
await writeAuditLog({ model: 'Certificate', documentId: cert._id, action: AUDIT_ACTIONS.DELETE_CERTIFICATE, before: cert.toObject(), after: null, req });
await cert.deleteOne();
req.flash('success', 'Certificate deleted');
res.redirect('/admin/certificate');
} catch (err) {
req.flash('error', 'Error deleting'); res.redirect('/admin/certificate');
}
};
// GET /api/verify-certificate/:cert_id?api_key=xxx
exports.apiVerify = async (req, res) => {
try {
const cert = await Certificate.findOne({
certification_number: { $regex: new RegExp('^' + req.params.cert_id + '$', 'i') }
}).populate('department level');
if (!cert) return res.status(404).json({ error: 'Certificate not found' });
if (cert.status === 'revoked') return res.status(404).json({ error: 'Certificate has been revoked' });
const baseUrl = `${req.protocol}://${req.get('host')}`;
const buildUrl = (f) => f ? [`${baseUrl}/secure-files/${path.basename(f)}?api_key=${req.query.api_key}`] : undefined;
const response = {
full_name: cert.student_name,
certification_title: cert.program_name,
certificate_id: cert.certification_number,
};
if (cert.passport_number) response.passport_number = cert.passport_number;
if (cert.address) response.address = cert.address;
const imgs = buildUrl(cert.certificate_image);
if (imgs) response.certificate_image = imgs;
return res.json(response);
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error' });
}
};

View File

@@ -1,388 +0,0 @@
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Contact = require("../models/contact");
const ContactSubmission = require("../models/contactSubmission");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Get contact data from MongoDB
const getContactData = async () => {
const contact = await Contact.findOne({ name: "default" });
if (!contact) {
return null;
}
return contact.toObject();
};
// API to get contact data
exports.api = async (req, res) => {
try {
const contact = await getContactData();
if (!contact) {
return res.status(404).json({ error: "Contact data not found" });
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(contact, baseUrl);
res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading contact data" });
}
};
// API để lấy toàn bộ contact data
exports.getContactData = async (req, res) => {
try {
const contactData = await getContactData();
if (!contactData) {
return res.status(404).json({ error: "Contact data not found" });
}
res.json(contactData);
} catch (error) {
console.error("Error getting contact data:", error);
res.status(500).json({ error: "Error loading contact data" });
}
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = (await getContactData()) || {
hero: {
title: "Contact Us",
backgroundImage: "",
overlayColor: "rgba(0, 0, 0, 0)",
sectionClass: "",
titleClass: "",
enableScrollspy: false,
backgroundPosition: "center",
},
contactCards: [],
map: {
coordinates: { lat: 0, lng: 0 },
zoom: 15,
location: "",
markerTitle: "",
embedUrl: "",
tileLayer: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "",
maxZoom: 18,
minZoom: 0,
},
},
form: {
sectionLabel: "",
heading: "",
description: "",
fields: [],
submitButton: {
text: "Send Message",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn style-2",
},
},
};
const { startDate, endDate } = req.query;
const query = {};
if (startDate || endDate) {
query.createdAt = {};
if (startDate) {
query.createdAt.$gte = new Date(startDate);
}
if (endDate) {
// Set end date to end of day
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
query.createdAt.$lte = end;
}
}
const submissions = await ContactSubmission.find(query)
.sort({ createdAt: -1 })
.limit(50);
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/contact/index", {
title: "Contact Management",
layout: "layouts/main",
data,
submissions,
startDate,
endDate,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in contact index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu contact
exports.update = async (req, res) => {
try {
const { hero, contactCards, map, form } = req.body;
// Parse JSON strings nếu cần
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
const heroData = parseJson(hero);
const contactCardsData = parseJson(contactCards);
const mapData = parseJson(map);
const formData = parseJson(form);
// Tìm hoặc tạo contact
let contact = await Contact.findOne({ name: "default" });
// ✅ Capture BEFORE state
const beforeData = contact
? JSON.parse(JSON.stringify(contact.toObject()))
: {};
if (!contact) {
// Tạo mới với default values
contact = new Contact({
name: "default",
hero: heroData || {
title: "Contact Us",
backgroundImage: "",
overlayColor: "rgba(0, 0, 0, 0)",
sectionClass: "",
titleClass: "",
enableScrollspy: false,
backgroundPosition: "center",
},
contactCards: (contactCardsData || []).map((card) => ({
...card,
iconType: card.iconType || "",
iconSource:
card.iconSource ||
(card.iconType && card.iconType.startsWith("/uploads/")
? "image"
: "fontawesome"),
})),
map: mapData || {
coordinates: { lat: 0, lng: 0 },
zoom: 15,
location: "",
markerTitle: "",
embedUrl: "",
tileLayer: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "",
maxZoom: 18,
minZoom: 0,
},
},
form: formData || {
sectionLabel: "",
heading: "",
description: "",
fields: [],
submitButton: {
text: "Send Message",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn style-2",
},
},
});
} else {
// Cập nhật dữ liệu
if (heroData) contact.hero = heroData;
if (contactCardsData && Array.isArray(contactCardsData)) {
// Đảm bảo mỗi card có iconType và iconSource
contact.contactCards = contactCardsData.map((card) => ({
...card,
iconType: card.iconType || "",
iconSource:
card.iconSource ||
(card.iconType && card.iconType.startsWith("/uploads/")
? "image"
: "fontawesome"),
}));
}
if (mapData) contact.map = mapData;
if (formData) contact.form = formData;
}
await contact.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(contact.toObject()));
// ✅ AUDIT LOGGING - Contact Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Contact",
documentId: contact._id,
action: AUDIT_ACTIONS.UPDATE_CONTACT,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Contact updated successfully");
res.redirect("/admin/contact");
} catch (err) {
console.error("Error updating contact:", err);
req.flash("error_msg", err.message || "Error updating contact");
res.redirect("/admin/contact");
}
};
// API để submit contact form (từ frontend)
exports.submitForm = async (req, res) => {
try {
const { name, email, phone, address, date, message } = req.body;
// Validation
if (!name || !email) {
return res.status(400).json({
success: false,
error: "Name and email are required",
});
}
// Create new submission
const submission = new ContactSubmission({
name: name.trim(),
email: email.trim().toLowerCase(),
phone: phone?.trim() || "",
address: address?.trim() || "",
date: date?.trim() || "",
message: message?.trim() || "",
ipAddress: req.ip || req.connection?.remoteAddress || "",
userAgent: req.get("User-Agent") || "",
});
await submission.save();
res.status(201).json({
success: true,
message: "Thank you for contacting us! We will get back to you soon.",
data: {
id: submission._id,
name: submission.name,
email: submission.email,
},
});
} catch (err) {
console.error("Error submitting contact form:", err);
// Handle validation errors
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({
success: false,
error: errors.join(", "),
});
}
res.status(500).json({
success: false,
error: "Error submitting form. Please try again later.",
});
}
};
// API để lấy danh sách submissions (cho admin)
exports.getSubmissions = async (req, res) => {
try {
const { status, page = 1, limit = 20 } = req.query;
const query = {};
if (status && ["pending", "read", "replied", "archived"].includes(status)) {
query.status = status;
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [submissions, total] = await Promise.all([
ContactSubmission.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
ContactSubmission.countDocuments(query),
]);
res.json({
success: true,
data: submissions,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / parseInt(limit)),
},
});
} catch (err) {
console.error("Error getting submissions:", err);
res.status(500).json({
success: false,
error: "Error loading submissions",
});
}
};
// API để cập nhật status của submission
exports.updateSubmissionStatus = async (req, res) => {
try {
const { id } = req.params;
const { status, notes } = req.body;
const validStatuses = ["pending", "read", "replied", "archived"];
if (!validStatuses.includes(status)) {
return res.status(400).json({
success: false,
error: "Invalid status",
});
}
const updateData = { status };
if (notes !== undefined) updateData.notes = notes;
if (status === "replied") updateData.repliedAt = new Date();
const submission = await ContactSubmission.findByIdAndUpdate(
id,
updateData,
{ new: true },
);
if (!submission) {
return res.status(404).json({
success: false,
error: "Submission not found",
});
}
res.json({
success: true,
data: submission,
});
} catch (err) {
console.error("Error updating submission:", err);
res.status(500).json({
success: false,
error: "Error updating submission",
});
}
};

View File

@@ -1,17 +1,54 @@
const { readJsonFile } = require('../utils/jsonHelper');
const Qualification = require('../models/qualification');
const Certificate = require('../models/certificate');
// Hiển thị dashboard
exports.getDashboard = async (req, res) => {
try {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [
qualificationCount,
certificationCount,
activeQual,
revokedQual,
activeCert,
revokedCert,
recentQual,
recentCert,
recentQualifications,
recentCertificates
] = await Promise.all([
Qualification.countDocuments(),
Certificate.countDocuments(),
Qualification.countDocuments({ status: 'active' }),
Qualification.countDocuments({ status: 'revoked' }),
Certificate.countDocuments({ status: 'active' }),
Certificate.countDocuments({ status: 'revoked' }),
Qualification.countDocuments({ createdAt: { $gte: thirtyDaysAgo } }),
Certificate.countDocuments({ createdAt: { $gte: thirtyDaysAgo } }),
Qualification.find().sort({ createdAt: -1 }).limit(5).populate('department level'),
Certificate.find().sort({ createdAt: -1 }).limit(5).populate('department level')
]);
res.render('admin/dashboard', {
title: 'Dashboard',
user: req.session.user
qualificationCount,
certificationCount,
total: qualificationCount + certificationCount,
activeCount: activeQual + activeCert,
revokedCount: revokedQual + revokedCert,
recentCount: recentQual + recentCert,
recentQualifications,
recentCertificates,
user: req.session.user,
layout: 'layouts/admin',
title: 'Dashboard'
});
} catch (err) {
console.error(err);
res.render('admin/dashboard', {
title: 'Dashboard',
user: req.session.user
qualificationCount: 0, certificationCount: 0,
total: 0, activeCount: 0, revokedCount: 0, recentCount: 0,
recentQualifications: [], recentCertificates: [],
user: req.session.user, layout: 'layouts/admin', title: 'Dashboard'
});
}
};
};

View File

@@ -0,0 +1,261 @@
const path = require('path');
const Degree = require('../models/degree');
const Department = require('../models/department');
const Level = require('../models/level');
const writeAuditLog = require('../audit/writeAuditLog');
const AUDIT_ACTIONS = require('../constants/auditAction');
// Helper: store only filename, served via /secure-files/ route
function normalizePath(filePath) {
if (!filePath) return undefined;
return path.basename(filePath.replace(/\\/g, '/'));
}
// GET /admin/degree
exports.index = async (req, res) => {
try {
const { search, type, department, level, status } = req.query;
const filter = {};
if (search) {
filter.$or = [
{ qualification_number: { $regex: search, $options: 'i' } },
{ certification_number: { $regex: search, $options: 'i' } },
{ student_name: { $regex: search, $options: 'i' } }
];
}
if (type) filter.type = type;
if (department) filter.department = department;
if (level) filter.level = level;
if (status) filter.status = status;
const [degrees, departments, levels] = await Promise.all([
Degree.find(filter).populate('department level').sort({ createdAt: -1 }),
Department.find(),
Level.find()
]);
res.render('admin/degree/index', {
degrees, departments, levels,
query: req.query,
user: req.session.user,
layout: 'layouts/admin',
title: 'Quản lý Văn bằng'
});
} catch (err) {
console.error('degreeController.index error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tải danh sách văn bằng');
res.redirect('/admin/dashboard');
}
};
// GET /admin/degree/create
exports.createForm = async (req, res) => {
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/degree/create', {
departments, levels,
user: req.session.user,
layout: 'layouts/admin',
title: 'Tạo Văn bằng mới'
});
} catch (err) {
console.error('degreeController.createForm error:', err);
req.flash('error', 'Đã xảy ra lỗi');
res.redirect('/admin/degree');
}
};
// POST /admin/degree/create
exports.create = async (req, res) => {
try {
const degreeData = { ...req.body };
const degreeImagePath = req.files?.degree_image?.[0]?.path;
const certificateImagePath = req.files?.certificate_image?.[0]?.path;
if (degreeImagePath) degreeData.degree_image = normalizePath(degreeImagePath);
if (certificateImagePath) degreeData.certificate_image = normalizePath(certificateImagePath);
const degree = new Degree(degreeData);
await degree.save();
await writeAuditLog({
model: 'Degree', documentId: degree._id,
action: AUDIT_ACTIONS.CREATE_DEGREE,
before: null, after: degree.toObject(), req
});
req.flash('success', 'Degree created');
res.redirect('/admin/degree');
} catch (err) {
console.error('degreeController.create error:', err);
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/degree/create', {
error: err.message, formData: req.body,
departments, levels,
user: req.session.user,
layout: 'layouts/admin',
title: 'Tạo Văn bằng mới'
});
} catch (renderErr) {
req.flash('error', err.message);
res.redirect('/admin/degree');
}
}
};
// GET /admin/degree/:id/edit
exports.editForm = async (req, res) => {
try {
const degree = await Degree.findById(req.params.id).populate('department level');
if (!degree) {
req.flash('error', 'Degree not found');
return res.redirect('/admin/degree');
}
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/degree/edit', {
degree, departments, levels,
user: req.session.user,
layout: 'layouts/admin',
title: 'Chỉnh sửa Văn bằng'
});
} catch (err) {
console.error('degreeController.editForm error:', err);
req.flash('error', 'Đã xảy ra lỗi');
res.redirect('/admin/degree');
}
};
// POST /admin/degree/:id/edit
exports.update = async (req, res) => {
try {
const degree = await Degree.findById(req.params.id);
if (!degree) {
req.flash('error', 'Degree not found');
return res.redirect('/admin/degree');
}
const beforeData = degree.toObject();
const fields = [
'qualification_number', 'certification_number', 'student_name', 'program_name',
'type', 'department', 'level', 'issued_date', 'status',
'passport_number', 'address', 'topic_name', 'topic_short_desc'
];
fields.forEach(field => { if (req.body[field] !== undefined) degree[field] = req.body[field]; });
const degreeImagePath = req.files?.degree_image?.[0]?.path;
const certificateImagePath = req.files?.certificate_image?.[0]?.path;
if (degreeImagePath) degree.degree_image = normalizePath(degreeImagePath);
if (certificateImagePath) degree.certificate_image = normalizePath(certificateImagePath);
await degree.save();
await writeAuditLog({
model: 'Degree', documentId: degree._id,
action: AUDIT_ACTIONS.UPDATE_DEGREE,
before: beforeData, after: degree.toObject(), req
});
req.flash('success', 'Degree updated');
res.redirect('/admin/degree');
} catch (err) {
console.error('degreeController.update error:', err);
req.flash('error', err.message);
res.redirect('back');
}
};
// POST /admin/degree/:id/delete
exports.destroy = async (req, res) => {
try {
const degree = await Degree.findById(req.params.id);
if (!degree) {
req.flash('error', 'Degree not found');
return res.redirect('/admin/degree');
}
await writeAuditLog({
model: 'Degree', documentId: degree._id,
action: AUDIT_ACTIONS.DELETE_DEGREE,
before: degree.toObject(), after: null, req
});
await degree.deleteOne();
req.flash('success', 'Degree deleted');
res.redirect('/admin/degree');
} catch (err) {
console.error('degreeController.destroy error:', err);
req.flash('error', 'Đã xảy ra lỗi khi xóa văn bằng');
res.redirect('/admin/degree');
}
};
// ─── Public API ───────────────────────────────────────────────────────────────
function buildSecureUrl(req, filename) {
if (!filename) return undefined;
const baseUrl = `${req.protocol}://${req.get('host')}`;
const name = path.basename(filename);
return `${baseUrl}/secure-files/${name}?api_key=${req.query.api_key}`;
}
// GET /api/verify-degree/:degree_id?api_key=xxx
// Lookup by qualification_number — returns degree fields + topic_name if PhD
exports.apiGetByQualification = async (req, res) => {
try {
const degree = await Degree.findOne({
qualification_number: { $regex: new RegExp('^' + req.params.degree_id + '$', 'i') }
}).populate('department level');
if (!degree) return res.status(404).json({ error: 'Degree not found' });
if (degree.status === 'revoked') return res.status(404).json({ error: 'Degree has been revoked' });
const imageUrl = buildSecureUrl(req, degree.degree_image);
const response = {
full_name: degree.student_name,
program_name: degree.program_name,
degree_id: degree.qualification_number,
};
if (degree.passport_number) response.passport_number = degree.passport_number;
if (degree.address) response.address = degree.address;
if (imageUrl) response.degree_image = [imageUrl];
// topic_name present → PhD view; absent → MBA/Master view
if (degree.topic_name) {
response.topic_name = degree.topic_name;
if (degree.topic_short_desc) response.topic_short_desc = degree.topic_short_desc;
}
return res.json(response);
} catch (err) {
console.error('apiGetByQualification error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
};
// GET /api/verify-certificate/:cert_id?api_key=xxx
// Lookup by certification_number — returns certificate fields (no topic_name)
exports.apiGetByCertification = async (req, res) => {
try {
const degree = await Degree.findOne({
certification_number: { $regex: new RegExp('^' + req.params.cert_id + '$', 'i') }
}).populate('department level');
if (!degree) return res.status(404).json({ error: 'Certificate not found' });
if (degree.status === 'revoked') return res.status(404).json({ error: 'Certificate has been revoked' });
const imageUrl = buildSecureUrl(req, degree.certificate_image);
const response = {
full_name: degree.student_name,
certification_title: degree.program_name,
certificate_id: degree.certification_number,
};
if (degree.passport_number) response.passport_number = degree.passport_number;
if (degree.address) response.address = degree.address;
if (imageUrl) response.certificate_image = [imageUrl];
return res.json(response);
} catch (err) {
console.error('apiGetByCertification error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
};

View File

@@ -0,0 +1,79 @@
const Department = require('../models/department');
const Degree = require('../models/degree');
// GET /admin/department
exports.index = async (req, res) => {
try {
const departments = await Department.find();
res.render('admin/department/index', {
departments,
user: req.session.user,
layout: 'layouts/admin',
title: 'Quản lý Khoa/Bộ môn'
});
} catch (err) {
console.error('departmentController.index error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tải danh sách khoa/bộ môn');
res.redirect('/admin/dashboard');
}
};
// POST /admin/department/create
exports.create = async (req, res) => {
try {
const { name } = req.body;
const slug = name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
const existing = await Department.findOne({ slug });
if (existing) {
req.flash('error', 'Department already exists');
return res.redirect('back');
}
await Department.create({ name, slug });
req.flash('success', 'Department created');
res.redirect('/admin/department');
} catch (err) {
console.error('departmentController.create error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tạo khoa/bộ môn');
res.redirect('back');
}
};
// POST /admin/department/:id/edit
exports.update = async (req, res) => {
try {
const { id } = req.params;
const { name } = req.body;
const slug = name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
await Department.findByIdAndUpdate(id, { name, slug });
req.flash('success', 'Department updated');
res.redirect('/admin/department');
} catch (err) {
console.error('departmentController.update error:', err);
req.flash('error', 'Đã xảy ra lỗi khi cập nhật khoa/bộ môn');
res.redirect('back');
}
};
// POST /admin/department/:id/delete
exports.destroy = async (req, res) => {
try {
const { id } = req.params;
const count = await Degree.countDocuments({ department: id });
if (count > 0) {
req.flash('error', 'Cannot delete: Department is referenced by existing degrees');
return res.redirect('back');
}
await Department.findByIdAndDelete(id);
req.flash('success', 'Department deleted');
res.redirect('/admin/department');
} catch (err) {
console.error('departmentController.destroy error:', err);
req.flash('error', 'Đã xảy ra lỗi khi xóa khoa/bộ môn');
res.redirect('back');
}
};

View File

@@ -1,154 +0,0 @@
const Home = require("../models/home");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Helper to get FAQ data from Home model
const getFaqData = async () => {
const home = await Home.findOne().sort({ updatedAt: -1 });
if (!home || !home.faq) {
return {
heading: "",
subheading: "",
description: "",
items: [],
ctaButton: { label: "", href: "" },
};
}
return home.faq.toObject ? home.faq.toObject() : home.faq;
};
// API to get FAQ data for frontend
exports.api = async (req, res) => {
try {
const faqData = await getFaqData();
return res.json(faqData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading FAQ data" });
}
};
// Method for legacy route compatibility or internal use
exports.getFAQData = async (req, res) => {
return exports.api(req, res);
};
// Render admin view
exports.index = async (req, res) => {
try {
const data = await getFaqData();
// Ensure default structure if data is partial
const safeData = {
heading: data.heading || "",
subheading: data.subheading || "",
description: data.description || "",
ctaButton: data.ctaButton || { label: "", href: "" },
items: data.items || [],
};
const frontendUrl = process.env.FRONTEND_URL;
res.render("admin/home/faq/index", {
title: "FAQ Section Management",
layout: "layouts/main",
data: safeData,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (error) {
console.error("Error in FAQ index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Update FAQ data
exports.update = async (req, res) => {
try {
const { heading, subheading, description, ctaLabel, ctaHref, items } =
req.body;
let parsedItems = [];
if (items) {
try {
parsedItems = typeof items === "string" ? JSON.parse(items) : items;
} catch (e) {
console.error("Error parsing items JSON:", e);
parsedItems = [];
}
}
let home = await Home.findOne().sort({ updatedAt: -1 });
if (!home) {
home = new Home({});
}
// ✅ Capture BEFORE state
const beforeData = home.faq
? JSON.parse(
JSON.stringify(home.faq.toObject ? home.faq.toObject() : home.faq),
)
: {};
const updatedFaqData = {
heading: heading || "",
subheading: subheading || "",
description: description || "",
ctaButton: {
label: ctaLabel || "",
href: ctaHref || "",
},
items: parsedItems.map((item) => ({
question: item.question || "",
answer: item.answer || "",
})),
};
home.faq = updatedFaqData;
await home.save();
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(updatedFaqData));
// ✅ AUDIT LOGGING - FAQ Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: home._id,
action: AUDIT_ACTIONS.UPDATE_FAQ,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "FAQ section updated successfully");
res.redirect("/admin/home/faq");
} catch (err) {
console.error("Error updating FAQ:", err);
req.flash("error_msg", err.message || "Error updating FAQ");
res.redirect("/admin/home/faq");
}
};
// Placeholder methods to prevent route crashes if routes are not cleaned up immediately
exports.addFAQ = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQItem = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQItem = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.addFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.deleteFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.reorderFAQSection = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });
exports.updateSidebarNav = (req, res) =>
res.status(404).json({ error: "Endpoint deprecated" });

View File

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

View File

@@ -1,44 +0,0 @@
const fs = require('fs').promises;
const path = require('path');
const formController = {
// Display form management page
index: async (req, res) => {
try {
res.render('admin/form/index', {
layout: 'layouts/admin',
title: 'Quản lý Form',
user: req.session.user,
});
} catch (error) {
console.error('Error loading form management page:', error);
res.status(500).render('error', {
message: 'Lỗi khi tải trang quản lý form',
error: error
});
}
},
// Update default form settings
updateDefaultForm: async (req, res) => {
try {
const formData = req.body;
// Here you would typically save form configuration to database or file
// For now, just return success response
res.json({
success: true,
message: 'Cập nhật form thành công'
});
} catch (error) {
console.error('Error updating form:', error);
res.status(500).json({
success: false,
message: 'Lỗi khi cập nhật form'
});
}
}
};
module.exports = formController;

View File

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

View File

@@ -1,205 +0,0 @@
const HeaderMenu = require("../models/headerMenu");
const slugify = require("slugify");
/**
* Helper: Build tree structure from flat array
*/
const buildMenuTree = (items, parentId = null, isPublic = false) => {
const branch = [];
const children = items.filter(
(item) => String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null),
);
for (const child of children) {
const item = child.toObject ? child.toObject() : { ...child };
// Clean data for public API if requested
let cleanItem = item;
if (isPublic) {
cleanItem = {
id: item._id,
title: item.title,
url: item.url,
type: item.type,
};
}
const subChildren = buildMenuTree(items, item._id, isPublic);
cleanItem.children = subChildren.length > 0 ? subChildren : [];
branch.push(cleanItem);
}
return branch.sort((a, b) => a.order - b.order);
};
/**
* Helper: Recursive delete children
*/
const deleteRecursive = async (parentId) => {
const children = await HeaderMenu.find({ parentId });
for (const child of children) {
await deleteRecursive(child._id);
await HeaderMenu.findByIdAndDelete(child._id);
}
};
// 1. Render Menu Tab logic
exports.index = async (req, res) => {
try {
const items = await HeaderMenu.find().sort({ order: 1 });
const menuTree = buildMenuTree(items);
return { menuTree, flatItems: items };
} catch (error) {
console.error("Error fetching menu items:", error);
throw error;
}
};
// 2. Create Menu Item
exports.store = async (req, res) => {
try {
console.log("=== BACKEND: store hit ===");
console.log("Body:", req.body);
const { title, url, parentId, order, status, type } = req.body;
const slug = slugify(title, { lower: true, strict: true });
const newItem = new HeaderMenu({
title,
slug,
url,
parentId: parentId || null,
order: order || 0,
status: status || "active",
type: type || "internal",
});
const savedItem = await newItem.save();
console.log("=== MENU CREATED ===", savedItem);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.json({ success: true, message: "Menu item created successfully", data: savedItem });
}
req.flash("success_msg", "Menu item created successfully");
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error("=== CREATE MENU ERROR ===", error);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(400).json({ success: false, message: error.message });
}
req.flash("error_msg", "Failed to create menu item: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 3. Update Menu Item
exports.update = async (req, res) => {
try {
const { id } = req.params;
console.log("=== BACKEND: update hit ===", { id });
console.log("Body:", req.body);
const { title, url, parentId, order, status, type } = req.body;
const updateData = {
url,
parentId: parentId || null,
order,
status,
type,
};
if (title) {
updateData.title = title;
updateData.slug = slugify(title, { lower: true, strict: true });
}
const updated = await HeaderMenu.findByIdAndUpdate(id, updateData, { new: true });
if (!updated) {
console.log("=== UPDATE MENU NOT FOUND ===", id);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(404).json({ success: false, message: "Menu item not found" });
}
req.flash("error_msg", "Menu item not found");
} else {
console.log("=== MENU UPDATED ===", updated);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.json({ success: true, message: "Menu item updated successfully", data: updated });
}
req.flash("success_msg", "Menu item updated successfully");
}
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error("=== UPDATE MENU ERROR ===", error);
// Return JSON for AJAX requests
if (req.xhr || req.headers.accept?.indexOf("json") > -1) {
return res.status(400).json({ success: false, message: error.message });
}
req.flash("error_msg", "Update failed: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 4. Delete Menu Item (Cascade delete children)
exports.destroy = async (req, res) => {
try {
const { id } = req.body;
const menuId = id || req.params.id;
console.log("=== BACKEND: destroy hit ===", { menuId, body: req.body });
await deleteRecursive(menuId);
await HeaderMenu.findByIdAndDelete(menuId);
console.log("=== MENU DELETED ===", menuId);
req.flash("success_msg", "Menu item and its sub-menu deleted successfully");
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error("=== DELETE MENU ERROR ===", error);
req.flash("error_msg", "Delete failed: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 5. Reorder Menu
exports.reorder = async (req, res) => {
try {
const { items } = req.body; // Array of { id, order, parentId }
if (items && Array.isArray(items)) {
const bulkOps = items.map((item) => ({
updateOne: {
filter: { _id: item.id },
update: { order: item.order, parentId: item.parentId || null },
},
}));
await HeaderMenu.bulkWrite(bulkOps);
return res.json({ success: true, message: "Reordered successfully" });
}
res.status(400).json({ success: false, message: "Invalid data" });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};
// Public API: Get active menu as clean tree
exports.api = async (req, res) => {
try {
const items = await HeaderMenu.find({ status: "active" }).sort({ order: 1 });
const tree = buildMenuTree(items, null, true);
res.json({ success: true, data: tree });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};

View File

@@ -1,249 +0,0 @@
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const Home = require("../models/home");
const Blog = require("../models/blog");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// Các hàm hỗ trợ
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
const getDefaultHomeData = () => ({
hero: {
backgroundImage: "",
slides: [],
title: "",
subtitle: "",
description: "",
heroImage: "",
videoUrl: "",
primaryButton: {},
secondaryButton: {},
},
whyChooseUs: {
heading: "",
subheading: "",
description: "",
highlightWord: "",
mainImage: "",
secondaryImage: "",
items: [],
features: [],
ctaButton: {},
},
visaSolutions: { heading: "", subheading: "", items: [] },
visaCountries: {
heading: "",
subheading: "",
description: "",
countries: [],
ctaButton: {},
},
testimonials: {
heading: "",
subheading: "",
videoUrl: "",
videoThumbnail: "",
items: [],
},
videoGallery: { heading: "", videoUrl: "", thumbnail: "" },
faq: {
heading: "",
subheading: "",
description: "",
ctaButton: {},
items: [],
},
achievements: { heading: "", subheading: "", items: [] },
partners: { visaConsultancy: { items: [] }, brands: { items: [] } },
blogPreview: {
heading: "Latest Insights & Updates",
subheading: "Visa Tips & Guides",
ctaButton: { label: "View All Articles", href: "/blog" },
items: [],
selectedBlogIds: [], // Array of manually selected blog IDs
},
});
// Admin: Xem trang quản lý
exports.index = async (req, res) => {
try {
let data = await getHomeData();
const defaults = getDefaultHomeData();
// Merge dữ liệu mặc định cho tất cả các phần
const sections = Object.keys(defaults);
sections.forEach((s) => {
data[s] = data[s] || defaults[s];
});
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
// Lấy tất cả blog để chọn trong CMS
const allBlogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.lean();
return res.render("admin/home/index", {
layout: "layouts/main",
title: "Home Management",
data,
allBlogs,
frontendUrl,
backendUrl,
getFullImageUrl,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("Home index error:", err);
req.flash("error_msg", "Error loading home data");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
};
// Admin: Cập nhật dữ liệu (tập trung vào achievements, partners; các phần khác giữ nguyên nếu không có dữ liệu mới)
exports.update = async (req, res) => {
try {
const sections = [
"hero",
"whyChooseUs",
"visaSolutions",
"visaCountries",
"testimonials",
"videoGallery",
"faq",
"achievements",
"partners",
"blogPreview",
];
let doc = await getHomeDoc();
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
if (!doc) {
doc = new Home({});
}
let hasChanges = false;
const updatedSections = [];
for (const section of sections) {
if (req.body[section]) {
try {
const payload = JSON.parse(req.body[section]);
// Gán trực tiếp vào doc, Mongoose sẽ tự check schema
doc[section] = payload;
doc.markModified(section);
hasChanges = true;
updatedSections.push(section);
} catch (e) {
console.error(`Invalid JSON for ${section}:`, e);
}
}
}
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return req.session.save(() => res.redirect("/admin/home"));
}
await doc.save();
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
// ✅ AUDIT LOGGING - Home Update
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Home",
documentId: doc._id,
action: AUDIT_ACTIONS.UPDATE_HOME,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success_msg", "Home page configuration has been updated!");
return req.session.save(() => res.redirect("/admin/home"));
} catch (err) {
console.error("Home update error:", err);
req.flash("error_msg", `Update error: ${err.message}`);
return req.session.save(() => res.redirect("/admin/home"));
}
};
// Public API// API lấy danh sách blog cho CMS
exports.apiGetBlogs = async (req, res) => {
try {
const blogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.select("title slug featuredImage author publishedAt")
.lean();
res.json(blogs);
} catch (err) {
res.status(500).json({ error: err.message });
}
};
exports.api = async (req, res) => {
try {
let data = await getHomeData();
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// === Xử lý Blog Preview động ===
const blogPreview = data.blogPreview || {};
let blogs = [];
// Nếu có chọn blog cụ thể
if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) {
blogs = await Blog.find({
_id: { $in: blogPreview.selectedBlogIds },
status: "published",
}).lean();
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
blogs.sort((a, b) => {
return (
blogPreview.selectedBlogIds.indexOf(a._id.toString()) -
blogPreview.selectedBlogIds.indexOf(b._id.toString())
);
});
}
// 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 (hoặc bù vào)
if (blogs.length === 0) {
blogs = await Blog.find({ status: "published" })
.sort({ createdAt: -1 })
.limit(3)
.lean();
}
// Map dữ liệu blog sang format mà frontend mong đợi
blogPreview.items = blogs.map((blog) => ({
title: blog.title,
excerpt: blog.excerpt,
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
date: blog.publishedAt || blog.createdAt,
author: {
name: blog.author || "Admin",
avatar: "", // Frontend đang tự xử lý hoặc dùng logo hệ thống
},
comments: blog.commentsCount || 0,
link: `/blog/${blog.slug}`,
thumbnail: blog.featuredImage,
}));
data.blogPreview = blogPreview;
// ===============================
const processed = addBaseUrlToImages(data, baseUrl);
return res.json(processed);
} catch (err) {
console.error("Home API error:", err);
return res.status(500).json({ error: "Error loading home data" });
}
};

View File

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

View File

@@ -0,0 +1,76 @@
const Level = require('../models/level');
const Degree = require('../models/degree');
// GET /admin/level
exports.index = async (req, res) => {
try {
const levels = await Level.find();
res.render('admin/level/index', {
levels,
user: req.session.user,
layout: 'layouts/admin',
title: 'Quản lý Cấp độ'
});
} catch (err) {
console.error('levelController.index error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tải danh sách cấp độ');
res.redirect('/admin/dashboard');
}
};
// POST /admin/level/create
exports.create = async (req, res) => {
try {
const { type } = req.body;
if (!type) {
req.flash('error', 'Type is required');
return res.redirect('back');
}
await Level.create({ type });
req.flash('success', 'Level created');
res.redirect('/admin/level');
} catch (err) {
console.error('levelController.create error:', err);
req.flash('error', 'Đã xảy ra lỗi khi tạo cấp độ');
res.redirect('back');
}
};
// POST /admin/level/:id/edit
exports.update = async (req, res) => {
try {
const { id } = req.params;
const { type } = req.body;
await Level.findByIdAndUpdate(id, { type });
req.flash('success', 'Level updated');
res.redirect('/admin/level');
} catch (err) {
console.error('levelController.update error:', err);
req.flash('error', 'Đã xảy ra lỗi khi cập nhật cấp độ');
res.redirect('back');
}
};
// POST /admin/level/:id/delete
exports.destroy = async (req, res) => {
try {
const { id } = req.params;
const count = await Degree.countDocuments({ level: id });
if (count > 0) {
req.flash('error', 'Cannot delete: Level is referenced by existing degrees');
return res.redirect('back');
}
await Level.findByIdAndDelete(id);
req.flash('success', 'Level deleted');
res.redirect('/admin/level');
} catch (err) {
console.error('levelController.destroy error:', err);
req.flash('error', 'Đã xảy ra lỗi khi xóa cấp độ');
res.redirect('back');
}
};

View File

@@ -1,228 +0,0 @@
const { readJsonFile, writeJsonFile } = require('../utils/jsonHelper');
const slugify = require('slugify');
// Hiển thị tất cả các trang
exports.getAllPages = async (req, res) => {
try {
const content = readJsonFile('content');
const pages = content.pages || [];
res.render('admin/pages/index', {
title: 'Quản lý trang',
pages
});
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading page list');
res.redirect('/admin/dashboard');
}
};
// Hiển thị form tạo trang mới
exports.getAddPage = (req, res) => {
res.render('admin/pages/add', {
title: 'Thêm trang mới'
});
};
// Xử lý tạo trang mới
exports.addPage = async (req, res) => {
try {
const { title, content } = req.body;
// Kiểm tra dữ liệu
if (!title || !content) {
req.flash('error_msg', 'Please fill in all required fields');
return res.redirect('/admin/pages/add');
}
// Tạo slug từ tiêu đề
const slug = slugify(title, {
lower: true,
strict: true,
locale: 'vi'
});
// Lấy dữ liệu hiện tại
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
// Kiểm tra slug đã tồn tại chưa
const existingPage = pages.find(page => page.slug === slug);
if (existingPage) {
req.flash('error_msg', 'This title is already in use. Please choose a different title.');
return res.redirect('/admin/pages/add');
}
// Tạo trang mới
const newPage = {
id: Date.now().toString(), // Sử dụng timestamp làm ID
title,
slug,
content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// Thêm trang mới vào danh sách
pages.push(newPage);
contentData.pages = pages;
// Lưu lại dữ liệu
writeJsonFile('content', contentData);
req.flash('success_msg', 'New page created successfully');
res.redirect('/admin/pages');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error creating new page');
res.redirect('/admin/pages/add');
}
};
// Hiển thị form chỉnh sửa trang
exports.getEditPage = async (req, res) => {
try {
const pageId = req.params.id;
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
const page = pages.find(p => p.id === pageId);
if (!page) {
req.flash('error_msg', 'Page not found');
return res.redirect('/admin/pages');
}
res.render('admin/pages/edit', {
title: 'Chỉnh sửa trang',
page
});
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading page');
res.redirect('/admin/pages');
}
};
// Xử lý chỉnh sửa trang
exports.updatePage = async (req, res) => {
try {
const pageId = req.params.id;
const { title, content } = req.body;
// Kiểm tra dữ liệu
if (!title || !content) {
req.flash('error_msg', 'Please fill in all required fields');
return res.redirect(`/admin/pages/edit/${pageId}`);
}
// Lấy dữ liệu hiện tại
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
// Tìm trang cần cập nhật
const pageIndex = pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) {
req.flash('error_msg', 'Page not found');
return res.redirect('/admin/pages');
}
const page = pages[pageIndex];
// Kiểm tra nếu tiêu đề thay đổi thì cập nhật slug
let newSlug = page.slug;
if (page.title !== title) {
newSlug = slugify(title, {
lower: true,
strict: true,
locale: 'vi'
});
// Kiểm tra slug đã tồn tại chưa (trừ trang hiện tại)
const existingPage = pages.find(p => p.slug === newSlug && p.id !== pageId);
if (existingPage) {
req.flash('error_msg', 'This title is already in use. Please choose a different title.');
return res.redirect(`/admin/pages/edit/${pageId}`);
}
}
// Cập nhật thông tin trang
pages[pageIndex] = {
...page,
title,
slug: newSlug,
content,
updatedAt: new Date().toISOString()
};
// Lưu lại dữ liệu
contentData.pages = pages;
writeJsonFile('content', contentData);
req.flash('success_msg', 'Page updated successfully');
res.redirect('/admin/pages');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error updating page');
res.redirect(`/admin/pages/edit/${req.params.id}`);
}
};
// Xử lý xóa trang
exports.deletePage = async (req, res) => {
try {
const pageId = req.params.id;
// Lấy dữ liệu hiện tại
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
// Lọc bỏ trang cần xóa
contentData.pages = pages.filter(p => p.id !== pageId);
// Lưu lại dữ liệu
writeJsonFile('content', contentData);
req.flash('success_msg', 'Page deleted successfully');
res.redirect('/admin/pages');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error deleting page');
res.redirect('/admin/pages');
}
};
// Hiển thị trang theo slug
exports.getPageBySlug = async (req, res) => {
try {
const { slug } = req.params;
// Lấy dữ liệu từ content.json
const contentData = readJsonFile('content');
const pages = contentData.pages || [];
// Tìm trang theo slug
const page = pages.find(p => p.slug === slug);
if (!page) {
return res.status(404).render('page/not-found', {
title: 'Page Not Found',
message: 'The page you are looking for does not exist or has been deleted.'
});
}
// Hiển thị trang
res.render('page/view', {
title: page.title,
page
});
} catch (err) {
console.error(err);
res.status(500).render('page/error', {
title: 'Error',
message: 'An error occurred while loading the page. Please try again later.'
});
}
};

View File

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

View File

@@ -0,0 +1,165 @@
const path = require('path');
const Qualification = require('../models/qualification');
const Department = require('../models/department');
const Level = require('../models/level');
const writeAuditLog = require('../audit/writeAuditLog');
const AUDIT_ACTIONS = require('../constants/auditAction');
function normalizePath(filePath) {
if (!filePath) return undefined;
return path.basename(filePath.replace(/\\/g, '/'));
}
// GET /admin/qualification
exports.index = async (req, res) => {
try {
const { search, status } = req.query;
const filter = {};
if (search) {
filter.$or = [
{ qualification_number: { $regex: search, $options: 'i' } },
{ student_name: { $regex: search, $options: 'i' } }
];
}
if (status) filter.status = status;
const [qualifications, departments, levels] = await Promise.all([
Qualification.find(filter).populate('department level').sort({ createdAt: -1 }),
Department.find(), Level.find()
]);
res.render('admin/qualification/index', {
qualifications, departments, levels, query: req.query,
user: req.session.user, layout: 'layouts/admin', title: 'Qualifications'
});
} catch (err) {
console.error(err);
req.flash('error', 'Error loading qualifications');
res.redirect('/admin/dashboard');
}
};
// GET /admin/qualification/create
exports.createForm = async (req, res) => {
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/qualification/create', {
departments, levels, user: req.session.user,
layout: 'layouts/admin', title: 'Create Qualification'
});
} catch (err) {
req.flash('error', 'Error'); res.redirect('/admin/qualification');
}
};
// POST /admin/qualification/create
exports.create = async (req, res) => {
try {
const data = { ...req.body };
const imgPath = req.files?.degree_image?.[0]?.path;
if (imgPath) data.degree_image = normalizePath(imgPath);
const qual = new Qualification(data);
await qual.save();
await writeAuditLog({ model: 'Qualification', documentId: qual._id, action: AUDIT_ACTIONS.CREATE_QUALIFICATION, before: null, after: qual.toObject(), req });
req.flash('success', 'Qualification created');
res.redirect('/admin/qualification');
} catch (err) {
console.error(err);
try {
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/qualification/create', {
error: err.message, formData: req.body, departments, levels,
user: req.session.user, layout: 'layouts/admin', title: 'Create Qualification'
});
} catch { req.flash('error', err.message); res.redirect('/admin/qualification'); }
}
};
// GET /admin/qualification/:id/edit
exports.editForm = async (req, res) => {
try {
const qual = await Qualification.findById(req.params.id).populate('department level');
if (!qual) { req.flash('error', 'Not found'); return res.redirect('/admin/qualification'); }
const [departments, levels] = await Promise.all([Department.find(), Level.find()]);
res.render('admin/qualification/edit', {
qual, departments, levels, user: req.session.user,
layout: 'layouts/admin', title: 'Edit Qualification'
});
} catch (err) {
req.flash('error', 'Error'); res.redirect('/admin/qualification');
}
};
// POST /admin/qualification/:id/edit
exports.update = async (req, res) => {
try {
const qual = await Qualification.findById(req.params.id);
if (!qual) { req.flash('error', 'Not found'); return res.redirect('/admin/qualification'); }
const before = qual.toObject();
const fields = ['qualification_number','student_name','program_name','department','level',
'issued_date','status','passport_number','address','topic_name','topic_short_desc'];
fields.forEach(f => { if (req.body[f] !== undefined) qual[f] = req.body[f]; });
const imgPath = req.files?.degree_image?.[0]?.path;
if (imgPath) qual.degree_image = normalizePath(imgPath);
await qual.save();
await writeAuditLog({ model: 'Qualification', documentId: qual._id, action: AUDIT_ACTIONS.UPDATE_QUALIFICATION, before, after: qual.toObject(), req });
req.flash('success', 'Qualification updated');
res.redirect('/admin/qualification');
} catch (err) {
req.flash('error', err.message); res.redirect('back');
}
};
// POST /admin/qualification/:id/delete
exports.destroy = async (req, res) => {
try {
const qual = await Qualification.findById(req.params.id);
if (!qual) { req.flash('error', 'Not found'); return res.redirect('/admin/qualification'); }
await writeAuditLog({ model: 'Qualification', documentId: qual._id, action: AUDIT_ACTIONS.DELETE_QUALIFICATION, before: qual.toObject(), after: null, req });
await qual.deleteOne();
req.flash('success', 'Qualification deleted');
res.redirect('/admin/qualification');
} catch (err) {
req.flash('error', 'Error deleting'); res.redirect('/admin/qualification');
}
};
// GET /api/verify-degree/:degree_id?api_key=xxx
exports.apiVerify = async (req, res) => {
try {
const qual = await Qualification.findOne({
qualification_number: { $regex: new RegExp('^' + req.params.degree_id + '$', 'i') }
}).populate('department level');
if (!qual) return res.status(404).json({ error: 'Degree not found' });
if (qual.status === 'revoked') return res.status(404).json({ error: 'Degree has been revoked' });
const baseUrl = `${req.protocol}://${req.get('host')}`;
const buildUrl = (f) => f ? [`${baseUrl}/secure-files/${path.basename(f)}?api_key=${req.query.api_key}`] : undefined;
const response = {
full_name: qual.student_name,
program_name: qual.program_name,
degree_id: qual.qualification_number,
};
if (qual.passport_number) response.passport_number = qual.passport_number;
if (qual.address) response.address = qual.address;
const imgs = buildUrl(qual.degree_image);
if (imgs) response.degree_image = imgs;
if (qual.topic_name) {
response.topic_name = qual.topic_name;
if (qual.topic_short_desc) response.topic_short_desc = qual.topic_short_desc;
}
return res.json(response);
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error' });
}
};

View File

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

View File

@@ -1,396 +0,0 @@
const { getServiceData } = require("../services/service.service");
const Service = require("../models/service");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const slugify = require("slugify");
// Admin page - Service list
exports.index = async (req, res) => {
try {
const data = await getServiceData();
console.log(data.services.items.image);
res.render("admin/service/index", {
title: "Service Management",
data,
layout: "layouts/main",
getFullImageUrl, // Truyền helper function vào view
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service data");
res.redirect("/admin/dashboard");
}
};
// Admin page - Service edit
exports.edit = async (req, res) => {
try {
const { slug } = req.params;
const data = await getServiceData();
const service = data.services?.items?.find((item) => item.slug === slug);
if (!service) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
res.render("admin/service/edit", {
title: `Edit Service - ${service.name}`,
service,
layout: "layouts/main",
getFullImageUrl,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service for editing");
res.redirect("/admin/service");
}
};
// Update single service
exports.updateService = async (req, res) => {
try {
const { slug } = req.params;
const currentData = await getServiceData();
const serviceIndex = currentData.services?.items?.findIndex(
(item) => item.slug === slug,
);
if (serviceIndex === -1) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
const oldItem = JSON.parse(
JSON.stringify(currentData.services.items[serviceIndex]),
);
// Update service data
const updatedData = { ...currentData.toObject?.() };
updatedData.services.items[serviceIndex] = {
...updatedData.services.items[serviceIndex],
name: req.body.name,
slug: req.body.slug,
description: req.body.description,
image: req.body.image,
layout: req.body.layout,
};
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
const newItem = updatedData.services.items[serviceIndex];
const changes = diffObject(oldItem, newItem);
console.log("USER:", req.session?.user || req.user || "No user found");
await writeAuditLog({
model: "Service",
documentId: currentData._id,
action: AUDIT_ACTIONS.UPDATE_SERVICE,
before: oldItem,
after: newItem,
changes,
req,
});
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// Admin page - Service details
exports.details = async (req, res) => {
try {
const { slug } = req.params;
const data = await getServiceData();
const service = data.services?.items?.find((item) => item.slug === slug);
if (!service) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
res.render("admin/service/details", {
title: `Service Details - ${service.name}`,
service,
layout: "layouts/main",
getFullImageUrl, // Truyền helper function vào view
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service details");
res.redirect("/admin/service");
}
};
// Update service list
exports.update = async (req, res) => {
try {
const currentData = await getServiceData();
const sections = [
"pageTitle",
"services",
"destinations",
"visas",
"reviews",
];
let updatedData = { ...currentData.toObject?.() };
let hasChanges = false;
sections.forEach((section) => {
if (!req.body[section]) return;
const newData = JSON.parse(req.body[section]);
if (JSON.stringify(newData) !== JSON.stringify(currentData[section])) {
updatedData[section] = newData;
hasChanges = true;
}
});
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return res.redirect("/admin/service");
}
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// Update service details
exports.updateDetails = async (req, res) => {
try {
const { slug } = req.params;
const currentData = await getServiceData();
const serviceIndex = currentData.services?.items?.findIndex(
(item) => item.slug === slug,
);
if (serviceIndex === -1) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
const beforeDetails = JSON.parse(
JSON.stringify(currentData.services.items[serviceIndex].details || {}),
);
// Parse features and FAQ from JSON strings
const features = req.body.features ? JSON.parse(req.body.features) : [];
const faq = req.body.faq ? JSON.parse(req.body.faq) : [];
// Update service details
const updatedData = { ...currentData.toObject?.() };
const updatedDetails = {
title: req.body.title,
description: req.body.description,
mainImage: req.body.mainImage,
overviewTitle: req.body.overviewTitle,
overviewDescription: req.body.overviewDescription,
additionalDescription: req.body.additionalDescription,
keyFeaturesTitle: req.body.keyFeaturesTitle,
keyFeaturesImage: req.body.keyFeaturesImage,
features,
faqTitle: req.body.faqTitle,
faqImage: req.body.faqImage,
faq,
};
updatedData.services.items[serviceIndex].details = updatedDetails;
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
const changes = diffObject(beforeDetails, updatedDetails);
if (changes.length > 0) {
await writeAuditLog({
model: "Service",
documentId: currentData._id,
action: AUDIT_ACTIONS.UPDATE_SERVICE_DETAILS,
before: beforeDetails,
after: updatedDetails,
changes,
req,
});
}
req.flash("success_msg", "Service details updated successfully");
res.redirect(`/admin/service/${slug}/details`);
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// API endpoint
exports.api = async (req, res) => {
try {
const serviceData = await getServiceData();
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(serviceData, baseUrl);
res.json(processedData);
} catch (err) {
res.status(500).json({ error: "Error loading service data" });
}
};
/**
* Get service details by slug - API endpoint
*/
exports.getServiceBySlug = async (req, res) => {
try {
const { slug } = req.params;
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc) {
return res.status(404).json({
success: false,
message: "Service data not found",
});
}
// Find service by slug
const service = serviceDoc.services?.items?.find(
(item) => item.slug === slug,
);
if (!service) {
return res.status(404).json({
success: false,
message: `Service with slug '${slug}' not found`,
});
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// Return service details in the expected format
const responseData = {
pageTitle: serviceDoc.pageTitle,
breadcrumb: {
...serviceDoc.breadcrumb,
title: "Service Details",
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: service.name, href: `/services/${slug}` },
],
},
serviceDetails: {
content: service.details,
keyFeatures: {
title: service.details.keyFeaturesTitle || "Key Features",
sideImage: service.details.keyFeaturesImage || "img/default.jpg",
items: service.details.features || [],
},
faq: {
title: service.details.faqTitle || "Frequently Asked Questions",
sideImage: service.details.faqImage || "img/default.jpg",
items: service.details.faq || [],
},
},
};
const processedData = addBaseUrlToImages(responseData, baseUrl);
res.json(processedData);
} catch (error) {
console.error("Error fetching service by slug:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
/**
* Generate slug from text - API endpoint
*/
exports.generateSlug = async (req, res) => {
try {
const { text } = req.body;
if (!text || typeof text !== "string") {
return res.status(400).json({
success: false,
message: "Text is required",
});
}
// Generate slug using slugify library with Vietnamese support
const slug = slugify(text, {
lower: true,
strict: true,
locale: "vi",
});
res.json({
success: true,
slug: slug,
});
} catch (error) {
console.error("Error generating slug:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
/**
* Get all service slugs - API endpoint
*/
exports.getServiceSlugs = async (req, res) => {
try {
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc?.services?.items) {
return res.json({
success: true,
slugs: [],
});
}
const slugs = serviceDoc.services.items.map((item) => ({
slug: item.slug,
name: item.name,
id: item.id,
}));
res.json({
success: true,
slugs,
});
} catch (error) {
console.error("Error fetching service slugs:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};

View File

@@ -1,56 +0,0 @@
const { readJsonFile, writeJsonFile } = require('../utils/jsonHelper');
// Hiển thị cài đặt
exports.getSettings = async (req, res) => {
try {
// Lấy cài đặt từ file content.json
const content = readJsonFile('content');
const settings = content.settings || {
siteName: 'CMS-SIMS',
description: 'Hệ thống quản lý nội dung đơn giản'
};
res.render('admin/settings', {
title: 'Cài đặt hệ thống',
settings
});
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error loading settings');
res.redirect('/admin/dashboard');
}
};
// Cập nhật cài đặt
exports.updateSettings = async (req, res) => {
try {
const { siteName, description } = req.body;
// Kiểm tra dữ liệu
if (!siteName) {
req.flash('error_msg', 'Website name cannot be empty');
return res.redirect('/admin/settings');
}
// Lấy dữ liệu hiện tại
const content = readJsonFile('content');
// Cập nhật thông tin
content.settings = {
...content.settings,
siteName,
description,
updatedAt: new Date().toISOString()
};
// Lưu lại dữ liệu
writeJsonFile('content', content);
req.flash('success_msg', 'Settings updated successfully');
res.redirect('/admin/settings');
} catch (err) {
console.error(err);
req.flash('error_msg', 'Error updating settings');
res.redirect('/admin/settings');
}
};

View File

@@ -1,321 +0,0 @@
const Header = require("../models/header");
// Get all social links
exports.index = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
res.json({
success: true,
data: header.top?.socialLinks || [],
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Get single social link by platform
exports.show = async (req, res) => {
try {
const { platform } = req.params;
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
const socialLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (!socialLink) {
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
res.json({
success: true,
data: socialLink,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Create social link
exports.store = async (req, res) => {
try {
let { platform, url, icon } = req.body;
// Convert platform to lowercase
platform = platform.toLowerCase().trim();
url = url.trim();
icon = icon ? icon.trim() : null;
console.log("Creating social link:", { platform, url, icon });
// Validate required fields
if (!platform || !url) {
console.log("Validation failed: platform or url missing");
return res.status(400).json({
success: false,
message: "Platform and URL are required",
});
}
// Validate platform is in enum
const validPlatforms = ["linkedin", "twitter", "instagram", "youtube", "facebook"];
if (!validPlatforms.includes(platform)) {
console.log("Invalid platform:", platform);
return res.status(400).json({
success: false,
message: `Invalid platform. Must be one of: ${validPlatforms.join(", ")}`,
});
}
// Find header
let header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
console.log("No active header found");
return res.status(404).json({
success: false,
message: "No active header found",
});
}
console.log("Found header:", header._id);
// Check if platform already exists
const existingLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (existingLink) {
console.log("Platform already exists:", platform);
return res.status(400).json({
success: false,
message: `Social link for ${platform} already exists`,
});
}
// Add new social link
if (!header.top) {
header.top = {};
}
if (!header.top.socialLinks) {
header.top.socialLinks = [];
}
// Calculate next order number
const maxOrder =
header.top.socialLinks.length > 0 ? Math.max(...header.top.socialLinks.map((link) => link.order || 0)) : 0;
header.top.socialLinks.push({
platform,
url,
icon: icon || `fa-brands fa-${platform}`,
order: maxOrder + 1,
});
console.log("Saving header with new social link");
await header.save();
console.log("Social link created successfully");
res.status(201).json({
success: true,
message: "Social link created successfully",
data: header.top.socialLinks[header.top.socialLinks.length - 1],
});
} catch (error) {
console.error("Error creating social link:", error);
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Update social link
exports.update = async (req, res) => {
try {
let { platform } = req.params;
let { url, icon } = req.body;
// Convert to lowercase
platform = platform.toLowerCase().trim();
url = url.trim();
icon = icon ? icon.trim() : null;
// Validate required fields
if (!url) {
return res.status(400).json({
success: false,
message: "URL is required",
});
}
// Find header
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Find and update social link
const socialLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (!socialLink) {
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
socialLink.url = url;
if (icon) {
socialLink.icon = icon;
}
await header.save();
res.json({
success: true,
message: "Social link updated successfully",
data: socialLink,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Delete social link
exports.destroy = async (req, res) => {
try {
let { platform } = req.params;
// Convert to lowercase
platform = platform.toLowerCase().trim();
console.log("Deleting social link:", platform);
// Find header
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
console.log("No active header found");
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Find and remove social link
const index = header.top?.socialLinks?.findIndex((link) => link.platform === platform);
if (index === -1 || index === undefined) {
console.log("Social link not found:", platform);
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
const deletedLink = header.top.socialLinks.splice(index, 1);
console.log("Saving header after delete");
await header.save();
console.log("Social link deleted successfully");
res.json({
success: true,
message: "Social link deleted successfully",
data: deletedLink[0],
});
} catch (error) {
console.error("Error deleting social link:", error);
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Bulk update social links (used for reordering and batch updates)
exports.reorder = async (req, res) => {
try {
const { socialLinks } = req.body;
if (!Array.isArray(socialLinks)) {
return res.status(400).json({
success: false,
message: "socialLinks must be an array",
});
}
// Find header
let header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Validate all social links
for (const link of socialLinks) {
if (!link.platform || !link.url) {
return res.status(400).json({
success: false,
message: "Each social link must have platform and url",
});
}
}
// Update social links with order field
if (!header.top) {
header.top = {};
}
header.top.socialLinks = socialLinks.map((link, index) => ({
platform: link.platform,
url: link.url,
icon: link.icon || `fa-brands fa-${link.platform}`,
order: link.order || index + 1, // Use provided order or calculate from index
}));
await header.save();
res.json({
success: true,
message: "Social links updated successfully",
data: header.top.socialLinks,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};

View File

@@ -1,574 +0,0 @@
// 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 processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: {
hero: processedData.hero,
page: processedData.page,
content: processedData.content,
},
});
} catch (error) {
console.error("API Error:", error);
res.status(500).json({
success: false,
error: "Error loading terms data",
message: error.message,
});
}
};
// API để lấy toàn bộ terms data (cho admin)
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",
});
}
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 processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: processedData,
});
} catch (error) {
console.error("Error getting terms data:", error);
res.status(500).json({
success: false,
error: "Error loading terms data",
});
}
};
// API để lấy data theo ngôn ngữ
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,
});
}
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 processedData = addBaseUrlToImages(termsData, baseUrl);
res.json({
success: true,
data: {
hero: processedData.hero,
page: processedData.page,
content: processedData.content,
},
});
} catch (error) {
console.error("Error getting terms by language:", error);
res.status(500).json({
success: false,
error: "Error loading terms data",
});
}
};
// Render admin view (không cần thêm baseUrl ở đây vì dùng trong CMS)
exports.index = async (req, res) => {
try {
// 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,
});
} catch (error) {
console.error("Error in terms index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
}
};
// Cập nhật dữ liệu terms (CẬP NHẬT CẤU TRÚC MỚI)
exports.update = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
return data;
};
// Parse all data với cấu trúc mới
const heroData = parseJson(hero) || {};
const pageData = parseJson(page) || {};
const contentData = parseJson(content) || {};
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
function extractYouTubeId(url) {
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})/,
);
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;
}
}
// Đảm bảo có videoId
else if (embedUrl && !item.videoId) {
const videoId = extractYouTubeId(embedUrl);
if (videoId) {
item.videoId = videoId;
}
}
}
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({
name: "default",
language: "en",
hero: heroData,
page: pageData,
content: contentData,
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false,
});
} else {
// Update existing với cấu trúc mới
terms.hero = heroData;
terms.page = pageData;
terms.content = contentData;
terms.version = "2.0.0";
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");
res.redirect("/admin/terms-conditions");
}
};
// 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");
// Đọ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));
// 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)");
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...");
terms = await Terms.migrateFromJson(jsonData, "en");
} else {
throw new Error("Unknown JSON structure");
}
res.json({
success: true,
message: "Terms data seeded successfully",
data: {
id: terms._id,
hero: terms.hero,
page: terms.page,
content: terms.content,
},
});
} catch (error) {
console.error("Error seeding terms:", error);
res.status(500).json({
success: false,
error: error.message || "Error seeding terms data",
});
}
};
// API preview cho admin (tạo HTML preview)
exports.preview = async (req, res) => {
try {
const { hero, page, content } = req.body;
// Parse JSON strings
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
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 processedHeroData = addBaseUrlToImages(heroData, baseUrl);
// Render preview HTML
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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 || ""}');
background-size: cover;
background-position: center;
color: white;
padding: 100px 20px;
text-align: center;
}
.page-header {
padding: 40px 20px;
background: #f8f9fa;
}
.content-section {
padding: 40px 20px;
}
.content-item {
margin-bottom: 20px;
}
</style>
</head>
<body>
<!-- Hero Section -->
<div class="hero-section">
<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>" : ""}
</div>
</div>
<!-- Content Section -->
<div class="content-section">
<div class="container">
${renderContentItems(contentData.content || [])}
</div>
</div>
</body>
</html>
`;
res.send(html);
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
}
};
// Helper function để render content items
function renderContentItems(contentItems) {
if (!Array.isArray(contentItems) || contentItems.length === 0) {
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>";
}
} 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;">
<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("");
}
// 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",
});
}
// Kiểm tra đã tồn tại chưa
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,
});
}
// Parse JSON nếu cần
const parseJson = (data) => {
if (!data) return null;
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch (e) {
console.error("JSON parse error:", e);
return null;
}
}
return data;
};
const terms = new Terms({
name: "default",
language: language,
hero: parseJson(hero) || {},
page: parseJson(page) || {},
content: parseJson(content) || {},
version: "2.0.0",
isActive: true,
migratedFromOldStructure: false,
});
await terms.save();
res.json({
success: true,
message: "Terms created successfully for language: " + language,
data: terms,
});
} catch (error) {
console.error("Error creating terms:", error);
res.status(500).json({
success: false,
error: error.message || "Error creating terms",
});
}
};
// API để xóa terms (theo ngôn ngữ)
exports.delete = async (req, res) => {
try {
const language = req.params.lang;
if (!language) {
return res.status(400).json({
success: false,
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",
});
}
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,
});
}
res.json({
success: true,
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",
});
}
};

View File

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

View File

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

View File

@@ -1,228 +0,0 @@
const path = require('path');
const fs = require('fs');
const jsonHelper = require('../utils/jsonHelper');
// Controller xử lý upload ảnh
const uploadController = {
// Upload ảnh và trả về đường dẫn
uploadImage: async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: 'No file was uploaded' });
}
// Lấy loại ảnh từ query params
const imageType = req.query.imageType || 'general';
// Tạo đường dẫn tương đối để lưu vào database
const relativePath = `/uploads/${imageType}/${req.file.filename}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const fullUrl = `${baseUrl}${relativePath}`;
// Kiểm tra nếu file đã tồn tại từ trước
const fileAlreadyExists = req.fileAlreadyExists || false;
// Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json),
// thì đồng bộ camps.image và camps.camp-detail.hero.bgImage
try {
const jsonFile = req.body && req.body.jsonFile;
const campLink = req.body && req.body.campLink;
if (jsonFile && campLink) {
// Đọc JSON và cập nhật camp có link khớp
const jsonFilePath = require('path').join(__dirname, '../data', jsonFile);
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
if (jsonData && Array.isArray(jsonData.camps)) {
// campLink có thể được gửi không có dấu / đầu, chuẩn hóa
const normalizedLink = campLink.startsWith('/') ? campLink : `/${campLink}`;
const camp = jsonData.camps.find(c => (c.link || '').toString() === normalizedLink || (c.link || '').toString() === campLink);
if (camp) {
// Đồng bộ camps.image và camps.camp-detail.hero.bgImage - 2 trường này luôn giống nhau
camp.image = relativePath;
// Đảm bảo camp-detail.hero tồn tại và sync bgImage
if (!camp['camp-detail']) camp['camp-detail'] = {};
if (!camp['camp-detail'].hero) camp['camp-detail'].hero = {};
camp['camp-detail'].hero.bgImage = relativePath;
// Lưu thay đổi
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
}
}
}
} catch (e) {
console.warn('Failed to update JSON file after upload:', e && e.message ? e.message : e);
}
return res.status(200).json({
success: true,
path: relativePath,
url: fullUrl,
reused: fileAlreadyExists,
message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully'
});
} catch (error) {
console.error('Error uploading image:', error);
return res.status(500).json({ success: false, error: 'Server error while uploading image' });
}
},
// Cập nhật đường dẫn ảnh trong file JSON
updateImagePath: async (req, res) => {
try {
const { jsonFile, jsonPath, newImagePath } = req.body;
if (!jsonFile || !jsonPath || !newImagePath) {
return res.status(400).json({
success: false,
message: 'Missing required information (jsonFile, jsonPath, newImagePath)'
});
}
// Đọc file JSON
const jsonFilePath = path.join(__dirname, '../data', jsonFile);
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
// Cập nhật đường dẫn ảnh theo jsonPath
// jsonPath có định dạng như "banner.image" hoặc "partners[0].logo"
const pathParts = jsonPath.split('.');
let current = jsonData;
// Duyệt qua các phần của path trừ phần cuối
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
// Kiểm tra nếu là mảng (ví dụ: partners[0])
if (part.includes('[') && part.includes(']')) {
const arrName = part.substring(0, part.indexOf('['));
const index = parseInt(part.substring(part.indexOf('[') + 1, part.indexOf(']')));
if (!current[arrName] || !Array.isArray(current[arrName])) {
return res.status(400).json({
success: false,
message: `Array ${arrName} not found in data`
});
}
current = current[arrName][index];
} else {
if (!current[part]) {
return res.status(400).json({
success: false,
message: `Property ${part} not found in data`
});
}
current = current[part];
}
}
// Cập nhật giá trị
const lastPart = pathParts[pathParts.length - 1];
current[lastPart] = newImagePath;
// Lưu lại file JSON
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
return res.status(200).json({
success: true,
message: 'Image path updated successfully',
data: { jsonPath, newImagePath }
});
} catch (error) {
console.error('Error updating image path:', error);
return res.status(500).json({ success: false, message: 'Server error while updating image path' });
}
},
// Xóa ảnh
deleteImage: async (req, res) => {
try {
const { imagePath } = req.body;
if (!imagePath) {
return res.status(400).json({ success: false, message: 'Missing image path to delete' });
}
// Chuyển đổi đường dẫn tương đối thành đường dẫn tuyệt đối
const fullPath = path.join(__dirname, '../public', imagePath);
// Kiểm tra xem file có tồn tại không
if (!fs.existsSync(fullPath)) {
return res.status(404).json({ success: false, message: 'Image file not found' });
}
// Xóa file
fs.unlinkSync(fullPath);
return res.status(200).json({
success: true,
message: 'Image deleted successfully',
data: { imagePath }
});
} catch (error) {
console.error('Error deleting image:', error);
return res.status(500).json({ success: false, message: 'Server error while deleting image' });
}
},
// List images in a folder
listImages: async (req, res) => {
try {
const imageType = req.query.imageType || 'general';
const dirPath = path.join(__dirname, '../public/uploads', imageType);
if (!fs.existsSync(dirPath)) {
return res.status(200).json({ success: true, images: [] });
}
const files = fs.readdirSync(dirPath).filter(f => !f.startsWith('.'));
const images = files.map(name => ({
name,
path: `/uploads/${imageType}/${name}`,
url: (process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`) + `/uploads/${imageType}/${name}`
}));
return res.status(200).json({ success: true, images });
} catch (error) {
console.error('Error listing images:', error);
return res.status(500).json({ success: false, error: 'Server error while listing images' });
}
},
// Upload video
uploadVideo: async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: 'No file was uploaded' });
}
// Kiểm tra loại file
const fileType = req.file.mimetype;
if (!fileType.startsWith('video/')) {
// Xóa file nếu không phải video
fs.unlinkSync(req.file.path);
return res.status(400).json({ success: false, error: 'Uploaded file is not a video' });
}
// Tạo đường dẫn tương đối để lưu vào database
const relativePath = `/uploads/videos/${req.file.filename}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const fullUrl = `${baseUrl}${relativePath}`;
return res.status(200).json({
success: true,
path: relativePath,
url: fullUrl
});
} catch (error) {
console.error('Error uploading video:', error);
return res.status(500).json({ success: false, error: 'Server error while uploading video' });
}
}
};
module.exports = uploadController;

View File

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

View File

@@ -1,695 +0,0 @@
// controllers/visaController.js
const addBaseUrlToImages = (data, baseUrl) => {
if (!data) return data;
// Nếu là mảng, duyệt từng phần tử
if (Array.isArray(data)) {
return data.map((item) => addBaseUrlToImages(item, baseUrl));
}
// Nếu là object, duyệt từng key
if (typeof data === "object") {
const newObj = {};
for (const [key, value] of Object.entries(data)) {
// Kiểm tra nếu key là các trường chứa ảnh và value là string
const imageKeys = ["icon", "mainImage", "bannerImage", "image"];
if (
imageKeys.includes(key) &&
typeof value === "string" &&
!value.startsWith("http")
) {
newObj[key] = `${baseUrl}/${value}`
.replace(/\/+/g, "/")
.replace(":/", "://");
}
// Xử lý riêng cho mảng gallery (mảng các chuỗi)
else if (key === "gallery" && Array.isArray(value)) {
newObj[key] = value.map((img) =>
img.startsWith("http")
? img
: `${baseUrl}/${img}`.replace(/\/+/g, "/").replace(":/", "://"),
);
}
// Nếu là object hoặc mảng con khác, đệ quy tiếp
else if (typeof value === "object" && value !== null) {
newObj[key] = addBaseUrlToImages(value, baseUrl);
} else {
newObj[key] = value;
}
}
return newObj;
}
return data;
};
const Visa = require("../models/visa");
const slugify = require("slugify");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const createSlug = (text) => {
return slugify(text, {
lower: true,
strict: true,
locale: "en",
trim: true,
});
};
// -------------------- Helper Functions --------------------
// Get visa data from MongoDB
const getVisaData = async () => {
const visa = await Visa.findOne().sort({ updatedAt: -1 });
return visa || {};
};
// Get default visa data structure (updated to match new JSON)
const getDefaultVisaData = () => ({
hero: {
title: "Visa Service",
summaryList: [],
},
});
// Helper function: Generate next country ID
const getNextCountryId = (countries) => {
if (!Array.isArray(countries) || countries.length === 0) return 1;
return Math.max(...countries.map((c) => c.id || 0)) + 1;
};
// -------------------- Admin Exports --------------------
// Display visa management page
exports.index = async (req, res) => {
try {
// Fetch Visa data
let data = await getVisaData();
// If no data exists, use default
if (!data || Object.keys(data).length === 0) {
data = getDefaultVisaData();
} else {
// Merge with defaults to ensure all fields exist
const defaultData = getDefaultVisaData();
// Ensure hero section exists with defaults
data.hero = data.hero || defaultData.hero;
data.hero.title = data.hero.title || "Visa Service";
data.hero.summaryList = data.hero.summaryList || [];
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/visa/index", {
layout: "layouts/main",
title: "Visa Management",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
// return res.json(data);
} catch (err) {
console.error("Visa index error:", err);
req.flash("error_msg", "Error loading visa data");
res.redirect("/admin/dashboard");
}
};
// Get single country for edit
exports.getCountry = async (req, res) => {
console.log("--------------------------------------------------");
console.log("🚀 [GET] Request nhận được tại /visa/edit/:id");
try {
const { id } = req.params;
console.log("📍 ID từ Params (URL):", id, "| Kiểu dữ liệu:", typeof id);
const visaData = await getVisaData();
// Kiểm tra cấu trúc dữ liệu tổng
if (!visaData) {
console.error("❌ Lỗi: Hàm getVisaData() trả về null/undefined");
return res.status(404).json({ error: "Dữ liệu gốc không tồn tại" });
}
if (!visaData.hero || !visaData.hero.summaryList) {
console.error("❌ Lỗi: Cấu trúc visaData.hero.summaryList không hợp lệ");
return res
.status(404)
.json({ error: "Không tìm thấy danh sách quốc gia" });
}
console.log(
"📊 Tổng số quốc gia hiện có trong mảng:",
visaData.hero.summaryList.length,
);
// 2. Tìm quốc gia theo ID
const targetId = parseInt(id);
console.log("🔍 Đang tìm kiếm Quốc gia có ID (sau khi parse):", targetId);
const country = visaData.hero.summaryList.find((c) => {
// Log từng phần tử để kiểm tra kiểu dữ liệu của c.id trong DB
// console.log(`Checking country: ${c.name} | ID in DB: ${c.id} (${typeof c.id})`);
return c.id === targetId;
});
if (!country) {
console.warn(`⚠️ Không tìm thấy quốc gia nào khớp với ID: ${targetId}`);
// In ra danh sách ID hiện có để so sánh
const existingIds = visaData.hero.summaryList.map((c) => c.id);
console.log("🆔 Các ID hiện có trong Database:", existingIds);
return res.status(404).json({
success: false,
error: `Không tìm thấy quốc gia có ID: ${id}`,
});
}
console.log("✅ Tìm thấy dữ liệu quốc gia:", country.name);
// 3. Trả về dữ liệu
res.json({
success: true,
country: country,
});
} catch (err) {
console.error("🔴 Lỗi nghiêm trọng tại getCountry Controller:", err);
res.status(500).json({ error: "Lỗi hệ thống khi tải thông tin quốc gia" });
}
};
// Update visa data (hero title only)
exports.update = async (req, res) => {
try {
// Get current data
const currentData = await getVisaData();
// ✅ Capture BEFORE state
const beforeData = currentData
? JSON.parse(
JSON.stringify(
currentData.toObject ? currentData.toObject() : currentData,
),
)
: {};
// Create updated data object
const updatedData = {
...(currentData.toObject ? currentData.toObject() : currentData),
};
// Ensure hero structure exists
updatedData.hero = updatedData.hero || {
title: "Visa Service",
summaryList: [],
};
// Update hero title
if (req.body.heroTitle) {
updatedData.hero.title = req.body.heroTitle;
}
// Update or create document
try {
let savedData;
if (currentData._id) {
savedData = await Visa.findByIdAndUpdate(currentData._id, updatedData, {
new: true,
});
} else {
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");
return req.session.save(() => res.redirect("/admin/visa"));
} catch (dbError) {
console.error("Database error:", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
};
// Add new country
exports.addCountry = async (req, res) => {
try {
let visaData = await getVisaData();
// Initialize hero structure if not exist
if (!visaData.hero || !visaData.hero.summaryList) {
visaData = getDefaultVisaData();
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// Validate required fields
if (!req.body.name) {
return res.status(400).json({ error: "Name is required" });
}
const finalSlug = req.body.slug
? createSlug(req.body.slug)
: createSlug(req.body.name);
// Parse services array
let services = [];
if (req.body.services) {
if (typeof req.body.services === "string") {
try {
services = JSON.parse(req.body.services);
} catch (e) {
services = [req.body.services];
}
} else if (Array.isArray(req.body.services)) {
services = req.body.services;
}
}
// Parse detailedView if provided (optional)
let detailedView = null;
if (req.body.detailedView) {
try {
detailedView =
typeof req.body.detailedView === "string"
? JSON.parse(req.body.detailedView)
: req.body.detailedView;
} catch (e) {
console.warn("Could not parse detailedView, creating without it");
}
}
// Create new country object
const newCountry = {
id: req.body.id || getNextCountryId(visaData.hero.summaryList),
name: req.body.name,
slug: finalSlug,
icon: req.body.icon || "",
services: services,
...(detailedView && { detailedView }),
};
// Add new country to summaryList
visaData.hero.summaryList.push(newCountry);
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
let savedData;
if (visaData._id) {
savedData = await Visa.findByIdAndUpdate(visaData._id, updatedData, {
new: true,
});
} else {
savedData = await Visa.create(updatedData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
);
// ✅ AUDIT LOGGING - Visa Country Added
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa country addition: ${changes.length} changes`,
);
}
console.log(`✅ Country "${newCountry.name}" added successfully`);
res.json({
success: true,
message: `Country "${newCountry.name}" added successfully`,
country: newCountry,
});
} catch (err) {
console.error("Add country error:", err);
res.status(500).json({ error: err.message });
}
};
// Update single country
exports.updateCountry = async (req, res) => {
try {
// 1. Lấy ID từ params (URL)
const { id } = req.params;
let visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res
.status(400)
.json({ error: "Cấu trúc dữ liệu Visa không hợp lệ" });
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.id === parseInt(id),
);
if (countryIndex === -1) {
return res
.status(404)
.json({ error: `Không tìm thấy quốc gia có ID: ${id}` });
}
const currentCountry = visaData.hero.summaryList[countryIndex];
let finalSlug = currentCountry.slug;
if (req.body.name) {
// Nếu name thay đổi, ta có thể cập nhật lại slug (tùy nhu cầu SEO)
// Ở đây ưu tiên: Nếu có slug mới truyền lên thì dùng, không thì tạo từ name mới
finalSlug = req.body.slug
? createSlug(req.body.slug)
: createSlug(req.body.name);
}
// 3. Xử lý dữ liệu từ req.body
// Vì Client đã gửi JSON stringify, ta lấy trực tiếp hoặc parse nếu cần
let services = req.body.services;
if (typeof services === "string") {
try {
services = JSON.parse(services);
} catch (e) {
services = [services];
}
}
let detailedView = req.body.detailedView;
if (typeof detailedView === "string") {
try {
detailedView = JSON.parse(detailedView);
} catch (e) {
detailedView = currentCountry.detailedView;
}
}
// 4. Cập nhật Object quốc gia
const updatedCountry = {
...currentCountry, // Giữ các trường cũ
id: parseInt(id), // Đảm bảo ID không đổi
name: req.body.name || currentCountry.name,
slug: finalSlug,
icon: req.body.icon || currentCountry.icon,
services: Array.isArray(services) ? services : currentCountry.services,
detailedView: detailedView || currentCountry.detailedView,
};
// 5. Cập nhật vào mảng chính
visaData.hero.summaryList[countryIndex] = updatedCountry;
// 6. Lưu vào Database
if (visaData.markModified) {
// Bắt buộc với Mongoose khi thay đổi nội dung bên trong Array/Object
visaData.markModified("hero.summaryList");
}
let savedData;
if (visaData._id) {
savedData = await visaData.save(); // Sử dụng save() trực tiếp nếu visaData là Mongoose Document
} else {
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(
`✅ Country "${updatedCountry.name}" updated successfully by ID: ${id}`,
);
res.json({
success: true,
message: `Quốc gia "${updatedCountry.name}" đã được cập nhật thành công`,
country: updatedCountry,
});
} catch (err) {
console.error("Update country error:", err);
res.status(500).json({ error: err.message });
}
};
// Delete country
exports.deleteCountry = async (req, res) => {
try {
// 1. Lấy id từ params
const { id } = req.params;
let visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res
.status(400)
.json({ success: false, error: "Cấu trúc dữ liệu Visa không hợp lệ" });
}
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.id === parseInt(id),
);
if (countryIndex === -1) {
return res.status(404).json({
success: false,
error: `Không tìm thấy quốc gia có ID: ${id}`,
});
}
// 3. Xóa phần tử khỏi mảng
const deletedCountry = visaData.hero.summaryList[countryIndex];
visaData.hero.summaryList.splice(countryIndex, 1);
// 4. Cập nhật vào Database
if (visaData.markModified) {
visaData.markModified("hero.summaryList");
}
if (visaData._id) {
await visaData.save();
} else {
await Visa.create(visaData);
}
console.log(`✅ Deleted Successfully: "${deletedCountry.name}"`);
return res.json({
success: true,
message: `Country "${deletedCountry.name}" Deleted Successfully`,
});
} catch (err) {
console.error("❌ Error Delete:", err);
return res.status(500).json({ success: false, error: err.message });
}
};
// -------------------- Public API Exports --------------------
// API to get all visa data for frontend
exports.api = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
const heroData = visaData?.hero;
// 2. Lấy riêng phần hero (Dùng biến mới, không gán đè vào const)
const processedData = heroData;
return res.json({
success: true,
hero: processedData,
});
} catch (err) {
console.error("Visa API error:", err);
res.status(500).json({
success: false,
error: "Error loading visa data",
});
}
};
// API to get all countries (summaryList only)
exports.apiCountries = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Countries data not found",
data: null,
});
}
// 1. Lọc bỏ 'viewDetail' khỏi từng quốc gia trong danh sách
const filteredCountries = visaData.hero.summaryList.map((item) => {
// Tách detailedView ra, gom phần còn lại vào countryInfo
const { detailedView, ...countryInfo } = item;
return {
...countryInfo,
// Lấy mainImage từ sâu bên trong detailedView và gán vào key mới
mainImage: detailedView?.activeCountry?.mainImage || "",
};
});
// 2. Gắn baseUrl vào ảnh cho danh sách đã lọc
const processedData = filteredCountries;
return res.json({
success: true,
data: processedData, // Lúc này data chỉ chứa thông tin quốc gia, không có viewDetail
});
} catch (err) {
console.error("Countries API error:", err);
res.status(500).json({
success: false,
error: "Error loading countries data",
});
}
};
// API to get single country by slug
exports.apiCountry = async (req, res) => {
try {
const { slug } = req.params;
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
// 1. Tìm quốc gia khớp với slug
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
// 2. Kiểm tra nếu không thấy quốc gia hoặc quốc gia đó không có viewDetail
if (!country || !country.viewDetail) {
return res.status(404).json({
success: false,
error: `Detailed information for country "${slug}" not found`,
data: null,
});
}
// 3. Chỉ lấy phần chi tiết (detailed view)
// Lưu ý: Chúng ta copy ra object mới để tránh tham chiếu dữ liệu gốc
const detailedData = JSON.parse(JSON.stringify(country.viewDetail));
// 4. Gắn baseUrl vào các ảnh nằm trong phần chi tiết này
const processedData = detailedData;
return res.json({
success: true,
data: processedData,
});
} catch (err) {
console.error("Visa country API error:", err);
res.status(500).json({
success: false,
error: "Error loading country detailed data",
});
}
};
// API to get hero data (title + summaryList)
exports.apiHero = async (req, res) => {
try {
const visaData = await getVisaData();
// 1. Kiểm tra dữ liệu gốc
if (!visaData || !visaData.hero) {
return res.status(404).json({
success: false,
error: "Hero data not found",
data: null,
});
}
const { summaryList, ...heroData } = JSON.parse(
JSON.stringify(visaData.hero),
);
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(heroData, baseUrl);
return res.json({
success: true,
data: processedData,
});
} catch (err) {
console.error("Visa hero API error:", err);
res.status(500).json({
success: false,
error: "Error loading hero data",
});
}
};