diff --git a/models/Booking.js b/models/Booking.js index 23bb854..024e110 100644 --- a/models/Booking.js +++ b/models/Booking.js @@ -12,20 +12,31 @@ const bookingSchema = new mongoose.Schema({ required: true, unique: true }, - + // Guest information guest: { type: mongoose.Schema.Types.ObjectId, ref: 'Guest', required: true }, - + // Room and dates room: { type: mongoose.Schema.Types.ObjectId, ref: 'Room', - required: true + required: false }, + + roomCategoryId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'RoomCategory', + required: false + }, + requestedRoomType: { + type: String, + maxlength: 200 + }, + checkInDate: { type: Date, required: true @@ -34,7 +45,7 @@ const bookingSchema = new mongoose.Schema({ type: Date, required: true }, - + // Guest details numberOfGuests: { adults: { @@ -48,7 +59,7 @@ const bookingSchema = new mongoose.Schema({ min: 0 } }, - + // Pricing roomRate: { type: Number, @@ -78,7 +89,7 @@ const bookingSchema = new mongoose.Schema({ type: Number, required: true }, - + // Payment information paymentStatus: { type: String, @@ -90,14 +101,14 @@ const bookingSchema = new mongoose.Schema({ enum: ['Credit Card', 'Debit Card', 'Bank Transfer', 'Cash', 'Online Payment'] }, stripePaymentIntentId: String, - + // Booking status status: { type: String, enum: ['Pending', 'Confirmed', 'Checked In', 'Checked Out', 'Cancelled', 'No Show'], default: 'Pending' }, - + // Special requests and notes specialRequests: { type: String, @@ -107,7 +118,7 @@ const bookingSchema = new mongoose.Schema({ type: String, maxlength: 1000 }, - + // Check-in/out details actualCheckInTime: Date, actualCheckOutTime: Date, @@ -119,18 +130,18 @@ const bookingSchema = new mongoose.Schema({ type: Boolean, default: false }, - + // Booking source bookingSource: { type: String, enum: ['Direct', 'Booking.com', 'Expedia', 'Trip.com', 'Phone', 'Walk-in', 'Travel Agent'], default: 'Direct' }, - + // External system IDs operaBookingId: String, externalBookingId: String, // ID from booking platforms - + // Cancellation cancellationReason: String, cancellationDate: Date, @@ -142,7 +153,7 @@ const bookingSchema = new mongoose.Schema({ type: Number, default: 0 }, - + // Communication emailConfirmationSent: { type: Boolean, @@ -152,7 +163,7 @@ const bookingSchema = new mongoose.Schema({ type: Boolean, default: false }, - + // Additional services addOns: [{ service: String, @@ -161,7 +172,7 @@ const bookingSchema = new mongoose.Schema({ unitPrice: Number, totalPrice: Number }], - + // Group booking isGroupBooking: { type: Boolean, @@ -169,7 +180,7 @@ const bookingSchema = new mongoose.Schema({ }, groupSize: Number, groupLeader: String, - + // Loyalty program loyaltyPointsEarned: { type: Number, @@ -213,7 +224,7 @@ bookingSchema.pre('validate', function(next) { const random = Math.floor(100000 + Math.random() * 900000); this.bookingNumber = `OVH${year}${random}`; } - + if (!this.confirmationCode) { // Generate confirmation code: 8 character alphanumeric const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -223,13 +234,13 @@ bookingSchema.pre('validate', function(next) { } this.confirmationCode = code; } - + // Calculate number of nights if not set if (!this.numberOfNights && this.checkInDate && this.checkOutDate) { const diffTime = Math.abs(this.checkOutDate - this.checkInDate); this.numberOfNights = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); } - + next(); }); @@ -265,9 +276,9 @@ bookingSchema.methods.canBeCancelled = function() { const now = new Date(); const checkInDate = new Date(this.checkInDate); const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60); - + return ( - this.status === 'Confirmed' && + this.status === 'Confirmed' && hoursUntilCheckIn > 24 // Can cancel up to 24 hours before check-in ); }; @@ -277,7 +288,7 @@ bookingSchema.methods.calculateCancellationFee = function() { const now = new Date(); const checkInDate = new Date(this.checkInDate); const hoursUntilCheckIn = (checkInDate - now) / (1000 * 60 * 60); - + if (hoursUntilCheckIn > 48) { return 0; // Free cancellation } else if (hoursUntilCheckIn > 24) { diff --git a/routes/bookings.js b/routes/bookings.js index c68b3f2..c56583c 100644 --- a/routes/bookings.js +++ b/routes/bookings.js @@ -10,6 +10,50 @@ const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const sendEmail = require('../utils/sendEmail'); const logger = require('../utils/logger'); +const escapeRegex = (s = '') => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +async function resolveRoomFromCategory({ roomCategoryId, requestedRoomType, checkIn, checkOut, totalGuests }) { + const or = []; + + if (roomCategoryId) { + or.push( + { category: roomCategoryId }, + { categoryId: roomCategoryId }, + { roomCategory: roomCategoryId }, + { roomCategoryId: roomCategoryId }, + { typeId: roomCategoryId } + ); + } + + if (requestedRoomType) { + const rx = new RegExp(`^${escapeRegex(requestedRoomType)}$`, 'i'); + or.push( + { type: rx }, + { roomType: rx }, + { categoryName: rx }, + { name: rx } + ); + } + + if (or.length === 0) return null; + + const candidates = await Room.find({ + isActive: true, + $or: or + }).limit(50); + + for (const room of candidates) { + try { + if (room.maxOccupancy != null && room.maxOccupancy < totalGuests) continue; + const ok = await room.isAvailable(checkIn, checkOut); + if (ok) return room; + } catch (e) { + // ignore and try next + } + } + + return null; +} // @route POST /api/bookings/request // @desc Create a booking request (no online payment) @@ -19,7 +63,21 @@ router.post('/request', [ body('guestInfo.lastName').notEmpty().withMessage('Last name is required'), body('guestInfo.email').isEmail().withMessage('Valid email is required'), body('guestInfo.phone').notEmpty().withMessage('Phone number is required'), - body('roomId').isMongoId().withMessage('Valid room ID is required'), + + // accept roomId OR roomCategoryId / requestedRoomType + body('roomId').optional().isMongoId().withMessage('Valid room ID is required'), + body('roomCategoryId').optional().isMongoId().withMessage('Valid room category ID is required'), + body('requestedRoomType').optional().isString().isLength({ max: 200 }), + body('roomCategory').optional().isString().isLength({ max: 200 }), + + body().custom((_, { req }) => { + if (req.body.roomId) return true; + if (req.body.roomCategoryId) return true; + if (req.body.requestedRoomType) return true; + if (req.body.roomCategory) return true; + throw new Error('roomId or roomCategoryId/requestedRoomType is required'); + }), + body('checkInDate').isISO8601().withMessage('Valid check-in date is required'), body('checkOutDate').isISO8601().withMessage('Valid check-out date is required'), body('numberOfGuests.adults').isInt({ min: 1 }).withMessage('At least 1 adult required'), @@ -35,7 +93,17 @@ router.post('/request', [ }); } - const { guestInfo, roomId, checkInDate, checkOutDate, numberOfGuests, specialRequests } = req.body; + const { + guestInfo, + roomId, + roomCategoryId, + roomCategory, + requestedRoomType, + checkInDate, + checkOutDate, + numberOfGuests, + specialRequests + } = req.body; const checkIn = new Date(checkInDate); const checkOut = new Date(checkOutDate); @@ -47,25 +115,48 @@ router.post('/request', [ return res.status(400).json({ success: false, message: 'Check-in date cannot be in the past' }); } - const room = await Room.findById(roomId); - if (!room || !room.isActive) { - return res.status(404).json({ success: false, message: 'Room not found' }); - } - const totalGuests = Number(numberOfGuests.adults) + Number(numberOfGuests.children || 0); - if (room.maxOccupancy < totalGuests) { - return res.status(400).json({ - success: false, - message: `Room can accommodate maximum ${room.maxOccupancy} guests` - }); - } - const isAvailable = await room.isAvailable(checkIn, checkOut); - if (!isAvailable) { - return res.status(400).json({ - success: false, - message: 'Room is not available for the selected dates' + // If roomId provided, keep old behavior + let room = null; + if (roomId) { + room = await Room.findById(roomId); + if (!room || !room.isActive) { + return res.status(404).json({ success: false, message: 'Room not found' }); + } + + if (room.maxOccupancy < totalGuests) { + return res.status(400).json({ + success: false, + message: `Room can accommodate maximum ${room.maxOccupancy} guests` + }); + } + + const isAvailable = await room.isAvailable(checkIn, checkOut); + if (!isAvailable) { + return res.status(400).json({ + success: false, + message: 'Room is not available for the selected dates' + }); + } + } else { + // No roomId => try to resolve an actual room from category/type + const typeName = requestedRoomType || roomCategory || null; + room = await resolveRoomFromCategory({ + roomCategoryId, + requestedRoomType: typeName, + checkIn, + checkOut, + totalGuests }); + + // If we found a room, enforce occupancy like before + if (room && room.maxOccupancy != null && room.maxOccupancy < totalGuests) { + return res.status(400).json({ + success: false, + message: `Room can accommodate maximum ${room.maxOccupancy} guests` + }); + } } // Find or create guest @@ -80,14 +171,23 @@ router.post('/request', [ // Pricing const numberOfNights = Math.ceil((checkOut - checkIn) / (1000 * 60 * 60 * 24)); - const roomRate = room.currentPrice; + + // If room exists => use its currentPrice (old behavior) + // If room doesn't exist => set roomRate to 0 (or you can later adjust manually in admin) + const roomRate = room ? Number(room.currentPrice || 0) : 0; + const subtotal = roomRate * numberOfNights; const taxes = subtotal * 0.12; const totalAmount = subtotal + taxes; const booking = new Booking({ guest: guest._id, - room: roomId, + room: room ? room._id : undefined, + + // store category/type for category-only requests + roomCategoryId: room ? undefined : (roomCategoryId || undefined), + requestedRoomType: room ? undefined : (requestedRoomType || roomCategory || undefined), + checkInDate: checkIn, checkOutDate: checkOut, numberOfGuests, @@ -155,7 +255,7 @@ router.post('/', [ const checkIn = new Date(checkInDate); const checkOut = new Date(checkOutDate); - + // Validate dates if (checkIn >= checkOut) { return res.status(400).json({ @@ -163,7 +263,7 @@ router.post('/', [ message: 'Check-out date must be after check-in date' }); } - + if (checkIn < new Date()) { return res.status(400).json({ success: false, @@ -284,7 +384,7 @@ router.post('/', [ room: room } }); - + booking.emailConfirmationSent = true; await booking.save(); } catch (emailError) { @@ -294,8 +394,8 @@ router.post('/', [ res.status(201).json({ success: true, - message: paymentIntent.status === 'succeeded' - ? 'Booking confirmed successfully' + message: paymentIntent.status === 'succeeded' + ? 'Booking confirmed successfully' : 'Booking created, payment processing', data: { booking, @@ -324,7 +424,7 @@ router.get('/:bookingNumber', async (req, res) => { const { confirmationCode } = req.query; let booking; - + // If confirmation code provided, allow public access if (confirmationCode) { booking = await Booking.findOne({ @@ -332,7 +432,6 @@ router.get('/:bookingNumber', async (req, res) => { confirmationCode }).populate(['guest', 'room']); } else { - // Otherwise require authentication (implement auth middleware check here) booking = await Booking.findOne({ bookingNumber }) .populate(['guest', 'room']); } @@ -486,13 +585,13 @@ router.get('/', adminAuth, async (req, res) => { // Build filter let filter = {}; - + if (status) filter.status = status; if (guestEmail) { const guest = await Guest.findOne({ email: guestEmail }); if (guest) filter.guest = guest._id; } - + if (checkInDate || checkOutDate) { filter.checkInDate = {}; if (checkInDate) filter.checkInDate.$gte = new Date(checkInDate); @@ -501,7 +600,7 @@ router.get('/', adminAuth, async (req, res) => { // Build aggregation pipeline for room type filter let aggregationPipeline = [{ $match: filter }]; - + if (roomType) { aggregationPipeline.push( { @@ -523,7 +622,7 @@ router.get('/', adminAuth, async (req, res) => { // Add pagination and sorting const sortOptions = {}; sortOptions[sortBy] = sortOrder === 'desc' ? -1 : 1; - + aggregationPipeline.push( { $sort: sortOptions }, { $skip: (parseInt(page) - 1) * parseInt(limit) }, @@ -544,12 +643,9 @@ router.get('/', adminAuth, async (req, res) => { as: 'room' } }, - { - $unwind: '$guest' - }, - { - $unwind: '$room' - } + { $unwind: '$guest' }, + // ✅ preserve bookings even if room is null + { $unwind: { path: '$room', preserveNullAndEmptyArrays: true } } ); const [bookings, totalCount] = await Promise.all([ @@ -596,6 +692,14 @@ router.put('/:id/checkin', adminAuth, async (req, res) => { }); } + // guard if no room assigned + if (!booking.room) { + return res.status(400).json({ + success: false, + message: 'Booking has no room assigned' + }); + } + if (booking.status !== 'Confirmed') { return res.status(400).json({ success: false, @@ -603,12 +707,10 @@ router.put('/:id/checkin', adminAuth, async (req, res) => { }); } - // Update booking status booking.status = 'Checked In'; booking.actualCheckInTime = new Date(); await booking.save(); - // Update room status const room = await Room.findById(booking.room._id); room.status = 'Occupied'; await room.save(); @@ -642,6 +744,14 @@ router.put('/:id/checkout', adminAuth, async (req, res) => { }); } + // guard if no room assigned + if (!booking.room) { + return res.status(400).json({ + success: false, + message: 'Booking has no room assigned' + }); + } + if (booking.status !== 'Checked In') { return res.status(400).json({ success: false, @@ -649,12 +759,10 @@ router.put('/:id/checkout', adminAuth, async (req, res) => { }); } - // Update booking status booking.status = 'Checked Out'; booking.actualCheckOutTime = new Date(); await booking.save(); - // Update room status const room = await Room.findById(booking.room._id); room.status = 'Available'; room.cleaningStatus = 'Dirty'; @@ -681,13 +789,12 @@ router.put('/:id/checkout', adminAuth, async (req, res) => { router.get('/analytics/revenue', adminAuth, async (req, res) => { try { const { startDate, endDate, groupBy = 'day' } = req.query; - + const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const end = endDate ? new Date(endDate) : new Date(); const revenueData = await Booking.generateRevenueReport(start, end); - - // Calculate total metrics + const totalRevenue = revenueData.reduce((sum, day) => sum + day.totalRevenue, 0); const totalBookings = revenueData.reduce((sum, day) => sum + day.bookingsCount, 0); const averageBookingValue = totalBookings > 0 ? totalRevenue / totalBookings : 0;