Files
marketplace/server/src/routes/booking.ts
delta-lynx-89e8 dcd2dcb841 feat: subscription tiers, period filter, dashboards, docs
- Add subscription tiers (Basic/Pro/Business) with listing limits and dynamic commission
- Add daily/monthly period filter on rentals page
- Add landlord dashboard with earnings chart, stat cards, property performance
- Add landlord subscription management page
- Add tenant dashboard with upcoming stays
- Add business model documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:19:33 -08:00

360 lines
12 KiB
TypeScript

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<string, unknown> = 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;