forked from UKSOURCE/cms.hailearning.edu.vn
1670 lines
60 KiB
JavaScript
1670 lines
60 KiB
JavaScript
const {addBaseUrlToImages} = require("../utils/imageHelper");
|
|
const Activity = require("../models/activity");
|
|
const mongoose = require('mongoose');
|
|
const {
|
|
validateLengthRules,
|
|
summarizeLengthErrors,
|
|
} = require("../utils/lengthValidation");
|
|
const {
|
|
ACTIVITY_LENGTH_RULES,
|
|
} = require("../constants/contentLengthRules");
|
|
|
|
const getActivityLengthMessage = (validation) =>
|
|
summarizeLengthErrors(validation, 3) ||
|
|
"One or more fields exceed the allowed length.";
|
|
|
|
const redirectWithLengthError = (req, res, path, validation) => {
|
|
req.flash("error_msg", getActivityLengthMessage(validation));
|
|
return req.session.save(() => res.redirect(path));
|
|
};
|
|
|
|
// -------------------- Public (API) exports --------------------
|
|
|
|
// API endpoint: return all active activities as JSON
|
|
exports.api = async (req, res) => {
|
|
try {
|
|
// Return structured response with filters and camps
|
|
const baseUrl =
|
|
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
|
|
|
// Get filters document (single doc with isFiltersDoc:true)
|
|
const filtersDoc = await Activity.findOne({ isFiltersDoc: true }).lean();
|
|
const filters = (filtersDoc && Array.isArray(filtersDoc.filters)) ? filtersDoc.filters : [];
|
|
|
|
// Fetch camps (activities) excluding the filters doc
|
|
const activities = await Activity.find({ isFiltersDoc: { $ne: true }, isActive: true })
|
|
.sort({ order: 1, createdAt: -1 })
|
|
.lean();
|
|
|
|
const camps = (activities || []).map((activity) => addBaseUrlToImages(activity, baseUrl));
|
|
|
|
// Get hero data from the first activity (assuming all activities share the same hero)
|
|
const heroRaw = activities.length > 0 && activities[0].hero ? activities[0].hero : {};
|
|
const hero = addBaseUrlToImages(heroRaw, baseUrl);
|
|
|
|
return res.json({ hero, filter: filters, camps });
|
|
} catch (err) {
|
|
console.error("activity.api error:", err);
|
|
return res.status(500).json({error: "Error loading activities data"});
|
|
}
|
|
};
|
|
|
|
// API endpoint: return a single activity by ID or link
|
|
exports.apiDetail = async (req, res) => {
|
|
try {
|
|
const {id} = req.params;
|
|
|
|
// Try to find by ID first, then by link
|
|
let activity;
|
|
if (id.match(/^[0-9a-fA-F]{24}$/)) {
|
|
activity = await Activity.findById(id).lean();
|
|
}
|
|
|
|
if (!activity) {
|
|
activity = await Activity.findOne({link: `/${id}`}).lean();
|
|
}
|
|
|
|
if (!activity) {
|
|
return res.status(404).json({error: "Activity not found"});
|
|
}
|
|
|
|
const baseUrl =
|
|
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
|
const processed = addBaseUrlToImages(activity, baseUrl);
|
|
|
|
return res.json(processed);
|
|
} catch (err) {
|
|
console.error("activity.apiDetail error:", err);
|
|
return res.status(500).json({error: "Error loading activity data"});
|
|
}
|
|
};
|
|
|
|
// -------------------- Admin exports --------------------
|
|
|
|
// Get default activity data for creating new activity
|
|
const getDefaultActivityData = () => ({
|
|
hero: {
|
|
title: "",
|
|
bannerImage: "",
|
|
},
|
|
name: "",
|
|
price: 0,
|
|
priceText: "",
|
|
season: [],
|
|
age: [12, 18],
|
|
locations: [],
|
|
image: "",
|
|
link: "",
|
|
program: "",
|
|
rating: 4,
|
|
isActive: true,
|
|
order: 0,
|
|
});
|
|
|
|
// Display activities management page (list)
|
|
exports.index = async (req, res) => {
|
|
try {
|
|
const page = parseInt(req.query.page) || 1;
|
|
const limit = parseInt(req.query.limit) || 20;
|
|
const skip = (page - 1) * limit;
|
|
|
|
const activitiesPromise = Activity.find({ isFiltersDoc: { $ne: true } })
|
|
.sort({order: 1, createdAt: -1})
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.lean();
|
|
|
|
const totalPromise = Activity.countDocuments({ isFiltersDoc: { $ne: true } });
|
|
const activePromise = Activity.countDocuments({ isFiltersDoc: { $ne: true }, isActive: true });
|
|
|
|
// Fetch filters from the consolidated Activity document (isFiltersDoc:true)
|
|
const filtersPromise = Activity.findOne({isFiltersDoc: true}).lean();
|
|
|
|
// Get all activities with booking sessions for extracting all bookings
|
|
const allActivitiesForBookingsPromise = Activity.find({
|
|
isFiltersDoc: { $ne: true },
|
|
'bookingSessions.bookingList': { $exists: true, $ne: [] }
|
|
}).lean();
|
|
|
|
const [activities, total, filtersDoc, activeCount, allActivitiesForBookings] = await Promise.all([
|
|
activitiesPromise,
|
|
totalPromise,
|
|
filtersPromise,
|
|
activePromise,
|
|
allActivitiesForBookingsPromise,
|
|
]);
|
|
|
|
// Extract all bookings from bookingSessions.bookingList
|
|
const allBookings = [];
|
|
const bookingCountMap = {};
|
|
const sessionBookingCountMap = {};
|
|
|
|
allActivitiesForBookings.forEach(activity => {
|
|
const actId = activity._id.toString();
|
|
let activityBookingCount = 0;
|
|
sessionBookingCountMap[actId] = {};
|
|
|
|
if (activity.bookingSessions && Array.isArray(activity.bookingSessions)) {
|
|
activity.bookingSessions.forEach(session => {
|
|
if (session.bookingList && Array.isArray(session.bookingList)) {
|
|
const sessionBookingCount = session.bookingList.length;
|
|
activityBookingCount += sessionBookingCount;
|
|
sessionBookingCountMap[actId][session.sessionId] = sessionBookingCount;
|
|
|
|
// Add each booking to allBookings array with activity info
|
|
session.bookingList.forEach(booking => {
|
|
const bookingWithActivityInfo = {
|
|
...booking,
|
|
activityId: {
|
|
_id: activity._id,
|
|
name: activity.name,
|
|
link: activity.link
|
|
},
|
|
sessionId: session.sessionId,
|
|
createdAt: booking.createdAt || booking.bookingDate || new Date(),
|
|
status: booking.status || booking.bookingStatus || 'pending',
|
|
paymentStatus: booking.paymentStatus || 'pending',
|
|
totalAmount: booking.totalAmount || 0,
|
|
paidAmount: booking.paidAmount || 0
|
|
};
|
|
allBookings.push(bookingWithActivityInfo);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
bookingCountMap[actId] = activityBookingCount;
|
|
});
|
|
|
|
// Sort all bookings by creation date (newest first)
|
|
allBookings.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
|
|
// Add booking counts to activities
|
|
const activitiesWithBookings = activities.map(activity => {
|
|
const actId = activity._id.toString();
|
|
return {
|
|
...activity,
|
|
bookingCount: bookingCountMap[actId] || 0,
|
|
sessionBookingCounts: sessionBookingCountMap[actId] || {}
|
|
};
|
|
});
|
|
|
|
const filters = (filtersDoc && Array.isArray(filtersDoc.filters)) ? filtersDoc.filters : [];
|
|
|
|
const totalPages = Math.ceil(total / limit);
|
|
|
|
// Calculate all bookings stats
|
|
const allBookingsStats = {
|
|
total: allBookings.length,
|
|
confirmed: allBookings.filter(b => b.status === 'confirmed').length,
|
|
pending: allBookings.filter(b => b.status === 'pending').length,
|
|
cancelled: allBookings.filter(b => b.status === 'cancelled').length,
|
|
completed: allBookings.filter(b => b.status === 'completed').length,
|
|
totalRevenue: allBookings.filter(b => b.status !== 'cancelled').reduce((sum, b) => sum + (b.totalAmount || 0), 0)
|
|
};
|
|
|
|
res.render("admin/activity/index", {
|
|
layout: "layouts/main",
|
|
title: "Activities Management",
|
|
items: activitiesWithBookings,
|
|
filters: filters, // Pass filters to view
|
|
activeCount,
|
|
allBookings: allBookings, // All bookings for the All Bookings tab
|
|
allBookingsStats: allBookingsStats, // Stats for bookings
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages,
|
|
hasNext: page < totalPages,
|
|
hasPrev: page > 1,
|
|
},
|
|
frontendUrl:
|
|
process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
|
currentPath: req.path,
|
|
user: req.session.user,
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
req.flash("error_msg", "Error loading activities data");
|
|
res.redirect("/admin/dashboard");
|
|
}
|
|
};
|
|
|
|
// Update activity filters
|
|
exports.updateFilters = async (req, res) => {
|
|
try {
|
|
// Accept filters submitted as an array or as an object with numeric keys
|
|
let filters = req.body.filters;
|
|
|
|
// If form submission uses `filters[0]`, `filters[1]` style names, Express/body-parser
|
|
// may produce an object with numeric keys rather than a true Array. Normalize it.
|
|
if (!filters) {
|
|
filters = [];
|
|
} else if (!Array.isArray(filters) && typeof filters === 'object') {
|
|
try {
|
|
filters = Object.keys(filters)
|
|
.sort((a, b) => (parseInt(a, 10) || 0) - (parseInt(b, 10) || 0))
|
|
.map((k) => filters[k]);
|
|
} catch (e) {
|
|
filters = [];
|
|
}
|
|
}
|
|
|
|
// Sanitize and normalize incoming filters robustly
|
|
const sanitizedFilters = [];
|
|
try {
|
|
const iterable = Array.isArray(filters) ? filters : Object.keys(filters || {}).map((k) => filters[k]);
|
|
for (let idx = 0; idx < iterable.length; idx++) {
|
|
const filterData = iterable[idx] || {};
|
|
|
|
// Items can be JSON string, array, or an object with numeric keys
|
|
let items = filterData.items;
|
|
if (typeof items === 'string') {
|
|
try {
|
|
items = JSON.parse(items);
|
|
} catch (e) {
|
|
items = [];
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(items) && typeof items === 'object' && items !== null) {
|
|
// convert object with numeric keys to array
|
|
try {
|
|
items = Object.keys(items)
|
|
.sort((a, b) => (parseInt(a, 10) || 0) - (parseInt(b, 10) || 0))
|
|
.map((k) => items[k]);
|
|
} catch (e) {
|
|
items = [];
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(items)) items = [];
|
|
|
|
const cleanedItems = items
|
|
.map((it) => ({ value: (it && it.value) ? it.value.toString().trim() : "", label: (it && it.label) ? it.label.toString().trim() : "" }))
|
|
.filter((it) => it.value && it.label);
|
|
|
|
// normalize id to ObjectId when possible
|
|
let subId = filterData._id || filterData.id || undefined;
|
|
if (subId && typeof subId === 'string' && /^[0-9a-fA-F]{24}$/.test(subId)) {
|
|
try {
|
|
subId = mongoose.Types.ObjectId(subId);
|
|
} catch (e) {
|
|
subId = undefined;
|
|
}
|
|
} else {
|
|
subId = undefined; // don't set invalid ids
|
|
}
|
|
|
|
const label = (filterData.label || '').toString().trim();
|
|
const value = (filterData.value || '').toString().trim();
|
|
const order = parseInt(filterData.order, 10) || idx + 1;
|
|
|
|
if (!label || !value) continue; // skip invalid
|
|
|
|
sanitizedFilters.push({ _id: subId, label, value, items: cleanedItems, order });
|
|
}
|
|
} catch (e) {
|
|
console.error('Error normalizing filters payload:', e);
|
|
}
|
|
|
|
if (!Array.isArray(sanitizedFilters)) {
|
|
req.flash('error_msg', 'Invalid filters payload');
|
|
return res.redirect('/admin/activity');
|
|
}
|
|
|
|
// Upsert the single filters document in Activities collection
|
|
try {
|
|
// Provide minimal valid fields when inserting a new filters document so
|
|
// schema validators (e.g., age validator) do not fail on upsert.
|
|
const filterLengthValidation = validateLengthRules(
|
|
{ filters: sanitizedFilters },
|
|
ACTIVITY_LENGTH_RULES,
|
|
);
|
|
if (!filterLengthValidation.valid) {
|
|
req.flash("error_msg", getActivityLengthMessage(filterLengthValidation));
|
|
return res.redirect("/admin/activity");
|
|
}
|
|
|
|
const setOnInsert = {
|
|
name: "_filters_doc",
|
|
price: 0,
|
|
priceText: "",
|
|
season: [],
|
|
age: [12, 18],
|
|
locations: [],
|
|
image: "",
|
|
link: "",
|
|
program: "",
|
|
rating: 4,
|
|
isActive: false,
|
|
order: 0,
|
|
isFiltersDoc: true,
|
|
};
|
|
|
|
const upsertResult = await Activity.findOneAndUpdate(
|
|
{ isFiltersDoc: true },
|
|
{ $set: { filters: sanitizedFilters }, $setOnInsert: setOnInsert },
|
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
);
|
|
|
|
req.flash('success_msg', 'Filters updated successfully');
|
|
return res.redirect('/admin/activity');
|
|
} catch (e) {
|
|
console.error('Activity upsert filters error:', e);
|
|
req.flash('error_msg', `Error saving filters: ${e.message || 'Unknown'}`);
|
|
return res.redirect('/admin/activity');
|
|
}
|
|
} catch (err) {
|
|
console.error("Update filters error:", err);
|
|
req.flash("error_msg", `Error updating filters: ${err.message || "Unknown error"}`);
|
|
res.redirect("/admin/activity");
|
|
}
|
|
};
|
|
|
|
// Update global hero section (admin) - updates filters doc and all activities
|
|
exports.updateHero = async (req, res) => {
|
|
try {
|
|
const titleActivities = (req.body.titleActivities || '').toString().trim();
|
|
const titleBooking = (req.body.titleBooking || '').toString().trim();
|
|
const bannerImageActivities = (req.body.bannerImageActivities || '').toString().trim();
|
|
const bannerImageBooking = (req.body.bannerImageBooking || '').toString().trim();
|
|
|
|
const hero = {
|
|
titleActivities: titleActivities || 'Activities',
|
|
titleBooking: titleBooking || 'Activities',
|
|
bannerImageActivities: bannerImageActivities || '/uploads/banner/b9.jpg',
|
|
bannerImageBooking: bannerImageBooking || '/uploads/banner/b9.jpg',
|
|
};
|
|
|
|
const heroLengthValidation = validateLengthRules(
|
|
{ hero },
|
|
ACTIVITY_LENGTH_RULES,
|
|
);
|
|
if (!heroLengthValidation.valid) {
|
|
return redirectWithLengthError(req, res, "/admin/activity", heroLengthValidation);
|
|
}
|
|
|
|
// Update all activity docs to keep hero consistent
|
|
await Activity.updateMany({ isFiltersDoc: { $ne: true } }, { $set: { hero } });
|
|
|
|
// Upsert hero into the filters document as well
|
|
const setOnInsert = {
|
|
name: "_filters_doc",
|
|
price: 0,
|
|
priceText: "",
|
|
season: [],
|
|
age: [12, 18],
|
|
locations: [],
|
|
image: "",
|
|
link: "",
|
|
program: "",
|
|
rating: 4,
|
|
isActive: false,
|
|
order: 0,
|
|
isFiltersDoc: true,
|
|
};
|
|
|
|
await Activity.findOneAndUpdate(
|
|
{ isFiltersDoc: true },
|
|
{ $set: { hero }, $setOnInsert: setOnInsert },
|
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
);
|
|
|
|
req.flash('success_msg', 'Hero updated successfully');
|
|
return res.redirect('/admin/activity');
|
|
} catch (e) {
|
|
console.error('Update hero error:', e);
|
|
req.flash('error_msg', `Error updating hero: ${e.message || 'Unknown'}`);
|
|
return res.redirect('/admin/activity');
|
|
}
|
|
};
|
|
|
|
// Display create form
|
|
exports.createForm = async (req, res) => {
|
|
try {
|
|
const data = getDefaultActivityData();
|
|
|
|
res.render("admin/activity/form", {
|
|
layout: "layouts/main",
|
|
title: "Create Activity",
|
|
data,
|
|
isEdit: false,
|
|
currentPath: req.path,
|
|
user: req.session.user,
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
req.flash("error_msg", "Error loading create form");
|
|
res.redirect("/admin/activity");
|
|
}
|
|
};
|
|
|
|
// Create new activity
|
|
exports.create = async (req, res) => {
|
|
try {
|
|
const activityData = parseActivityFormData(req.body);
|
|
|
|
const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES);
|
|
if (!lengthValidation.valid) {
|
|
return redirectWithLengthError(
|
|
req,
|
|
res,
|
|
"/admin/activity/create",
|
|
lengthValidation,
|
|
);
|
|
}
|
|
|
|
const newActivity = new Activity(activityData);
|
|
await newActivity.save();
|
|
|
|
req.flash("success_msg", "Activity created successfully");
|
|
res.redirect("/admin/activity");
|
|
} catch (err) {
|
|
console.error("Create error:", err);
|
|
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
|
|
res.redirect("/admin/activity/create");
|
|
}
|
|
};
|
|
|
|
// Display edit form
|
|
exports.editForm = async (req, res) => {
|
|
try {
|
|
const activity = await Activity.findById(req.params.id).lean();
|
|
|
|
if (!activity) {
|
|
req.flash("error_msg", "Activity not found");
|
|
return res.redirect("/admin/activity");
|
|
}
|
|
|
|
res.render("admin/activity/form", {
|
|
layout: "layouts/main",
|
|
title: "Edit Activity",
|
|
data: activity,
|
|
isEdit: true,
|
|
currentPath: req.path,
|
|
user: req.session.user,
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
req.flash("error_msg", "Error loading edit form");
|
|
res.redirect("/admin/activity");
|
|
}
|
|
};
|
|
|
|
// Update activity
|
|
exports.update = async (req, res) => {
|
|
try {
|
|
const activity = await Activity.findById(req.params.id);
|
|
|
|
if (!activity) {
|
|
req.flash("error_msg", "Activity not found");
|
|
return res.redirect("/admin/activity");
|
|
}
|
|
|
|
const activityData = parseActivityFormData(req.body, activity);
|
|
|
|
// Force status to active on update (always set isActive true when editing)
|
|
activityData.isActive = true;
|
|
|
|
const lengthValidation = validateLengthRules(activityData, ACTIVITY_LENGTH_RULES);
|
|
if (!lengthValidation.valid) {
|
|
return redirectWithLengthError(
|
|
req,
|
|
res,
|
|
`/admin/activity/${req.params.id}/edit`,
|
|
lengthValidation,
|
|
);
|
|
}
|
|
|
|
await Activity.findByIdAndUpdate(req.params.id, activityData, {new: true});
|
|
|
|
req.flash("success_msg", "Activity updated successfully");
|
|
return req.session.save(() => res.redirect("/admin/activity"));
|
|
} catch (err) {
|
|
console.error("Update error:", err);
|
|
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
|
return req.session.save(() =>
|
|
res.redirect(`/admin/activity/${req.params.id}/edit`)
|
|
);
|
|
}
|
|
};
|
|
|
|
// Delete activity
|
|
exports.delete = async (req, res) => {
|
|
try {
|
|
const activity = await Activity.findById(req.params.id);
|
|
|
|
if (!activity) {
|
|
req.flash("error_msg", "Activity not found");
|
|
return res.redirect("/admin/activity");
|
|
}
|
|
|
|
await Activity.findByIdAndDelete(req.params.id);
|
|
|
|
req.flash("success_msg", "Activity deleted successfully");
|
|
res.redirect("/admin/activity");
|
|
} catch (err) {
|
|
console.error("Delete error:", err);
|
|
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`);
|
|
res.redirect("/admin/activity");
|
|
}
|
|
};
|
|
|
|
// Toggle activity status (active/inactive)
|
|
exports.toggleStatus = async (req, res) => {
|
|
try {
|
|
const activity = await Activity.findById(req.params.id);
|
|
|
|
if (!activity) {
|
|
return res.status(404).json({error: "Activity not found"});
|
|
}
|
|
|
|
activity.isActive = !activity.isActive;
|
|
await activity.save();
|
|
|
|
// Return updated global counts so front-end widgets can reflect totals
|
|
const total = await Activity.countDocuments({ isFiltersDoc: { $ne: true } });
|
|
const activeCount = await Activity.countDocuments({ isFiltersDoc: { $ne: true }, isActive: true });
|
|
|
|
return res.json({
|
|
success: true,
|
|
isActive: activity.isActive,
|
|
message: `Activity ${
|
|
activity.isActive ? "activated" : "deactivated"
|
|
} successfully`,
|
|
total,
|
|
activeCount,
|
|
});
|
|
} catch (err) {
|
|
console.error("Toggle status error:", err);
|
|
return res.status(500).json({error: "Error toggling activity status"});
|
|
}
|
|
};
|
|
|
|
// Update activity order (for drag & drop reordering)
|
|
exports.updateOrder = async (req, res) => {
|
|
try {
|
|
const {items} = req.body; // Array of { id, order }
|
|
|
|
if (!Array.isArray(items)) {
|
|
return res.status(400).json({error: "Invalid data format"});
|
|
}
|
|
|
|
const bulkOps = items.map((item) => ({
|
|
updateOne: {
|
|
filter: {_id: item.id},
|
|
update: {$set: {order: item.order}},
|
|
},
|
|
}));
|
|
|
|
await Activity.bulkWrite(bulkOps);
|
|
|
|
return res.json({success: true, message: "Order updated successfully"});
|
|
} catch (err) {
|
|
console.error("Update order error:", err);
|
|
return res.status(500).json({error: "Error updating order"});
|
|
}
|
|
};
|
|
|
|
// Preview activity
|
|
exports.preview = async (req, res) => {
|
|
try {
|
|
const activity = await Activity.findById(req.params.id).lean();
|
|
|
|
if (!activity) {
|
|
return res.status(404).json({error: "Activity not found"});
|
|
}
|
|
|
|
const baseUrl =
|
|
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
|
const processed = addBaseUrlToImages(activity, baseUrl);
|
|
|
|
res.json(processed);
|
|
} catch (err) {
|
|
console.error("Preview error:", err);
|
|
res.status(500).json({error: "Error loading preview data"});
|
|
}
|
|
};
|
|
|
|
// -------------------- Helper functions --------------------
|
|
|
|
function parseActivityFormData(body, existingActivity = null) {
|
|
// Parse season (can be string or array)
|
|
let season = body.season || [];
|
|
if (typeof season === "string") {
|
|
season = season
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// Parse age range
|
|
let age = [12, 18];
|
|
if (body.ageMin && body.ageMax) {
|
|
age = [parseInt(body.ageMin) || 12, parseInt(body.ageMax) || 18];
|
|
} else if (body.age) {
|
|
try {
|
|
age = JSON.parse(body.age);
|
|
} catch (e) {
|
|
// Keep default
|
|
}
|
|
}
|
|
|
|
// Parse locations (can be string or array)
|
|
let locations = body.locations || [];
|
|
if (typeof locations === "string") {
|
|
locations = locations
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// Parse campDetail from form data
|
|
let campDetail = {};
|
|
try {
|
|
if (body.campDetail) {
|
|
if (typeof body.campDetail === "string") {
|
|
campDetail = JSON.parse(body.campDetail);
|
|
} else {
|
|
campDetail = body.campDetail;
|
|
}
|
|
}
|
|
|
|
// Handle individual campDetail fields if sent separately
|
|
// Hero section
|
|
if (body.campDetailHeroTitle || body.campDetailHeroBgImage) {
|
|
campDetail.hero = campDetail.hero || {};
|
|
if (body.campDetailHeroTitle) campDetail.hero.title = body.campDetailHeroTitle.trim();
|
|
if (body.campDetailHeroBgImage) campDetail.hero.bgImage = body.campDetailHeroBgImage.trim();
|
|
}
|
|
|
|
// Basic Info section
|
|
if (body.campDetailBasicInfoLocation || body.campDetailBasicInfoAgeRange ||
|
|
body.campDetailBasicInfoAccommodationType || body.campDetailBasicInfoCareLevel ||
|
|
body.campDetailBasicInfoLanguages) {
|
|
campDetail.basicInfo = campDetail.basicInfo || {};
|
|
if (body.campDetailBasicInfoLocation) campDetail.basicInfo.location = body.campDetailBasicInfoLocation.trim();
|
|
if (body.campDetailBasicInfoAgeRange) campDetail.basicInfo.ageRange = body.campDetailBasicInfoAgeRange.trim();
|
|
if (body.campDetailBasicInfoAccommodationType) campDetail.basicInfo.accommodationType = body.campDetailBasicInfoAccommodationType.trim();
|
|
if (body.campDetailBasicInfoCareLevel) campDetail.basicInfo.careLevel = body.campDetailBasicInfoCareLevel.trim();
|
|
if (body.campDetailBasicInfoLanguages) campDetail.basicInfo.languages = body.campDetailBasicInfoLanguages.trim();
|
|
}
|
|
|
|
// Sidebar section
|
|
if (body.campDetailSidebarContactPhone || body.campDetailSidebarContactEmail ||
|
|
body.campDetailSidebarMenuItems || body.campDetailSidebarUpcomingTours) {
|
|
campDetail.sidebar = campDetail.sidebar || {};
|
|
|
|
// Contact info
|
|
if (body.campDetailSidebarContactPhone || body.campDetailSidebarContactEmail) {
|
|
campDetail.sidebar.contact = campDetail.sidebar.contact || {};
|
|
if (body.campDetailSidebarContactPhone) campDetail.sidebar.contact.phone = body.campDetailSidebarContactPhone.trim();
|
|
if (body.campDetailSidebarContactEmail) campDetail.sidebar.contact.email = body.campDetailSidebarContactEmail.trim();
|
|
}
|
|
|
|
// Menu items (JSON array)
|
|
if (body.campDetailSidebarMenuItems) {
|
|
try {
|
|
campDetail.sidebar.menuItems = JSON.parse(body.campDetailSidebarMenuItems);
|
|
} catch (e) {
|
|
console.warn("Error parsing sidebar menuItems:", e);
|
|
}
|
|
}
|
|
|
|
// Upcoming tours (JSON array)
|
|
if (body.campDetailSidebarUpcomingTours) {
|
|
try {
|
|
campDetail.sidebar.upcomingTours = JSON.parse(body.campDetailSidebarUpcomingTours);
|
|
} catch (e) {
|
|
console.warn("Error parsing sidebar upcomingTours:", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Main Gallery section
|
|
if (body.campDetailMainGallerySlides || body.campDetailMainGalleryOverlayLocation ||
|
|
body.campDetailMainGalleryOverlaySeason || body.campDetailMainGalleryOverlayLanguages) {
|
|
campDetail.mainGallery = campDetail.mainGallery || {};
|
|
|
|
// Gallery slides (JSON array)
|
|
if (body.campDetailMainGallerySlides) {
|
|
try {
|
|
campDetail.mainGallery.slides = JSON.parse(body.campDetailMainGallerySlides);
|
|
} catch (e) {
|
|
console.warn("Error parsing mainGallery slides:", e);
|
|
}
|
|
}
|
|
|
|
// Overlay info
|
|
if (body.campDetailMainGalleryOverlayLocation || body.campDetailMainGalleryOverlaySeason ||
|
|
body.campDetailMainGalleryOverlayLanguages) {
|
|
campDetail.mainGallery.overlayInfo = campDetail.mainGallery.overlayInfo || {};
|
|
if (body.campDetailMainGalleryOverlayLocation) campDetail.mainGallery.overlayInfo.location = body.campDetailMainGalleryOverlayLocation.trim();
|
|
if (body.campDetailMainGalleryOverlaySeason) campDetail.mainGallery.overlayInfo.season = body.campDetailMainGalleryOverlaySeason.trim();
|
|
if (body.campDetailMainGalleryOverlayLanguages) campDetail.mainGallery.overlayInfo.languages = body.campDetailMainGalleryOverlayLanguages.trim();
|
|
}
|
|
}
|
|
|
|
// Event Schedule section
|
|
if (body.campDetailEventScheduleStartDate || body.campDetailEventScheduleDuration ||
|
|
body.campDetailEventScheduleTickets) {
|
|
campDetail.eventSchedule = campDetail.eventSchedule || {};
|
|
if (body.campDetailEventScheduleStartDate) campDetail.eventSchedule.startDate = body.campDetailEventScheduleStartDate.trim();
|
|
if (body.campDetailEventScheduleDuration) campDetail.eventSchedule.duration = body.campDetailEventScheduleDuration.trim();
|
|
if (body.campDetailEventScheduleTickets) campDetail.eventSchedule.tickets = body.campDetailEventScheduleTickets.trim();
|
|
}
|
|
|
|
// Sections - handle both overview individual fields and complete sections JSON
|
|
if (body.campDetailSectionsOverviewIntro || body.campDetailSectionsOverviewMainText ||
|
|
body.campDetailSectionsOverviewFeatures || body.campDetailSectionsOverviewFeatureImage ||
|
|
body.campDetailSections) {
|
|
|
|
// If complete sections JSON is provided, use it
|
|
if (body.campDetailSections) {
|
|
try {
|
|
campDetail.sections = JSON.parse(body.campDetailSections);
|
|
} catch (e) {
|
|
console.warn("Error parsing complete sections JSON:", e);
|
|
}
|
|
}
|
|
|
|
// Handle individual overview fields (will override sections JSON if both provided)
|
|
if (body.campDetailSectionsOverviewIntro || body.campDetailSectionsOverviewMainText ||
|
|
body.campDetailSectionsOverviewFeatures || body.campDetailSectionsOverviewFeatureImage) {
|
|
campDetail.sections = campDetail.sections || {};
|
|
campDetail.sections.overview = campDetail.sections.overview || {};
|
|
|
|
if (body.campDetailSectionsOverviewIntro) campDetail.sections.overview.intro = body.campDetailSectionsOverviewIntro.trim();
|
|
if (body.campDetailSectionsOverviewMainText) campDetail.sections.overview.mainText = body.campDetailSectionsOverviewMainText.trim();
|
|
if (body.campDetailSectionsOverviewFeatureImage) campDetail.sections.overview.featureImage = body.campDetailSectionsOverviewFeatureImage.trim();
|
|
|
|
// Features array
|
|
if (body.campDetailSectionsOverviewFeatures) {
|
|
try {
|
|
campDetail.sections.overview.features = JSON.parse(body.campDetailSectionsOverviewFeatures);
|
|
} catch (e) {
|
|
console.warn("Error parsing overview features:", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Map tipsImage (sidebar) into campDetail.hero.bgImage when provided
|
|
if (body.tipsImage && typeof body.tipsImage === 'string' && body.tipsImage.trim()) {
|
|
campDetail.hero = campDetail.hero || {};
|
|
campDetail.hero.bgImage = body.tipsImage.trim();
|
|
}
|
|
} catch (e) {
|
|
console.warn("Error parsing campDetail:", e);
|
|
campDetail = {};
|
|
}
|
|
|
|
// Parse hero section (activities + booking variants)
|
|
const existingHero = existingActivity?.hero || {};
|
|
const hero = {
|
|
titleActivities: body.titleActivities?.trim() || existingHero.titleActivities || "",
|
|
titleBooking: body.titleBooking?.trim() || existingHero.titleBooking || "",
|
|
bannerImageActivities: body.bannerImageActivities?.trim() || existingHero.bannerImageActivities || "",
|
|
bannerImageBooking: body.bannerImageBooking?.trim() || existingHero.bannerImageBooking || "",
|
|
};
|
|
|
|
// Parse bookingSessions
|
|
let bookingSessions = [];
|
|
try {
|
|
if (body.bookingSessions) {
|
|
if (typeof body.bookingSessions === "string") {
|
|
bookingSessions = JSON.parse(body.bookingSessions);
|
|
} else if (Array.isArray(body.bookingSessions)) {
|
|
bookingSessions = body.bookingSessions;
|
|
} else if (typeof body.bookingSessions === "object") {
|
|
bookingSessions = Object.keys(body.bookingSessions)
|
|
.sort((a, b) => parseInt(a) - parseInt(b))
|
|
.map(k => body.bookingSessions[k]);
|
|
}
|
|
}
|
|
|
|
// Validate và clean sessions
|
|
bookingSessions = bookingSessions
|
|
.filter(s => s && s.startDate && s.endDate)
|
|
.map((s, index) => ({
|
|
// Auto generate sessionId if not provided
|
|
sessionId: s.sessionId?.trim() || `session-${Date.now()}-${index}`,
|
|
startDate: new Date(s.startDate),
|
|
endDate: new Date(s.endDate),
|
|
overnightStays: parseInt(s.overnightStays) || 14,
|
|
// Spots theo giới tính
|
|
totalMaleSpots: parseInt(s.totalMaleSpots) || 25,
|
|
totalFemaleSpots: parseInt(s.totalFemaleSpots) || 25,
|
|
bookedMaleSpots: parseInt(s.bookedMaleSpots) || 0,
|
|
bookedFemaleSpots: parseInt(s.bookedFemaleSpots) || 0,
|
|
price: s.price ? parseFloat(s.price) : null,
|
|
isActive: s.isActive === true || s.isActive === "true" || s.isActive === "on"
|
|
}));
|
|
} catch (e) {
|
|
console.warn("Error parsing bookingSessions:", e);
|
|
bookingSessions = existingActivity?.bookingSessions || [];
|
|
}
|
|
|
|
// Determine final image value from various input sources
|
|
const finalImageValue = (function(){
|
|
const img = body.image?.trim() || (body.sidebarImage?.trim() || '') || (body.tipsImage?.trim() || '');
|
|
return img || "";
|
|
})();
|
|
|
|
// Đồng bộ campDetail.hero.bgImage với main image - 2 trường này luôn giống nhau
|
|
if (finalImageValue && campDetail && campDetail.hero) {
|
|
campDetail.hero.bgImage = finalImageValue;
|
|
} else if (finalImageValue) {
|
|
// Tạo campDetail.hero nếu chưa có và gán bgImage
|
|
campDetail = campDetail || {};
|
|
campDetail.hero = campDetail.hero || {};
|
|
campDetail.hero.bgImage = finalImageValue;
|
|
}
|
|
|
|
return {
|
|
hero,
|
|
name: body.name?.trim() || "",
|
|
price: Math.max(0, parseFloat(body.price) || 0),
|
|
priceText: body.priceText?.trim() || `from ${body.price || 0} USD`,
|
|
season,
|
|
age,
|
|
locations,
|
|
image: finalImageValue,
|
|
link: body.link?.trim() ? (body.link.trim().startsWith('/') ? body.link.trim() : '/' + body.link.trim()) : "",
|
|
program: body.program?.trim() || "",
|
|
rating: Math.max(1, Math.min(5, parseInt(body.rating) || 4)),
|
|
isActive:
|
|
body.isActive === "true" ||
|
|
body.isActive === true ||
|
|
body.isActive === "on" ||
|
|
body.isActive === 1,
|
|
order: Math.max(0, parseInt(body.order) || 0),
|
|
campDetail: campDetail,
|
|
bookingSessions: bookingSessions,
|
|
};
|
|
}
|
|
|
|
// -------------------- Booking Submissions Management --------------------
|
|
|
|
// Get booking count for an activity
|
|
exports.getBookingCount = async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const BookingSubmission = require('../models/bookingSubmission');
|
|
|
|
let count = await BookingSubmission.countDocuments({ activityId: id });
|
|
|
|
// Fallback to embedded bookingList in Activity if no separate BookingSubmission docs
|
|
if (!count) {
|
|
const activity = await Activity.findById(id).lean();
|
|
if (activity && Array.isArray(activity.bookingSessions)) {
|
|
count = activity.bookingSessions.reduce((sum, s) => {
|
|
return sum + (Array.isArray(s.bookingList) ? s.bookingList.length : 0);
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
return res.json({ count });
|
|
} catch (err) {
|
|
console.error("getBookingCount error:", err);
|
|
return res.status(500).json({ error: "Error loading booking count" });
|
|
}
|
|
};
|
|
|
|
// Get booking submissions for an activity with stats
|
|
exports.getBookingSubmissions = async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const BookingSubmission = require('../models/bookingSubmission');
|
|
|
|
// Get activity with sessions
|
|
const activity = await Activity.findById(id).lean();
|
|
if (!activity) {
|
|
return res.status(404).json({ error: "Activity not found" });
|
|
}
|
|
|
|
// Get all booking submissions for this activity (separate collection)
|
|
let bookings = await BookingSubmission.find({ activityId: id })
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
// Fallback: if there are no BookingSubmission documents, attempt to read embedded bookingList from Activity.bookingSessions
|
|
if ((!bookings || bookings.length === 0) && Array.isArray(activity.bookingSessions)) {
|
|
bookings = [];
|
|
activity.bookingSessions.forEach((session) => {
|
|
if (Array.isArray(session.bookingList)) {
|
|
session.bookingList.forEach((b) => {
|
|
// normalize embedded booking fields to match BookingSubmission shape
|
|
const item = Object.assign({}, b);
|
|
item.sessionId = session.sessionId || item.sessionDate || item.sessionId;
|
|
item.createdAt = item.bookingDate || item.createdAt || new Date();
|
|
// normalize status/payment field names
|
|
item.status = item.status || item.bookingStatus || 'pending';
|
|
item.paymentStatus = item.paymentStatus || item.paymentStatus || 'pending';
|
|
// ensure participantBirthDate is Date
|
|
if (item.participantBirthDate && typeof item.participantBirthDate === 'string') {
|
|
item.participantBirthDate = new Date(item.participantBirthDate);
|
|
}
|
|
bookings.push(item);
|
|
});
|
|
}
|
|
});
|
|
// sort by createdAt desc
|
|
bookings.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
}
|
|
|
|
// Calculate statistics
|
|
const stats = {
|
|
total: bookings.length,
|
|
confirmed: bookings.filter(b => b.status === 'confirmed').length,
|
|
pending: bookings.filter(b => b.status === 'pending').length,
|
|
cancelled: bookings.filter(b => b.status === 'cancelled').length,
|
|
completed: bookings.filter(b => b.status === 'completed').length,
|
|
totalRevenue: bookings.filter(b => b.status !== 'cancelled').reduce((sum, b) => sum + (b.totalAmount || 0), 0)
|
|
};
|
|
|
|
// Create session breakdown
|
|
const sessionBreakdown = {};
|
|
const sessions = activity.bookingSessions || [];
|
|
|
|
sessions.forEach(session => {
|
|
const sessionBookings = bookings.filter(b => b.sessionId === session.sessionId);
|
|
const totalCapacity = session.totalMaleSpots + session.totalFemaleSpots;
|
|
const bookedCount = sessionBookings.length;
|
|
|
|
sessionBreakdown[session.sessionId] = {
|
|
sessionName: `${new Date(session.startDate).toLocaleDateString()} - ${new Date(session.endDate).toLocaleDateString()}`,
|
|
dateRange: `${new Date(session.startDate).toLocaleDateString()} - ${new Date(session.endDate).toLocaleDateString()}`,
|
|
totalCapacity,
|
|
bookedCount,
|
|
bookings: sessionBookings.length
|
|
};
|
|
});
|
|
|
|
// Format sessions for filter dropdown
|
|
const sessionsForFilter = sessions.map(s => ({
|
|
sessionId: s.sessionId,
|
|
sessionName: `${new Date(s.startDate).toLocaleDateString()} - ${new Date(s.endDate).toLocaleDateString()}`
|
|
}));
|
|
|
|
return res.json({
|
|
bookings,
|
|
stats,
|
|
sessionBreakdown,
|
|
sessions: sessionsForFilter
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error("getBookingSubmissions error:", err);
|
|
return res.status(500).json({ error: "Error loading booking submissions" });
|
|
}
|
|
};
|
|
|
|
// Export booking data as CSV
|
|
exports.exportBookingData = async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const BookingSubmission = require('../models/bookingSubmission');
|
|
|
|
const bookings = await BookingSubmission.find({ activityId: id })
|
|
.populate('activityId', 'name')
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
if (bookings.length === 0) {
|
|
return res.status(404).json({ error: "No bookings found" });
|
|
}
|
|
|
|
// CSV headers
|
|
const headers = [
|
|
'Date Submitted',
|
|
'Activity',
|
|
'Session ID',
|
|
'Participant Name',
|
|
'Participant Gender',
|
|
'Participant Birth Date',
|
|
'Parent Name',
|
|
'Email',
|
|
'Phone',
|
|
'Address',
|
|
'City',
|
|
'Country',
|
|
'Postal Code',
|
|
'Number of Participants',
|
|
'Medical Conditions',
|
|
'Dietary Restrictions',
|
|
'Special Requests',
|
|
'Emergency Contact',
|
|
'Emergency Phone',
|
|
'Status',
|
|
'Payment Status',
|
|
'Total Amount',
|
|
'Paid Amount'
|
|
];
|
|
|
|
// Convert bookings to CSV rows
|
|
const rows = bookings.map(booking => [
|
|
new Date(booking.createdAt).toISOString().split('T')[0],
|
|
booking.activityId?.name || 'Unknown Activity',
|
|
booking.sessionId,
|
|
`${booking.participantFirstName} ${booking.participantLastName}`,
|
|
booking.participantGender,
|
|
new Date(booking.participantBirthDate).toISOString().split('T')[0],
|
|
`${booking.parentFirstName} ${booking.parentLastName}`,
|
|
booking.email,
|
|
booking.phone,
|
|
booking.address,
|
|
booking.city,
|
|
booking.country,
|
|
booking.postalCode,
|
|
booking.numberOfParticipants,
|
|
booking.medicalConditions || '',
|
|
booking.dietaryRestrictions || 'none',
|
|
booking.specialRequests || '',
|
|
booking.emergencyContact,
|
|
booking.emergencyPhone,
|
|
booking.status,
|
|
booking.paymentStatus,
|
|
booking.totalAmount || 0,
|
|
booking.paidAmount || 0
|
|
]);
|
|
|
|
// Generate CSV content
|
|
const csvContent = [headers, ...rows]
|
|
.map(row => row.map(field => `"${(field || '').toString().replace(/"/g, '""')}"`).join(','))
|
|
.join('\n');
|
|
|
|
// Set response headers for CSV download
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', `attachment; filename="bookings_${id}_${new Date().toISOString().split('T')[0]}.csv"`);
|
|
|
|
return res.send(csvContent);
|
|
|
|
} catch (err) {
|
|
console.error("exportBookingData error:", err);
|
|
return res.status(500).json({ error: "Error exporting booking data" });
|
|
}
|
|
};
|
|
|
|
// Export ALL booking data as CSV (across all activities)
|
|
exports.exportAllBookingsData = async (req, res) => {
|
|
try {
|
|
// Get all activities with booking sessions
|
|
const allActivities = await Activity.find({
|
|
isFiltersDoc: { $ne: true },
|
|
'bookingSessions.bookingList': { $exists: true, $ne: [] }
|
|
}).lean();
|
|
|
|
// Extract all bookings from bookingSessions.bookingList
|
|
const allBookings = [];
|
|
|
|
allActivities.forEach(activity => {
|
|
if (activity.bookingSessions && Array.isArray(activity.bookingSessions)) {
|
|
activity.bookingSessions.forEach(session => {
|
|
if (session.bookingList && Array.isArray(session.bookingList)) {
|
|
session.bookingList.forEach(booking => {
|
|
const bookingWithActivityInfo = {
|
|
...booking,
|
|
activityName: activity.name,
|
|
sessionId: session.sessionId,
|
|
createdAt: booking.createdAt || booking.bookingDate || new Date(),
|
|
status: booking.status || booking.bookingStatus || 'pending',
|
|
paymentStatus: booking.paymentStatus || 'pending',
|
|
totalAmount: booking.totalAmount || 0,
|
|
paidAmount: booking.paidAmount || 0
|
|
};
|
|
allBookings.push(bookingWithActivityInfo);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
if (allBookings.length === 0) {
|
|
return res.status(404).json({ error: "No bookings found" });
|
|
}
|
|
|
|
// Sort by creation date (newest first)
|
|
allBookings.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
|
|
// CSV headers
|
|
const headers = [
|
|
'Date Submitted',
|
|
'Activity',
|
|
'Session ID',
|
|
'Participant Name',
|
|
'Participant Gender',
|
|
'Participant Birth Date',
|
|
'Parent Name',
|
|
'Email',
|
|
'Phone',
|
|
'Address',
|
|
'City',
|
|
'Country',
|
|
'Postal Code',
|
|
'Number of Participants',
|
|
'Medical Conditions',
|
|
'Dietary Restrictions',
|
|
'Special Requests',
|
|
'Emergency Contact',
|
|
'Emergency Phone',
|
|
'Status',
|
|
'Payment Status',
|
|
'Total Amount',
|
|
'Paid Amount'
|
|
];
|
|
|
|
// Convert bookings to CSV rows
|
|
const rows = allBookings.map(booking => [
|
|
new Date(booking.createdAt).toISOString().split('T')[0],
|
|
booking.activityName || 'Unknown Activity',
|
|
booking.sessionId,
|
|
`${booking.participantFirstName} ${booking.participantLastName}`,
|
|
booking.participantGender,
|
|
booking.participantBirthDate ? new Date(booking.participantBirthDate).toISOString().split('T')[0] : '',
|
|
`${booking.parentFirstName} ${booking.parentLastName}`,
|
|
booking.email,
|
|
booking.phone,
|
|
booking.address,
|
|
booking.city,
|
|
booking.country,
|
|
booking.postalCode,
|
|
booking.numberOfParticipants,
|
|
booking.medicalConditions || '',
|
|
booking.dietaryRestrictions || 'none',
|
|
booking.specialRequests || '',
|
|
booking.emergencyContact,
|
|
booking.emergencyPhone,
|
|
booking.status,
|
|
booking.paymentStatus,
|
|
booking.totalAmount || 0,
|
|
booking.paidAmount || 0
|
|
]);
|
|
|
|
// Generate CSV content
|
|
const csvContent = [headers, ...rows]
|
|
.map(row => row.map(field => `"${(field || '').toString().replace(/"/g, '""')}"`).join(','))
|
|
.join('\n');
|
|
|
|
// Set response headers for CSV download
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', `attachment; filename="all_bookings_${new Date().toISOString().split('T')[0]}.csv"`);
|
|
|
|
return res.send(csvContent);
|
|
|
|
} catch (err) {
|
|
console.error("exportAllBookingsData error:", err);
|
|
return res.status(500).json({ error: "Error exporting all booking data" });
|
|
}
|
|
};
|
|
|
|
// Delete a booking submission
|
|
exports.deleteBookingSubmission = async (req, res) => {
|
|
try {
|
|
const { bookingId } = req.params;
|
|
const BookingSubmission = require('../models/bookingSubmission');
|
|
|
|
const booking = await BookingSubmission.findById(bookingId);
|
|
if (!booking) {
|
|
return res.status(404).json({ error: "Booking not found" });
|
|
}
|
|
|
|
await BookingSubmission.findByIdAndDelete(bookingId);
|
|
|
|
return res.json({ message: "Booking deleted successfully" });
|
|
|
|
} catch (err) {
|
|
console.error("deleteBookingSubmission error:", err);
|
|
return res.status(500).json({ error: "Error deleting booking" });
|
|
}
|
|
};
|
|
|
|
// -------------------- Camp Session Booking Management --------------------
|
|
|
|
// Create a new booking directly into camp session
|
|
exports.createSessionBooking = async (req, res) => {
|
|
try {
|
|
const { activityId, sessionId } = req.params;
|
|
const bookingData = req.body;
|
|
|
|
// Validate required fields
|
|
const requiredFields = [
|
|
'address', 'agreeTerms', 'city', 'country', 'email', 'emergencyContact',
|
|
'emergencyPhone', 'numberOfParticipants', 'parentFirstName', 'parentLastName',
|
|
'participantBirthDate', 'participantFirstName', 'participantGender',
|
|
'participantLastName', 'phone', 'postalCode'
|
|
];
|
|
|
|
for (let field of requiredFields) {
|
|
if (!bookingData[field]) {
|
|
return res.status(400).json({
|
|
error: `Missing required field: ${field}`
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate email format
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(bookingData.email)) {
|
|
return res.status(400).json({ error: "Invalid email format" });
|
|
}
|
|
|
|
// Find the activity
|
|
const activity = await Activity.findById(activityId);
|
|
if (!activity) {
|
|
return res.status(404).json({ error: "Activity not found" });
|
|
}
|
|
|
|
// Find the specific session
|
|
const sessionIndex = activity.bookingSessions.findIndex(s => s.sessionId === sessionId);
|
|
if (sessionIndex === -1) {
|
|
return res.status(404).json({ error: "Session not found" });
|
|
}
|
|
|
|
const session = activity.bookingSessions[sessionIndex];
|
|
|
|
// Check if session is active
|
|
if (!session.isActive) {
|
|
return res.status(400).json({ error: "Session is not active for booking" });
|
|
}
|
|
|
|
// Check availability based on participant gender
|
|
const participantGender = bookingData.participantGender;
|
|
const numberOfParticipants = parseInt(bookingData.numberOfParticipants) || 1;
|
|
|
|
let availableSpots = 0;
|
|
if (participantGender === 'male') {
|
|
availableSpots = session.totalMaleSpots - session.bookedMaleSpots;
|
|
} else if (participantGender === 'female') {
|
|
availableSpots = session.totalFemaleSpots - session.bookedFemaleSpots;
|
|
} else {
|
|
// For 'other' gender, check both male and female availability
|
|
const maleAvailable = session.totalMaleSpots - session.bookedMaleSpots;
|
|
const femaleAvailable = session.totalFemaleSpots - session.bookedFemaleSpots;
|
|
availableSpots = Math.max(maleAvailable, femaleAvailable);
|
|
}
|
|
|
|
if (availableSpots < numberOfParticipants) {
|
|
return res.status(400).json({
|
|
error: `Not enough spots available. Only ${availableSpots} spots left for ${participantGender} participants.`,
|
|
availableSpots
|
|
});
|
|
}
|
|
|
|
// Generate unique confirmation code
|
|
const confirmationCode = `GG${Date.now()}${Math.random().toString(36).substr(2, 5).toUpperCase()}`;
|
|
|
|
// Calculate total amount
|
|
const pricePerParticipant = session.price || activity.price || 0;
|
|
const totalAmount = pricePerParticipant * numberOfParticipants;
|
|
|
|
// Create booking object
|
|
const newBooking = {
|
|
address: bookingData.address.trim(),
|
|
agreeNewsletter: bookingData.agreeNewsletter === true || bookingData.agreeNewsletter === 'true',
|
|
agreeTerms: bookingData.agreeTerms === true || bookingData.agreeTerms === 'true',
|
|
city: bookingData.city.trim(),
|
|
country: bookingData.country.trim(),
|
|
dietaryRestrictions: bookingData.dietaryRestrictions || 'none',
|
|
email: bookingData.email.toLowerCase().trim(),
|
|
emergencyContact: bookingData.emergencyContact.trim(),
|
|
emergencyPhone: bookingData.emergencyPhone.trim(),
|
|
medicalConditions: bookingData.medicalConditions || '',
|
|
numberOfParticipants: numberOfParticipants,
|
|
parentFirstName: bookingData.parentFirstName.trim(),
|
|
parentLastName: bookingData.parentLastName.trim(),
|
|
participantBirthDate: new Date(bookingData.participantBirthDate),
|
|
participantFirstName: bookingData.participantFirstName.trim(),
|
|
participantGender: participantGender,
|
|
participantLastName: bookingData.participantLastName.trim(),
|
|
phone: bookingData.phone.trim(),
|
|
postalCode: bookingData.postalCode.trim(),
|
|
sessionDate: sessionId,
|
|
specialRequests: bookingData.specialRequests || '',
|
|
bookingStatus: 'pending',
|
|
paymentStatus: 'pending',
|
|
totalAmount: totalAmount,
|
|
paidAmount: 0,
|
|
bookingDate: new Date(),
|
|
confirmationCode: confirmationCode,
|
|
adminNotes: ''
|
|
};
|
|
|
|
// Add booking to session
|
|
if (!activity.bookingSessions[sessionIndex].bookingList) {
|
|
activity.bookingSessions[sessionIndex].bookingList = [];
|
|
}
|
|
activity.bookingSessions[sessionIndex].bookingList.push(newBooking);
|
|
|
|
// Update booked spots count
|
|
if (participantGender === 'male') {
|
|
activity.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
|
|
} else if (participantGender === 'female') {
|
|
activity.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
|
|
} else {
|
|
// For 'other' gender, distribute to the gender with more availability
|
|
const maleAvailable = session.totalMaleSpots - session.bookedMaleSpots;
|
|
const femaleAvailable = session.totalFemaleSpots - session.bookedFemaleSpots;
|
|
if (maleAvailable >= femaleAvailable) {
|
|
activity.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
|
|
} else {
|
|
activity.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
|
|
}
|
|
}
|
|
|
|
// Save the updated activity
|
|
await activity.save();
|
|
|
|
// Return success response with booking details
|
|
return res.status(201).json({
|
|
message: "Booking created successfully",
|
|
booking: {
|
|
id: activity.bookingSessions[sessionIndex].bookingList[activity.bookingSessions[sessionIndex].bookingList.length - 1]._id,
|
|
confirmationCode: confirmationCode,
|
|
activityName: activity.name,
|
|
sessionId: sessionId,
|
|
participantName: `${bookingData.participantFirstName} ${bookingData.participantLastName}`,
|
|
parentName: `${bookingData.parentFirstName} ${bookingData.parentLastName}`,
|
|
email: bookingData.email,
|
|
totalAmount: totalAmount,
|
|
numberOfParticipants: numberOfParticipants,
|
|
status: 'pending',
|
|
sessionDetails: {
|
|
startDate: session.startDate,
|
|
endDate: session.endDate,
|
|
overnightStays: session.overnightStays
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error("createSessionBooking error:", err);
|
|
return res.status(500).json({ error: "Error creating booking" });
|
|
}
|
|
};
|
|
|
|
// Create booking by program (wrapper) - find activity by `program` then delegate
|
|
exports.createSessionBookingByProgram = async (req, res) => {
|
|
try {
|
|
const { program, sessionId } = req.params;
|
|
// Find activity by program field
|
|
const activity = await Activity.findOne({ program: program });
|
|
if (!activity) return res.status(404).json({ error: 'Activity not found for program: ' + program });
|
|
|
|
// Inject activityId into params and call existing handler
|
|
req.params.activityId = activity._id.toString();
|
|
req.params.sessionId = sessionId;
|
|
return await exports.createSessionBooking(req, res);
|
|
} catch (err) {
|
|
console.error('createSessionBookingByProgram error:', err);
|
|
return res.status(500).json({ error: 'Error creating booking by program' });
|
|
}
|
|
};
|
|
|
|
// Get all bookings for a specific session
|
|
exports.getSessionBookings = async (req, res) => {
|
|
try {
|
|
const { activityId, sessionId } = req.params;
|
|
const page = parseInt(req.query.page) || 1;
|
|
const limit = parseInt(req.query.limit) || 20;
|
|
const status = req.query.status;
|
|
const search = req.query.search;
|
|
|
|
// Find the activity
|
|
const activity = await Activity.findById(activityId);
|
|
if (!activity) {
|
|
return res.status(404).json({ error: "Activity not found" });
|
|
}
|
|
|
|
// Find the specific session
|
|
const session = activity.bookingSessions.find(s => s.sessionId === sessionId);
|
|
if (!session) {
|
|
return res.status(404).json({ error: "Session not found" });
|
|
}
|
|
|
|
let bookings = session.bookingList || [];
|
|
|
|
// Apply filters
|
|
if (status) {
|
|
bookings = bookings.filter(b => b.bookingStatus === status);
|
|
}
|
|
|
|
if (search) {
|
|
const searchLower = search.toLowerCase();
|
|
bookings = bookings.filter(b =>
|
|
b.participantFirstName.toLowerCase().includes(searchLower) ||
|
|
b.participantLastName.toLowerCase().includes(searchLower) ||
|
|
b.parentFirstName.toLowerCase().includes(searchLower) ||
|
|
b.parentLastName.toLowerCase().includes(searchLower) ||
|
|
b.email.toLowerCase().includes(searchLower) ||
|
|
b.confirmationCode.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
// Calculate pagination
|
|
const totalBookings = bookings.length;
|
|
const totalPages = Math.ceil(totalBookings / limit);
|
|
const startIndex = (page - 1) * limit;
|
|
const endIndex = startIndex + limit;
|
|
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
|
|
|
// Calculate statistics
|
|
const stats = {
|
|
total: session.bookingList?.length || 0,
|
|
pending: bookings.filter(b => b.bookingStatus === 'pending').length,
|
|
confirmed: bookings.filter(b => b.bookingStatus === 'confirmed').length,
|
|
cancelled: bookings.filter(b => b.bookingStatus === 'cancelled').length,
|
|
completed: bookings.filter(b => b.bookingStatus === 'completed').length,
|
|
totalRevenue: bookings.filter(b => b.bookingStatus !== 'cancelled').reduce((sum, b) => sum + b.totalAmount, 0),
|
|
paidAmount: bookings.reduce((sum, b) => sum + b.paidAmount, 0)
|
|
};
|
|
|
|
return res.json({
|
|
bookings: paginatedBookings,
|
|
pagination: {
|
|
currentPage: page,
|
|
totalPages: totalPages,
|
|
totalBookings: totalBookings,
|
|
limit: limit
|
|
},
|
|
session: {
|
|
sessionId: session.sessionId,
|
|
startDate: session.startDate,
|
|
endDate: session.endDate,
|
|
totalMaleSpots: session.totalMaleSpots,
|
|
totalFemaleSpots: session.totalFemaleSpots,
|
|
bookedMaleSpots: session.bookedMaleSpots,
|
|
bookedFemaleSpots: session.bookedFemaleSpots,
|
|
isActive: session.isActive
|
|
},
|
|
stats: stats,
|
|
activity: {
|
|
id: activity._id,
|
|
name: activity.name,
|
|
price: activity.price
|
|
}
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error("getSessionBookings error:", err);
|
|
return res.status(500).json({ error: "Error retrieving session bookings" });
|
|
}
|
|
};
|
|
|
|
// Get session bookings by program (wrapper)
|
|
exports.getSessionBookingsByProgram = async (req, res) => {
|
|
try {
|
|
const { program, sessionId } = req.params;
|
|
const activity = await Activity.findOne({ program: program });
|
|
if (!activity) return res.status(404).json({ error: 'Activity not found for program: ' + program });
|
|
|
|
req.params.activityId = activity._id.toString();
|
|
req.params.sessionId = sessionId;
|
|
return await exports.getSessionBookings(req, res);
|
|
} catch (err) {
|
|
console.error('getSessionBookingsByProgram error:', err);
|
|
return res.status(500).json({ error: 'Error retrieving session bookings by program' });
|
|
}
|
|
};
|
|
|
|
// Update a specific booking in a session
|
|
exports.updateSessionBooking = async (req, res) => {
|
|
try {
|
|
const { activityId, sessionId, bookingId } = req.params;
|
|
const updateData = req.body;
|
|
|
|
// Find the activity
|
|
const activity = await Activity.findById(activityId);
|
|
if (!activity) {
|
|
return res.status(404).json({ error: "Activity not found" });
|
|
}
|
|
|
|
// Find the specific session
|
|
const sessionIndex = activity.bookingSessions.findIndex(s => s.sessionId === sessionId);
|
|
if (sessionIndex === -1) {
|
|
return res.status(404).json({ error: "Session not found" });
|
|
}
|
|
|
|
// Find the specific booking
|
|
const bookingIndex = activity.bookingSessions[sessionIndex].bookingList.findIndex(
|
|
b => b._id.toString() === bookingId
|
|
);
|
|
if (bookingIndex === -1) {
|
|
return res.status(404).json({ error: "Booking not found" });
|
|
}
|
|
|
|
const currentBooking = activity.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
|
|
|
// Update allowed fields
|
|
const allowedUpdates = [
|
|
'bookingStatus', 'paymentStatus', 'paidAmount', 'adminNotes',
|
|
'emergencyContact', 'emergencyPhone', 'medicalConditions',
|
|
'specialRequests', 'dietaryRestrictions'
|
|
];
|
|
|
|
for (let field of allowedUpdates) {
|
|
if (updateData[field] !== undefined) {
|
|
activity.bookingSessions[sessionIndex].bookingList[bookingIndex][field] = updateData[field];
|
|
}
|
|
}
|
|
|
|
// Handle status changes that might affect spot counts
|
|
if (updateData.bookingStatus && updateData.bookingStatus !== currentBooking.bookingStatus) {
|
|
const numberOfParticipants = currentBooking.numberOfParticipants;
|
|
const participantGender = currentBooking.participantGender;
|
|
|
|
// If booking is being cancelled, free up spots
|
|
if (updateData.bookingStatus === 'cancelled' && currentBooking.bookingStatus !== 'cancelled') {
|
|
if (participantGender === 'male') {
|
|
activity.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0,
|
|
activity.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
|
} else if (participantGender === 'female') {
|
|
activity.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0,
|
|
activity.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
|
}
|
|
}
|
|
|
|
// If booking is being restored from cancelled, book spots again
|
|
if (currentBooking.bookingStatus === 'cancelled' && updateData.bookingStatus !== 'cancelled') {
|
|
if (participantGender === 'male') {
|
|
const totalMale = activity.bookingSessions[sessionIndex].totalMaleSpots;
|
|
const currentMale = activity.bookingSessions[sessionIndex].bookedMaleSpots;
|
|
if (currentMale + numberOfParticipants > totalMale) {
|
|
return res.status(400).json({
|
|
error: "Not enough male spots available to restore this booking",
|
|
availableSpots: totalMale - currentMale
|
|
});
|
|
}
|
|
activity.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
|
|
} else if (participantGender === 'female') {
|
|
const totalFemale = activity.bookingSessions[sessionIndex].totalFemaleSpots;
|
|
const currentFemale = activity.bookingSessions[sessionIndex].bookedFemaleSpots;
|
|
if (currentFemale + numberOfParticipants > totalFemale) {
|
|
return res.status(400).json({
|
|
error: "Not enough female spots available to restore this booking",
|
|
availableSpots: totalFemale - currentFemale
|
|
});
|
|
}
|
|
activity.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save the updated activity
|
|
await activity.save();
|
|
|
|
return res.json({
|
|
message: "Booking updated successfully",
|
|
booking: activity.bookingSessions[sessionIndex].bookingList[bookingIndex]
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error("updateSessionBooking error:", err);
|
|
return res.status(500).json({ error: "Error updating booking" });
|
|
}
|
|
};
|
|
|
|
// Delete a specific booking from a session
|
|
exports.deleteSessionBooking = async (req, res) => {
|
|
try {
|
|
const { activityId, sessionId, bookingId } = req.params;
|
|
|
|
// Find the activity
|
|
const activity = await Activity.findById(activityId);
|
|
if (!activity) {
|
|
return res.status(404).json({ error: "Activity not found" });
|
|
}
|
|
|
|
// Find the specific session
|
|
const sessionIndex = activity.bookingSessions.findIndex(s => s.sessionId === sessionId);
|
|
if (sessionIndex === -1) {
|
|
return res.status(404).json({ error: "Session not found" });
|
|
}
|
|
|
|
// Find the specific booking
|
|
const bookingIndex = activity.bookingSessions[sessionIndex].bookingList.findIndex(
|
|
b => b._id.toString() === bookingId
|
|
);
|
|
if (bookingIndex === -1) {
|
|
return res.status(404).json({ error: "Booking not found" });
|
|
}
|
|
|
|
const bookingToDelete = activity.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
|
|
|
// Free up spots if booking is not cancelled
|
|
if (bookingToDelete.bookingStatus !== 'cancelled') {
|
|
const numberOfParticipants = bookingToDelete.numberOfParticipants;
|
|
const participantGender = bookingToDelete.participantGender;
|
|
|
|
if (participantGender === 'male') {
|
|
activity.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0,
|
|
activity.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
|
} else if (participantGender === 'female') {
|
|
activity.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0,
|
|
activity.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
|
}
|
|
}
|
|
|
|
// Remove the booking from the array
|
|
activity.bookingSessions[sessionIndex].bookingList.splice(bookingIndex, 1);
|
|
|
|
// Save the updated activity
|
|
await activity.save();
|
|
|
|
return res.json({
|
|
message: "Booking deleted successfully",
|
|
deletedBooking: {
|
|
id: bookingId,
|
|
confirmationCode: bookingToDelete.confirmationCode,
|
|
participantName: `${bookingToDelete.participantFirstName} ${bookingToDelete.participantLastName}`
|
|
}
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error("deleteSessionBooking error:", err);
|
|
return res.status(500).json({ error: "Error deleting booking" });
|
|
}
|
|
};
|