const {addBaseUrlToImages} = require("../utils/imageHelper"); const Activity = require("../models/activity"); const mongoose = require('mongoose'); // -------------------- 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 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', }; // 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 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; 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" }); } };