import { Router } from 'express'; import { prisma } from '../config/database.js'; import { authenticate } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; import { createBookingSchema, rejectBookingSchema, cancelBookingSchema } from '../validators/booking.js'; import { AppError } from '../middleware/errorHandler.js'; import { checkAvailability, calculateCancellationRefund, autoTransitionBooking } from '../utils/rental.js'; import { getPlatformConfig } from '../utils/moderation.js'; import { getLandlordTier, TIER_CONFIG } from '../utils/subscription.js'; const router = Router(); const bookingInclude = { rentalListing: { select: { id: true, title: true, category: true, location: true, cancellationPolicy: true, images: { take: 1, orderBy: { order: 'asc' as const } }, }, }, tenant: { select: { id: true, fullName: true, nickname: true, avatar: true } }, landlord: { select: { id: true, fullName: true, nickname: true, avatar: true } }, payout: true, review: true, }; // --- List bookings --- router.get('/', authenticate, async (req, res, next) => { try { const { role = 'tenant', status } = req.query; const where: Record = role === 'landlord' ? { landlordId: req.userId } : { tenantId: req.userId }; if (status && typeof status === 'string') { where.status = status; } const bookings = await prisma.booking.findMany({ where, include: bookingInclude, orderBy: { createdAt: 'desc' }, }); // Auto-transition stale bookings const updated = await Promise.all(bookings.map(b => autoTransitionBooking(b))); res.json(updated); } catch (error) { next(error); } }); // --- Get single booking --- router.get('/:id', authenticate, async (req, res, next) => { try { const booking = await prisma.booking.findUnique({ where: { id: req.params.id }, include: bookingInclude, }); if (!booking) throw new AppError(404, 'Booking not found'); if (booking.tenantId !== req.userId && booking.landlordId !== req.userId) { throw new AppError(403, 'Not authorized'); } const transitioned = await autoTransitionBooking(booking); res.json(transitioned); } catch (error) { next(error); } }); // --- Create booking request --- router.post('/', authenticate, validate(createBookingSchema), async (req, res, next) => { try { const { rentalListingId, periodType, startDate: startStr, endDate: endStr, message } = req.body; const rental = await prisma.rentalListing.findUnique({ where: { id: rentalListingId } }); if (!rental) throw new AppError(404, 'Rental not found'); if (rental.status !== 'ACTIVE') throw new AppError(400, 'Rental is not active'); if (rental.landlordId === req.userId) throw new AppError(400, 'Cannot book your own rental'); const startDate = new Date(startStr); const endDate = new Date(endStr); if (startDate >= endDate) throw new AppError(400, 'End date must be after start date'); if (startDate < new Date()) throw new AppError(400, 'Start date must be in the future'); // Check price exists for period type if (periodType === 'DAILY' && !rental.dailyPrice) throw new AppError(400, 'Daily rental not available'); if (periodType === 'MONTHLY' && !rental.monthlyPrice) throw new AppError(400, 'Monthly rental not available'); // Check availability const available = await checkAvailability(rentalListingId, startDate, endDate); if (!available) throw new AppError(409, 'Selected dates are not available'); // Calculate pricing const pricePerPeriod = periodType === 'DAILY' ? rental.dailyPrice! : rental.monthlyPrice!; let totalPeriods: number; if (periodType === 'DAILY') { totalPeriods = Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)); } else { totalPeriods = Math.ceil((endDate.getTime() - startDate.getTime()) / (30 * 24 * 60 * 60 * 1000)); } if (totalPeriods < 1) totalPeriods = 1; // Min/max checks if (periodType === 'DAILY') { if (rental.minDays && totalPeriods < rental.minDays) throw new AppError(400, `Minimum ${rental.minDays} days required`); if (rental.maxDays && totalPeriods > rental.maxDays) throw new AppError(400, `Maximum ${rental.maxDays} days allowed`); } else { if (rental.minMonths && totalPeriods < rental.minMonths) throw new AppError(400, `Minimum ${rental.minMonths} months required`); if (rental.maxMonths && totalPeriods > rental.maxMonths) throw new AppError(400, `Maximum ${rental.maxMonths} months allowed`); } const config = await getPlatformConfig(); const landlordTier = await getLandlordTier(rental.landlordId); const subtotal = pricePerPeriod * totalPeriods; const commissionRate = TIER_CONFIG[landlordTier].commissionPercent; const commissionAmount = subtotal * (commissionRate / 100); const depositAmount = rental.depositAmount || 0; const totalAmount = subtotal + depositAmount; const booking = await prisma.booking.create({ data: { rentalListingId, tenantId: req.userId!, landlordId: rental.landlordId, periodType, startDate, endDate, pricePerPeriod, totalPeriods, subtotal, commissionRate, commissionAmount, depositAmount, totalAmount, message, expiresAt: new Date(Date.now() + config.bookingExpiryHours * 60 * 60 * 1000), }, include: bookingInclude, }); // Notify landlord const tenant = await prisma.user.findUnique({ where: { id: req.userId }, select: { fullName: true } }); const notification = await prisma.notification.create({ data: { userId: rental.landlordId, type: 'BOOKING_REQUEST', title: 'New Booking Request', body: `${tenant?.fullName || 'Someone'} requested to book "${rental.title}"`, data: { bookingId: booking.id, rentalListingId }, }, }); const io = req.app.get('io'); if (io) { io.to(`user:${rental.landlordId}`).emit('new_notification', notification); } res.status(201).json(booking); } catch (error) { next(error); } }); // --- Confirm booking (landlord) --- router.patch('/:id/confirm', authenticate, async (req, res, next) => { try { const booking = await prisma.booking.findUnique({ where: { id: req.params.id } }); if (!booking) throw new AppError(404, 'Booking not found'); if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized'); if (booking.status !== 'PENDING') throw new AppError(400, 'Can only confirm pending bookings'); // Check if expired if (booking.expiresAt && booking.expiresAt < new Date()) { await prisma.booking.update({ where: { id: booking.id }, data: { status: 'EXPIRED' } }); throw new AppError(400, 'Booking has expired'); } const updated = await prisma.booking.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' }, include: bookingInclude, }); // Notify tenant const notification = await prisma.notification.create({ data: { userId: booking.tenantId, type: 'BOOKING_CONFIRMED', title: 'Booking Confirmed', body: `Your booking has been confirmed! Please proceed with payment.`, data: { bookingId: booking.id }, }, }); const io = req.app.get('io'); if (io) { io.to(`user:${booking.tenantId}`).emit('new_notification', notification); } res.json(updated); } catch (error) { next(error); } }); // --- Reject booking (landlord) --- router.patch('/:id/reject', authenticate, validate(rejectBookingSchema), async (req, res, next) => { try { const booking = await prisma.booking.findUnique({ where: { id: req.params.id } }); if (!booking) throw new AppError(404, 'Booking not found'); if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized'); if (booking.status !== 'PENDING') throw new AppError(400, 'Can only reject pending bookings'); const updated = await prisma.booking.update({ where: { id: req.params.id }, data: { status: 'REJECTED', rejectionReason: req.body.reason }, include: bookingInclude, }); const notification = await prisma.notification.create({ data: { userId: booking.tenantId, type: 'BOOKING_REJECTED', title: 'Booking Rejected', body: `Your booking was rejected. Reason: ${req.body.reason}`, data: { bookingId: booking.id }, }, }); const io = req.app.get('io'); if (io) { io.to(`user:${booking.tenantId}`).emit('new_notification', notification); } res.json(updated); } catch (error) { next(error); } }); // --- Cancel booking (either side) --- router.patch('/:id/cancel', authenticate, validate(cancelBookingSchema), async (req, res, next) => { try { const booking = await prisma.booking.findUnique({ where: { id: req.params.id }, include: { rentalListing: true }, }); if (!booking) throw new AppError(404, 'Booking not found'); if (booking.tenantId !== req.userId && booking.landlordId !== req.userId) { throw new AppError(403, 'Not authorized'); } const isTenant = booking.tenantId === req.userId; const cancelStatus = isTenant ? 'CANCELLED_BY_TENANT' : 'CANCELLED_BY_LANDLORD'; if (!['PENDING', 'CONFIRMED', 'ACTIVE'].includes(booking.status)) { throw new AppError(400, 'Cannot cancel this booking'); } // Calculate refund if already paid let refundAmount = 0; let depositRefund = booking.depositAmount; if (booking.status === 'CONFIRMED' || booking.status === 'ACTIVE') { const refund = calculateCancellationRefund( booking.rentalListing.cancellationPolicy, booking.startDate, booking.subtotal, booking.depositAmount, ); refundAmount = refund.refundAmount; depositRefund = refund.depositRefund; } const updated = await prisma.booking.update({ where: { id: req.params.id }, data: { status: cancelStatus as any, cancellationReason: req.body.reason, }, include: bookingInclude, }); // Notify other party const recipientId = isTenant ? booking.landlordId : booking.tenantId; const notification = await prisma.notification.create({ data: { userId: recipientId, type: 'BOOKING_CANCELLED', title: 'Booking Cancelled', body: `A booking has been cancelled. Reason: ${req.body.reason}`, data: { bookingId: booking.id, refundAmount, depositRefund }, }, }); const io = req.app.get('io'); if (io) { io.to(`user:${recipientId}`).emit('new_notification', notification); } res.json({ ...updated, refundAmount, depositRefund }); } catch (error) { next(error); } }); // --- Complete booking (landlord) --- router.patch('/:id/complete', authenticate, async (req, res, next) => { try { const booking = await prisma.booking.findUnique({ where: { id: req.params.id } }); if (!booking) throw new AppError(404, 'Booking not found'); if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized'); if (booking.status !== 'ACTIVE' && booking.status !== 'CONFIRMED') { throw new AppError(400, 'Can only complete active/confirmed bookings'); } const updated = await prisma.booking.update({ where: { id: req.params.id }, data: { status: 'COMPLETED' }, include: bookingInclude, }); // Create payout const netAmount = booking.subtotal - booking.commissionAmount; await prisma.payout.create({ data: { bookingId: booking.id, landlordId: booking.landlordId, grossAmount: booking.subtotal, commissionAmount: booking.commissionAmount, netAmount, status: 'PENDING', }, }); // Notify tenant const notification = await prisma.notification.create({ data: { userId: booking.tenantId, type: 'BOOKING_COMPLETED', title: 'Booking Completed', body: 'Your booking has been completed. Please leave a review!', data: { bookingId: booking.id }, }, }); const io = req.app.get('io'); if (io) { io.to(`user:${booking.tenantId}`).emit('new_notification', notification); } res.json(updated); } catch (error) { next(error); } }); export default router;