forked from UKSOURCE/cms.hailearning.edu.vn
first commit
This commit is contained in:
@@ -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
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
161
controllers/certificateController.js
Normal file
161
controllers/certificateController.js
Normal 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' });
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
261
controllers/degreeController.js
Normal file
261
controllers/degreeController.js
Normal 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' });
|
||||
}
|
||||
};
|
||||
79
controllers/departmentController.js
Normal file
79
controllers/departmentController.js
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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" });
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
76
controllers/levelController.js
Normal file
76
controllers/levelController.js
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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.'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
165
controllers/qualificationController.js
Normal file
165
controllers/qualificationController.js
Normal 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' });
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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 === " ") 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");
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user