- 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>
360 lines
12 KiB
TypeScript
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;
|