Initial commit: CMS backend for Old Vine Hotel

- Complete Express.js API server
- MongoDB integration with Mongoose
- Admin authentication and authorization
- Room management (CRUD operations)
- Booking management system
- Guest management
- Payment processing (Stripe integration)
- Content management (pages, blog, gallery)
- Media upload and management
- Integration services (Booking.com, Expedia, Opera PMS, Trip.com)
- Email notifications
- Comprehensive logging and error handling
This commit is contained in:
Talal Sharabi
2026-01-06 12:21:56 +04:00
commit a3308a26e2
48 changed files with 15294 additions and 0 deletions

608
routes/bookings.js Normal file
View File

@@ -0,0 +1,608 @@
const express = require('express');
const router = express.Router();
const Booking = require('../models/Booking');
const Room = require('../models/Room');
const Guest = require('../models/Guest');
const { body, validationResult } = require('express-validator');
const auth = require('../middleware/auth');
const adminAuth = require('../middleware/adminAuth');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const sendEmail = require('../utils/sendEmail');
const logger = require('../utils/logger');
// @route POST /api/bookings
// @desc Create a new booking
// @access Public
router.post('/', [
body('guestInfo.firstName').notEmpty().withMessage('First name is required'),
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'),
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'),
body('numberOfGuests.children').optional().isInt({ min: 0 }),
body('paymentMethodId').notEmpty().withMessage('Payment method is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation errors',
errors: errors.array()
});
}
const {
guestInfo,
roomId,
checkInDate,
checkOutDate,
numberOfGuests,
specialRequests,
paymentMethodId
} = req.body;
const checkIn = new Date(checkInDate);
const checkOut = new Date(checkOutDate);
// Validate dates
if (checkIn >= checkOut) {
return res.status(400).json({
success: false,
message: 'Check-out date must be after check-in date'
});
}
if (checkIn < new Date()) {
return res.status(400).json({
success: false,
message: 'Check-in date cannot be in the past'
});
}
// Check room availability
const room = await Room.findById(roomId);
if (!room || !room.isActive) {
return res.status(404).json({
success: false,
message: 'Room not found'
});
}
const totalGuests = numberOfGuests.adults + (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'
});
}
// Find or create guest
let guest = await Guest.findOne({ email: guestInfo.email });
if (!guest) {
guest = new Guest({
...guestInfo,
isRegistered: false
});
await guest.save();
} else {
// Update guest information if provided
Object.assign(guest, guestInfo);
await guest.save();
}
// Calculate pricing
const numberOfNights = Math.ceil((checkOut - checkIn) / (1000 * 60 * 60 * 24));
const roomRate = room.currentPrice;
const subtotal = roomRate * numberOfNights;
const taxes = subtotal * 0.12; // 12% tax
const totalAmount = subtotal + taxes;
// Create Stripe payment intent
let paymentIntent;
try {
paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(totalAmount * 100), // Stripe uses cents
currency: 'usd',
payment_method: paymentMethodId,
confirmation_method: 'manual',
confirm: true,
metadata: {
roomId: roomId,
guestEmail: guestInfo.email,
checkInDate: checkInDate,
checkOutDate: checkOutDate
}
});
} catch (stripeError) {
logger.error('Stripe payment error:', stripeError);
return res.status(400).json({
success: false,
message: 'Payment processing failed',
error: stripeError.message
});
}
// Create booking
const booking = new Booking({
guest: guest._id,
room: roomId,
checkInDate: checkIn,
checkOutDate: checkOut,
numberOfGuests,
roomRate,
numberOfNights,
subtotal,
taxes,
totalAmount,
specialRequests,
paymentStatus: paymentIntent.status === 'succeeded' ? 'Paid' : 'Pending',
paymentMethod: 'Credit Card',
stripePaymentIntentId: paymentIntent.id,
status: paymentIntent.status === 'succeeded' ? 'Confirmed' : 'Pending',
bookingSource: 'Direct'
});
await booking.save();
await booking.populate(['guest', 'room']);
// Update guest statistics if payment successful
if (paymentIntent.status === 'succeeded') {
await guest.updateStayStats(totalAmount);
await guest.addLoyaltyPoints(Math.floor(totalAmount / 10)); // 1 point per $10
}
// Send confirmation email
if (paymentIntent.status === 'succeeded') {
try {
await sendEmail({
to: guest.email,
subject: 'Booking Confirmation - The Old Vine Hotel',
template: 'bookingConfirmation',
context: {
guest: guest,
booking: booking,
room: room
}
});
booking.emailConfirmationSent = true;
await booking.save();
} catch (emailError) {
logger.error('Email sending error:', emailError);
}
}
res.status(201).json({
success: true,
message: paymentIntent.status === 'succeeded'
? 'Booking confirmed successfully'
: 'Booking created, payment processing',
data: {
booking,
paymentIntent: {
id: paymentIntent.id,
status: paymentIntent.status,
client_secret: paymentIntent.client_secret
}
}
});
} catch (error) {
logger.error('Error creating booking:', error);
res.status(500).json({
success: false,
message: 'Server error while creating booking'
});
}
});
// @route GET /api/bookings/:bookingNumber
// @desc Get booking by booking number
// @access Public (with confirmation code) / Private
router.get('/:bookingNumber', async (req, res) => {
try {
const { bookingNumber } = req.params;
const { confirmationCode } = req.query;
let booking;
// If confirmation code provided, allow public access
if (confirmationCode) {
booking = await Booking.findOne({
bookingNumber,
confirmationCode
}).populate(['guest', 'room']);
} else {
// Otherwise require authentication (implement auth middleware check here)
booking = await Booking.findOne({ bookingNumber })
.populate(['guest', 'room']);
}
if (!booking) {
return res.status(404).json({
success: false,
message: 'Booking not found'
});
}
res.json({
success: true,
data: booking
});
} catch (error) {
logger.error('Error fetching booking:', error);
res.status(500).json({
success: false,
message: 'Server error while fetching booking'
});
}
});
// @route PUT /api/bookings/:bookingNumber/cancel
// @desc Cancel a booking
// @access Public (with confirmation code)
router.put('/:bookingNumber/cancel', [
body('confirmationCode').notEmpty().withMessage('Confirmation code is required'),
body('reason').optional().isLength({ max: 500 }).withMessage('Reason too long')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validation errors',
errors: errors.array()
});
}
const { bookingNumber } = req.params;
const { confirmationCode, reason } = req.body;
const booking = await Booking.findOne({
bookingNumber,
confirmationCode
}).populate(['guest', 'room']);
if (!booking) {
return res.status(404).json({
success: false,
message: 'Booking not found'
});
}
if (!booking.canBeCancelled()) {
return res.status(400).json({
success: false,
message: 'Booking cannot be cancelled at this time'
});
}
// Calculate cancellation fee
const cancellationFee = booking.calculateCancellationFee();
const refundAmount = booking.totalAmount - cancellationFee;
// Process refund with Stripe
if (booking.stripePaymentIntentId && refundAmount > 0) {
try {
await stripe.refunds.create({
payment_intent: booking.stripePaymentIntentId,
amount: Math.round(refundAmount * 100), // Stripe uses cents
metadata: {
bookingNumber: bookingNumber,
reason: reason || 'Guest cancellation'
}
});
} catch (stripeError) {
logger.error('Stripe refund error:', stripeError);
return res.status(400).json({
success: false,
message: 'Refund processing failed'
});
}
}
// Update booking
booking.status = 'Cancelled';
booking.cancellationReason = reason;
booking.cancellationDate = new Date();
booking.cancellationFee = cancellationFee;
booking.refundAmount = refundAmount;
booking.paymentStatus = refundAmount > 0 ? 'Refunded' : 'Paid';
await booking.save();
// Send cancellation email
try {
await sendEmail({
to: booking.guest.email,
subject: 'Booking Cancellation - The Old Vine Hotel',
template: 'bookingCancellation',
context: {
guest: booking.guest,
booking: booking,
room: booking.room,
cancellationFee,
refundAmount
}
});
} catch (emailError) {
logger.error('Cancellation email error:', emailError);
}
res.json({
success: true,
message: 'Booking cancelled successfully',
data: {
bookingNumber,
cancellationFee,
refundAmount,
status: booking.status
}
});
} catch (error) {
logger.error('Error cancelling booking:', error);
res.status(500).json({
success: false,
message: 'Server error while cancelling booking'
});
}
});
// @route GET /api/bookings
// @desc Get all bookings (Admin only)
// @access Private/Admin
router.get('/', adminAuth, async (req, res) => {
try {
const {
page = 1,
limit = 20,
status,
checkInDate,
checkOutDate,
guestEmail,
roomType,
sortBy = 'createdAt',
sortOrder = 'desc'
} = req.query;
// 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);
if (checkOutDate) filter.checkInDate.$lte = new Date(checkOutDate);
}
// Build aggregation pipeline for room type filter
let aggregationPipeline = [{ $match: filter }];
if (roomType) {
aggregationPipeline.push(
{
$lookup: {
from: 'rooms',
localField: 'room',
foreignField: '_id',
as: 'roomData'
}
},
{
$match: {
'roomData.type': roomType
}
}
);
}
// Add pagination and sorting
const sortOptions = {};
sortOptions[sortBy] = sortOrder === 'desc' ? -1 : 1;
aggregationPipeline.push(
{ $sort: sortOptions },
{ $skip: (parseInt(page) - 1) * parseInt(limit) },
{ $limit: parseInt(limit) },
{
$lookup: {
from: 'guests',
localField: 'guest',
foreignField: '_id',
as: 'guest'
}
},
{
$lookup: {
from: 'rooms',
localField: 'room',
foreignField: '_id',
as: 'room'
}
},
{
$unwind: '$guest'
},
{
$unwind: '$room'
}
);
const [bookings, totalCount] = await Promise.all([
Booking.aggregate(aggregationPipeline),
Booking.countDocuments(filter)
]);
const totalPages = Math.ceil(totalCount / parseInt(limit));
res.json({
success: true,
data: {
bookings,
pagination: {
currentPage: parseInt(page),
totalPages,
totalCount,
hasNextPage: parseInt(page) < totalPages,
hasPrevPage: parseInt(page) > 1
}
}
});
} catch (error) {
logger.error('Error fetching bookings:', error);
res.status(500).json({
success: false,
message: 'Server error while fetching bookings'
});
}
});
// @route PUT /api/bookings/:id/checkin
// @desc Check in a guest (Admin only)
// @access Private/Admin
router.put('/:id/checkin', adminAuth, async (req, res) => {
try {
const booking = await Booking.findById(req.params.id)
.populate(['guest', 'room']);
if (!booking) {
return res.status(404).json({
success: false,
message: 'Booking not found'
});
}
if (booking.status !== 'Confirmed') {
return res.status(400).json({
success: false,
message: 'Booking must be confirmed to check in'
});
}
// 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();
res.json({
success: true,
message: 'Guest checked in successfully',
data: booking
});
} catch (error) {
logger.error('Error checking in guest:', error);
res.status(500).json({
success: false,
message: 'Server error during check-in'
});
}
});
// @route PUT /api/bookings/:id/checkout
// @desc Check out a guest (Admin only)
// @access Private/Admin
router.put('/:id/checkout', adminAuth, async (req, res) => {
try {
const booking = await Booking.findById(req.params.id)
.populate(['guest', 'room']);
if (!booking) {
return res.status(404).json({
success: false,
message: 'Booking not found'
});
}
if (booking.status !== 'Checked In') {
return res.status(400).json({
success: false,
message: 'Guest must be checked in to check out'
});
}
// 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';
room.lastCleaning = new Date();
await room.save();
res.json({
success: true,
message: 'Guest checked out successfully',
data: booking
});
} catch (error) {
logger.error('Error checking out guest:', error);
res.status(500).json({
success: false,
message: 'Server error during check-out'
});
}
});
// @route GET /api/bookings/analytics/revenue
// @desc Get revenue analytics (Admin only)
// @access Private/Admin
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;
res.json({
success: true,
data: {
revenueData,
summary: {
totalRevenue,
totalBookings,
averageBookingValue,
dateRange: { start, end }
}
}
});
} catch (error) {
logger.error('Error generating revenue analytics:', error);
res.status(500).json({
success: false,
message: 'Server error while generating analytics'
});
}
});
module.exports = router;