forked from UKSOURCE/cms.lams
Refactor Homepage, About, Footer (Controller,Model, View, Data), Update: Dashboard, Delete old file
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,549 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const Booking = require("../models/booking");
|
||||
|
||||
// -------------------- Public helpers --------------------
|
||||
const getBookingData = async () => {
|
||||
const booking = await Booking.findOne().sort({ updatedAt: -1 });
|
||||
return booking ? (booking.toObject ? booking.toObject() : booking) : null;
|
||||
};
|
||||
|
||||
// Load static booking JSON from `data/booking.json` (if present)
|
||||
const loadStaticBooking = () => {
|
||||
try {
|
||||
const p = path.join(__dirname, '..', 'data', 'booking.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.error('booking.loadStaticBooking error:', e && e.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Normalize booking shape: ensure configuration exists with discounts/vouchers
|
||||
const normalizeBookingShape = (booking) => {
|
||||
if (!booking || typeof booking !== 'object') return booking;
|
||||
const b = JSON.parse(JSON.stringify(booking));
|
||||
|
||||
if (!b.configuration || typeof b.configuration !== 'object') {
|
||||
b.configuration = { currency: 'USD', discounts: [], vouchers: [] };
|
||||
}
|
||||
|
||||
// Ensure configuration.discounts and configuration.vouchers exist
|
||||
if (!Array.isArray(b.configuration.discounts)) {
|
||||
b.configuration.discounts = [];
|
||||
}
|
||||
if (!Array.isArray(b.configuration.vouchers)) {
|
||||
b.configuration.vouchers = [];
|
||||
}
|
||||
|
||||
return b;
|
||||
};
|
||||
|
||||
// Deep merge: properties from `overrides` replace / merge into `base`.
|
||||
const deepMerge = (base, overrides) => {
|
||||
if (overrides === undefined) return base;
|
||||
if (base === undefined || base === null) return overrides;
|
||||
if (Array.isArray(overrides)) return overrides;
|
||||
if (typeof overrides !== 'object' || overrides === null) return overrides;
|
||||
const out = Object.assign({}, base);
|
||||
Object.keys(overrides).forEach((k) => {
|
||||
if (Array.isArray(overrides[k]) || typeof overrides[k] !== 'object' || overrides[k] === null) {
|
||||
out[k] = overrides[k];
|
||||
} else {
|
||||
out[k] = deepMerge(base[k], overrides[k]);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
};
|
||||
|
||||
// Ensure booking data fields have the expected shapes to avoid runtime errors
|
||||
const sanitizeBookingData = (raw) => {
|
||||
const defaults = {
|
||||
hero: { title: '', backgroundImage: '' },
|
||||
searchBar: { locationLabel: '', holidaySeasonLabel: '', searchButtonText: '' },
|
||||
filterPanel: {
|
||||
title: '',
|
||||
priceTitle: '',
|
||||
priceLabel: '',
|
||||
pricePlaceholder: '',
|
||||
priceMin: 0,
|
||||
priceMax: 0,
|
||||
ageTitle: '',
|
||||
ageMin: 0,
|
||||
ageMax: 0,
|
||||
ageSelectPlaceholder: '',
|
||||
activitiesTitle: '',
|
||||
ratingTitle: '',
|
||||
ratingOptions: [],
|
||||
resetButtonText: ''
|
||||
},
|
||||
programs: [],
|
||||
holidays: [],
|
||||
locations: [],
|
||||
camps: [],
|
||||
configuration: { currency: 'USD', discounts: [], vouchers: [] },
|
||||
formSteps: [],
|
||||
validation: {}
|
||||
};
|
||||
|
||||
if (!raw || typeof raw !== 'object') return defaults;
|
||||
|
||||
// Use raw data first, then fill in missing fields with defaults
|
||||
const safe = Object.assign({}, raw);
|
||||
|
||||
// Ensure nested objects/arrays have correct types (use raw data if valid, otherwise defaults)
|
||||
safe.hero = (safe.hero && typeof safe.hero === 'object') ? safe.hero : defaults.hero;
|
||||
safe.searchBar = (safe.searchBar && typeof safe.searchBar === 'object') ? safe.searchBar : defaults.searchBar;
|
||||
safe.filterPanel = (safe.filterPanel && typeof safe.filterPanel === 'object') ? safe.filterPanel : defaults.filterPanel;
|
||||
|
||||
if (!Array.isArray(safe.filterPanel.ratingOptions)) safe.filterPanel.ratingOptions = defaults.filterPanel.ratingOptions;
|
||||
|
||||
safe.programs = Array.isArray(safe.programs) ? safe.programs : defaults.programs;
|
||||
safe.holidays = Array.isArray(safe.holidays) ? safe.holidays : defaults.holidays;
|
||||
safe.locations = Array.isArray(safe.locations) ? safe.locations : defaults.locations;
|
||||
safe.camps = Array.isArray(safe.camps) ? safe.camps : defaults.camps;
|
||||
|
||||
// Ensure configuration has proper structure
|
||||
if (!safe.configuration || typeof safe.configuration !== 'object') {
|
||||
safe.configuration = defaults.configuration;
|
||||
}
|
||||
if (!Array.isArray(safe.configuration.discounts)) {
|
||||
safe.configuration.discounts = defaults.configuration.discounts;
|
||||
}
|
||||
if (!Array.isArray(safe.configuration.vouchers)) {
|
||||
safe.configuration.vouchers = defaults.configuration.vouchers;
|
||||
}
|
||||
|
||||
// Ensure formSteps and validation have correct types
|
||||
safe.formSteps = Array.isArray(safe.formSteps) ? safe.formSteps : defaults.formSteps;
|
||||
safe.validation = (safe.validation && typeof safe.validation === 'object' && !Array.isArray(safe.validation)) ? safe.validation : defaults.validation;
|
||||
|
||||
return safe;
|
||||
};
|
||||
|
||||
// Safe JSON parse with better error handling - handles double-encoded JSON and JS object notation
|
||||
const safeParse = (value, fieldName = 'unknown') => {
|
||||
// If already an object or array, return as-is
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// If string, try to parse
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
let cleaned = value.trim();
|
||||
|
||||
// Check if it looks like JavaScript object notation (has single quotes or unquoted keys)
|
||||
if (cleaned.includes("'") || /\{\s*\w+:/.test(cleaned)) {
|
||||
console.warn(`safeParse: Converting JS notation to JSON for "${fieldName}"`);
|
||||
|
||||
// Aggressive conversion approach
|
||||
cleaned = cleaned
|
||||
.replace(/'/g, '"') // Replace ALL single quotes with double quotes
|
||||
.replace(/\r?\n|\r/g, ' ') // Remove all newlines
|
||||
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
|
||||
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas before } or ]
|
||||
.replace(/([{,]\s*)(\w+):/g, '$1"$2":'); // Quote unquoted object keys
|
||||
}
|
||||
|
||||
// Try parsing
|
||||
let parsed = JSON.parse(cleaned);
|
||||
|
||||
// If result is still a string, try parsing again (double-encoded)
|
||||
if (typeof parsed === 'string') {
|
||||
console.warn(`safeParse: Double-encoded JSON detected for "${fieldName}"`);
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.error(`safeParse: Failed to parse field "${fieldName}"`, {
|
||||
error: e.message,
|
||||
valuePreview: value.substring(0, 200)
|
||||
});
|
||||
|
||||
throw new Error(`Invalid JSON format for field: ${fieldName}. Error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// For other types, return empty array or object
|
||||
console.warn(`safeParse: Unexpected type for field "${fieldName}": ${typeof value}`);
|
||||
return Array.isArray(value) ? [] : {};
|
||||
};
|
||||
|
||||
// Validate booking data structure
|
||||
const validateBookingData = (data) => {
|
||||
const errors = [];
|
||||
|
||||
// Check required fields
|
||||
if (!data.hero || typeof data.hero !== 'object') {
|
||||
errors.push('Hero data is required and must be an object');
|
||||
}
|
||||
|
||||
if (!data.searchBar || typeof data.searchBar !== 'object') {
|
||||
errors.push('SearchBar data is required and must be an object');
|
||||
}
|
||||
|
||||
if (!data.filterPanel || typeof data.filterPanel !== 'object') {
|
||||
errors.push('FilterPanel data is required and must be an object');
|
||||
}
|
||||
|
||||
// Validate arrays
|
||||
if (data.programs && !Array.isArray(data.programs)) {
|
||||
errors.push('Programs must be an array');
|
||||
}
|
||||
|
||||
if (data.holidays && !Array.isArray(data.holidays)) {
|
||||
errors.push('Holidays must be an array');
|
||||
}
|
||||
|
||||
if (data.locations && !Array.isArray(data.locations)) {
|
||||
errors.push('Locations must be an array');
|
||||
}
|
||||
|
||||
if (data.camps && !Array.isArray(data.camps)) {
|
||||
errors.push('Camps must be an array');
|
||||
}
|
||||
|
||||
// Validate configuration structure
|
||||
if (data.configuration) {
|
||||
if (typeof data.configuration !== 'object') {
|
||||
errors.push('Configuration must be an object');
|
||||
} else {
|
||||
if (data.configuration.discounts && !Array.isArray(data.configuration.discounts)) {
|
||||
errors.push('Configuration.discounts must be an array');
|
||||
}
|
||||
if (data.configuration.vouchers && !Array.isArray(data.configuration.vouchers)) {
|
||||
errors.push('Configuration.vouchers must be an array');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate formSteps and validation structure if provided
|
||||
if (data.formSteps && !Array.isArray(data.formSteps)) {
|
||||
errors.push('formSteps must be an array');
|
||||
}
|
||||
|
||||
if (data.validation && (typeof data.validation !== 'object' || Array.isArray(data.validation))) {
|
||||
errors.push('validation must be an object');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
};
|
||||
|
||||
// -------------------- Public endpoints --------------------
|
||||
// Public endpoint: return Booking JSON
|
||||
exports.page = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
|
||||
// Build final payload according to BOOKING_MODE env var
|
||||
const finalBooking = getFinalBooking(normStatic, normDb);
|
||||
|
||||
if (!finalBooking) {
|
||||
return res.status(404).json({
|
||||
error: "No booking data found",
|
||||
message: "Please configure booking data in admin panel"
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(finalBooking, baseUrl);
|
||||
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("booking.page error:", err);
|
||||
return res.status(500).json({
|
||||
error: "Error loading booking data",
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// API endpoint to return booking JSON
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
|
||||
const finalBooking = getFinalBooking(normStatic, normDb);
|
||||
|
||||
if (!finalBooking) {
|
||||
return res.status(404).json({
|
||||
error: "No booking data found",
|
||||
message: "Please configure booking data in admin panel"
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(finalBooking, baseUrl);
|
||||
|
||||
return res.json(processed);
|
||||
} catch (err) {
|
||||
console.error("booking.api error:", err);
|
||||
return res.status(500).json({
|
||||
error: "Error loading booking data",
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Admin endpoints --------------------
|
||||
// Display Booking management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const dbBooking = await getBookingData();
|
||||
const staticBooking = loadStaticBooking();
|
||||
|
||||
// Merge static booking with DB data (use same merge logic as public endpoints)
|
||||
const normStatic = normalizeBookingShape(staticBooking);
|
||||
const normDb = normalizeBookingShape(dbBooking);
|
||||
const mergedData = getFinalBooking(normStatic, normDb);
|
||||
|
||||
// Normalize again after merge to ensure discounts/vouchers are synced to top-level
|
||||
const data = normalizeBookingShape(mergedData);
|
||||
|
||||
// Sanitize data to ensure nested fields are objects/arrays (fixes malformed DB entries)
|
||||
const safeData = sanitizeBookingData(data);
|
||||
|
||||
res.render("admin/booking/index", {
|
||||
layout: "layouts/main",
|
||||
title: "Booking Management",
|
||||
data: safeData,
|
||||
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("booking.index error:", err);
|
||||
req.flash("error_msg", "Error loading booking page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Update booking data
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// ADD THIS DEBUG LOG
|
||||
console.log('=== RAW REQUEST BODY ===');
|
||||
console.log('Discounts type:', typeof req.body.discounts);
|
||||
console.log('Discounts first 500 chars:', req.body.discounts?.substring(0, 500));
|
||||
console.log('Vouchers type:', typeof req.body.vouchers);
|
||||
console.log('Vouchers first 500 chars:', req.body.vouchers?.substring(0, 500));
|
||||
console.log('========================');
|
||||
const {
|
||||
hero,
|
||||
searchBar,
|
||||
filterPanel,
|
||||
programs,
|
||||
holidays,
|
||||
locations,
|
||||
camps,
|
||||
discounts,
|
||||
vouchers,
|
||||
formSteps,
|
||||
validation: validationRaw
|
||||
} = req.body;
|
||||
|
||||
// Parse JSON strings
|
||||
const errors = [];
|
||||
let updateData = {};
|
||||
|
||||
try {
|
||||
console.log('Raw discounts from req.body:', typeof discounts, discounts);
|
||||
console.log('Raw vouchers from req.body:', typeof vouchers, vouchers);
|
||||
|
||||
const parsedDiscounts = safeParse(discounts, 'discounts');
|
||||
const parsedVouchers = safeParse(vouchers, 'vouchers');
|
||||
|
||||
console.log('Parsed discounts:', typeof parsedDiscounts, Array.isArray(parsedDiscounts), parsedDiscounts);
|
||||
console.log('Parsed vouchers:', typeof parsedVouchers, Array.isArray(parsedVouchers), parsedVouchers);
|
||||
|
||||
updateData = {
|
||||
hero: safeParse(hero, 'hero'),
|
||||
searchBar: safeParse(searchBar, 'searchBar'),
|
||||
filterPanel: safeParse(filterPanel, 'filterPanel'),
|
||||
programs: safeParse(programs, 'programs'),
|
||||
holidays: safeParse(holidays, 'holidays'),
|
||||
locations: safeParse(locations, 'locations'),
|
||||
camps: safeParse(camps, 'camps'),
|
||||
formSteps: safeParse(formSteps, 'formSteps'),
|
||||
validation: safeParse(validationRaw, 'validation'),
|
||||
configuration: {
|
||||
currency: 'USD',
|
||||
discounts: parsedDiscounts,
|
||||
vouchers: parsedVouchers
|
||||
}
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.error('booking.update: Parse error', parseError);
|
||||
req.flash("error_msg", `Data processing error: ${parseError.message}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
|
||||
// Validate data structure
|
||||
const validation = validateBookingData(updateData);
|
||||
if (!validation.isValid) {
|
||||
console.error('booking.update: Validation failed', validation.errors);
|
||||
req.flash("error_msg", `Validation failed: ${validation.errors[0]}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
|
||||
console.log('Final updateData keys:', Object.keys(updateData));
|
||||
console.log('updateData.discounts:', updateData.discounts);
|
||||
console.log('updateData.configuration:', updateData.configuration);
|
||||
|
||||
// CRITICAL: Remove any top-level discounts/vouchers to prevent schema conflicts
|
||||
// These should ONLY exist in configuration object
|
||||
delete updateData.discounts;
|
||||
delete updateData.vouchers;
|
||||
|
||||
// Update or create booking document
|
||||
let result;
|
||||
try {
|
||||
if (id && id !== 'undefined') {
|
||||
result = await Booking.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
...updateData,
|
||||
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
runValidators: false, // TẮT validator để tránh lỗi cast
|
||||
strict: false // TẮT strict mode
|
||||
}
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
req.flash("error_msg", "Booking document not found");
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
} else {
|
||||
// Upsert: update existing or create new
|
||||
result = await Booking.findOneAndUpdate(
|
||||
{},
|
||||
{
|
||||
...updateData,
|
||||
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
new: true,
|
||||
runValidators: false, // TẮT validator
|
||||
strict: false // TẮT strict mode
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Booking data updated successfully");
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
} catch (dbError) {
|
||||
console.error("booking.update: Database error", dbError);
|
||||
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("booking.update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/booking"));
|
||||
}
|
||||
};
|
||||
|
||||
// Booking selection mode: 'merge' (default) = static base, DB overrides;
|
||||
// 'static' = use `data/booking.json` only; 'db' = use DB only.
|
||||
const getFinalBooking = (staticBooking, dbBooking) => {
|
||||
const mode = (process.env.BOOKING_MODE || 'merge').toLowerCase();
|
||||
if (mode === 'static') return staticBooking || dbBooking || null;
|
||||
if (mode === 'db') return dbBooking || staticBooking || null;
|
||||
// default: merge static (base) with DB overrides
|
||||
// If both static and db present, attempt to map DB primitive lists (e.g. ["915"]) to
|
||||
// full objects from the static file (e.g. {id:"915", name:..., type:...}).
|
||||
const mapDbPrimitivesToObjects = (db, stat) => {
|
||||
if (!db || !stat) return db;
|
||||
const dbCfg = db.configuration || {};
|
||||
const statCfg = stat.configuration || {};
|
||||
|
||||
console.log('DB discounts/vouchers:', db.discounts, db.vouchers);
|
||||
console.log('DB config:', dbCfg.discounts, dbCfg.vouchers);
|
||||
console.log('Static config:', statCfg.discounts, statCfg.vouchers);
|
||||
|
||||
// Handle legacy: if top-level discounts/vouchers exist as strings, migrate to configuration
|
||||
if (Array.isArray(db.discounts) && db.discounts.length > 0 && (!dbCfg.discounts || dbCfg.discounts.length === 0)) {
|
||||
const statDiscountById = {};
|
||||
if (statCfg.discounts) {
|
||||
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
|
||||
}
|
||||
if (typeof db.discounts[0] === 'string') {
|
||||
dbCfg.discounts = db.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
|
||||
} else {
|
||||
dbCfg.discounts = db.discounts;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(db.vouchers) && db.vouchers.length > 0 && (!dbCfg.vouchers || dbCfg.vouchers.length === 0)) {
|
||||
const statVouchByCode = {};
|
||||
if (statCfg.vouchers) {
|
||||
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
|
||||
}
|
||||
if (typeof db.vouchers[0] === 'string') {
|
||||
dbCfg.vouchers = db.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
|
||||
} else {
|
||||
dbCfg.vouchers = db.vouchers;
|
||||
}
|
||||
}
|
||||
|
||||
// If DB configuration still empty, use static data
|
||||
if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length === 0 && statCfg.discounts && statCfg.discounts.length > 0) {
|
||||
dbCfg.discounts = statCfg.discounts;
|
||||
} else if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length > 0 && typeof dbCfg.discounts[0] === 'string') {
|
||||
// Map string IDs to full objects from static
|
||||
const statDiscountById = {};
|
||||
if (statCfg.discounts) {
|
||||
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
|
||||
}
|
||||
dbCfg.discounts = dbCfg.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
|
||||
}
|
||||
|
||||
if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length === 0 && statCfg.vouchers && statCfg.vouchers.length > 0) {
|
||||
dbCfg.vouchers = statCfg.vouchers;
|
||||
} else if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length > 0 && typeof dbCfg.vouchers[0] === 'string') {
|
||||
// Map string codes to full objects from static
|
||||
const statVouchByCode = {};
|
||||
if (statCfg.vouchers) {
|
||||
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
|
||||
}
|
||||
dbCfg.vouchers = dbCfg.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
|
||||
}
|
||||
|
||||
return Object.assign({}, db, { configuration: dbCfg });
|
||||
};
|
||||
|
||||
const mappedDb = mapDbPrimitivesToObjects(dbBooking, staticBooking);
|
||||
const merged = staticBooking ? deepMerge(staticBooking, mappedDb || {}) : (mappedDb || null);
|
||||
|
||||
// Clean up: remove top-level discounts/vouchers after migrating to configuration
|
||||
if (merged) {
|
||||
delete merged.discounts;
|
||||
delete merged.vouchers;
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
@@ -1,558 +0,0 @@
|
||||
const BookingSubmission = require('../models/bookingSubmission');
|
||||
const Activity = require('../models/activity');
|
||||
|
||||
// API endpoint để tạo booking submission mới
|
||||
exports.submitBooking = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
activityId,
|
||||
sessionId,
|
||||
parentFirstName,
|
||||
parentLastName,
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
city,
|
||||
country,
|
||||
postalCode,
|
||||
participantFirstName,
|
||||
participantLastName,
|
||||
participantBirthDate,
|
||||
participantGender,
|
||||
numberOfParticipants,
|
||||
medicalConditions,
|
||||
dietaryRestrictions,
|
||||
specialRequests,
|
||||
emergencyContact,
|
||||
emergencyPhone,
|
||||
agreeTerms,
|
||||
agreeNewsletter
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!activityId || !sessionId || !parentFirstName || !parentLastName ||
|
||||
!email || !phone || !address || !city || !country || !postalCode ||
|
||||
!participantFirstName || !participantLastName || !participantBirthDate ||
|
||||
!participantGender || !emergencyContact || !emergencyPhone || !agreeTerms) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
message: 'Please fill in all required fields'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify activity exists
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({
|
||||
error: 'Activity not found',
|
||||
message: 'The selected activity does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify session exists and is active
|
||||
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({
|
||||
error: 'Session not found',
|
||||
message: 'The selected session does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
if (!session.isActive) {
|
||||
return res.status(400).json({
|
||||
error: 'Session not available',
|
||||
message: 'The selected session is no longer available for booking'
|
||||
});
|
||||
}
|
||||
|
||||
// Check availability based on participant gender
|
||||
const currentBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender,
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const availableSpots = participantGender === 'male'
|
||||
? session.totalMaleSpots - session.bookedMaleSpots
|
||||
: session.totalFemaleSpots - session.bookedFemaleSpots;
|
||||
|
||||
if (currentBookings >= availableSpots) {
|
||||
return res.status(400).json({
|
||||
error: 'Session full',
|
||||
message: `No more spots available for ${participantGender} participants in this session`
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total amount based on activity price and number of participants
|
||||
const totalAmount = (activity.price || 0) * (parseInt(numberOfParticipants) || 1);
|
||||
|
||||
// Create booking submission
|
||||
const bookingSubmission = new BookingSubmission({
|
||||
activityId,
|
||||
sessionId,
|
||||
parentFirstName: parentFirstName.trim(),
|
||||
parentLastName: parentLastName.trim(),
|
||||
email: email.toLowerCase().trim(),
|
||||
phone: phone.trim(),
|
||||
address: address.trim(),
|
||||
city: city.trim(),
|
||||
country: country.trim(),
|
||||
postalCode: postalCode.trim(),
|
||||
participantFirstName: participantFirstName.trim(),
|
||||
participantLastName: participantLastName.trim(),
|
||||
participantBirthDate: new Date(participantBirthDate),
|
||||
participantGender,
|
||||
numberOfParticipants: parseInt(numberOfParticipants) || 1,
|
||||
medicalConditions: (medicalConditions || '').trim(),
|
||||
dietaryRestrictions: dietaryRestrictions || 'none',
|
||||
specialRequests: (specialRequests || '').trim(),
|
||||
emergencyContact: emergencyContact.trim(),
|
||||
emergencyPhone: emergencyPhone.trim(),
|
||||
agreeTerms: Boolean(agreeTerms),
|
||||
agreeNewsletter: Boolean(agreeNewsletter),
|
||||
totalAmount,
|
||||
status: 'pending',
|
||||
paymentStatus: 'pending'
|
||||
});
|
||||
|
||||
await bookingSubmission.save();
|
||||
|
||||
// Update session booked spots
|
||||
const updateField = participantGender === 'male' ? 'bookingSessions.$.bookedMaleSpots' : 'bookingSessions.$.bookedFemaleSpots';
|
||||
await Activity.updateOne(
|
||||
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
|
||||
{ $inc: { [updateField]: 1 } }
|
||||
);
|
||||
|
||||
// Populate activity info for response
|
||||
await bookingSubmission.populate('activityId', 'name price');
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: 'Booking submitted successfully',
|
||||
booking: {
|
||||
id: bookingSubmission._id,
|
||||
activityName: bookingSubmission.activityId.name,
|
||||
sessionId: bookingSubmission.sessionId,
|
||||
participantName: `${bookingSubmission.participantFirstName} ${bookingSubmission.participantLastName}`,
|
||||
totalAmount: bookingSubmission.totalAmount,
|
||||
status: bookingSubmission.status
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('submitBooking error:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error.name === 'ValidationError') {
|
||||
const validationErrors = Object.values(error.errors).map(err => err.message);
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
message: validationErrors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while processing your booking. Please try again.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để lấy thông tin session availability
|
||||
exports.getSessionAvailability = async (req, res) => {
|
||||
try {
|
||||
const { activityId, sessionId } = req.params;
|
||||
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
|
||||
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Get current booking counts
|
||||
const maleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender: 'male',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const femaleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId,
|
||||
participantGender: 'female',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
sessionId,
|
||||
isActive: session.isActive,
|
||||
startDate: session.startDate,
|
||||
endDate: session.endDate,
|
||||
overnightStays: session.overnightStays,
|
||||
price: session.price || activity.price,
|
||||
availability: {
|
||||
male: {
|
||||
total: session.totalMaleSpots,
|
||||
booked: maleBookings,
|
||||
available: Math.max(0, session.totalMaleSpots - maleBookings)
|
||||
},
|
||||
female: {
|
||||
total: session.totalFemaleSpots,
|
||||
booked: femaleBookings,
|
||||
available: Math.max(0, session.totalFemaleSpots - femaleBookings)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('getSessionAvailability error:', error);
|
||||
return res.status(500).json({ error: 'Error loading session availability' });
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để lấy tất cả sessions có sẵn cho một activity
|
||||
exports.getAvailableSessions = async (req, res) => {
|
||||
try {
|
||||
const { activityId } = req.params;
|
||||
|
||||
const activity = await Activity.findById(activityId);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
|
||||
const sessions = activity.bookingSessions || [];
|
||||
const availableSessions = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.isActive) continue;
|
||||
|
||||
// Get current booking counts
|
||||
const maleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId: session.sessionId,
|
||||
participantGender: 'male',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const femaleBookings = await BookingSubmission.countDocuments({
|
||||
activityId,
|
||||
sessionId: session.sessionId,
|
||||
participantGender: 'female',
|
||||
status: { $in: ['pending', 'confirmed'] }
|
||||
});
|
||||
|
||||
const maleAvailable = Math.max(0, session.totalMaleSpots - maleBookings);
|
||||
const femaleAvailable = Math.max(0, session.totalFemaleSpots - femaleBookings);
|
||||
|
||||
// Only include sessions that have available spots
|
||||
if (maleAvailable > 0 || femaleAvailable > 0) {
|
||||
availableSessions.push({
|
||||
sessionId: session.sessionId,
|
||||
startDate: session.startDate,
|
||||
endDate: session.endDate,
|
||||
overnightStays: session.overnightStays,
|
||||
price: session.price || activity.price,
|
||||
availability: {
|
||||
male: {
|
||||
total: session.totalMaleSpots,
|
||||
booked: maleBookings,
|
||||
available: maleAvailable
|
||||
},
|
||||
female: {
|
||||
total: session.totalFemaleSpots,
|
||||
booked: femaleBookings,
|
||||
available: femaleAvailable
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
activityId,
|
||||
activityName: activity.name,
|
||||
sessions: availableSessions
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('getAvailableSessions error:', error);
|
||||
return res.status(500).json({ error: 'Error loading available sessions' });
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để cập nhật booking submission
|
||||
exports.updateBookingSubmission = async (req, res) => {
|
||||
try {
|
||||
const { bookingId } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// Find the booking
|
||||
let booking = await BookingSubmission.findById(bookingId);
|
||||
|
||||
// If not found as a separate document, try to find it as an embedded booking in Activity.bookingSessions
|
||||
let activityContaining = null;
|
||||
let sessionIndex = -1;
|
||||
let bookingIndex = -1;
|
||||
if (!booking) {
|
||||
activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
|
||||
if (!activityContaining) {
|
||||
return res.status(404).json({
|
||||
error: 'Booking not found',
|
||||
message: 'The booking submission does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// locate the exact session and booking positions
|
||||
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
|
||||
const bl = activityContaining.bookingSessions[si].bookingList || [];
|
||||
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
|
||||
if (bi !== -1) {
|
||||
sessionIndex = si;
|
||||
bookingIndex = bi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionIndex === -1 || bookingIndex === -1) {
|
||||
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
|
||||
}
|
||||
|
||||
booking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
}
|
||||
|
||||
// Define allowed fields to update
|
||||
const allowedUpdates = [
|
||||
'status',
|
||||
'paymentStatus',
|
||||
'paidAmount',
|
||||
'totalAmount',
|
||||
'adminNotes',
|
||||
'emergencyContact',
|
||||
'emergencyPhone',
|
||||
'medicalConditions',
|
||||
'dietaryRestrictions',
|
||||
'specialRequests'
|
||||
];
|
||||
|
||||
// Build update object with only allowed fields
|
||||
const updateFields = {};
|
||||
for (const field of allowedUpdates) {
|
||||
if (updateData[field] !== undefined) {
|
||||
updateFields[field] = updateData[field];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No valid fields to update',
|
||||
message: 'Please provide at least one valid field to update'
|
||||
});
|
||||
}
|
||||
|
||||
// If booking is a separate document, update the BookingSubmission collection
|
||||
if (activityContaining === null) {
|
||||
const updatedBooking = await BookingSubmission.findByIdAndUpdate(
|
||||
bookingId,
|
||||
updateFields,
|
||||
{ new: true, runValidators: true }
|
||||
).populate('activityId', 'name price');
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Booking updated successfully',
|
||||
booking: updatedBooking
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise update the embedded booking in the Activity document
|
||||
const currentBooking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
|
||||
// Handle status updates and spot adjustments
|
||||
const newStatus = updateData.status || updateData.bookingStatus;
|
||||
const currentStatus = currentBooking.status || currentBooking.bookingStatus;
|
||||
|
||||
// Apply allowed updates to the embedded booking
|
||||
const allowedEmbeddedUpdates = [
|
||||
'status', 'bookingStatus', 'paymentStatus', 'paidAmount', 'totalAmount', 'adminNotes',
|
||||
'emergencyContact', 'emergencyPhone', 'medicalConditions', 'dietaryRestrictions', 'specialRequests'
|
||||
];
|
||||
|
||||
for (const field of allowedEmbeddedUpdates) {
|
||||
if (updateData[field] !== undefined) {
|
||||
if (field === 'status') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].status = updateData.status;
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].bookingStatus = updateData.status;
|
||||
} else {
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex][field] = updateData[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If status change affects spots, adjust counts
|
||||
if (newStatus && newStatus !== currentStatus) {
|
||||
const numberOfParticipants = currentBooking.numberOfParticipants || 1;
|
||||
const participantGender = currentBooking.participantGender;
|
||||
|
||||
// If booking is being cancelled, free up spots
|
||||
if (newStatus === 'cancelled' && currentStatus !== 'cancelled') {
|
||||
if (participantGender === 'male') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
||||
} else if (participantGender === 'female') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
||||
}
|
||||
}
|
||||
|
||||
// If restoring from cancelled, ensure capacity then book spots
|
||||
if (currentStatus === 'cancelled' && newStatus !== 'cancelled') {
|
||||
if (participantGender === 'male') {
|
||||
const totalMale = activityContaining.bookingSessions[sessionIndex].totalMaleSpots;
|
||||
const currentMale = activityContaining.bookingSessions[sessionIndex].bookedMaleSpots;
|
||||
if (currentMale + numberOfParticipants > totalMale) {
|
||||
return res.status(400).json({ error: "Not enough male spots available to restore this booking" });
|
||||
}
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
|
||||
} else if (participantGender === 'female') {
|
||||
const totalFemale = activityContaining.bookingSessions[sessionIndex].totalFemaleSpots;
|
||||
const currentFemale = activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots;
|
||||
if (currentFemale + numberOfParticipants > totalFemale) {
|
||||
return res.status(400).json({ error: "Not enough female spots available to restore this booking" });
|
||||
}
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await activityContaining.save();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Embedded booking updated successfully',
|
||||
booking: activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('updateBookingSubmission error:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error.name === 'ValidationError') {
|
||||
const validationErrors = Object.values(error.errors).map(err => err.message);
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
message: validationErrors.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while updating the booking'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint để xóa booking submission
|
||||
exports.deleteBookingSubmission = async (req, res) => {
|
||||
try {
|
||||
const { bookingId } = req.params;
|
||||
|
||||
// Find and delete the booking
|
||||
let booking = await BookingSubmission.findById(bookingId);
|
||||
|
||||
// If not found in separate collection, try to delete embedded booking in Activity
|
||||
if (!booking) {
|
||||
const activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
|
||||
if (!activityContaining) {
|
||||
return res.status(404).json({
|
||||
error: 'Booking not found',
|
||||
message: 'The booking submission does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// locate session and booking
|
||||
let sessionIndex = -1;
|
||||
let bookingIndex = -1;
|
||||
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
|
||||
const bl = activityContaining.bookingSessions[si].bookingList || [];
|
||||
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
|
||||
if (bi !== -1) {
|
||||
sessionIndex = si;
|
||||
bookingIndex = bi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionIndex === -1 || bookingIndex === -1) {
|
||||
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
|
||||
}
|
||||
|
||||
const bookingToDelete = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||
|
||||
// Free up spots if booking is not cancelled
|
||||
if ((bookingToDelete.bookingStatus || bookingToDelete.status) !== 'cancelled') {
|
||||
const numberOfParticipants = bookingToDelete.numberOfParticipants || 1;
|
||||
const participantGender = bookingToDelete.participantGender;
|
||||
|
||||
if (participantGender === 'male') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
||||
} else if (participantGender === 'female') {
|
||||
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove booking and save
|
||||
activityContaining.bookingSessions[sessionIndex].bookingList.splice(bookingIndex, 1);
|
||||
await activityContaining.save();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Embedded booking deleted successfully',
|
||||
booking: {
|
||||
id: bookingId,
|
||||
participantName: `${bookingToDelete.participantFirstName} ${bookingToDelete.participantLastName}`,
|
||||
email: bookingToDelete.email
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store info for session spot adjustment
|
||||
const { activityId, sessionId, participantGender, numberOfParticipants } = booking;
|
||||
|
||||
// Delete the booking
|
||||
await BookingSubmission.findByIdAndDelete(bookingId);
|
||||
|
||||
// Update session booked spots (decrease the count)
|
||||
if (booking.status !== 'cancelled') {
|
||||
const updateField = participantGender === 'male'
|
||||
? 'bookingSessions.$.bookedMaleSpots'
|
||||
: 'bookingSessions.$.bookedFemaleSpots';
|
||||
|
||||
await Activity.updateOne(
|
||||
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
|
||||
{ $inc: { [updateField]: -numberOfParticipants } }
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Booking deleted successfully',
|
||||
booking: {
|
||||
id: bookingId,
|
||||
participantName: `${booking.participantFirstName} ${booking.participantLastName}`,
|
||||
email: booking.email
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('deleteBookingSubmission error:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Server error',
|
||||
message: 'An error occurred while deleting the booking'
|
||||
});
|
||||
}
|
||||
};
|
||||
+101
-145
@@ -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;
|
||||
|
||||
+35
-152
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,396 +0,0 @@
|
||||
const { getServiceData } = require("../services/service.service");
|
||||
const Service = require("../models/service");
|
||||
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
const slugify = require("slugify");
|
||||
|
||||
// Admin page - Service list
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await getServiceData();
|
||||
console.log(data.services.items.image);
|
||||
res.render("admin/service/index", {
|
||||
title: "Service Management",
|
||||
data,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl, // Truyền helper function vào view
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service data");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Admin page - Service edit
|
||||
exports.edit = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const data = await getServiceData();
|
||||
|
||||
const service = data.services?.items?.find((item) => item.slug === slug);
|
||||
if (!service) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
res.render("admin/service/edit", {
|
||||
title: `Edit Service - ${service.name}`,
|
||||
service,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service for editing");
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update single service
|
||||
exports.updateService = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const currentData = await getServiceData();
|
||||
|
||||
const serviceIndex = currentData.services?.items?.findIndex(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
if (serviceIndex === -1) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
const oldItem = JSON.parse(
|
||||
JSON.stringify(currentData.services.items[serviceIndex]),
|
||||
);
|
||||
|
||||
// Update service data
|
||||
const updatedData = { ...currentData.toObject?.() };
|
||||
updatedData.services.items[serviceIndex] = {
|
||||
...updatedData.services.items[serviceIndex],
|
||||
name: req.body.name,
|
||||
slug: req.body.slug,
|
||||
description: req.body.description,
|
||||
image: req.body.image,
|
||||
layout: req.body.layout,
|
||||
};
|
||||
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
const newItem = updatedData.services.items[serviceIndex];
|
||||
|
||||
const changes = diffObject(oldItem, newItem);
|
||||
console.log("USER:", req.session?.user || req.user || "No user found");
|
||||
|
||||
await writeAuditLog({
|
||||
model: "Service",
|
||||
documentId: currentData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_SERVICE,
|
||||
before: oldItem,
|
||||
after: newItem,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
req.flash("success_msg", "Service updated successfully");
|
||||
res.redirect("/admin/service");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Admin page - Service details
|
||||
exports.details = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const data = await getServiceData();
|
||||
|
||||
const service = data.services?.items?.find((item) => item.slug === slug);
|
||||
if (!service) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
res.render("admin/service/details", {
|
||||
title: `Service Details - ${service.name}`,
|
||||
service,
|
||||
layout: "layouts/main",
|
||||
getFullImageUrl, // Truyền helper function vào view
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", "Error loading service details");
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update service list
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const currentData = await getServiceData();
|
||||
const sections = [
|
||||
"pageTitle",
|
||||
"services",
|
||||
"destinations",
|
||||
"visas",
|
||||
"reviews",
|
||||
];
|
||||
|
||||
let updatedData = { ...currentData.toObject?.() };
|
||||
let hasChanges = false;
|
||||
|
||||
sections.forEach((section) => {
|
||||
if (!req.body[section]) return;
|
||||
|
||||
const newData = JSON.parse(req.body[section]);
|
||||
if (JSON.stringify(newData) !== JSON.stringify(currentData[section])) {
|
||||
updatedData[section] = newData;
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasChanges) {
|
||||
req.flash("info_msg", "No changes were made");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Service updated successfully");
|
||||
res.redirect("/admin/service");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// Update service details
|
||||
exports.updateDetails = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const currentData = await getServiceData();
|
||||
|
||||
const serviceIndex = currentData.services?.items?.findIndex(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
if (serviceIndex === -1) {
|
||||
req.flash("error_msg", "Service not found");
|
||||
return res.redirect("/admin/service");
|
||||
}
|
||||
const beforeDetails = JSON.parse(
|
||||
JSON.stringify(currentData.services.items[serviceIndex].details || {}),
|
||||
);
|
||||
// Parse features and FAQ from JSON strings
|
||||
const features = req.body.features ? JSON.parse(req.body.features) : [];
|
||||
const faq = req.body.faq ? JSON.parse(req.body.faq) : [];
|
||||
|
||||
// Update service details
|
||||
const updatedData = { ...currentData.toObject?.() };
|
||||
const updatedDetails = {
|
||||
title: req.body.title,
|
||||
description: req.body.description,
|
||||
mainImage: req.body.mainImage,
|
||||
overviewTitle: req.body.overviewTitle,
|
||||
overviewDescription: req.body.overviewDescription,
|
||||
additionalDescription: req.body.additionalDescription,
|
||||
keyFeaturesTitle: req.body.keyFeaturesTitle,
|
||||
keyFeaturesImage: req.body.keyFeaturesImage,
|
||||
features,
|
||||
faqTitle: req.body.faqTitle,
|
||||
faqImage: req.body.faqImage,
|
||||
faq,
|
||||
};
|
||||
|
||||
updatedData.services.items[serviceIndex].details = updatedDetails;
|
||||
if (currentData._id) {
|
||||
await Service.findByIdAndUpdate(currentData._id, updatedData);
|
||||
} else {
|
||||
await Service.create(updatedData);
|
||||
}
|
||||
const changes = diffObject(beforeDetails, updatedDetails);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Service",
|
||||
documentId: currentData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_SERVICE_DETAILS,
|
||||
before: beforeDetails,
|
||||
after: updatedDetails,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Service details updated successfully");
|
||||
res.redirect(`/admin/service/${slug}/details`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
req.flash("error_msg", err.message);
|
||||
res.redirect("/admin/service");
|
||||
}
|
||||
};
|
||||
|
||||
// API endpoint
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const serviceData = await getServiceData();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
const processedData = addBaseUrlToImages(serviceData, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Error loading service data" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get service details by slug - API endpoint
|
||||
*/
|
||||
exports.getServiceBySlug = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
const serviceDoc = await Service.findOne().lean();
|
||||
|
||||
if (!serviceDoc) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Service data not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Find service by slug
|
||||
const service = serviceDoc.services?.items?.find(
|
||||
(item) => item.slug === slug,
|
||||
);
|
||||
|
||||
if (!service) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: `Service with slug '${slug}' not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// Return service details in the expected format
|
||||
const responseData = {
|
||||
pageTitle: serviceDoc.pageTitle,
|
||||
breadcrumb: {
|
||||
...serviceDoc.breadcrumb,
|
||||
title: "Service Details",
|
||||
items: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Services", href: "/services" },
|
||||
{ label: service.name, href: `/services/${slug}` },
|
||||
],
|
||||
},
|
||||
serviceDetails: {
|
||||
content: service.details,
|
||||
keyFeatures: {
|
||||
title: service.details.keyFeaturesTitle || "Key Features",
|
||||
sideImage: service.details.keyFeaturesImage || "img/default.jpg",
|
||||
items: service.details.features || [],
|
||||
},
|
||||
faq: {
|
||||
title: service.details.faqTitle || "Frequently Asked Questions",
|
||||
sideImage: service.details.faqImage || "img/default.jpg",
|
||||
items: service.details.faq || [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const processedData = addBaseUrlToImages(responseData, baseUrl);
|
||||
res.json(processedData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching service by slug:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate slug from text - API endpoint
|
||||
*/
|
||||
exports.generateSlug = async (req, res) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
|
||||
if (!text || typeof text !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Text is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate slug using slugify library with Vietnamese support
|
||||
const slug = slugify(text, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale: "vi",
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
slug: slug,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating slug:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all service slugs - API endpoint
|
||||
*/
|
||||
exports.getServiceSlugs = async (req, res) => {
|
||||
try {
|
||||
const serviceDoc = await Service.findOne().lean();
|
||||
|
||||
if (!serviceDoc?.services?.items) {
|
||||
return res.json({
|
||||
success: true,
|
||||
slugs: [],
|
||||
});
|
||||
}
|
||||
|
||||
const slugs = serviceDoc.services.items.map((item) => ({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
id: item.id,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
slugs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching service slugs:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,290 +0,0 @@
|
||||
const Travel = require("../models/travel");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
/**
|
||||
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
|
||||
*/
|
||||
function extractYouTubeId(url) {
|
||||
if (!url || typeof url !== "string") return null;
|
||||
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
|
||||
const regex =
|
||||
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hàm Helper: Làm sạch danh sách blocks của Editor.js
|
||||
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
|
||||
*/
|
||||
function sanitizeContentBlocks(blocks) {
|
||||
if (!blocks || !Array.isArray(blocks)) return [];
|
||||
|
||||
const seenVideoIds = new Set();
|
||||
|
||||
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
|
||||
const processedBlocks = blocks.map((block) => {
|
||||
if (block.type === "embed") {
|
||||
const url = block.data.source || block.data.embed || "";
|
||||
const videoId = extractYouTubeId(url);
|
||||
if (videoId) {
|
||||
seenVideoIds.add(videoId);
|
||||
// Cập nhật lại data chuẩn cho Editor.js
|
||||
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||
block.data.source = url;
|
||||
block.data.videoId = videoId;
|
||||
block.data.service = "youtube";
|
||||
}
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
// Bước 2: Lọc bỏ paragraph rác
|
||||
return processedBlocks.filter((block) => {
|
||||
if (block.type === "paragraph") {
|
||||
const text = (block.data?.text || "").trim();
|
||||
|
||||
// Xóa paragraph rỗng
|
||||
if (text === "" || text === "<br>" || text === " ") return false;
|
||||
|
||||
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
|
||||
const videoIdInText = extractYouTubeId(text);
|
||||
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
|
||||
console.log(
|
||||
`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// GET: Show travel editor
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
|
||||
if (!travel) {
|
||||
return res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: {
|
||||
page: {
|
||||
title: "Travel Information",
|
||||
description: "",
|
||||
metadata: { title: "", description: "" },
|
||||
},
|
||||
hero: { title: "Travel Information", backgroundImage: "" },
|
||||
content: { blocks: [] },
|
||||
enableScrollspy: false,
|
||||
},
|
||||
message: "No travel data found. Please run migration first.",
|
||||
});
|
||||
}
|
||||
|
||||
res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: travel,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading travel page:", error);
|
||||
res.status(500).send("Error loading travel page");
|
||||
}
|
||||
};
|
||||
|
||||
// POST: Update travel information
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { page, hero, content, enableScrollspy } = req.body;
|
||||
|
||||
// Get current data for before state
|
||||
const currentTravel = await Travel.findOne();
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = currentTravel
|
||||
? JSON.parse(
|
||||
JSON.stringify(
|
||||
currentTravel.toObject ? currentTravel.toObject() : currentTravel,
|
||||
),
|
||||
)
|
||||
: {};
|
||||
|
||||
const updateData = {};
|
||||
|
||||
if (page) updateData.page = JSON.parse(page);
|
||||
if (hero) updateData.hero = JSON.parse(hero);
|
||||
|
||||
if (content) {
|
||||
let contentObj = JSON.parse(content);
|
||||
// Áp dụng bộ lọc dọn dẹp nội dung
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
updateData.content = contentObj;
|
||||
}
|
||||
|
||||
if (enableScrollspy !== undefined) {
|
||||
updateData.enableScrollspy =
|
||||
enableScrollspy === "true" || enableScrollspy === true;
|
||||
}
|
||||
|
||||
const updatedTravel = await Travel.findOneAndUpdate({}, updateData, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
});
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(
|
||||
JSON.stringify(
|
||||
updatedTravel.toObject ? updatedTravel.toObject() : updatedTravel,
|
||||
),
|
||||
);
|
||||
|
||||
// ✅ AUDIT LOGGING - Travel Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Travel",
|
||||
documentId: updatedTravel._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_TRAVEL,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash(
|
||||
"success",
|
||||
"Travel information updated and sanitized successfully",
|
||||
);
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error updating travel:", error);
|
||||
req.flash("error", "Error updating travel information");
|
||||
res.redirect("/admin/travel");
|
||||
}
|
||||
};
|
||||
|
||||
// GET: Travel data API (Sử dụng cho Frontend/Public)
|
||||
exports.api = exports.getTravelData = async (req, res) => {
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
if (!travel) {
|
||||
return res.status(404).json({ error: "Travel data not found" });
|
||||
}
|
||||
|
||||
const travelObj = travel.toObject();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const processed = addBaseUrlToImages(travelObj, baseUrl);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hero: processed.hero,
|
||||
page: processed.page,
|
||||
content: processed.content,
|
||||
enableScrollspy: processed.enableScrollspy,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching travel data:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: Preview travel
|
||||
exports.preview = async (req, res) => {
|
||||
try {
|
||||
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } =
|
||||
req.body;
|
||||
|
||||
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
|
||||
let contentObj = JSON.parse(content);
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
|
||||
const previewData = {
|
||||
page: {
|
||||
title: pageTitle || "Travel Information",
|
||||
year: pageYear || "",
|
||||
},
|
||||
hero: {
|
||||
title: heroTitle || "Travel Information",
|
||||
backgroundImage: heroBackgroundImage || "",
|
||||
},
|
||||
content: contentObj,
|
||||
enableScrollspy: false,
|
||||
};
|
||||
|
||||
res.render("page/travel", {
|
||||
title: "Travel Preview",
|
||||
data: previewData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating preview:", error);
|
||||
res.status(500).send("Error generating preview");
|
||||
}
|
||||
};
|
||||
|
||||
// GET: Seed/Import from JSON
|
||||
exports.seed = async (req, res) => {
|
||||
try {
|
||||
const jsonPath = path.join(__dirname, "../data/travel.json");
|
||||
const jsonData = await fs.readFile(jsonPath, "utf-8");
|
||||
const jsonTravelData = JSON.parse(jsonData);
|
||||
|
||||
let contentBlocks = [];
|
||||
|
||||
// Trường hợp JSON đã có định dạng bài viết (blog format)
|
||||
if (
|
||||
Array.isArray(jsonTravelData.posts) &&
|
||||
jsonTravelData.posts.length > 0
|
||||
) {
|
||||
const firstPost = jsonTravelData.posts[0];
|
||||
contentBlocks =
|
||||
firstPost.content && firstPost.content.blocks
|
||||
? firstPost.content.blocks
|
||||
: [];
|
||||
}
|
||||
// Trường hợp format cũ (legacy)
|
||||
else {
|
||||
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
|
||||
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
|
||||
}
|
||||
|
||||
// Luôn làm sạch dữ liệu trước khi seed vào DB
|
||||
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
|
||||
|
||||
const travelData = {
|
||||
page: {
|
||||
title:
|
||||
jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
|
||||
year: jsonTravelData.page?.year || "",
|
||||
metadata: {
|
||||
title: "Travel Guide - Go and Grow Camp",
|
||||
description:
|
||||
"Everything you need to know about traveling to our camps",
|
||||
},
|
||||
},
|
||||
hero: {
|
||||
title: jsonTravelData.hero?.title || "Travel Information",
|
||||
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
|
||||
},
|
||||
content: { blocks: cleanedBlocks },
|
||||
enableScrollspy: true,
|
||||
};
|
||||
|
||||
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
|
||||
|
||||
req.flash("success", "Travel data seeded and sanitized successfully");
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error seeding travel data:", error);
|
||||
req.flash("error", "Failed to seed travel data");
|
||||
res.redirect("/admin/travel");
|
||||
}
|
||||
};
|
||||
@@ -1,695 +0,0 @@
|
||||
// controllers/visaController.js
|
||||
|
||||
const addBaseUrlToImages = (data, baseUrl) => {
|
||||
if (!data) return data;
|
||||
|
||||
// Nếu là mảng, duyệt từng phần tử
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => addBaseUrlToImages(item, baseUrl));
|
||||
}
|
||||
|
||||
// Nếu là object, duyệt từng key
|
||||
if (typeof data === "object") {
|
||||
const newObj = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Kiểm tra nếu key là các trường chứa ảnh và value là string
|
||||
const imageKeys = ["icon", "mainImage", "bannerImage", "image"];
|
||||
|
||||
if (
|
||||
imageKeys.includes(key) &&
|
||||
typeof value === "string" &&
|
||||
!value.startsWith("http")
|
||||
) {
|
||||
newObj[key] = `${baseUrl}/${value}`
|
||||
.replace(/\/+/g, "/")
|
||||
.replace(":/", "://");
|
||||
}
|
||||
// Xử lý riêng cho mảng gallery (mảng các chuỗi)
|
||||
else if (key === "gallery" && Array.isArray(value)) {
|
||||
newObj[key] = value.map((img) =>
|
||||
img.startsWith("http")
|
||||
? img
|
||||
: `${baseUrl}/${img}`.replace(/\/+/g, "/").replace(":/", "://"),
|
||||
);
|
||||
}
|
||||
// Nếu là object hoặc mảng con khác, đệ quy tiếp
|
||||
else if (typeof value === "object" && value !== null) {
|
||||
newObj[key] = addBaseUrlToImages(value, baseUrl);
|
||||
} else {
|
||||
newObj[key] = value;
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
const Visa = require("../models/visa");
|
||||
const slugify = require("slugify");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
const createSlug = (text) => {
|
||||
return slugify(text, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale: "en",
|
||||
trim: true,
|
||||
});
|
||||
};
|
||||
// -------------------- Helper Functions --------------------
|
||||
|
||||
// Get visa data from MongoDB
|
||||
const getVisaData = async () => {
|
||||
const visa = await Visa.findOne().sort({ updatedAt: -1 });
|
||||
return visa || {};
|
||||
};
|
||||
|
||||
// Get default visa data structure (updated to match new JSON)
|
||||
const getDefaultVisaData = () => ({
|
||||
hero: {
|
||||
title: "Visa Service",
|
||||
summaryList: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function: Generate next country ID
|
||||
const getNextCountryId = (countries) => {
|
||||
if (!Array.isArray(countries) || countries.length === 0) return 1;
|
||||
return Math.max(...countries.map((c) => c.id || 0)) + 1;
|
||||
};
|
||||
|
||||
// -------------------- Admin Exports --------------------
|
||||
|
||||
// Display visa management page
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
// Fetch Visa data
|
||||
let data = await getVisaData();
|
||||
|
||||
// If no data exists, use default
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
data = getDefaultVisaData();
|
||||
} else {
|
||||
// Merge with defaults to ensure all fields exist
|
||||
const defaultData = getDefaultVisaData();
|
||||
|
||||
// Ensure hero section exists with defaults
|
||||
data.hero = data.hero || defaultData.hero;
|
||||
data.hero.title = data.hero.title || "Visa Service";
|
||||
data.hero.summaryList = data.hero.summaryList || [];
|
||||
}
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
|
||||
res.render("admin/visa/index", {
|
||||
layout: "layouts/main",
|
||||
title: "Visa Management",
|
||||
data,
|
||||
frontendUrl,
|
||||
currentPath: req.path,
|
||||
user: req.session.user,
|
||||
});
|
||||
// return res.json(data);
|
||||
} catch (err) {
|
||||
console.error("Visa index error:", err);
|
||||
req.flash("error_msg", "Error loading visa data");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Get single country for edit
|
||||
exports.getCountry = async (req, res) => {
|
||||
console.log("--------------------------------------------------");
|
||||
console.log("🚀 [GET] Request nhận được tại /visa/edit/:id");
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
console.log("📍 ID từ Params (URL):", id, "| Kiểu dữ liệu:", typeof id);
|
||||
|
||||
const visaData = await getVisaData();
|
||||
|
||||
// Kiểm tra cấu trúc dữ liệu tổng
|
||||
if (!visaData) {
|
||||
console.error("❌ Lỗi: Hàm getVisaData() trả về null/undefined");
|
||||
return res.status(404).json({ error: "Dữ liệu gốc không tồn tại" });
|
||||
}
|
||||
|
||||
if (!visaData.hero || !visaData.hero.summaryList) {
|
||||
console.error("❌ Lỗi: Cấu trúc visaData.hero.summaryList không hợp lệ");
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Không tìm thấy danh sách quốc gia" });
|
||||
}
|
||||
|
||||
console.log(
|
||||
"📊 Tổng số quốc gia hiện có trong mảng:",
|
||||
visaData.hero.summaryList.length,
|
||||
);
|
||||
|
||||
// 2. Tìm quốc gia theo ID
|
||||
const targetId = parseInt(id);
|
||||
console.log("🔍 Đang tìm kiếm Quốc gia có ID (sau khi parse):", targetId);
|
||||
|
||||
const country = visaData.hero.summaryList.find((c) => {
|
||||
// Log từng phần tử để kiểm tra kiểu dữ liệu của c.id trong DB
|
||||
// console.log(`Checking country: ${c.name} | ID in DB: ${c.id} (${typeof c.id})`);
|
||||
return c.id === targetId;
|
||||
});
|
||||
|
||||
if (!country) {
|
||||
console.warn(`⚠️ Không tìm thấy quốc gia nào khớp với ID: ${targetId}`);
|
||||
// In ra danh sách ID hiện có để so sánh
|
||||
const existingIds = visaData.hero.summaryList.map((c) => c.id);
|
||||
console.log("🆔 Các ID hiện có trong Database:", existingIds);
|
||||
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Không tìm thấy quốc gia có ID: ${id}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ Tìm thấy dữ liệu quốc gia:", country.name);
|
||||
|
||||
// 3. Trả về dữ liệu
|
||||
res.json({
|
||||
success: true,
|
||||
country: country,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("🔴 Lỗi nghiêm trọng tại getCountry Controller:", err);
|
||||
res.status(500).json({ error: "Lỗi hệ thống khi tải thông tin quốc gia" });
|
||||
}
|
||||
};
|
||||
|
||||
// Update visa data (hero title only)
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
// Get current data
|
||||
const currentData = await getVisaData();
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = currentData
|
||||
? JSON.parse(
|
||||
JSON.stringify(
|
||||
currentData.toObject ? currentData.toObject() : currentData,
|
||||
),
|
||||
)
|
||||
: {};
|
||||
|
||||
// Create updated data object
|
||||
const updatedData = {
|
||||
...(currentData.toObject ? currentData.toObject() : currentData),
|
||||
};
|
||||
|
||||
// Ensure hero structure exists
|
||||
updatedData.hero = updatedData.hero || {
|
||||
title: "Visa Service",
|
||||
summaryList: [],
|
||||
};
|
||||
|
||||
// Update hero title
|
||||
if (req.body.heroTitle) {
|
||||
updatedData.hero.title = req.body.heroTitle;
|
||||
}
|
||||
|
||||
// Update or create document
|
||||
try {
|
||||
let savedData;
|
||||
if (currentData._id) {
|
||||
savedData = await Visa.findByIdAndUpdate(currentData._id, updatedData, {
|
||||
new: true,
|
||||
});
|
||||
} else {
|
||||
savedData = await Visa.create(updatedData);
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(JSON.stringify(savedData.toObject()));
|
||||
|
||||
// ✅ AUDIT LOGGING - Visa Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Visa",
|
||||
documentId: savedData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_VISA,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
console.log(
|
||||
`✅ Audit log created for Visa update: ${changes.length} changes`,
|
||||
);
|
||||
} else {
|
||||
console.log("ℹ️ No changes detected for Visa update");
|
||||
}
|
||||
|
||||
req.flash("success_msg", "Visa data updated successfully");
|
||||
return req.session.save(() => res.redirect("/admin/visa"));
|
||||
} catch (dbError) {
|
||||
console.error("Database error:", dbError);
|
||||
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/visa"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Update error:", err);
|
||||
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||
return req.session.save(() => res.redirect("/admin/visa"));
|
||||
}
|
||||
};
|
||||
|
||||
// Add new country
|
||||
exports.addCountry = async (req, res) => {
|
||||
try {
|
||||
let visaData = await getVisaData();
|
||||
|
||||
// Initialize hero structure if not exist
|
||||
if (!visaData.hero || !visaData.hero.summaryList) {
|
||||
visaData = getDefaultVisaData();
|
||||
}
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = JSON.parse(
|
||||
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
|
||||
);
|
||||
|
||||
// Validate required fields
|
||||
if (!req.body.name) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
const finalSlug = req.body.slug
|
||||
? createSlug(req.body.slug)
|
||||
: createSlug(req.body.name);
|
||||
|
||||
// Parse services array
|
||||
let services = [];
|
||||
if (req.body.services) {
|
||||
if (typeof req.body.services === "string") {
|
||||
try {
|
||||
services = JSON.parse(req.body.services);
|
||||
} catch (e) {
|
||||
services = [req.body.services];
|
||||
}
|
||||
} else if (Array.isArray(req.body.services)) {
|
||||
services = req.body.services;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse detailedView if provided (optional)
|
||||
let detailedView = null;
|
||||
if (req.body.detailedView) {
|
||||
try {
|
||||
detailedView =
|
||||
typeof req.body.detailedView === "string"
|
||||
? JSON.parse(req.body.detailedView)
|
||||
: req.body.detailedView;
|
||||
} catch (e) {
|
||||
console.warn("Could not parse detailedView, creating without it");
|
||||
}
|
||||
}
|
||||
|
||||
// Create new country object
|
||||
const newCountry = {
|
||||
id: req.body.id || getNextCountryId(visaData.hero.summaryList),
|
||||
name: req.body.name,
|
||||
slug: finalSlug,
|
||||
icon: req.body.icon || "",
|
||||
services: services,
|
||||
...(detailedView && { detailedView }),
|
||||
};
|
||||
|
||||
// Add new country to summaryList
|
||||
visaData.hero.summaryList.push(newCountry);
|
||||
|
||||
// Update database
|
||||
const updatedData = {
|
||||
...(visaData.toObject ? visaData.toObject() : visaData),
|
||||
};
|
||||
|
||||
let savedData;
|
||||
if (visaData._id) {
|
||||
savedData = await Visa.findByIdAndUpdate(visaData._id, updatedData, {
|
||||
new: true,
|
||||
});
|
||||
} else {
|
||||
savedData = await Visa.create(updatedData);
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(
|
||||
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
|
||||
);
|
||||
|
||||
// ✅ AUDIT LOGGING - Visa Country Added
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Visa",
|
||||
documentId: savedData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_VISA,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
console.log(
|
||||
`✅ Audit log created for Visa country addition: ${changes.length} changes`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ Country "${newCountry.name}" added successfully`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Country "${newCountry.name}" added successfully`,
|
||||
country: newCountry,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Add country error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Update single country
|
||||
exports.updateCountry = async (req, res) => {
|
||||
try {
|
||||
// 1. Lấy ID từ params (URL)
|
||||
const { id } = req.params;
|
||||
let visaData = await getVisaData();
|
||||
|
||||
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cấu trúc dữ liệu Visa không hợp lệ" });
|
||||
}
|
||||
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = JSON.parse(
|
||||
JSON.stringify(visaData.toObject ? visaData.toObject() : visaData),
|
||||
);
|
||||
|
||||
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
|
||||
const countryIndex = visaData.hero.summaryList.findIndex(
|
||||
(c) => c.id === parseInt(id),
|
||||
);
|
||||
|
||||
if (countryIndex === -1) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: `Không tìm thấy quốc gia có ID: ${id}` });
|
||||
}
|
||||
|
||||
const currentCountry = visaData.hero.summaryList[countryIndex];
|
||||
let finalSlug = currentCountry.slug;
|
||||
if (req.body.name) {
|
||||
// Nếu name thay đổi, ta có thể cập nhật lại slug (tùy nhu cầu SEO)
|
||||
// Ở đây ưu tiên: Nếu có slug mới truyền lên thì dùng, không thì tạo từ name mới
|
||||
finalSlug = req.body.slug
|
||||
? createSlug(req.body.slug)
|
||||
: createSlug(req.body.name);
|
||||
}
|
||||
// 3. Xử lý dữ liệu từ req.body
|
||||
// Vì Client đã gửi JSON stringify, ta lấy trực tiếp hoặc parse nếu cần
|
||||
let services = req.body.services;
|
||||
if (typeof services === "string") {
|
||||
try {
|
||||
services = JSON.parse(services);
|
||||
} catch (e) {
|
||||
services = [services];
|
||||
}
|
||||
}
|
||||
|
||||
let detailedView = req.body.detailedView;
|
||||
if (typeof detailedView === "string") {
|
||||
try {
|
||||
detailedView = JSON.parse(detailedView);
|
||||
} catch (e) {
|
||||
detailedView = currentCountry.detailedView;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cập nhật Object quốc gia
|
||||
const updatedCountry = {
|
||||
...currentCountry, // Giữ các trường cũ
|
||||
id: parseInt(id), // Đảm bảo ID không đổi
|
||||
name: req.body.name || currentCountry.name,
|
||||
slug: finalSlug,
|
||||
icon: req.body.icon || currentCountry.icon,
|
||||
services: Array.isArray(services) ? services : currentCountry.services,
|
||||
detailedView: detailedView || currentCountry.detailedView,
|
||||
};
|
||||
|
||||
// 5. Cập nhật vào mảng chính
|
||||
visaData.hero.summaryList[countryIndex] = updatedCountry;
|
||||
|
||||
// 6. Lưu vào Database
|
||||
if (visaData.markModified) {
|
||||
// Bắt buộc với Mongoose khi thay đổi nội dung bên trong Array/Object
|
||||
visaData.markModified("hero.summaryList");
|
||||
}
|
||||
|
||||
let savedData;
|
||||
if (visaData._id) {
|
||||
savedData = await visaData.save(); // Sử dụng save() trực tiếp nếu visaData là Mongoose Document
|
||||
} else {
|
||||
savedData = await Visa.create(visaData);
|
||||
}
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(
|
||||
JSON.stringify(savedData.toObject ? savedData.toObject() : savedData),
|
||||
);
|
||||
|
||||
// ✅ AUDIT LOGGING - Visa Country Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Visa",
|
||||
documentId: savedData._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_VISA,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
console.log(
|
||||
`✅ Audit log created for Visa country update: ${changes.length} changes`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Country "${updatedCountry.name}" updated successfully by ID: ${id}`,
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Quốc gia "${updatedCountry.name}" đã được cập nhật thành công`,
|
||||
country: updatedCountry,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Update country error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Delete country
|
||||
exports.deleteCountry = async (req, res) => {
|
||||
try {
|
||||
// 1. Lấy id từ params
|
||||
const { id } = req.params;
|
||||
let visaData = await getVisaData();
|
||||
|
||||
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Cấu trúc dữ liệu Visa không hợp lệ" });
|
||||
}
|
||||
|
||||
// 2. Tìm index theo ID (Chuyển về Number để so sánh chính xác)
|
||||
const countryIndex = visaData.hero.summaryList.findIndex(
|
||||
(c) => c.id === parseInt(id),
|
||||
);
|
||||
|
||||
if (countryIndex === -1) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Không tìm thấy quốc gia có ID: ${id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Xóa phần tử khỏi mảng
|
||||
const deletedCountry = visaData.hero.summaryList[countryIndex];
|
||||
visaData.hero.summaryList.splice(countryIndex, 1);
|
||||
|
||||
// 4. Cập nhật vào Database
|
||||
if (visaData.markModified) {
|
||||
visaData.markModified("hero.summaryList");
|
||||
}
|
||||
|
||||
if (visaData._id) {
|
||||
await visaData.save();
|
||||
} else {
|
||||
await Visa.create(visaData);
|
||||
}
|
||||
|
||||
console.log(`✅ Deleted Successfully: "${deletedCountry.name}"`);
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Country "${deletedCountry.name}" Deleted Successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("❌ Error Delete:", err);
|
||||
return res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------- Public API Exports --------------------
|
||||
|
||||
// API to get all visa data for frontend
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
const visaData = await getVisaData();
|
||||
if (!visaData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Visa data not found",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
const heroData = visaData?.hero;
|
||||
|
||||
// 2. Lấy riêng phần hero (Dùng biến mới, không gán đè vào const)
|
||||
const processedData = heroData;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
hero: processedData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Visa API error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading visa data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API to get all countries (summaryList only)
|
||||
exports.apiCountries = async (req, res) => {
|
||||
try {
|
||||
const visaData = await getVisaData();
|
||||
|
||||
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Countries data not found",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Lọc bỏ 'viewDetail' khỏi từng quốc gia trong danh sách
|
||||
const filteredCountries = visaData.hero.summaryList.map((item) => {
|
||||
// Tách detailedView ra, gom phần còn lại vào countryInfo
|
||||
const { detailedView, ...countryInfo } = item;
|
||||
|
||||
return {
|
||||
...countryInfo,
|
||||
// Lấy mainImage từ sâu bên trong detailedView và gán vào key mới
|
||||
mainImage: detailedView?.activeCountry?.mainImage || "",
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Gắn baseUrl vào ảnh cho danh sách đã lọc
|
||||
const processedData = filteredCountries;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: processedData, // Lúc này data chỉ chứa thông tin quốc gia, không có viewDetail
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Countries API error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading countries data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API to get single country by slug
|
||||
exports.apiCountry = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const visaData = await getVisaData();
|
||||
|
||||
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Visa data not found",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Tìm quốc gia khớp với slug
|
||||
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
|
||||
|
||||
// 2. Kiểm tra nếu không thấy quốc gia hoặc quốc gia đó không có viewDetail
|
||||
if (!country || !country.viewDetail) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Detailed information for country "${slug}" not found`,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
// 3. Chỉ lấy phần chi tiết (detailed view)
|
||||
// Lưu ý: Chúng ta copy ra object mới để tránh tham chiếu dữ liệu gốc
|
||||
const detailedData = JSON.parse(JSON.stringify(country.viewDetail));
|
||||
|
||||
// 4. Gắn baseUrl vào các ảnh nằm trong phần chi tiết này
|
||||
const processedData = detailedData;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: processedData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Visa country API error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading country detailed data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API to get hero data (title + summaryList)
|
||||
exports.apiHero = async (req, res) => {
|
||||
try {
|
||||
const visaData = await getVisaData();
|
||||
|
||||
// 1. Kiểm tra dữ liệu gốc
|
||||
|
||||
if (!visaData || !visaData.hero) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Hero data not found",
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
const { summaryList, ...heroData } = JSON.parse(
|
||||
JSON.stringify(visaData.hero),
|
||||
);
|
||||
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
||||
const processedData = addBaseUrlToImages(heroData, baseUrl);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: processedData,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Visa hero API error:", err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Error loading hero data",
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user