Compare commits

..

1 Commits

Author SHA1 Message Date
yotakii
9e5919fb6c fix validation error 2026-03-05 11:01:49 +03:00
2 changed files with 184 additions and 66 deletions

View File

@@ -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) {

View File

@@ -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;