const fs = require('fs'); const path = require('path'); const { addBaseUrlToImages } = require("../utils/imageHelper"); const Booking = require("../models/booking"); const { validateLengthRules, summarizeLengthErrors, } = require("../utils/lengthValidation"); const { BOOKING_LENGTH_RULES, } = require("../constants/contentLengthRules"); const getBookingLengthMessage = (validation) => summarizeLengthErrors(validation, 3) || "One or more fields exceed the allowed length."; // -------------------- Public helpers -------------------- const getBookingData = async () => { const booking = await Booking.findOne().sort({ updatedAt: -1 }); return booking ? (booking.toObject ? booking.toObject() : booking) : null; }; // Load static booking JSON from `data/booking.json` (if present) const loadStaticBooking = () => { try { const p = path.join(__dirname, '..', 'data', 'booking.json'); if (!fs.existsSync(p)) return null; const raw = fs.readFileSync(p, 'utf8'); return JSON.parse(raw); } catch (e) { console.error('booking.loadStaticBooking error:', e && e.message); return null; } }; // Normalize booking shape: ensure configuration exists with discounts/vouchers const normalizeBookingShape = (booking) => { if (!booking || typeof booking !== 'object') return booking; const b = JSON.parse(JSON.stringify(booking)); if (!b.configuration || typeof b.configuration !== 'object') { b.configuration = { currency: 'USD', discounts: [], vouchers: [] }; } // Ensure configuration.discounts and configuration.vouchers exist if (!Array.isArray(b.configuration.discounts)) { b.configuration.discounts = []; } if (!Array.isArray(b.configuration.vouchers)) { b.configuration.vouchers = []; } return b; }; // Deep merge: properties from `overrides` replace / merge into `base`. const deepMerge = (base, overrides) => { if (overrides === undefined) return base; if (base === undefined || base === null) return overrides; if (Array.isArray(overrides)) return overrides; if (typeof overrides !== 'object' || overrides === null) return overrides; const out = Object.assign({}, base); Object.keys(overrides).forEach((k) => { if (Array.isArray(overrides[k]) || typeof overrides[k] !== 'object' || overrides[k] === null) { out[k] = overrides[k]; } else { out[k] = deepMerge(base[k], overrides[k]); } }); return out; }; // Ensure booking data fields have the expected shapes to avoid runtime errors const sanitizeBookingData = (raw) => { const defaults = { hero: { title: '', backgroundImage: '' }, searchBar: { locationLabel: '', holidaySeasonLabel: '', searchButtonText: '' }, filterPanel: { title: '', priceTitle: '', priceLabel: '', pricePlaceholder: '', priceMin: 0, priceMax: 0, ageTitle: '', ageMin: 0, ageMax: 0, ageSelectPlaceholder: '', activitiesTitle: '', ratingTitle: '', ratingOptions: [], resetButtonText: '' }, programs: [], holidays: [], locations: [], camps: [], configuration: { currency: 'USD', discounts: [], vouchers: [] }, formSteps: [], validation: {} }; if (!raw || typeof raw !== 'object') return defaults; // Use raw data first, then fill in missing fields with defaults const safe = Object.assign({}, raw); // Ensure nested objects/arrays have correct types (use raw data if valid, otherwise defaults) safe.hero = (safe.hero && typeof safe.hero === 'object') ? safe.hero : defaults.hero; safe.searchBar = (safe.searchBar && typeof safe.searchBar === 'object') ? safe.searchBar : defaults.searchBar; safe.filterPanel = (safe.filterPanel && typeof safe.filterPanel === 'object') ? safe.filterPanel : defaults.filterPanel; if (!Array.isArray(safe.filterPanel.ratingOptions)) safe.filterPanel.ratingOptions = defaults.filterPanel.ratingOptions; safe.programs = Array.isArray(safe.programs) ? safe.programs : defaults.programs; safe.holidays = Array.isArray(safe.holidays) ? safe.holidays : defaults.holidays; safe.locations = Array.isArray(safe.locations) ? safe.locations : defaults.locations; safe.camps = Array.isArray(safe.camps) ? safe.camps : defaults.camps; // Ensure configuration has proper structure if (!safe.configuration || typeof safe.configuration !== 'object') { safe.configuration = defaults.configuration; } if (!Array.isArray(safe.configuration.discounts)) { safe.configuration.discounts = defaults.configuration.discounts; } if (!Array.isArray(safe.configuration.vouchers)) { safe.configuration.vouchers = defaults.configuration.vouchers; } // Ensure formSteps and validation have correct types safe.formSteps = Array.isArray(safe.formSteps) ? safe.formSteps : defaults.formSteps; safe.validation = (safe.validation && typeof safe.validation === 'object' && !Array.isArray(safe.validation)) ? safe.validation : defaults.validation; return safe; }; // Safe JSON parse with better error handling - handles double-encoded JSON and JS object notation const safeParse = (value, fieldName = 'unknown') => { // If already an object or array, return as-is if (typeof value === 'object' && value !== null) { return value; } // If string, try to parse if (typeof value === 'string') { try { let cleaned = value.trim(); // Check if it looks like JavaScript object notation (has single quotes or unquoted keys) if (cleaned.includes("'") || /\{\s*\w+:/.test(cleaned)) { console.warn(`safeParse: Converting JS notation to JSON for "${fieldName}"`); // Aggressive conversion approach cleaned = cleaned .replace(/'/g, '"') // Replace ALL single quotes with double quotes .replace(/\r?\n|\r/g, ' ') // Remove all newlines .replace(/\s+/g, ' ') // Normalize multiple spaces to single space .replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas before } or ] .replace(/([{,]\s*)(\w+):/g, '$1"$2":'); // Quote unquoted object keys } // Try parsing let parsed = JSON.parse(cleaned); // If result is still a string, try parsing again (double-encoded) if (typeof parsed === 'string') { console.warn(`safeParse: Double-encoded JSON detected for "${fieldName}"`); parsed = JSON.parse(parsed); } return parsed; } catch (e) { console.error(`safeParse: Failed to parse field "${fieldName}"`, { error: e.message, valuePreview: value.substring(0, 200) }); throw new Error(`Invalid JSON format for field: ${fieldName}. Error: ${e.message}`); } } // For other types, return empty array or object console.warn(`safeParse: Unexpected type for field "${fieldName}": ${typeof value}`); return Array.isArray(value) ? [] : {}; }; // Validate booking data structure const validateBookingData = (data) => { const errors = []; // Check required fields if (!data.hero || typeof data.hero !== 'object') { errors.push('Hero data is required and must be an object'); } if (!data.searchBar || typeof data.searchBar !== 'object') { errors.push('SearchBar data is required and must be an object'); } if (!data.filterPanel || typeof data.filterPanel !== 'object') { errors.push('FilterPanel data is required and must be an object'); } // Validate arrays if (data.programs && !Array.isArray(data.programs)) { errors.push('Programs must be an array'); } if (data.holidays && !Array.isArray(data.holidays)) { errors.push('Holidays must be an array'); } if (data.locations && !Array.isArray(data.locations)) { errors.push('Locations must be an array'); } if (data.camps && !Array.isArray(data.camps)) { errors.push('Camps must be an array'); } // Validate configuration structure if (data.configuration) { if (typeof data.configuration !== 'object') { errors.push('Configuration must be an object'); } else { if (data.configuration.discounts && !Array.isArray(data.configuration.discounts)) { errors.push('Configuration.discounts must be an array'); } if (data.configuration.vouchers && !Array.isArray(data.configuration.vouchers)) { errors.push('Configuration.vouchers must be an array'); } } } // Validate formSteps and validation structure if provided if (data.formSteps && !Array.isArray(data.formSteps)) { errors.push('formSteps must be an array'); } if (data.validation && (typeof data.validation !== 'object' || Array.isArray(data.validation))) { errors.push('validation must be an object'); } return { isValid: errors.length === 0, errors }; }; // -------------------- Public endpoints -------------------- // Public endpoint: return Booking JSON exports.page = async (req, res) => { try { const dbBooking = await getBookingData(); const staticBooking = loadStaticBooking(); // Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist const normStatic = normalizeBookingShape(staticBooking); const normDb = normalizeBookingShape(dbBooking); // Build final payload according to BOOKING_MODE env var const finalBooking = getFinalBooking(normStatic, normDb); if (!finalBooking) { return res.status(404).json({ error: "No booking data found", message: "Please configure booking data in admin panel" }); } const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const processed = addBaseUrlToImages(finalBooking, baseUrl); return res.json(processed); } catch (err) { console.error("booking.page error:", err); return res.status(500).json({ error: "Error loading booking data", message: process.env.NODE_ENV === 'development' ? err.message : undefined }); } }; // API endpoint to return booking JSON exports.api = async (req, res) => { try { const dbBooking = await getBookingData(); const staticBooking = loadStaticBooking(); // Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist const normStatic = normalizeBookingShape(staticBooking); const normDb = normalizeBookingShape(dbBooking); const finalBooking = getFinalBooking(normStatic, normDb); if (!finalBooking) { return res.status(404).json({ error: "No booking data found", message: "Please configure booking data in admin panel" }); } const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const processed = addBaseUrlToImages(finalBooking, baseUrl); return res.json(processed); } catch (err) { console.error("booking.api error:", err); return res.status(500).json({ error: "Error loading booking data", message: process.env.NODE_ENV === 'development' ? err.message : undefined }); } }; // -------------------- Admin endpoints -------------------- // Display Booking management page exports.index = async (req, res) => { try { const dbBooking = await getBookingData(); const staticBooking = loadStaticBooking(); // Merge static booking with DB data (use same merge logic as public endpoints) const normStatic = normalizeBookingShape(staticBooking); const normDb = normalizeBookingShape(dbBooking); const mergedData = getFinalBooking(normStatic, normDb); // Normalize again after merge to ensure discounts/vouchers are synced to top-level const data = normalizeBookingShape(mergedData); // Sanitize data to ensure nested fields are objects/arrays (fixes malformed DB entries) const safeData = sanitizeBookingData(data); res.render("admin/booking/index", { layout: "layouts/main", title: "Booking Management", data: safeData, frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"), currentPath: req.path, user: req.session.user, }); } catch (err) { console.error("booking.index error:", err); req.flash("error_msg", "Error loading booking page"); res.redirect("/admin/dashboard"); } }; // Update booking data exports.update = async (req, res) => { try { const { id } = req.params; // ADD THIS DEBUG LOG console.log('=== RAW REQUEST BODY ==='); console.log('Discounts type:', typeof req.body.discounts); console.log('Discounts first 500 chars:', req.body.discounts?.substring(0, 500)); console.log('Vouchers type:', typeof req.body.vouchers); console.log('Vouchers first 500 chars:', req.body.vouchers?.substring(0, 500)); console.log('========================'); const { hero, searchBar, filterPanel, programs, holidays, locations, camps, discounts, vouchers, formSteps, validation: validationRaw } = req.body; // Parse JSON strings const errors = []; let updateData = {}; try { console.log('Raw discounts from req.body:', typeof discounts, discounts); console.log('Raw vouchers from req.body:', typeof vouchers, vouchers); const parsedDiscounts = safeParse(discounts, 'discounts'); const parsedVouchers = safeParse(vouchers, 'vouchers'); console.log('Parsed discounts:', typeof parsedDiscounts, Array.isArray(parsedDiscounts), parsedDiscounts); console.log('Parsed vouchers:', typeof parsedVouchers, Array.isArray(parsedVouchers), parsedVouchers); updateData = { hero: safeParse(hero, 'hero'), searchBar: safeParse(searchBar, 'searchBar'), filterPanel: safeParse(filterPanel, 'filterPanel'), programs: safeParse(programs, 'programs'), holidays: safeParse(holidays, 'holidays'), locations: safeParse(locations, 'locations'), camps: safeParse(camps, 'camps'), formSteps: safeParse(formSteps, 'formSteps'), validation: safeParse(validationRaw, 'validation'), configuration: { currency: 'USD', discounts: parsedDiscounts, vouchers: parsedVouchers } }; } catch (parseError) { console.error('booking.update: Parse error', parseError); req.flash("error_msg", `Data processing error: ${parseError.message}`); return req.session.save(() => res.redirect("/admin/booking")); } const lengthValidation = validateLengthRules(updateData, BOOKING_LENGTH_RULES); if (!lengthValidation.valid) { req.flash("error_msg", getBookingLengthMessage(lengthValidation)); return req.session.save(() => res.redirect("/admin/booking")); } // Validate data structure const validation = validateBookingData(updateData); if (!validation.isValid) { console.error('booking.update: Validation failed', validation.errors); req.flash("error_msg", `Validation failed: ${validation.errors[0]}`); return req.session.save(() => res.redirect("/admin/booking")); } console.log('Final updateData keys:', Object.keys(updateData)); console.log('updateData.discounts:', updateData.discounts); console.log('updateData.configuration:', updateData.configuration); // CRITICAL: Remove any top-level discounts/vouchers to prevent schema conflicts // These should ONLY exist in configuration object delete updateData.discounts; delete updateData.vouchers; // Update or create booking document let result; try { if (id && id !== 'undefined') { result = await Booking.findByIdAndUpdate( id, { ...updateData, $unset: { discounts: "", vouchers: "" } // Remove old top-level fields }, { new: true, runValidators: false, // TẮT validator để tránh lỗi cast strict: false // TẮT strict mode } ); if (!result) { req.flash("error_msg", "Booking document not found"); return req.session.save(() => res.redirect("/admin/booking")); } } else { // Upsert: update existing or create new result = await Booking.findOneAndUpdate( {}, { ...updateData, $unset: { discounts: "", vouchers: "" } // Remove old top-level fields }, { upsert: true, new: true, runValidators: false, // TẮT validator strict: false // TẮT strict mode } ); } req.flash("success_msg", "Booking data updated successfully"); return req.session.save(() => res.redirect("/admin/booking")); } catch (dbError) { console.error("booking.update: Database error", dbError); req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`); return req.session.save(() => res.redirect("/admin/booking")); } } catch (err) { console.error("booking.update error:", err); req.flash("error_msg", `Update error: ${err.message || "Unknown"}`); return req.session.save(() => res.redirect("/admin/booking")); } }; // Booking selection mode: 'merge' (default) = static base, DB overrides; // 'static' = use `data/booking.json` only; 'db' = use DB only. const getFinalBooking = (staticBooking, dbBooking) => { const mode = (process.env.BOOKING_MODE || 'merge').toLowerCase(); if (mode === 'static') return staticBooking || dbBooking || null; if (mode === 'db') return dbBooking || staticBooking || null; // default: merge static (base) with DB overrides // If both static and db present, attempt to map DB primitive lists (e.g. ["915"]) to // full objects from the static file (e.g. {id:"915", name:..., type:...}). const mapDbPrimitivesToObjects = (db, stat) => { if (!db || !stat) return db; const dbCfg = db.configuration || {}; const statCfg = stat.configuration || {}; console.log('DB discounts/vouchers:', db.discounts, db.vouchers); console.log('DB config:', dbCfg.discounts, dbCfg.vouchers); console.log('Static config:', statCfg.discounts, statCfg.vouchers); // Handle legacy: if top-level discounts/vouchers exist as strings, migrate to configuration if (Array.isArray(db.discounts) && db.discounts.length > 0 && (!dbCfg.discounts || dbCfg.discounts.length === 0)) { const statDiscountById = {}; if (statCfg.discounts) { statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; }); } if (typeof db.discounts[0] === 'string') { dbCfg.discounts = db.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 }); } else { dbCfg.discounts = db.discounts; } } if (Array.isArray(db.vouchers) && db.vouchers.length > 0 && (!dbCfg.vouchers || dbCfg.vouchers.length === 0)) { const statVouchByCode = {}; if (statCfg.vouchers) { statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; }); } if (typeof db.vouchers[0] === 'string') { dbCfg.vouchers = db.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 }); } else { dbCfg.vouchers = db.vouchers; } } // If DB configuration still empty, use static data if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length === 0 && statCfg.discounts && statCfg.discounts.length > 0) { dbCfg.discounts = statCfg.discounts; } else if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length > 0 && typeof dbCfg.discounts[0] === 'string') { // Map string IDs to full objects from static const statDiscountById = {}; if (statCfg.discounts) { statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; }); } dbCfg.discounts = dbCfg.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 }); } if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length === 0 && statCfg.vouchers && statCfg.vouchers.length > 0) { dbCfg.vouchers = statCfg.vouchers; } else if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length > 0 && typeof dbCfg.vouchers[0] === 'string') { // Map string codes to full objects from static const statVouchByCode = {}; if (statCfg.vouchers) { statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; }); } dbCfg.vouchers = dbCfg.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 }); } return Object.assign({}, db, { configuration: dbCfg }); }; const mappedDb = mapDbPrimitivesToObjects(dbBooking, staticBooking); const merged = staticBooking ? deepMerge(staticBooking, mappedDb || {}) : (mappedDb || null); // Clean up: remove top-level discounts/vouchers after migrating to configuration if (merged) { delete merged.discounts; delete merged.vouchers; } return merged; };