Merge branch 'develop' into fea/dat-20042026-CMS-Partnerships-Accreditation-History-Admissions-Policies

This commit is contained in:
Tống Thành Đạt
2026-04-22 18:08:53 +07:00
80 changed files with 3334 additions and 19058 deletions
+159
View File
@@ -0,0 +1,159 @@
const About = require("../models/about");
const { addBaseUrlToImages } = require("../utils/imageHelper");
// Helpers
const getAboutDoc = async () => About.findOne().sort({ updatedAt: -1 });
const getAboutData = async () => (await getAboutDoc())?.toObject() || {};
const getDefaultAboutData = () => ({
hero: {
badge: "",
title: "",
description: "",
studentCount: "",
image: "",
imageAlt: "",
coreValues: [],
},
leadership: {
heading: "",
description: "",
members: [],
},
learningModel: {
heading: "",
description: "",
async: {
title: "",
description: "",
features: [],
},
sync: {
title: "",
description: "",
features: [],
},
},
accreditation: {
heading: "",
description: "",
badges: [],
stats: [],
},
successStories: {
heading: "",
description: "",
stories: [],
},
cta: {
heading: "",
description: "",
primaryButton: { label: "", href: "" },
secondaryButton: { label: "", href: "" },
},
});
// Admin: render management view with data from MongoDB
exports.index = async (req, res) => {
try {
let data = await getAboutData();
const defaults = getDefaultAboutData();
// Merge defaults for any missing sections
const sections = Object.keys(defaults);
sections.forEach((s) => {
data[s] = data[s] || defaults[s];
});
return res.render("admin/about/index", {
layout: "layouts/main",
title: "About Management",
data,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("About index error:", err);
req.flash("error_msg", "Error loading about data");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
};
// Admin: parse req.body sections and save to MongoDB
exports.update = async (req, res) => {
try {
const sections = [
"hero",
"leadership",
"learningModel",
"accreditation",
"successStories",
"cta",
];
let doc = await getAboutDoc();
if (!doc) {
doc = new About({});
}
let hasChanges = false;
for (const section of sections) {
if (req.body[section]) {
try {
const payload = JSON.parse(req.body[section]);
doc[section] = payload;
doc.markModified(section);
hasChanges = true;
} 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/about-us"));
}
await doc.save();
req.flash("success_msg", "About page configuration has been updated!");
return req.session.save(() => res.redirect("/admin/about-us"));
} catch (err) {
console.error("About update error:", err);
req.flash("error_msg", `Update error: ${err.message}`);
return req.session.save(() => res.redirect("/admin/about-us"));
}
};
// Recursively prepend BACKEND_URL to any relative /uploads/... paths in an object
function resolveImageUrls(obj, baseUrl) {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map((item) => resolveImageUrls(item, baseUrl));
const result = {};
for (const [key, val] of Object.entries(obj)) {
if (typeof val === "string" && val.startsWith("/uploads/")) {
result[key] = `${baseUrl}${val}`;
} else {
result[key] = resolveImageUrls(val, baseUrl);
}
}
return result;
}
// Public API: return JSON data for frontend
exports.api = async (req, res) => {
try {
let data = await getAboutData();
if (!data || Object.keys(data).length === 0) {
data = getDefaultAboutData();
}
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
return res.json(resolveImageUrls(data, baseUrl));
} catch (err) {
console.error("About API error:", err);
return res.status(500).json({ error: "Error loading about data" });
}
};
-450
View File
@@ -1,450 +0,0 @@
const AppointmentSubmission = require("../models/appointmentSubmission");
const Appointment = require("../models/appointment");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// ==================== CMS ADMIN FUNCTIONS ====================
// Render admin page for appointment management
exports.index = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
// If no data in DB, try to load from JSON file
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
} else {
// Create default appointment
appointment = await Appointment.create({
name: "default",
hero: {
title: "Make Appointment",
backgroundImage: "",
subtitle: "",
heading: "",
description: "",
},
visaOptions: [],
form: {
heading: "Request Appointment",
fields: [],
submitButton: {
text: "Request Appointment",
icon: "fa-solid fa-arrow-right",
buttonClass: "theme-btn",
},
},
});
}
}
const { startDate, endDate } = req.query;
const query = {};
if (startDate || endDate) {
query.createdAt = {};
if (startDate) {
query.createdAt.$gte = new Date(startDate);
}
if (endDate) {
// Set end date to end of day
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
query.createdAt.$lte = end;
}
}
const submissions = await AppointmentSubmission.find(query)
.sort({ createdAt: -1 })
.limit(50);
res.render("admin/appointment/index", {
layout: "layouts/main",
title: "Appointment Management",
data: appointment,
submissions,
startDate,
endDate,
user: req.session.user,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
});
} catch (err) {
console.error("Error loading appointment admin page:", err);
req.flash("error", "Error loading appointment data");
res.redirect("/admin/dashboard");
}
};
// Update appointment data
exports.update = async (req, res) => {
try {
const { hero, visaOptions, form } = req.body;
// Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const visaOptionsData =
typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions;
const formData = typeof form === "string" ? JSON.parse(form) : form;
let appointment = await Appointment.findOne({ name: "default" });
// Capture before state for audit logging
const beforeState = appointment
? JSON.parse(JSON.stringify(appointment.toObject()))
: null;
if (appointment) {
appointment.hero = heroData;
appointment.visaOptions = visaOptionsData;
appointment.form = formData;
await appointment.save();
} else {
appointment = await Appointment.create({
name: "default",
hero: heroData,
visaOptions: visaOptionsData,
form: formData,
});
}
// Capture after state for audit logging
const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
// Generate changes diff
const changes = beforeState ? diffObject(beforeState, afterState) : [];
// Write audit log
await writeAuditLog({
model: "Appointment",
documentId: appointment._id,
action: AUDIT_ACTIONS.UPDATE_APPOINTMENT,
before: beforeState,
after: afterState,
changes,
req,
});
req.flash("success", "Appointment data updated successfully");
res.redirect("/admin/appointment");
} catch (err) {
console.error("Error updating appointment:", err);
req.flash("error", "Error updating appointment data");
res.redirect("/admin/appointment");
}
};
// API to get appointment data
exports.getAppointmentData = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
}
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error getting appointment data:", err);
res.status(500).json({
success: false,
error: "Error loading appointment data",
});
}
};
// Public API to get appointment page data (for frontend)
exports.api = async (req, res) => {
try {
let appointment = await Appointment.findOne({ name: "default" });
if (!appointment) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/appointment.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
appointment = await Appointment.migrateFromJson(jsonData);
}
}
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment data not found",
});
}
res.json({
success: true,
data: {
hero: appointment.hero,
visaOptions: appointment.visaOptions,
form: appointment.form,
},
});
} catch (err) {
console.error("Error getting appointment API data:", err);
res.status(500).json({
success: false,
error: "Error loading appointment data",
});
}
};
// ==================== APPOINTMENT SUBMISSIONS API ====================
// API để submit appointment form (từ frontend)
exports.submitAppointment = async (req, res) => {
try {
const { name, email, phone, address, appointmentDate, message, visaTypes } =
req.body;
// Validation
if (!name || !email) {
return res.status(400).json({
success: false,
error: "Name and email are required",
});
}
// Create new submission
const submission = new AppointmentSubmission({
name: name.trim(),
email: email.trim().toLowerCase(),
phone: phone?.trim() || "",
address: address?.trim() || "",
appointmentDate: appointmentDate?.trim() || "",
message: message?.trim() || "",
visaTypes: Array.isArray(visaTypes) ? visaTypes : [],
ipAddress: req.ip || req.connection?.remoteAddress || "",
userAgent: req.get("User-Agent") || "",
});
await submission.save();
res.status(201).json({
success: true,
message:
"Thank you! Your appointment request has been submitted. We will contact you soon.",
data: {
id: submission._id,
name: submission.name,
email: submission.email,
appointmentDate: submission.appointmentDate,
},
});
} catch (err) {
console.error("Error submitting appointment:", err);
// Handle validation errors
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(400).json({
success: false,
error: errors.join(", "),
});
}
res.status(500).json({
success: false,
error: "Error submitting appointment. Please try again later.",
});
}
};
// API để lấy danh sách appointments (cho admin)
exports.getAppointments = async (req, res) => {
try {
const { status, page = 1, limit = 20 } = req.query;
const query = {};
if (
status &&
["pending", "confirmed", "completed", "cancelled"].includes(status)
) {
query.status = status;
}
const skip = (parseInt(page) - 1) * parseInt(limit);
const [appointments, total] = await Promise.all([
AppointmentSubmission.find(query)
.sort({ createdAt: -1 })
.skip(skip)
.limit(parseInt(limit)),
AppointmentSubmission.countDocuments(query),
]);
res.json({
success: true,
data: appointments,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / parseInt(limit)),
},
});
} catch (err) {
console.error("Error getting appointments:", err);
res.status(500).json({
success: false,
error: "Error loading appointments",
});
}
};
// API để cập nhật status của appointment
exports.updateAppointmentStatus = async (req, res) => {
try {
const { id } = req.params;
const { status, notes } = req.body;
const validStatuses = ["pending", "confirmed", "completed", "cancelled"];
if (!validStatuses.includes(status)) {
return res.status(400).json({
success: false,
error: "Invalid status",
});
}
// Get the appointment before update for audit logging
const beforeAppointment = await AppointmentSubmission.findById(id);
if (!beforeAppointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
const beforeState = JSON.parse(
JSON.stringify(beforeAppointment.toObject()),
);
const updateData = { status };
if (notes !== undefined) updateData.notes = notes;
if (status === "confirmed") updateData.confirmedAt = new Date();
if (status === "completed") updateData.completedAt = new Date();
const appointment = await AppointmentSubmission.findByIdAndUpdate(
id,
updateData,
{ new: true },
);
// Capture after state for audit logging
const afterState = JSON.parse(JSON.stringify(appointment.toObject()));
// Generate changes diff
const changes = diffObject(beforeState, afterState);
// Write audit log
await writeAuditLog({
model: "AppointmentSubmission",
documentId: appointment._id,
action: AUDIT_ACTIONS.UPDATE_APPOINTMENT_STATUS,
before: beforeState,
after: afterState,
changes,
req,
});
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error updating appointment:", err);
res.status(500).json({
success: false,
error: "Error updating appointment",
});
}
};
// API để lấy chi tiết một appointment
exports.getAppointmentById = async (req, res) => {
try {
const { id } = req.params;
const appointment = await AppointmentSubmission.findById(id);
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
res.json({
success: true,
data: appointment,
});
} catch (err) {
console.error("Error getting appointment:", err);
res.status(500).json({
success: false,
error: "Error loading appointment",
});
}
};
// API để xóa appointment
exports.deleteAppointment = async (req, res) => {
try {
const { id } = req.params;
// Get the appointment before deletion for audit logging
const appointment = await AppointmentSubmission.findById(id);
if (!appointment) {
return res.status(404).json({
success: false,
error: "Appointment not found",
});
}
const beforeState = JSON.parse(JSON.stringify(appointment.toObject()));
// Delete the appointment
await AppointmentSubmission.findByIdAndDelete(id);
// Write audit log
await writeAuditLog({
model: "AppointmentSubmission",
documentId: appointment._id,
action: AUDIT_ACTIONS.DELETE_APPOINTMENT,
before: beforeState,
after: null,
changes: [],
req,
});
res.json({
success: true,
message: "Appointment deleted successfully",
});
} catch (err) {
console.error("Error deleting appointment:", err);
res.status(500).json({
success: false,
error: "Error deleting appointment",
});
}
};
-549
View File
@@ -1,549 +0,0 @@
const fs = require('fs');
const path = require('path');
const { addBaseUrlToImages } = require("../utils/imageHelper");
const Booking = require("../models/booking");
// -------------------- Public helpers --------------------
const getBookingData = async () => {
const booking = await Booking.findOne().sort({ updatedAt: -1 });
return booking ? (booking.toObject ? booking.toObject() : booking) : null;
};
// Load static booking JSON from `data/booking.json` (if present)
const loadStaticBooking = () => {
try {
const p = path.join(__dirname, '..', 'data', 'booking.json');
if (!fs.existsSync(p)) return null;
const raw = fs.readFileSync(p, 'utf8');
return JSON.parse(raw);
} catch (e) {
console.error('booking.loadStaticBooking error:', e && e.message);
return null;
}
};
// Normalize booking shape: ensure configuration exists with discounts/vouchers
const normalizeBookingShape = (booking) => {
if (!booking || typeof booking !== 'object') return booking;
const b = JSON.parse(JSON.stringify(booking));
if (!b.configuration || typeof b.configuration !== 'object') {
b.configuration = { currency: 'USD', discounts: [], vouchers: [] };
}
// Ensure configuration.discounts and configuration.vouchers exist
if (!Array.isArray(b.configuration.discounts)) {
b.configuration.discounts = [];
}
if (!Array.isArray(b.configuration.vouchers)) {
b.configuration.vouchers = [];
}
return b;
};
// Deep merge: properties from `overrides` replace / merge into `base`.
const deepMerge = (base, overrides) => {
if (overrides === undefined) return base;
if (base === undefined || base === null) return overrides;
if (Array.isArray(overrides)) return overrides;
if (typeof overrides !== 'object' || overrides === null) return overrides;
const out = Object.assign({}, base);
Object.keys(overrides).forEach((k) => {
if (Array.isArray(overrides[k]) || typeof overrides[k] !== 'object' || overrides[k] === null) {
out[k] = overrides[k];
} else {
out[k] = deepMerge(base[k], overrides[k]);
}
});
return out;
};
// Ensure booking data fields have the expected shapes to avoid runtime errors
const sanitizeBookingData = (raw) => {
const defaults = {
hero: { title: '', backgroundImage: '' },
searchBar: { locationLabel: '', holidaySeasonLabel: '', searchButtonText: '' },
filterPanel: {
title: '',
priceTitle: '',
priceLabel: '',
pricePlaceholder: '',
priceMin: 0,
priceMax: 0,
ageTitle: '',
ageMin: 0,
ageMax: 0,
ageSelectPlaceholder: '',
activitiesTitle: '',
ratingTitle: '',
ratingOptions: [],
resetButtonText: ''
},
programs: [],
holidays: [],
locations: [],
camps: [],
configuration: { currency: 'USD', discounts: [], vouchers: [] },
formSteps: [],
validation: {}
};
if (!raw || typeof raw !== 'object') return defaults;
// Use raw data first, then fill in missing fields with defaults
const safe = Object.assign({}, raw);
// Ensure nested objects/arrays have correct types (use raw data if valid, otherwise defaults)
safe.hero = (safe.hero && typeof safe.hero === 'object') ? safe.hero : defaults.hero;
safe.searchBar = (safe.searchBar && typeof safe.searchBar === 'object') ? safe.searchBar : defaults.searchBar;
safe.filterPanel = (safe.filterPanel && typeof safe.filterPanel === 'object') ? safe.filterPanel : defaults.filterPanel;
if (!Array.isArray(safe.filterPanel.ratingOptions)) safe.filterPanel.ratingOptions = defaults.filterPanel.ratingOptions;
safe.programs = Array.isArray(safe.programs) ? safe.programs : defaults.programs;
safe.holidays = Array.isArray(safe.holidays) ? safe.holidays : defaults.holidays;
safe.locations = Array.isArray(safe.locations) ? safe.locations : defaults.locations;
safe.camps = Array.isArray(safe.camps) ? safe.camps : defaults.camps;
// Ensure configuration has proper structure
if (!safe.configuration || typeof safe.configuration !== 'object') {
safe.configuration = defaults.configuration;
}
if (!Array.isArray(safe.configuration.discounts)) {
safe.configuration.discounts = defaults.configuration.discounts;
}
if (!Array.isArray(safe.configuration.vouchers)) {
safe.configuration.vouchers = defaults.configuration.vouchers;
}
// Ensure formSteps and validation have correct types
safe.formSteps = Array.isArray(safe.formSteps) ? safe.formSteps : defaults.formSteps;
safe.validation = (safe.validation && typeof safe.validation === 'object' && !Array.isArray(safe.validation)) ? safe.validation : defaults.validation;
return safe;
};
// Safe JSON parse with better error handling - handles double-encoded JSON and JS object notation
const safeParse = (value, fieldName = 'unknown') => {
// If already an object or array, return as-is
if (typeof value === 'object' && value !== null) {
return value;
}
// If string, try to parse
if (typeof value === 'string') {
try {
let cleaned = value.trim();
// Check if it looks like JavaScript object notation (has single quotes or unquoted keys)
if (cleaned.includes("'") || /\{\s*\w+:/.test(cleaned)) {
console.warn(`safeParse: Converting JS notation to JSON for "${fieldName}"`);
// Aggressive conversion approach
cleaned = cleaned
.replace(/'/g, '"') // Replace ALL single quotes with double quotes
.replace(/\r?\n|\r/g, ' ') // Remove all newlines
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas before } or ]
.replace(/([{,]\s*)(\w+):/g, '$1"$2":'); // Quote unquoted object keys
}
// Try parsing
let parsed = JSON.parse(cleaned);
// If result is still a string, try parsing again (double-encoded)
if (typeof parsed === 'string') {
console.warn(`safeParse: Double-encoded JSON detected for "${fieldName}"`);
parsed = JSON.parse(parsed);
}
return parsed;
} catch (e) {
console.error(`safeParse: Failed to parse field "${fieldName}"`, {
error: e.message,
valuePreview: value.substring(0, 200)
});
throw new Error(`Invalid JSON format for field: ${fieldName}. Error: ${e.message}`);
}
}
// For other types, return empty array or object
console.warn(`safeParse: Unexpected type for field "${fieldName}": ${typeof value}`);
return Array.isArray(value) ? [] : {};
};
// Validate booking data structure
const validateBookingData = (data) => {
const errors = [];
// Check required fields
if (!data.hero || typeof data.hero !== 'object') {
errors.push('Hero data is required and must be an object');
}
if (!data.searchBar || typeof data.searchBar !== 'object') {
errors.push('SearchBar data is required and must be an object');
}
if (!data.filterPanel || typeof data.filterPanel !== 'object') {
errors.push('FilterPanel data is required and must be an object');
}
// Validate arrays
if (data.programs && !Array.isArray(data.programs)) {
errors.push('Programs must be an array');
}
if (data.holidays && !Array.isArray(data.holidays)) {
errors.push('Holidays must be an array');
}
if (data.locations && !Array.isArray(data.locations)) {
errors.push('Locations must be an array');
}
if (data.camps && !Array.isArray(data.camps)) {
errors.push('Camps must be an array');
}
// Validate configuration structure
if (data.configuration) {
if (typeof data.configuration !== 'object') {
errors.push('Configuration must be an object');
} else {
if (data.configuration.discounts && !Array.isArray(data.configuration.discounts)) {
errors.push('Configuration.discounts must be an array');
}
if (data.configuration.vouchers && !Array.isArray(data.configuration.vouchers)) {
errors.push('Configuration.vouchers must be an array');
}
}
}
// Validate formSteps and validation structure if provided
if (data.formSteps && !Array.isArray(data.formSteps)) {
errors.push('formSteps must be an array');
}
if (data.validation && (typeof data.validation !== 'object' || Array.isArray(data.validation))) {
errors.push('validation must be an object');
}
return {
isValid: errors.length === 0,
errors
};
};
// -------------------- Public endpoints --------------------
// Public endpoint: return Booking JSON
exports.page = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
// Build final payload according to BOOKING_MODE env var
const finalBooking = getFinalBooking(normStatic, normDb);
if (!finalBooking) {
return res.status(404).json({
error: "No booking data found",
message: "Please configure booking data in admin panel"
});
}
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(finalBooking, baseUrl);
return res.json(processed);
} catch (err) {
console.error("booking.page error:", err);
return res.status(500).json({
error: "Error loading booking data",
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
};
// API endpoint to return booking JSON
exports.api = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
const finalBooking = getFinalBooking(normStatic, normDb);
if (!finalBooking) {
return res.status(404).json({
error: "No booking data found",
message: "Please configure booking data in admin panel"
});
}
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
const processed = addBaseUrlToImages(finalBooking, baseUrl);
return res.json(processed);
} catch (err) {
console.error("booking.api error:", err);
return res.status(500).json({
error: "Error loading booking data",
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
};
// -------------------- Admin endpoints --------------------
// Display Booking management page
exports.index = async (req, res) => {
try {
const dbBooking = await getBookingData();
const staticBooking = loadStaticBooking();
// Merge static booking with DB data (use same merge logic as public endpoints)
const normStatic = normalizeBookingShape(staticBooking);
const normDb = normalizeBookingShape(dbBooking);
const mergedData = getFinalBooking(normStatic, normDb);
// Normalize again after merge to ensure discounts/vouchers are synced to top-level
const data = normalizeBookingShape(mergedData);
// Sanitize data to ensure nested fields are objects/arrays (fixes malformed DB entries)
const safeData = sanitizeBookingData(data);
res.render("admin/booking/index", {
layout: "layouts/main",
title: "Booking Management",
data: safeData,
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("booking.index error:", err);
req.flash("error_msg", "Error loading booking page");
res.redirect("/admin/dashboard");
}
};
// Update booking data
exports.update = async (req, res) => {
try {
const { id } = req.params;
// ADD THIS DEBUG LOG
console.log('=== RAW REQUEST BODY ===');
console.log('Discounts type:', typeof req.body.discounts);
console.log('Discounts first 500 chars:', req.body.discounts?.substring(0, 500));
console.log('Vouchers type:', typeof req.body.vouchers);
console.log('Vouchers first 500 chars:', req.body.vouchers?.substring(0, 500));
console.log('========================');
const {
hero,
searchBar,
filterPanel,
programs,
holidays,
locations,
camps,
discounts,
vouchers,
formSteps,
validation: validationRaw
} = req.body;
// Parse JSON strings
const errors = [];
let updateData = {};
try {
console.log('Raw discounts from req.body:', typeof discounts, discounts);
console.log('Raw vouchers from req.body:', typeof vouchers, vouchers);
const parsedDiscounts = safeParse(discounts, 'discounts');
const parsedVouchers = safeParse(vouchers, 'vouchers');
console.log('Parsed discounts:', typeof parsedDiscounts, Array.isArray(parsedDiscounts), parsedDiscounts);
console.log('Parsed vouchers:', typeof parsedVouchers, Array.isArray(parsedVouchers), parsedVouchers);
updateData = {
hero: safeParse(hero, 'hero'),
searchBar: safeParse(searchBar, 'searchBar'),
filterPanel: safeParse(filterPanel, 'filterPanel'),
programs: safeParse(programs, 'programs'),
holidays: safeParse(holidays, 'holidays'),
locations: safeParse(locations, 'locations'),
camps: safeParse(camps, 'camps'),
formSteps: safeParse(formSteps, 'formSteps'),
validation: safeParse(validationRaw, 'validation'),
configuration: {
currency: 'USD',
discounts: parsedDiscounts,
vouchers: parsedVouchers
}
};
} catch (parseError) {
console.error('booking.update: Parse error', parseError);
req.flash("error_msg", `Data processing error: ${parseError.message}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
// Validate data structure
const validation = validateBookingData(updateData);
if (!validation.isValid) {
console.error('booking.update: Validation failed', validation.errors);
req.flash("error_msg", `Validation failed: ${validation.errors[0]}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
console.log('Final updateData keys:', Object.keys(updateData));
console.log('updateData.discounts:', updateData.discounts);
console.log('updateData.configuration:', updateData.configuration);
// CRITICAL: Remove any top-level discounts/vouchers to prevent schema conflicts
// These should ONLY exist in configuration object
delete updateData.discounts;
delete updateData.vouchers;
// Update or create booking document
let result;
try {
if (id && id !== 'undefined') {
result = await Booking.findByIdAndUpdate(
id,
{
...updateData,
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
},
{
new: true,
runValidators: false, // TẮT validator để tránh lỗi cast
strict: false // TẮT strict mode
}
);
if (!result) {
req.flash("error_msg", "Booking document not found");
return req.session.save(() => res.redirect("/admin/booking"));
}
} else {
// Upsert: update existing or create new
result = await Booking.findOneAndUpdate(
{},
{
...updateData,
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
},
{
upsert: true,
new: true,
runValidators: false, // TẮT validator
strict: false // TẮT strict mode
}
);
}
req.flash("success_msg", "Booking data updated successfully");
return req.session.save(() => res.redirect("/admin/booking"));
} catch (dbError) {
console.error("booking.update: Database error", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
} catch (err) {
console.error("booking.update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/booking"));
}
};
// Booking selection mode: 'merge' (default) = static base, DB overrides;
// 'static' = use `data/booking.json` only; 'db' = use DB only.
const getFinalBooking = (staticBooking, dbBooking) => {
const mode = (process.env.BOOKING_MODE || 'merge').toLowerCase();
if (mode === 'static') return staticBooking || dbBooking || null;
if (mode === 'db') return dbBooking || staticBooking || null;
// default: merge static (base) with DB overrides
// If both static and db present, attempt to map DB primitive lists (e.g. ["915"]) to
// full objects from the static file (e.g. {id:"915", name:..., type:...}).
const mapDbPrimitivesToObjects = (db, stat) => {
if (!db || !stat) return db;
const dbCfg = db.configuration || {};
const statCfg = stat.configuration || {};
console.log('DB discounts/vouchers:', db.discounts, db.vouchers);
console.log('DB config:', dbCfg.discounts, dbCfg.vouchers);
console.log('Static config:', statCfg.discounts, statCfg.vouchers);
// Handle legacy: if top-level discounts/vouchers exist as strings, migrate to configuration
if (Array.isArray(db.discounts) && db.discounts.length > 0 && (!dbCfg.discounts || dbCfg.discounts.length === 0)) {
const statDiscountById = {};
if (statCfg.discounts) {
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
}
if (typeof db.discounts[0] === 'string') {
dbCfg.discounts = db.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
} else {
dbCfg.discounts = db.discounts;
}
}
if (Array.isArray(db.vouchers) && db.vouchers.length > 0 && (!dbCfg.vouchers || dbCfg.vouchers.length === 0)) {
const statVouchByCode = {};
if (statCfg.vouchers) {
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
}
if (typeof db.vouchers[0] === 'string') {
dbCfg.vouchers = db.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
} else {
dbCfg.vouchers = db.vouchers;
}
}
// If DB configuration still empty, use static data
if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length === 0 && statCfg.discounts && statCfg.discounts.length > 0) {
dbCfg.discounts = statCfg.discounts;
} else if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length > 0 && typeof dbCfg.discounts[0] === 'string') {
// Map string IDs to full objects from static
const statDiscountById = {};
if (statCfg.discounts) {
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
}
dbCfg.discounts = dbCfg.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
}
if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length === 0 && statCfg.vouchers && statCfg.vouchers.length > 0) {
dbCfg.vouchers = statCfg.vouchers;
} else if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length > 0 && typeof dbCfg.vouchers[0] === 'string') {
// Map string codes to full objects from static
const statVouchByCode = {};
if (statCfg.vouchers) {
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
}
dbCfg.vouchers = dbCfg.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
}
return Object.assign({}, db, { configuration: dbCfg });
};
const mappedDb = mapDbPrimitivesToObjects(dbBooking, staticBooking);
const merged = staticBooking ? deepMerge(staticBooking, mappedDb || {}) : (mappedDb || null);
// Clean up: remove top-level discounts/vouchers after migrating to configuration
if (merged) {
delete merged.discounts;
delete merged.vouchers;
}
return merged;
};
-558
View File
@@ -1,558 +0,0 @@
const BookingSubmission = require('../models/bookingSubmission');
const Activity = require('../models/activity');
// API endpoint để tạo booking submission mới
exports.submitBooking = async (req, res) => {
try {
const {
activityId,
sessionId,
parentFirstName,
parentLastName,
email,
phone,
address,
city,
country,
postalCode,
participantFirstName,
participantLastName,
participantBirthDate,
participantGender,
numberOfParticipants,
medicalConditions,
dietaryRestrictions,
specialRequests,
emergencyContact,
emergencyPhone,
agreeTerms,
agreeNewsletter
} = req.body;
// Validate required fields
if (!activityId || !sessionId || !parentFirstName || !parentLastName ||
!email || !phone || !address || !city || !country || !postalCode ||
!participantFirstName || !participantLastName || !participantBirthDate ||
!participantGender || !emergencyContact || !emergencyPhone || !agreeTerms) {
return res.status(400).json({
error: 'Missing required fields',
message: 'Please fill in all required fields'
});
}
// Verify activity exists
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({
error: 'Activity not found',
message: 'The selected activity does not exist'
});
}
// Verify session exists and is active
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
if (!session) {
return res.status(404).json({
error: 'Session not found',
message: 'The selected session does not exist'
});
}
if (!session.isActive) {
return res.status(400).json({
error: 'Session not available',
message: 'The selected session is no longer available for booking'
});
}
// Check availability based on participant gender
const currentBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender,
status: { $in: ['pending', 'confirmed'] }
});
const availableSpots = participantGender === 'male'
? session.totalMaleSpots - session.bookedMaleSpots
: session.totalFemaleSpots - session.bookedFemaleSpots;
if (currentBookings >= availableSpots) {
return res.status(400).json({
error: 'Session full',
message: `No more spots available for ${participantGender} participants in this session`
});
}
// Calculate total amount based on activity price and number of participants
const totalAmount = (activity.price || 0) * (parseInt(numberOfParticipants) || 1);
// Create booking submission
const bookingSubmission = new BookingSubmission({
activityId,
sessionId,
parentFirstName: parentFirstName.trim(),
parentLastName: parentLastName.trim(),
email: email.toLowerCase().trim(),
phone: phone.trim(),
address: address.trim(),
city: city.trim(),
country: country.trim(),
postalCode: postalCode.trim(),
participantFirstName: participantFirstName.trim(),
participantLastName: participantLastName.trim(),
participantBirthDate: new Date(participantBirthDate),
participantGender,
numberOfParticipants: parseInt(numberOfParticipants) || 1,
medicalConditions: (medicalConditions || '').trim(),
dietaryRestrictions: dietaryRestrictions || 'none',
specialRequests: (specialRequests || '').trim(),
emergencyContact: emergencyContact.trim(),
emergencyPhone: emergencyPhone.trim(),
agreeTerms: Boolean(agreeTerms),
agreeNewsletter: Boolean(agreeNewsletter),
totalAmount,
status: 'pending',
paymentStatus: 'pending'
});
await bookingSubmission.save();
// Update session booked spots
const updateField = participantGender === 'male' ? 'bookingSessions.$.bookedMaleSpots' : 'bookingSessions.$.bookedFemaleSpots';
await Activity.updateOne(
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
{ $inc: { [updateField]: 1 } }
);
// Populate activity info for response
await bookingSubmission.populate('activityId', 'name price');
return res.status(201).json({
success: true,
message: 'Booking submitted successfully',
booking: {
id: bookingSubmission._id,
activityName: bookingSubmission.activityId.name,
sessionId: bookingSubmission.sessionId,
participantName: `${bookingSubmission.participantFirstName} ${bookingSubmission.participantLastName}`,
totalAmount: bookingSubmission.totalAmount,
status: bookingSubmission.status
}
});
} catch (error) {
console.error('submitBooking error:', error);
// Handle validation errors
if (error.name === 'ValidationError') {
const validationErrors = Object.values(error.errors).map(err => err.message);
return res.status(400).json({
error: 'Validation failed',
message: validationErrors.join(', ')
});
}
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while processing your booking. Please try again.'
});
}
};
// API endpoint để lấy thông tin session availability
exports.getSessionAvailability = async (req, res) => {
try {
const { activityId, sessionId } = req.params;
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({ error: 'Activity not found' });
}
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Get current booking counts
const maleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender: 'male',
status: { $in: ['pending', 'confirmed'] }
});
const femaleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId,
participantGender: 'female',
status: { $in: ['pending', 'confirmed'] }
});
return res.json({
sessionId,
isActive: session.isActive,
startDate: session.startDate,
endDate: session.endDate,
overnightStays: session.overnightStays,
price: session.price || activity.price,
availability: {
male: {
total: session.totalMaleSpots,
booked: maleBookings,
available: Math.max(0, session.totalMaleSpots - maleBookings)
},
female: {
total: session.totalFemaleSpots,
booked: femaleBookings,
available: Math.max(0, session.totalFemaleSpots - femaleBookings)
}
}
});
} catch (error) {
console.error('getSessionAvailability error:', error);
return res.status(500).json({ error: 'Error loading session availability' });
}
};
// API endpoint để lấy tất cả sessions có sẵn cho một activity
exports.getAvailableSessions = async (req, res) => {
try {
const { activityId } = req.params;
const activity = await Activity.findById(activityId);
if (!activity) {
return res.status(404).json({ error: 'Activity not found' });
}
const sessions = activity.bookingSessions || [];
const availableSessions = [];
for (const session of sessions) {
if (!session.isActive) continue;
// Get current booking counts
const maleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId: session.sessionId,
participantGender: 'male',
status: { $in: ['pending', 'confirmed'] }
});
const femaleBookings = await BookingSubmission.countDocuments({
activityId,
sessionId: session.sessionId,
participantGender: 'female',
status: { $in: ['pending', 'confirmed'] }
});
const maleAvailable = Math.max(0, session.totalMaleSpots - maleBookings);
const femaleAvailable = Math.max(0, session.totalFemaleSpots - femaleBookings);
// Only include sessions that have available spots
if (maleAvailable > 0 || femaleAvailable > 0) {
availableSessions.push({
sessionId: session.sessionId,
startDate: session.startDate,
endDate: session.endDate,
overnightStays: session.overnightStays,
price: session.price || activity.price,
availability: {
male: {
total: session.totalMaleSpots,
booked: maleBookings,
available: maleAvailable
},
female: {
total: session.totalFemaleSpots,
booked: femaleBookings,
available: femaleAvailable
}
}
});
}
}
return res.json({
activityId,
activityName: activity.name,
sessions: availableSessions
});
} catch (error) {
console.error('getAvailableSessions error:', error);
return res.status(500).json({ error: 'Error loading available sessions' });
}
};
// API endpoint để cập nhật booking submission
exports.updateBookingSubmission = async (req, res) => {
try {
const { bookingId } = req.params;
const updateData = req.body;
// Find the booking
let booking = await BookingSubmission.findById(bookingId);
// If not found as a separate document, try to find it as an embedded booking in Activity.bookingSessions
let activityContaining = null;
let sessionIndex = -1;
let bookingIndex = -1;
if (!booking) {
activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
if (!activityContaining) {
return res.status(404).json({
error: 'Booking not found',
message: 'The booking submission does not exist'
});
}
// locate the exact session and booking positions
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
const bl = activityContaining.bookingSessions[si].bookingList || [];
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
if (bi !== -1) {
sessionIndex = si;
bookingIndex = bi;
break;
}
}
if (sessionIndex === -1 || bookingIndex === -1) {
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
}
booking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
}
// Define allowed fields to update
const allowedUpdates = [
'status',
'paymentStatus',
'paidAmount',
'totalAmount',
'adminNotes',
'emergencyContact',
'emergencyPhone',
'medicalConditions',
'dietaryRestrictions',
'specialRequests'
];
// Build update object with only allowed fields
const updateFields = {};
for (const field of allowedUpdates) {
if (updateData[field] !== undefined) {
updateFields[field] = updateData[field];
}
}
if (Object.keys(updateFields).length === 0) {
return res.status(400).json({
error: 'No valid fields to update',
message: 'Please provide at least one valid field to update'
});
}
// If booking is a separate document, update the BookingSubmission collection
if (activityContaining === null) {
const updatedBooking = await BookingSubmission.findByIdAndUpdate(
bookingId,
updateFields,
{ new: true, runValidators: true }
).populate('activityId', 'name price');
return res.json({
success: true,
message: 'Booking updated successfully',
booking: updatedBooking
});
}
// Otherwise update the embedded booking in the Activity document
const currentBooking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
// Handle status updates and spot adjustments
const newStatus = updateData.status || updateData.bookingStatus;
const currentStatus = currentBooking.status || currentBooking.bookingStatus;
// Apply allowed updates to the embedded booking
const allowedEmbeddedUpdates = [
'status', 'bookingStatus', 'paymentStatus', 'paidAmount', 'totalAmount', 'adminNotes',
'emergencyContact', 'emergencyPhone', 'medicalConditions', 'dietaryRestrictions', 'specialRequests'
];
for (const field of allowedEmbeddedUpdates) {
if (updateData[field] !== undefined) {
if (field === 'status') {
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].status = updateData.status;
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].bookingStatus = updateData.status;
} else {
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex][field] = updateData[field];
}
}
}
// If status change affects spots, adjust counts
if (newStatus && newStatus !== currentStatus) {
const numberOfParticipants = currentBooking.numberOfParticipants || 1;
const participantGender = currentBooking.participantGender;
// If booking is being cancelled, free up spots
if (newStatus === 'cancelled' && currentStatus !== 'cancelled') {
if (participantGender === 'male') {
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
} else if (participantGender === 'female') {
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
}
}
// If restoring from cancelled, ensure capacity then book spots
if (currentStatus === 'cancelled' && newStatus !== 'cancelled') {
if (participantGender === 'male') {
const totalMale = activityContaining.bookingSessions[sessionIndex].totalMaleSpots;
const currentMale = activityContaining.bookingSessions[sessionIndex].bookedMaleSpots;
if (currentMale + numberOfParticipants > totalMale) {
return res.status(400).json({ error: "Not enough male spots available to restore this booking" });
}
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
} else if (participantGender === 'female') {
const totalFemale = activityContaining.bookingSessions[sessionIndex].totalFemaleSpots;
const currentFemale = activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots;
if (currentFemale + numberOfParticipants > totalFemale) {
return res.status(400).json({ error: "Not enough female spots available to restore this booking" });
}
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
}
}
}
await activityContaining.save();
return res.json({
success: true,
message: 'Embedded booking updated successfully',
booking: activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex]
});
} catch (error) {
console.error('updateBookingSubmission error:', error);
// Handle validation errors
if (error.name === 'ValidationError') {
const validationErrors = Object.values(error.errors).map(err => err.message);
return res.status(400).json({
error: 'Validation failed',
message: validationErrors.join(', ')
});
}
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while updating the booking'
});
}
};
// API endpoint để xóa booking submission
exports.deleteBookingSubmission = async (req, res) => {
try {
const { bookingId } = req.params;
// Find and delete the booking
let booking = await BookingSubmission.findById(bookingId);
// If not found in separate collection, try to delete embedded booking in Activity
if (!booking) {
const activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
if (!activityContaining) {
return res.status(404).json({
error: 'Booking not found',
message: 'The booking submission does not exist'
});
}
// locate session and booking
let sessionIndex = -1;
let bookingIndex = -1;
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
const bl = activityContaining.bookingSessions[si].bookingList || [];
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
if (bi !== -1) {
sessionIndex = si;
bookingIndex = bi;
break;
}
}
if (sessionIndex === -1 || bookingIndex === -1) {
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
}
const bookingToDelete = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
// Free up spots if booking is not cancelled
if ((bookingToDelete.bookingStatus || bookingToDelete.status) !== 'cancelled') {
const numberOfParticipants = bookingToDelete.numberOfParticipants || 1;
const participantGender = bookingToDelete.participantGender;
if (participantGender === 'male') {
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
} else if (participantGender === 'female') {
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
}
}
// Remove booking and save
activityContaining.bookingSessions[sessionIndex].bookingList.splice(bookingIndex, 1);
await activityContaining.save();
return res.json({
success: true,
message: 'Embedded booking deleted successfully',
booking: {
id: bookingId,
participantName: `${bookingToDelete.participantFirstName} ${bookingToDelete.participantLastName}`,
email: bookingToDelete.email
}
});
}
// Store info for session spot adjustment
const { activityId, sessionId, participantGender, numberOfParticipants } = booking;
// Delete the booking
await BookingSubmission.findByIdAndDelete(bookingId);
// Update session booked spots (decrease the count)
if (booking.status !== 'cancelled') {
const updateField = participantGender === 'male'
? 'bookingSessions.$.bookedMaleSpots'
: 'bookingSessions.$.bookedFemaleSpots';
await Activity.updateOne(
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
{ $inc: { [updateField]: -numberOfParticipants } }
);
}
return res.json({
success: true,
message: 'Booking deleted successfully',
booking: {
id: bookingId,
participantName: `${booking.participantFirstName} ${booking.participantLastName}`,
email: booking.email
}
});
} catch (error) {
console.error('deleteBookingSubmission error:', error);
return res.status(500).json({
error: 'Server error',
message: 'An error occurred while deleting the booking'
});
}
};
+101 -145
View File
@@ -1,169 +1,125 @@
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");
const { addBaseUrlToImages } = require("../utils/imageHelper");
// 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());
// Helpers
const getFooterDoc = async () => Footer.findOne().sort({ updatedAt: -1 });
const getFooterData = async () => (await getFooterDoc())?.toObject() || {};
res.json(processedData);
} catch (error) {
console.error("Error getting footer:", error);
res.status(500).json({
error: "Failed to get footer data",
});
}
};
const getDefaultFooterData = () => ({
brand: {
logo: {
image: "",
href: "/",
},
description: "",
social: [],
},
explore: {
heading: "",
links: [],
},
contact: {
heading: "",
address: "",
phone: "",
email: "",
},
newsletter: {
heading: "",
description: "",
placeholder: "",
buttonText: "",
},
bottom: {
copyright: "",
links: [],
},
});
// 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)
// Admin: render management view with data from MongoDB
exports.index = async (req, res) => {
try {
const data = await Footer.getSingle();
const processedData = addBaseUrlToImages(data.toObject());
let data = await getFooterData();
const defaults = getDefaultFooterData();
res.render("admin/footer/index", {
title: "Footer Management",
data: processedData,
// Merge defaults for any missing sections
const sections = Object.keys(defaults);
sections.forEach((s) => {
data[s] = data[s] || defaults[s];
});
} catch (error) {
console.error("Error in footer index:", error);
req.flash("error_msg", "An error occurred while loading the page");
res.redirect("/admin/dashboard");
return res.render("admin/footer/index", {
layout: "layouts/main",
title: "Footer Management",
data,
currentPath: req.path,
user: req.session.user,
});
} catch (err) {
console.error("Footer index error:", err);
req.flash("error_msg", "Error loading footer data");
return req.session.save(() => res.redirect("/admin/dashboard"));
}
};
// Update method cho form hiện tại (giống Header pattern)
// Admin: parse req.body sections and save to MongoDB
exports.update = async (req, res) => {
try {
let updateData = req.body;
const sections = ["brand", "explore", "contact", "newsletter", "bottom"];
console.log("=== FOOTER FORM UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
let doc = await getFooterDoc();
// 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");
if (!doc) {
doc = new Footer({});
}
let hasChanges = false;
for (const section of sections) {
if (req.body[section]) {
try {
const payload = JSON.parse(req.body[section]);
doc[section] = payload;
doc.markModified(section);
hasChanges = true;
} catch (e) {
console.error(`Invalid JSON for ${section}:`, e.message);
}
}
}
// 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");
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return req.session.save(() => res.redirect("/admin/footer"));
}
const activeTab = req.body.activeTab || "about";
res.redirect(`/admin/footer?activeTab=${activeTab}`);
await doc.save();
req.flash("success_msg", "Footer configuration has been updated!");
return req.session.save(() => res.redirect("/admin/footer"));
} catch (err) {
console.error("✗ Error updating footer:", err);
req.flash("error_msg", err.message || "Error updating footer");
res.redirect("/admin/footer");
console.error("Footer update error:", err);
req.flash("error_msg", `Update error: ${err.message}`);
return req.session.save(() => res.redirect("/admin/footer"));
}
};
// Legacy API endpoints (giữ lại cho tương thích)
exports.api = exports.getFooter;
exports.getFooterData = exports.getFooter;
// Public API: return JSON data for frontend
exports.api = async (req, res) => {
try {
let data = await getFooterData();
if (!data || Object.keys(data).length === 0) {
data = getDefaultFooterData();
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
return res.json(addBaseUrlToImages(data, baseUrl));
} catch (err) {
console.error("Footer API error:", err);
return res.status(500).json({ error: "Error loading footer data" });
}
};
// Aliases for routes/index.js compatibility
exports.getFooter = exports.api;
exports.updateFooter = exports.update;
+88 -151
View File
@@ -33,29 +33,23 @@ exports.index = async (req, res) => {
// 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 || "",
signInButton: header.signInButton || {
label: "Sign In",
href: "/signin",
},
ctaButton: header.ctaButton || {
label: "Request Info",
href: "/request",
},
}
: {
topbar: {
contactInfo: {
phone: "",
email: "",
location: "",
},
socialLinks: [],
},
logo: "",
signInButton: { label: "Sign In", href: "/signin" },
ctaButton: { label: "Request Info", href: "/request" },
};
const activeTab = req.query.tab || "topbar";
const activeTab = req.query.tab || "logo";
// Always fetch menu items to ensure they are available even if the user
// switches tabs client-side
@@ -123,14 +117,12 @@ exports.show = async (req, res) => {
// Admin: Create header
exports.store = async (req, res) => {
try {
const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body;
const { logo, signInButton, ctaButton, status, order } = req.body;
const header = new Header({
top,
offcanvas,
menu,
logo,
ctaButton,
logo: logo ? { light: logo } : {},
signInButton: signInButton || { label: "Sign In", href: "/signin" },
ctaButton: ctaButton || {},
status: status || "active",
order: order || 1,
});
@@ -152,129 +144,81 @@ exports.store = async (req, res) => {
// Admin: Update header
exports.update = async (req, res) => {
try {
let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } =
req.body;
const { logo, signInButton, 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 || [],
};
// Upsert logic: find existing header or prepare to create new one
let header = await Header.findOne().sort({ order: 1 });
let headerId = header?._id;
if (logo) {
updateData.logo = logoData;
}
// Capture BEFORE state for audit logging
const beforeData = header
? JSON.parse(JSON.stringify(header.toObject()))
: {};
console.log(
"Preparing to update header with data:",
JSON.stringify(updateData, null, 2),
);
if (!header) {
console.log("No existing header found, creating new one");
// Create new header document
header = new Header({
logo: logo ? { light: logo } : {},
signInButton: signInButton || { label: "Sign In", href: "/signin" },
ctaButton: ctaButton || {},
status: status || "active",
order: order || 1,
});
await header.save();
console.log("✓ Header created:", header._id);
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,
// Audit log for creation
const afterData = JSON.parse(JSON.stringify(header.toObject()));
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Header",
documentId: header._id,
action: AUDIT_ACTIONS.UPDATE_HEADER,
before: beforeData,
after: afterData,
changes,
req,
});
}
return res.json({
success: true,
message: "Header created successfully",
data: header,
});
}
// 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 = {};
// Prepare logo data - merge with existing data
let logoData = header.logo || {};
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 || "",
dark: header.logo?.dark || "",
alt: header.logo?.alt || "",
};
}
// Prepare update data
const updateData = {
top,
offcanvas,
menu,
ctaButton,
status,
order,
logo: logoData,
signInButton: signInButton || header.signInButton,
ctaButton: ctaButton || header.ctaButton,
};
if (logo) {
updateData.logo = logoData;
}
if (status !== undefined) updateData.status = status;
if (order !== undefined) updateData.order = order;
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()))
: {};
// Update existing header
const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, {
new: true,
runValidators: true,
@@ -288,10 +232,10 @@ exports.update = async (req, res) => {
});
}
// Capture AFTER state
// Capture AFTER state for audit logging
const afterData = JSON.parse(JSON.stringify(updatedHeader.toObject()));
// ✅ AUDIT LOGGING - Header Updated
// Audit logging - Header Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
@@ -384,23 +328,41 @@ exports.destroy = async (req, res) => {
}
};
// Public API: Get active header
// Public API: Get header (returns header regardless of status)
exports.api = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({
const header = await Header.findOne().sort({
order: 1,
});
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
message: "No header found",
});
}
const baseUrl = (process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
const rawLogoPath = header.logo?.light || '';
const logoImage = rawLogoPath.startsWith('http') ? rawLogoPath : `${baseUrl}${rawLogoPath}`;
res.json({
success: true,
data: header,
data: {
logo: {
image: logoImage,
href: "/",
},
signInButton: {
label: header.signInButton?.label || "Sign In",
href: header.signInButton?.href || "/signin",
},
ctaButton: {
label: header.ctaButton?.label || "Request Info",
href: header.ctaButton?.href || "/request",
},
status: header.status || "active",
},
});
} catch (error) {
res.status(500).json({
@@ -410,28 +372,3 @@ exports.api = async (req, res) => {
}
};
// 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,
});
}
};
+2 -1
View File
@@ -21,6 +21,7 @@ const buildMenuTree = (items, parentId = null, isPublic = false) => {
title: item.title,
url: item.url,
type: item.type,
status: item.status || 'active',
};
}
@@ -196,7 +197,7 @@ exports.reorder = async (req, res) => {
// 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 items = await HeaderMenu.find().sort({ order: 1 });
const tree = buildMenuTree(items, null, true);
res.json({ success: true, data: tree });
} catch (error) {
+35 -152
View File
@@ -1,99 +1,63 @@
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");
const { addBaseUrlToImages } = require("../utils/imageHelper");
// Các hàm hỗ trợ
// Helpers
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
const getDefaultHomeData = () => ({
hero: {
backgroundImage: "",
slides: [],
badge: "",
title: "",
subtitle: "",
description: "",
heroImage: "",
videoUrl: "",
primaryButton: {},
secondaryButton: {},
searchPlaceholder: "",
buttonLabel: "",
image: "",
imageAlt: "",
floatingBadge: {
icon: "",
value: "",
label: "",
},
},
whyChooseUs: {
heading: "",
subheading: "",
quickLinks: [],
valueProp: {
badge: "",
title: "",
description: "",
highlightWord: "",
mainImage: "",
secondaryImage: "",
items: [],
features: [],
ctaButton: {},
stats: [],
},
visaSolutions: { heading: "", subheading: "", items: [] },
visaCountries: {
programs: {
heading: "",
subheading: "",
description: "",
countries: [],
ctaButton: {},
},
testimonials: {
heading: "",
subheading: "",
videoUrl: "",
videoThumbnail: "",
items: [],
},
videoGallery: { heading: "", videoUrl: "", thumbnail: "" },
faq: {
requestInfo: {
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
phone: "",
email: "",
programs: [],
},
});
// Admin: Xem trang quản lý
// Admin: render management view with data from MongoDB
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
// Merge defaults for any missing sections
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,
});
@@ -104,43 +68,34 @@ exports.index = async (req, res) => {
}
};
// 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)
// Admin: parse req.body sections and save to MongoDB
exports.update = async (req, res) => {
try {
const sections = [
"hero",
"whyChooseUs",
"visaSolutions",
"visaCountries",
"testimonials",
"videoGallery",
"faq",
"achievements",
"partners",
"blogPreview",
"quickLinks",
"valueProp",
"programs",
"requestInfo",
];
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);
console.error(`Invalid JSON for ${section}:`, e.message);
}
}
}
@@ -151,21 +106,6 @@ exports.update = async (req, res) => {
}
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"));
@@ -176,72 +116,15 @@ exports.update = async (req, res) => {
}
};
// 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 });
}
};
// Public API: return JSON data for frontend
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())
);
});
if (!data || Object.keys(data).length === 0) {
data = getDefaultHomeData();
}
// 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);
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
return res.json(addBaseUrlToImages(data, baseUrl));
} catch (err) {
console.error("Home API error:", err);
return res.status(500).json({ error: "Error loading home data" });
-229
View File
@@ -1,229 +0,0 @@
const Pricing = require("../models/pricing");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
// ==================== CMS ADMIN FUNCTIONS ====================
// Render admin page for pricing management
exports.index = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
// If no data in DB, try to load from JSON file
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
} else {
// Create default pricing
pricing = await Pricing.create({
name: "default",
hero: {
title: "Pricing Plan",
backgroundImage: "/assets/img/inner-page/breadcrumb.jpg",
shapeImage: "/assets/img/inner-page/shape.png",
breadcrumb: [
{ text: "Home", link: "/" },
{ text: "Pricing Plan", link: "" },
],
},
pricingSection: {
subtitle: "pricing plan",
heading: "Flexible Plans to Suit Every Traveler",
description:
"Choose the plan that fits your visa needs and enjoy expert guidance every step of the way.",
},
plans: {
monthly: [],
yearly: [],
},
testimonials: {
subtitle: "What Our Clients Say",
heading: "Immigration Success Stories",
buttonText: "View All Review",
buttonLink: "/contact",
buttonIcon: "fa-solid fa-arrow-right",
image: "",
items: [],
},
});
}
}
res.render("admin/pricing/index", {
layout: "layouts/main",
title: "Pricing Management",
data: pricing,
user: req.session.user,
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
});
} catch (err) {
console.error("Error loading pricing admin page:", err);
req.flash("error", "Error loading pricing data");
res.redirect("/admin/dashboard");
}
};
// Update pricing data
exports.update = async (req, res) => {
try {
const { hero, pricingSection, plans, testimonials } = req.body;
// Parse JSON strings if needed
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
const pricingSectionData =
typeof pricingSection === "string"
? JSON.parse(pricingSection)
: pricingSection;
const plansData = typeof plans === "string" ? JSON.parse(plans) : plans;
const testimonialsData =
typeof testimonials === "string"
? JSON.parse(testimonials)
: testimonials;
let pricing = await Pricing.findOne({ name: "default" });
// ✅ Capture BEFORE state
const beforeData = pricing
? JSON.parse(JSON.stringify(pricing.toObject()))
: {};
if (pricing) {
pricing.hero = heroData;
pricing.pricingSection = pricingSectionData;
pricing.plans = plansData;
pricing.testimonials = testimonialsData;
await pricing.save();
} else {
pricing = await Pricing.create({
name: "default",
hero: heroData,
pricingSection: pricingSectionData,
plans: plansData,
testimonials: testimonialsData,
});
}
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(pricing.toObject()));
// ✅ AUDIT LOGGING - Pricing Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Pricing",
documentId: pricing._id,
action: AUDIT_ACTIONS.UPDATE_PRICING,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash("success", "Pricing data updated successfully");
res.redirect("/admin/pricing");
} catch (err) {
console.error("Error updating pricing:", err);
req.flash("error", "Error updating pricing data");
res.redirect("/admin/pricing");
}
};
// API to get pricing data (admin)
exports.getPricingData = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
}
}
res.json({
success: true,
data: pricing,
});
} catch (err) {
console.error("Error getting pricing data:", err);
res.status(500).json({
success: false,
error: "Error loading pricing data",
});
}
};
// Public API to get pricing page data (for frontend)
exports.api = async (req, res) => {
try {
let pricing = await Pricing.findOne({ name: "default" });
if (!pricing) {
const fs = require("fs");
const path = require("path");
const jsonPath = path.join(__dirname, "../data/pricing.json");
if (fs.existsSync(jsonPath)) {
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
pricing = await Pricing.migrateFromJson(jsonData);
}
}
if (!pricing) {
return res.status(404).json({
success: false,
error: "Pricing data not found",
});
}
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
const getFullUrl = (path) => {
if (!path || path.startsWith("http")) return path;
return `${backendUrl}${path.startsWith("/") ? "" : "/"}${path}`;
};
// Convert to plain object to modify properties safely
const pricingData = pricing.toObject ? pricing.toObject() : pricing;
if (pricingData.hero) {
pricingData.hero.backgroundImage = getFullUrl(
pricingData.hero.backgroundImage,
);
pricingData.hero.shapeImage = getFullUrl(pricingData.hero.shapeImage);
}
if (pricingData.testimonials) {
pricingData.testimonials.image = getFullUrl(
pricingData.testimonials.image,
);
}
res.json({
success: true,
data: {
hero: pricingData.hero,
pricingSection: pricingData.pricingSection,
plans: pricingData.plans,
testimonials: pricingData.testimonials,
},
});
} catch (err) {
console.error("Error getting pricing API data:", err);
res.status(500).json({
success: false,
error: "Error loading pricing data",
});
}
};
-396
View File
@@ -1,396 +0,0 @@
const { getServiceData } = require("../services/service.service");
const Service = require("../models/service");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const slugify = require("slugify");
// Admin page - Service list
exports.index = async (req, res) => {
try {
const data = await getServiceData();
console.log(data.services.items.image);
res.render("admin/service/index", {
title: "Service Management",
data,
layout: "layouts/main",
getFullImageUrl, // Truyền helper function vào view
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service data");
res.redirect("/admin/dashboard");
}
};
// Admin page - Service edit
exports.edit = async (req, res) => {
try {
const { slug } = req.params;
const data = await getServiceData();
const service = data.services?.items?.find((item) => item.slug === slug);
if (!service) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
res.render("admin/service/edit", {
title: `Edit Service - ${service.name}`,
service,
layout: "layouts/main",
getFullImageUrl,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service for editing");
res.redirect("/admin/service");
}
};
// Update single service
exports.updateService = async (req, res) => {
try {
const { slug } = req.params;
const currentData = await getServiceData();
const serviceIndex = currentData.services?.items?.findIndex(
(item) => item.slug === slug,
);
if (serviceIndex === -1) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
const oldItem = JSON.parse(
JSON.stringify(currentData.services.items[serviceIndex]),
);
// Update service data
const updatedData = { ...currentData.toObject?.() };
updatedData.services.items[serviceIndex] = {
...updatedData.services.items[serviceIndex],
name: req.body.name,
slug: req.body.slug,
description: req.body.description,
image: req.body.image,
layout: req.body.layout,
};
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
const newItem = updatedData.services.items[serviceIndex];
const changes = diffObject(oldItem, newItem);
console.log("USER:", req.session?.user || req.user || "No user found");
await writeAuditLog({
model: "Service",
documentId: currentData._id,
action: AUDIT_ACTIONS.UPDATE_SERVICE,
before: oldItem,
after: newItem,
changes,
req,
});
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// Admin page - Service details
exports.details = async (req, res) => {
try {
const { slug } = req.params;
const data = await getServiceData();
const service = data.services?.items?.find((item) => item.slug === slug);
if (!service) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
res.render("admin/service/details", {
title: `Service Details - ${service.name}`,
service,
layout: "layouts/main",
getFullImageUrl, // Truyền helper function vào view
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service details");
res.redirect("/admin/service");
}
};
// Update service list
exports.update = async (req, res) => {
try {
const currentData = await getServiceData();
const sections = [
"pageTitle",
"services",
"destinations",
"visas",
"reviews",
];
let updatedData = { ...currentData.toObject?.() };
let hasChanges = false;
sections.forEach((section) => {
if (!req.body[section]) return;
const newData = JSON.parse(req.body[section]);
if (JSON.stringify(newData) !== JSON.stringify(currentData[section])) {
updatedData[section] = newData;
hasChanges = true;
}
});
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return res.redirect("/admin/service");
}
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// Update service details
exports.updateDetails = async (req, res) => {
try {
const { slug } = req.params;
const currentData = await getServiceData();
const serviceIndex = currentData.services?.items?.findIndex(
(item) => item.slug === slug,
);
if (serviceIndex === -1) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
const beforeDetails = JSON.parse(
JSON.stringify(currentData.services.items[serviceIndex].details || {}),
);
// Parse features and FAQ from JSON strings
const features = req.body.features ? JSON.parse(req.body.features) : [];
const faq = req.body.faq ? JSON.parse(req.body.faq) : [];
// Update service details
const updatedData = { ...currentData.toObject?.() };
const updatedDetails = {
title: req.body.title,
description: req.body.description,
mainImage: req.body.mainImage,
overviewTitle: req.body.overviewTitle,
overviewDescription: req.body.overviewDescription,
additionalDescription: req.body.additionalDescription,
keyFeaturesTitle: req.body.keyFeaturesTitle,
keyFeaturesImage: req.body.keyFeaturesImage,
features,
faqTitle: req.body.faqTitle,
faqImage: req.body.faqImage,
faq,
};
updatedData.services.items[serviceIndex].details = updatedDetails;
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
const changes = diffObject(beforeDetails, updatedDetails);
if (changes.length > 0) {
await writeAuditLog({
model: "Service",
documentId: currentData._id,
action: AUDIT_ACTIONS.UPDATE_SERVICE_DETAILS,
before: beforeDetails,
after: updatedDetails,
changes,
req,
});
}
req.flash("success_msg", "Service details updated successfully");
res.redirect(`/admin/service/${slug}/details`);
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// API endpoint
exports.api = async (req, res) => {
try {
const serviceData = await getServiceData();
const baseUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(serviceData, baseUrl);
res.json(processedData);
} catch (err) {
res.status(500).json({ error: "Error loading service data" });
}
};
/**
* Get service details by slug - API endpoint
*/
exports.getServiceBySlug = async (req, res) => {
try {
const { slug } = req.params;
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc) {
return res.status(404).json({
success: false,
message: "Service data not found",
});
}
// Find service by slug
const service = serviceDoc.services?.items?.find(
(item) => item.slug === slug,
);
if (!service) {
return res.status(404).json({
success: false,
message: `Service with slug '${slug}' not found`,
});
}
const baseUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
// Return service details in the expected format
const responseData = {
pageTitle: serviceDoc.pageTitle,
breadcrumb: {
...serviceDoc.breadcrumb,
title: "Service Details",
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: service.name, href: `/services/${slug}` },
],
},
serviceDetails: {
content: service.details,
keyFeatures: {
title: service.details.keyFeaturesTitle || "Key Features",
sideImage: service.details.keyFeaturesImage || "img/default.jpg",
items: service.details.features || [],
},
faq: {
title: service.details.faqTitle || "Frequently Asked Questions",
sideImage: service.details.faqImage || "img/default.jpg",
items: service.details.faq || [],
},
},
};
const processedData = addBaseUrlToImages(responseData, baseUrl);
res.json(processedData);
} catch (error) {
console.error("Error fetching service by slug:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
/**
* Generate slug from text - API endpoint
*/
exports.generateSlug = async (req, res) => {
try {
const { text } = req.body;
if (!text || typeof text !== "string") {
return res.status(400).json({
success: false,
message: "Text is required",
});
}
// Generate slug using slugify library with Vietnamese support
const slug = slugify(text, {
lower: true,
strict: true,
locale: "vi",
});
res.json({
success: true,
slug: slug,
});
} catch (error) {
console.error("Error generating slug:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
/**
* Get all service slugs - API endpoint
*/
exports.getServiceSlugs = async (req, res) => {
try {
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc?.services?.items) {
return res.json({
success: true,
slugs: [],
});
}
const slugs = serviceDoc.services.items.map((item) => ({
slug: item.slug,
name: item.name,
id: item.id,
}));
res.json({
success: true,
slugs,
});
} catch (error) {
console.error("Error fetching service slugs:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
-290
View File
@@ -1,290 +0,0 @@
const Travel = require("../models/travel");
const { addBaseUrlToImages } = require("../utils/imageHelper");
const fs = require("fs").promises;
const path = require("path");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
/**
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
*/
function extractYouTubeId(url) {
if (!url || typeof url !== "string") return null;
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
const regex =
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
}
/**
* Hàm Helper: Làm sạch danh sách blocks của Editor.js
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
*/
function sanitizeContentBlocks(blocks) {
if (!blocks || !Array.isArray(blocks)) return [];
const seenVideoIds = new Set();
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
const processedBlocks = blocks.map((block) => {
if (block.type === "embed") {
const url = block.data.source || block.data.embed || "";
const videoId = extractYouTubeId(url);
if (videoId) {
seenVideoIds.add(videoId);
// Cập nhật lại data chuẩn cho Editor.js
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
block.data.source = url;
block.data.videoId = videoId;
block.data.service = "youtube";
}
}
return block;
});
// Bước 2: Lọc bỏ paragraph rác
return processedBlocks.filter((block) => {
if (block.type === "paragraph") {
const text = (block.data?.text || "").trim();
// Xóa paragraph rỗng
if (text === "" || text === "<br>" || text === "&nbsp;") return false;
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
const videoIdInText = extractYouTubeId(text);
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
console.log(
`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`,
);
return false;
}
}
return true;
});
}
// GET: Show travel editor
exports.index = async (req, res) => {
try {
const travel = await Travel.findOne();
if (!travel) {
return res.render("admin/travel/index", {
title: "Travel Management",
data: {
page: {
title: "Travel Information",
description: "",
metadata: { title: "", description: "" },
},
hero: { title: "Travel Information", backgroundImage: "" },
content: { blocks: [] },
enableScrollspy: false,
},
message: "No travel data found. Please run migration first.",
});
}
res.render("admin/travel/index", {
title: "Travel Management",
data: travel,
});
} catch (error) {
console.error("Error loading travel page:", error);
res.status(500).send("Error loading travel page");
}
};
// POST: Update travel information
exports.update = async (req, res) => {
try {
const { page, hero, content, enableScrollspy } = req.body;
// Get current data for before state
const currentTravel = await Travel.findOne();
// ✅ Capture BEFORE state
const beforeData = currentTravel
? JSON.parse(
JSON.stringify(
currentTravel.toObject ? currentTravel.toObject() : currentTravel,
),
)
: {};
const updateData = {};
if (page) updateData.page = JSON.parse(page);
if (hero) updateData.hero = JSON.parse(hero);
if (content) {
let contentObj = JSON.parse(content);
// Áp dụng bộ lọc dọn dẹp nội dung
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
updateData.content = contentObj;
}
if (enableScrollspy !== undefined) {
updateData.enableScrollspy =
enableScrollspy === "true" || enableScrollspy === true;
}
const updatedTravel = await Travel.findOneAndUpdate({}, updateData, {
upsert: true,
new: true,
});
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(
updatedTravel.toObject ? updatedTravel.toObject() : updatedTravel,
),
);
// ✅ AUDIT LOGGING - Travel Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Travel",
documentId: updatedTravel._id,
action: AUDIT_ACTIONS.UPDATE_TRAVEL,
before: beforeData,
after: afterData,
changes,
req,
});
}
req.flash(
"success",
"Travel information updated and sanitized successfully",
);
res.redirect("/admin/travel");
} catch (error) {
console.error("Error updating travel:", error);
req.flash("error", "Error updating travel information");
res.redirect("/admin/travel");
}
};
// GET: Travel data API (Sử dụng cho Frontend/Public)
exports.api = exports.getTravelData = async (req, res) => {
try {
const travel = await Travel.findOne();
if (!travel) {
return res.status(404).json({ error: "Travel data not found" });
}
const travelObj = travel.toObject();
const baseUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
const processed = addBaseUrlToImages(travelObj, baseUrl);
return res.json({
success: true,
data: {
hero: processed.hero,
page: processed.page,
content: processed.content,
enableScrollspy: processed.enableScrollspy,
},
});
} catch (error) {
console.error("Error fetching travel data:", error);
res.status(500).json({ error: "Internal server error" });
}
};
// POST: Preview travel
exports.preview = async (req, res) => {
try {
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } =
req.body;
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
let contentObj = JSON.parse(content);
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
const previewData = {
page: {
title: pageTitle || "Travel Information",
year: pageYear || "",
},
hero: {
title: heroTitle || "Travel Information",
backgroundImage: heroBackgroundImage || "",
},
content: contentObj,
enableScrollspy: false,
};
res.render("page/travel", {
title: "Travel Preview",
data: previewData,
});
} catch (error) {
console.error("Error generating preview:", error);
res.status(500).send("Error generating preview");
}
};
// GET: Seed/Import from JSON
exports.seed = async (req, res) => {
try {
const jsonPath = path.join(__dirname, "../data/travel.json");
const jsonData = await fs.readFile(jsonPath, "utf-8");
const jsonTravelData = JSON.parse(jsonData);
let contentBlocks = [];
// Trường hợp JSON đã có định dạng bài viết (blog format)
if (
Array.isArray(jsonTravelData.posts) &&
jsonTravelData.posts.length > 0
) {
const firstPost = jsonTravelData.posts[0];
contentBlocks =
firstPost.content && firstPost.content.blocks
? firstPost.content.blocks
: [];
}
// Trường hợp format cũ (legacy)
else {
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
}
// Luôn làm sạch dữ liệu trước khi seed vào DB
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
const travelData = {
page: {
title:
jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
year: jsonTravelData.page?.year || "",
metadata: {
title: "Travel Guide - Go and Grow Camp",
description:
"Everything you need to know about traveling to our camps",
},
},
hero: {
title: jsonTravelData.hero?.title || "Travel Information",
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
},
content: { blocks: cleanedBlocks },
enableScrollspy: true,
};
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
req.flash("success", "Travel data seeded and sanitized successfully");
res.redirect("/admin/travel");
} catch (error) {
console.error("Error seeding travel data:", error);
req.flash("error", "Failed to seed travel data");
res.redirect("/admin/travel");
}
};
-695
View File
@@ -1,695 +0,0 @@
// controllers/visaController.js
const addBaseUrlToImages = (data, baseUrl) => {
if (!data) return data;
// Nếu là mảng, duyệt từng phần tử
if (Array.isArray(data)) {
return data.map((item) => addBaseUrlToImages(item, baseUrl));
}
// Nếu là object, duyệt từng key
if (typeof data === "object") {
const newObj = {};
for (const [key, value] of Object.entries(data)) {
// Kiểm tra nếu key là các trường chứa ảnh và value là string
const imageKeys = ["icon", "mainImage", "bannerImage", "image"];
if (
imageKeys.includes(key) &&
typeof value === "string" &&
!value.startsWith("http")
) {
newObj[key] = `${baseUrl}/${value}`
.replace(/\/+/g, "/")
.replace(":/", "://");
}
// Xử lý riêng cho mảng gallery (mảng các chuỗi)
else if (key === "gallery" && Array.isArray(value)) {
newObj[key] = value.map((img) =>
img.startsWith("http")
? img
: `${baseUrl}/${img}`.replace(/\/+/g, "/").replace(":/", "://"),
);
}
// Nếu là object hoặc mảng con khác, đệ quy tiếp
else if (typeof value === "object" && value !== null) {
newObj[key] = addBaseUrlToImages(value, baseUrl);
} else {
newObj[key] = value;
}
}
return newObj;
}
return data;
};
const Visa = require("../models/visa");
const slugify = require("slugify");
const writeAuditLog = require("../audit/writeAuditLog");
const diffObject = require("../audit/diffObject");
const AUDIT_ACTIONS = require("../constants/auditAction");
const createSlug = (text) => {
return slugify(text, {
lower: true,
strict: true,
locale: "en",
trim: true,
});
};
// -------------------- Helper Functions --------------------
// Get visa data from MongoDB
const getVisaData = async () => {
const visa = await Visa.findOne().sort({ updatedAt: -1 });
return visa || {};
};
// Get default visa data structure (updated to match new JSON)
const getDefaultVisaData = () => ({
hero: {
title: "Visa Service",
summaryList: [],
},
});
// Helper function: Generate next country ID
const getNextCountryId = (countries) => {
if (!Array.isArray(countries) || countries.length === 0) return 1;
return Math.max(...countries.map((c) => c.id || 0)) + 1;
};
// -------------------- Admin Exports --------------------
// Display visa management page
exports.index = async (req, res) => {
try {
// Fetch Visa data
let data = await getVisaData();
// If no data exists, use default
if (!data || Object.keys(data).length === 0) {
data = getDefaultVisaData();
} else {
// Merge with defaults to ensure all fields exist
const defaultData = getDefaultVisaData();
// Ensure hero section exists with defaults
data.hero = data.hero || defaultData.hero;
data.hero.title = data.hero.title || "Visa Service";
data.hero.summaryList = data.hero.summaryList || [];
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/visa/index", {
layout: "layouts/main",
title: "Visa Management",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
// return res.json(data);
} catch (err) {
console.error("Visa index error:", err);
req.flash("error_msg", "Error loading visa data");
res.redirect("/admin/dashboard");
}
};
// Get single country for edit
exports.getCountry = async (req, res) => {
console.log("--------------------------------------------------");
console.log("🚀 [GET] Request nhận được tại /visa/edit/:id");
try {
const { id } = req.params;
console.log("📍 ID từ Params (URL):", id, "| Kiểu dữ liệu:", typeof id);
const visaData = await getVisaData();
// Kiểm tra cấu trúc dữ liệu tổng
if (!visaData) {
console.error("❌ Lỗi: Hàm getVisaData() trả về null/undefined");
return res.status(404).json({ error: "Dữ liệu gốc không tồn tại" });
}
if (!visaData.hero || !visaData.hero.summaryList) {
console.error("❌ Lỗi: Cấu trúc visaData.hero.summaryList không hợp lệ");
return res
.status(404)
.json({ error: "Không tìm thấy danh sách quốc gia" });
}
console.log(
"📊 Tổng số quốc gia hiện có trong mảng:",
visaData.hero.summaryList.length,
);
// 2. Tìm quốc gia theo ID
const targetId = parseInt(id);
console.log("🔍 Đang tìm kiếm Quốc gia có ID (sau khi parse):", targetId);
const country = visaData.hero.summaryList.find((c) => {
// Log từng phần tử để kiểm tra kiểu dữ liệu của c.id trong DB
// console.log(`Checking country: ${c.name} | ID in DB: ${c.id} (${typeof c.id})`);
return c.id === targetId;
});
if (!country) {
console.warn(`⚠️ Không tìm thấy quốc gia nào khớp với ID: ${targetId}`);
// In ra danh sách ID hiện có để so sánh
const existingIds = visaData.hero.summaryList.map((c) => c.id);
console.log("🆔 Các ID hiện có trong Database:", existingIds);
return res.status(404).json({
success: false,
error: `Không tìm thấy quốc gia có ID: ${id}`,
});
}
console.log("✅ Tìm thấy dữ liệu quốc gia:", country.name);
// 3. Trả về dữ liệu
res.json({
success: true,
country: country,
});
} catch (err) {
console.error("🔴 Lỗi nghiêm trọng tại getCountry Controller:", err);
res.status(500).json({ error: "Lỗi hệ thống khi tải thông tin quốc gia" });
}
};
// Update visa data (hero title only)
exports.update = async (req, res) => {
try {
// Get current data
const currentData = await getVisaData();
// ✅ Capture BEFORE state
const beforeData = currentData
? JSON.parse(
JSON.stringify(
currentData.toObject ? currentData.toObject() : currentData,
),
)
: {};
// Create updated data object
const updatedData = {
...(currentData.toObject ? currentData.toObject() : currentData),
};
// Ensure hero structure exists
updatedData.hero = updatedData.hero || {
title: "Visa Service",
summaryList: [],
};
// Update hero title
if (req.body.heroTitle) {
updatedData.hero.title = req.body.heroTitle;
}
// Update or create document
try {
let savedData;
if (currentData._id) {
savedData = await Visa.findByIdAndUpdate(currentData._id, updatedData, {
new: true,
});
} else {
savedData = await Visa.create(updatedData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(JSON.stringify(savedData.toObject()));
// ✅ AUDIT LOGGING - Visa Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa update: ${changes.length} changes`,
);
} else {
console.log("️ No changes detected for Visa update");
}
req.flash("success_msg", "Visa data updated successfully");
return req.session.save(() => res.redirect("/admin/visa"));
} catch (dbError) {
console.error("Database error:", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
};
// Add new country
exports.addCountry = async (req, res) => {
try {
let visaData = await getVisaData();
// Initialize hero structure if not exist
if (!visaData.hero || !visaData.hero.summaryList) {
visaData = getDefaultVisaData();
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// Validate required fields
if (!req.body.name) {
return res.status(400).json({ error: "Name is required" });
}
const finalSlug = req.body.slug
? createSlug(req.body.slug)
: createSlug(req.body.name);
// Parse services array
let services = [];
if (req.body.services) {
if (typeof req.body.services === "string") {
try {
services = JSON.parse(req.body.services);
} catch (e) {
services = [req.body.services];
}
} else if (Array.isArray(req.body.services)) {
services = req.body.services;
}
}
// Parse detailedView if provided (optional)
let detailedView = null;
if (req.body.detailedView) {
try {
detailedView =
typeof req.body.detailedView === "string"
? JSON.parse(req.body.detailedView)
: req.body.detailedView;
} catch (e) {
console.warn("Could not parse detailedView, creating without it");
}
}
// Create new country object
const newCountry = {
id: req.body.id || getNextCountryId(visaData.hero.summaryList),
name: req.body.name,
slug: finalSlug,
icon: req.body.icon || "",
services: services,
...(detailedView && { detailedView }),
};
// Add new country to summaryList
visaData.hero.summaryList.push(newCountry);
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
let savedData;
if (visaData._id) {
savedData = await Visa.findByIdAndUpdate(visaData._id, updatedData, {
new: true,
});
} else {
savedData = await Visa.create(updatedData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
);
// ✅ AUDIT LOGGING - Visa Country Added
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa country addition: ${changes.length} changes`,
);
}
console.log(`✅ Country "${newCountry.name}" added successfully`);
res.json({
success: true,
message: `Country "${newCountry.name}" added successfully`,
country: newCountry,
});
} catch (err) {
console.error("Add country error:", err);
res.status(500).json({ error: err.message });
}
};
// Update single country
exports.updateCountry = async (req, res) => {
try {
// 1. Lấy ID từ params (URL)
const { id } = req.params;
let visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res
.status(400)
.json({ error: "Cấu trúc dữ liệu Visa không hợp lệ" });
}
// ✅ Capture BEFORE state
const beforeData = JSON.parse(
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
);
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.id === parseInt(id),
);
if (countryIndex === -1) {
return res
.status(404)
.json({ error: `Không tìm thấy quốc gia có ID: ${id}` });
}
const currentCountry = visaData.hero.summaryList[countryIndex];
let finalSlug = currentCountry.slug;
if (req.body.name) {
// Nếu name thay đổi, ta có thể cập nhật lại slug (tùy nhu cầu SEO)
// Ở đây ưu tiên: Nếu có slug mới truyền lên thì dùng, không thì tạo từ name mới
finalSlug = req.body.slug
? createSlug(req.body.slug)
: createSlug(req.body.name);
}
// 3. Xử lý dữ liệu từ req.body
// Vì Client đã gửi JSON stringify, ta lấy trực tiếp hoặc parse nếu cần
let services = req.body.services;
if (typeof services === "string") {
try {
services = JSON.parse(services);
} catch (e) {
services = [services];
}
}
let detailedView = req.body.detailedView;
if (typeof detailedView === "string") {
try {
detailedView = JSON.parse(detailedView);
} catch (e) {
detailedView = currentCountry.detailedView;
}
}
// 4. Cập nhật Object quốc gia
const updatedCountry = {
...currentCountry, // Giữ các trường cũ
id: parseInt(id), // Đảm bảo ID không đổi
name: req.body.name || currentCountry.name,
slug: finalSlug,
icon: req.body.icon || currentCountry.icon,
services: Array.isArray(services) ? services : currentCountry.services,
detailedView: detailedView || currentCountry.detailedView,
};
// 5. Cập nhật vào mảng chính
visaData.hero.summaryList[countryIndex] = updatedCountry;
// 6. Lưu vào Database
if (visaData.markModified) {
// Bắt buộc với Mongoose khi thay đổi nội dung bên trong Array/Object
visaData.markModified("hero.summaryList");
}
let savedData;
if (visaData._id) {
savedData = await visaData.save(); // Sử dụng save() trực tiếp nếu visaData là Mongoose Document
} else {
savedData = await Visa.create(visaData);
}
// ✅ Capture AFTER state
const afterData = JSON.parse(
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
);
// ✅ AUDIT LOGGING - Visa Country Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Visa",
documentId: savedData._id,
action: AUDIT_ACTIONS.UPDATE_VISA,
before: beforeData,
after: afterData,
changes,
req,
});
console.log(
`✅ Audit log created for Visa country update: ${changes.length} changes`,
);
}
console.log(
`✅ Country "${updatedCountry.name}" updated successfully by ID: ${id}`,
);
res.json({
success: true,
message: `Quốc gia "${updatedCountry.name}" đã được cập nhật thành công`,
country: updatedCountry,
});
} catch (err) {
console.error("Update country error:", err);
res.status(500).json({ error: err.message });
}
};
// Delete country
exports.deleteCountry = async (req, res) => {
try {
// 1. Lấy id từ params
const { id } = req.params;
let visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res
.status(400)
.json({ success: false, error: "Cấu trúc dữ liệu Visa không hợp lệ" });
}
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.id === parseInt(id),
);
if (countryIndex === -1) {
return res.status(404).json({
success: false,
error: `Không tìm thấy quốc gia có ID: ${id}`,
});
}
// 3. Xóa phần tử khỏi mảng
const deletedCountry = visaData.hero.summaryList[countryIndex];
visaData.hero.summaryList.splice(countryIndex, 1);
// 4. Cập nhật vào Database
if (visaData.markModified) {
visaData.markModified("hero.summaryList");
}
if (visaData._id) {
await visaData.save();
} else {
await Visa.create(visaData);
}
console.log(`✅ Deleted Successfully: "${deletedCountry.name}"`);
return res.json({
success: true,
message: `Country "${deletedCountry.name}" Deleted Successfully`,
});
} catch (err) {
console.error("❌ Error Delete:", err);
return res.status(500).json({ success: false, error: err.message });
}
};
// -------------------- Public API Exports --------------------
// API to get all visa data for frontend
exports.api = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
const heroData = visaData?.hero;
// 2. Lấy riêng phần hero (Dùng biến mới, không gán đè vào const)
const processedData = heroData;
return res.json({
success: true,
hero: processedData,
});
} catch (err) {
console.error("Visa API error:", err);
res.status(500).json({
success: false,
error: "Error loading visa data",
});
}
};
// API to get all countries (summaryList only)
exports.apiCountries = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Countries data not found",
data: null,
});
}
// 1. Lọc bỏ 'viewDetail' khỏi từng quốc gia trong danh sách
const filteredCountries = visaData.hero.summaryList.map((item) => {
// Tách detailedView ra, gom phần còn lại vào countryInfo
const { detailedView, ...countryInfo } = item;
return {
...countryInfo,
// Lấy mainImage từ sâu bên trong detailedView và gán vào key mới
mainImage: detailedView?.activeCountry?.mainImage || "",
};
});
// 2. Gắn baseUrl vào ảnh cho danh sách đã lọc
const processedData = filteredCountries;
return res.json({
success: true,
data: processedData, // Lúc này data chỉ chứa thông tin quốc gia, không có viewDetail
});
} catch (err) {
console.error("Countries API error:", err);
res.status(500).json({
success: false,
error: "Error loading countries data",
});
}
};
// API to get single country by slug
exports.apiCountry = async (req, res) => {
try {
const { slug } = req.params;
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
// 1. Tìm quốc gia khớp với slug
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
// 2. Kiểm tra nếu không thấy quốc gia hoặc quốc gia đó không có viewDetail
if (!country || !country.viewDetail) {
return res.status(404).json({
success: false,
error: `Detailed information for country "${slug}" not found`,
data: null,
});
}
// 3. Chỉ lấy phần chi tiết (detailed view)
// Lưu ý: Chúng ta copy ra object mới để tránh tham chiếu dữ liệu gốc
const detailedData = JSON.parse(JSON.stringify(country.viewDetail));
// 4. Gắn baseUrl vào các ảnh nằm trong phần chi tiết này
const processedData = detailedData;
return res.json({
success: true,
data: processedData,
});
} catch (err) {
console.error("Visa country API error:", err);
res.status(500).json({
success: false,
error: "Error loading country detailed data",
});
}
};
// API to get hero data (title + summaryList)
exports.apiHero = async (req, res) => {
try {
const visaData = await getVisaData();
// 1. Kiểm tra dữ liệu gốc
if (!visaData || !visaData.hero) {
return res.status(404).json({
success: false,
error: "Hero data not found",
data: null,
});
}
const { summaryList, ...heroData } = JSON.parse(
JSON.stringify(visaData.hero),
);
const baseUrl =
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(heroData, baseUrl);
return res.json({
success: true,
data: processedData,
});
} catch (err) {
console.error("Visa hero API error:", err);
res.status(500).json({
success: false,
error: "Error loading hero data",
});
}
};