Add rental system: listings, bookings, payments, payouts, reviews

Full rental marketplace with 6 categories (apartment, house, car, motorcycle, bicycle, ebike).
Booking workflow: create → confirm → pay → active → complete → payout.
Landlord dashboard, admin moderation, availability calendar, Stripe Connect payouts.
14 QA bugs found and fixed including validator schemas, API response types, HTTP methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
delta-lynx-89e8
2026-02-22 15:33:29 -08:00
parent 8961fa701a
commit dbbbbd26f4
90 changed files with 11052 additions and 124 deletions

View File

@@ -18,6 +18,15 @@ import notificationRoutes from './routes/notification.js';
import paymentRoutes from './routes/payment.js';
import locationRoutes from './routes/location.js';
import miscRoutes from './routes/misc.js';
import reportRoutes from './routes/report.js';
import subscriptionRoutes from './routes/subscription.js';
import promotionRoutes from './routes/promotion.js';
import adminRoutes from './routes/admin/index.js';
import rentalRoutes from './routes/rental.js';
import bookingRoutes from './routes/booking.js';
import rentalPaymentRoutes from './routes/rental-payment.js';
import payoutRoutes from './routes/payout.js';
import rentalReviewRoutes from './routes/rental-review.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -35,10 +44,11 @@ app.use(cookieParser());
// Stripe webhook needs raw body
app.use('/api/payments/webhook', express.raw({ type: 'application/json' }));
app.use('/api/rental-payments/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
// Rate limiting
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 50 });
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
@@ -55,6 +65,15 @@ app.use('/api/notifications', notificationRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/location', locationRoutes);
app.use('/api', miscRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/promotions', promotionRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/rentals', rentalRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/rental-payments', rentalPaymentRoutes);
app.use('/api/payouts', payoutRoutes);
app.use('/api/rental-reviews', rentalReviewRoutes);
// Health check
app.get('/api/health', (_req, res) => {

View File

@@ -5,6 +5,7 @@ declare global {
namespace Express {
interface Request {
userId?: string;
userRole?: string;
}
}
}

View File

@@ -0,0 +1,24 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
export async function checkBanned(req: Request, res: Response, next: NextFunction): Promise<void> {
if (!req.userId) {
next();
return;
}
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { isBanned: true, banReason: true },
});
if (user?.isBanned) {
res.status(403).json({
message: 'Account suspended',
reason: user.banReason || 'Your account has been suspended. Contact support for more information.',
});
return;
}
next();
}

View File

@@ -0,0 +1,47 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
type Role = 'USER' | 'MODERATOR' | 'ADMIN' | 'SUPER_ADMIN';
const ROLE_HIERARCHY: Record<Role, number> = {
USER: 0,
MODERATOR: 1,
ADMIN: 2,
SUPER_ADMIN: 3,
};
export function requireRole(...roles: Role[]) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.userId) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { role: true, isBanned: true },
});
if (!user) {
res.status(401).json({ message: 'User not found' });
return;
}
if (user.isBanned) {
res.status(403).json({ message: 'Account is suspended' });
return;
}
if (!roles.includes(user.role as Role)) {
res.status(403).json({ message: 'Insufficient permissions' });
return;
}
(req as any).userRole = user.role;
next();
};
}
export const requireModerator = requireRole('MODERATOR', 'ADMIN', 'SUPER_ADMIN');
export const requireAdmin = requireRole('ADMIN', 'SUPER_ADMIN');
export const requireSuperAdmin = requireRole('SUPER_ADMIN');

View File

@@ -0,0 +1,38 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator } from '../../middleware/requireRole.js';
const router = Router();
// --- List all bookings ---
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
const [data, total] = await Promise.all([
prisma.booking.findMany({
where,
include: {
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, email: true } },
landlord: { select: { id: true, fullName: true, email: true } },
payout: true,
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.booking.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,30 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.js';
import statsRouter from './stats.js';
import usersRouter from './users.js';
import listingsRouter from './listings.js';
import reportsRouter from './reports.js';
import moderationRouter from './moderation.js';
import paymentsRouter from './payments.js';
import settingsRouter from './settings.js';
import rentalRouter from './rentals.js';
import bookingsRouter from './bookings.js';
import rentalPayoutsRouter from './rental-payouts.js';
const router = Router();
// All admin routes require authentication
router.use(authenticate);
router.use('/stats', statsRouter);
router.use('/users', usersRouter);
router.use('/listings', listingsRouter);
router.use('/reports', reportsRouter);
router.use('/moderation', moderationRouter);
router.use('/payments', paymentsRouter);
router.use('/settings', settingsRouter);
router.use('/rentals', rentalRouter);
router.use('/bookings', bookingsRouter);
router.use('/rental-payouts', rentalPayoutsRouter);
export default router;

View File

@@ -0,0 +1,187 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { rejectListingSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/listings - All listings
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, category, search } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (status) where.status = status;
if (category) where.category = category;
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
];
}
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where,
select: {
id: true, title: true, price: true, category: true, condition: true,
status: true, isFeatured: true, createdAt: true, viewCount: true,
seller: { select: { id: true, fullName: true, avatar: true } },
images: { take: 1, orderBy: { order: 'asc' } },
_count: { select: { offers: true, favorites: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.listing.count({ where }),
]);
res.json({
data: listings,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/approve
router.post('/:id/approve', requireModerator, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
if (listing.status !== 'PENDING_REVIEW') throw new AppError(400, 'Listing is not pending review');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE', reviewedBy: req.userId, reviewedAt: new Date() },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'APPROVED',
reason: 'Listing approved',
},
});
await prisma.notification.create({
data: {
userId: listing.sellerId,
type: 'LISTING_APPROVED',
title: 'Listing Approved',
body: `Your listing "${listing.title}" has been approved and is now live.`,
data: { listingId: listing.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/reject
router.post('/:id/reject', requireModerator, validate(rejectListingSchema), async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: {
status: 'DELETED',
rejectionReason: req.body.reason,
reviewedBy: req.userId,
reviewedAt: new Date(),
},
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'REJECTED',
reason: req.body.reason,
},
});
await prisma.notification.create({
data: {
userId: listing.sellerId,
type: 'LISTING_REJECTED',
title: 'Listing Rejected',
body: `Your listing "${listing.title}" was rejected. Reason: ${req.body.reason}`,
data: { listingId: listing.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// DELETE /api/admin/listings/:id - Force delete
router.delete('/:id', requireAdmin, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'DELETED' },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
targetUserId: listing.sellerId,
action: 'LISTING_DELETED',
reason: 'Force deleted by admin',
},
});
res.json({ message: 'Listing deleted' });
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/feature - Toggle featured
router.post('/:id/feature', requireAdmin, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: { isFeatured: !listing.isFeatured },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'LISTING_FEATURED',
reason: updated.isFeatured ? 'Listing featured' : 'Listing unfeatured',
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,73 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/moderation/queue - Pending review listings
router.get('/queue', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where: { status: 'PENDING_REVIEW' },
select: {
id: true, title: true, description: true, price: true, category: true,
condition: true, location: true, createdAt: true,
seller: { select: { id: true, fullName: true, avatar: true, email: true } },
images: { orderBy: { order: 'asc' } },
},
skip,
take,
orderBy: { createdAt: 'asc' },
}),
prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
]);
res.json({
data: listings,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/moderation/logs - Moderation history
router.get('/logs', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [logs, total] = await Promise.all([
prisma.moderationLog.findMany({
include: {
moderator: { select: { id: true, fullName: true, avatar: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.moderationLog.count(),
]);
res.json({
data: logs,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,65 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/payments - All payments
router.get('/', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', type, status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (type) where.type = type;
if (status) where.status = status;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: {
user: { select: { id: true, fullName: true, avatar: true } },
listing: { select: { id: true, title: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.payment.count({ where }),
]);
res.json({
data: payments,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/payments/revenue - Revenue breakdown
router.get('/revenue', requireAdmin, async (_req, res, next) => {
try {
const [listingFees, commissions, promotions, subscriptions] = await Promise.all([
prisma.payment.aggregate({ where: { type: 'LISTING_FEE', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'COMMISSION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'PROMOTION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'SUBSCRIPTION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
]);
res.json({
listingFees: { total: listingFees._sum.amount || 0, count: listingFees._count },
commissions: { total: commissions._sum.amount || 0, count: commissions._count },
promotions: { total: promotions._sum.amount || 0, count: promotions._count },
subscriptions: { total: subscriptions._sum.amount || 0, count: subscriptions._count },
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,109 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../../config/database.js';
import { requireAdmin } from '../../middleware/requireRole.js';
import { env } from '../../config/env.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- List all payouts ---
router.get('/', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
const [data, total] = await Promise.all([
prisma.payout.findMany({
where,
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
},
},
landlord: { select: { id: true, fullName: true, email: true, stripeAccountId: true } },
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.payout.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Retry failed payout ---
router.patch('/:id/retry', requireAdmin, async (req, res, next) => {
try {
const payout = await prisma.payout.findUnique({
where: { id: req.params.id },
include: { landlord: true },
});
if (!payout) throw new AppError(404, 'Payout not found');
if (payout.status !== 'FAILED' && payout.status !== 'PENDING') {
throw new AppError(400, 'Can only retry failed or pending payouts');
}
if (!payout.landlord.stripeAccountId) {
throw new AppError(400, 'Landlord has no Stripe Connect account');
}
if (stripe) {
try {
const transfer = await stripe.transfers.create({
amount: Math.round(payout.netAmount * 100),
currency: 'usd',
destination: payout.landlord.stripeAccountId,
metadata: { payoutId: payout.id, bookingId: payout.bookingId },
});
await prisma.payout.update({
where: { id: payout.id },
data: { status: 'COMPLETED', stripeTransferId: transfer.id },
});
// Notify landlord
await prisma.notification.create({
data: {
userId: payout.landlordId,
type: 'PAYOUT_SENT',
title: 'Payout Sent',
body: `Your payout of $${payout.netAmount.toFixed(2)} has been sent.`,
data: { payoutId: payout.id },
},
});
} catch {
await prisma.payout.update({ where: { id: payout.id }, data: { status: 'FAILED' } });
await prisma.notification.create({
data: {
userId: payout.landlordId,
type: 'PAYOUT_FAILED',
title: 'Payout Failed',
body: `Your payout of $${payout.netAmount.toFixed(2)} failed. Our team is investigating.`,
data: { payoutId: payout.id },
},
});
}
}
const updated = await prisma.payout.findUnique({ where: { id: payout.id } });
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,150 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// --- List all rentals ---
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, category, search } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (category) where.category = category;
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ location: { contains: search as string, mode: 'insensitive' } },
];
}
const [data, total] = await Promise.all([
prisma.rentalListing.findMany({
where,
select: {
id: true, title: true, category: true, location: true, status: true,
dailyPrice: true, monthlyPrice: true, viewCount: true, isFeatured: true, isVerified: true,
createdAt: true,
landlord: { select: { id: true, fullName: true, email: true } },
images: { take: 1, orderBy: { order: 'asc' } },
_count: { select: { bookings: true, reviews: true } },
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.rentalListing.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Approve rental ---
router.patch('/:id/approve', requireModerator, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
if (rental.status !== 'PENDING_REVIEW') throw new AppError(400, 'Rental is not pending review');
const updated = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE', reviewedBy: req.userId, reviewedAt: new Date() },
});
// Notify landlord
await prisma.notification.create({
data: {
userId: rental.landlordId,
type: 'LISTING_APPROVED',
title: 'Rental Approved',
body: `Your rental "${rental.title}" has been approved and is now live!`,
data: { rentalListingId: rental.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Reject rental ---
router.patch('/:id/reject', requireModerator, async (req, res, next) => {
try {
const { reason } = req.body;
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
if (rental.status !== 'PENDING_REVIEW') throw new AppError(400, 'Rental is not pending review');
const updated = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'DRAFT', rejectionReason: reason, reviewedBy: req.userId, reviewedAt: new Date() },
});
await prisma.notification.create({
data: {
userId: rental.landlordId,
type: 'LISTING_REJECTED',
title: 'Rental Rejected',
body: `Your rental "${rental.title}" was rejected. Reason: ${reason || 'Not specified'}`,
data: { rentalListingId: rental.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Force delete rental (admin) ---
router.delete('/:id', requireAdmin, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { status: 'DELETED' } });
res.json({ message: 'Rental deleted' });
} catch (error) {
next(error);
}
});
// --- Rental stats ---
router.get('/stats', requireModerator, async (req, res, next) => {
try {
const [totalRentals, activeRentals, pendingRentals, totalBookings, activeBookings, completedBookings, totalPayouts, pendingPayouts] = await Promise.all([
prisma.rentalListing.count({ where: { status: { not: 'DELETED' } } }),
prisma.rentalListing.count({ where: { status: 'ACTIVE' } }),
prisma.rentalListing.count({ where: { status: 'PENDING_REVIEW' } }),
prisma.booking.count(),
prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'ACTIVE'] } } }),
prisma.booking.count({ where: { status: 'COMPLETED' } }),
prisma.payout.count(),
prisma.payout.count({ where: { status: 'PENDING' } }),
]);
const payoutAgg = await prisma.payout.aggregate({
where: { status: 'COMPLETED' },
_sum: { commissionAmount: true, netAmount: true },
});
res.json({
totalRentals, activeRentals, pendingRentals,
totalBookings, activeBookings, completedBookings,
totalPayouts, pendingPayouts,
totalRentalRevenue: payoutAgg._sum.commissionAmount || 0,
totalPaidOut: payoutAgg._sum.netAmount || 0,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,107 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { resolveReportSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/reports
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, targetType } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (status) where.status = status;
if (targetType) where.targetType = targetType;
const [reports, total] = await Promise.all([
prisma.report.findMany({
where,
include: {
reporter: { select: { id: true, fullName: true, avatar: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.report.count({ where }),
]);
res.json({
data: reports,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/reports/:id
router.get('/:id', requireModerator, async (req, res, next) => {
try {
const report = await prisma.report.findUnique({
where: { id: req.params.id },
include: {
reporter: { select: { id: true, fullName: true, avatar: true, email: true } },
},
});
if (!report) throw new AppError(404, 'Report not found');
let target: any = null;
if (report.targetType === 'LISTING') {
target = await prisma.listing.findUnique({
where: { id: report.targetId },
select: { id: true, title: true, status: true, seller: { select: { id: true, fullName: true } } },
});
} else {
target = await prisma.user.findUnique({
where: { id: report.targetId },
select: { id: true, fullName: true, email: true, isBanned: true },
});
}
res.json({ report, target });
} catch (error) {
next(error);
}
});
// PATCH /api/admin/reports/:id
router.patch('/:id', requireModerator, validate(resolveReportSchema), async (req, res, next) => {
try {
const report = await prisma.report.findUnique({ where: { id: req.params.id } });
if (!report) throw new AppError(404, 'Report not found');
const updated = await prisma.report.update({
where: { id: req.params.id },
data: {
status: req.body.status,
resolution: req.body.resolution,
resolvedBy: req.userId,
},
});
await prisma.notification.create({
data: {
userId: report.reporterId,
type: 'REPORT_RESOLVED',
title: 'Report Updated',
body: `Your report has been ${req.body.status.toLowerCase()}.`,
data: { reportId: report.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,43 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireAdmin, requireSuperAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { updateSettingsSchema } from '../../validators/admin.js';
import { invalidateConfigCache } from '../../utils/moderation.js';
const router = Router();
// GET /api/admin/settings
router.get('/', requireAdmin, async (_req, res, next) => {
try {
let config = await prisma.platformConfig.findFirst();
if (!config) {
config = await prisma.platformConfig.create({ data: {} });
}
res.json(config);
} catch (error) {
next(error);
}
});
// PATCH /api/admin/settings
router.patch('/', requireSuperAdmin, validate(updateSettingsSchema), async (req, res, next) => {
try {
let config = await prisma.platformConfig.findFirst();
if (!config) {
config = await prisma.platformConfig.create({ data: {} });
}
const updated = await prisma.platformConfig.update({
where: { id: config.id },
data: req.body,
});
invalidateConfigCache();
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,83 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/stats - General stats
router.get('/', requireModerator, async (_req, res, next) => {
try {
const [totalUsers, totalListings, activeListings, pendingListings, totalOffers, totalPayments, activeToday] = await Promise.all([
prisma.user.count(),
prisma.listing.count(),
prisma.listing.count({ where: { status: 'ACTIVE' } }),
prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
prisma.offer.count(),
prisma.payment.aggregate({ where: { status: 'COMPLETED' }, _sum: { amount: true } }),
prisma.user.count({ where: { updatedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } } }),
]);
res.json({
totalUsers,
totalListings,
activeListings,
pendingListings,
totalOffers,
totalRevenue: totalPayments._sum.amount || 0,
activeToday,
});
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/revenue - Revenue over time
router.get('/revenue', requireAdmin, async (req, res, next) => {
try {
const { period = 'daily' } = req.query;
const days = period === 'monthly' ? 365 : period === 'weekly' ? 90 : 30;
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const payments = await prisma.payment.findMany({
where: { status: 'COMPLETED', createdAt: { gte: since } },
select: { amount: true, type: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
res.json(payments);
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/users - User growth
router.get('/users', requireAdmin, async (_req, res, next) => {
try {
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const users = await prisma.user.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
orderBy: { createdAt: 'asc' },
});
res.json(users);
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/listings - Listing activity
router.get('/listings', requireModerator, async (_req, res, next) => {
try {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const listings = await prisma.listing.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true, status: true },
orderBy: { createdAt: 'asc' },
});
res.json(listings);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,195 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin, requireSuperAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { banUserSchema, changeRoleSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/users - List users
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', search, role, status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (search) {
where.OR = [
{ fullName: { contains: search as string, mode: 'insensitive' } },
{ email: { contains: search as string, mode: 'insensitive' } },
];
}
if (role) where.role = role;
if (status === 'banned') where.isBanned = true;
if (status === 'active') where.isBanned = false;
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true, email: true, fullName: true, nickname: true, avatar: true,
role: true, isBanned: true, banReason: true, createdAt: true, location: true,
_count: { select: { listings: true, sentOffers: true, reports: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
res.json({
data: users,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/users/:id - User detail
router.get('/:id', requireModerator, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: {
id: true, email: true, fullName: true, nickname: true, avatar: true,
phone: true, location: true, bio: true, rating: true, ratingCount: true,
role: true, isBanned: true, banReason: true, bannedAt: true, bannedBy: true,
createdAt: true, updatedAt: true,
_count: { select: { listings: true, sentOffers: true, receivedOffers: true, reports: true } },
},
});
if (!user) throw new AppError(404, 'User not found');
const moderationLogs = await prisma.moderationLog.findMany({
where: { OR: [{ targetUserId: req.params.id }, { moderatorId: req.params.id }] },
orderBy: { createdAt: 'desc' },
take: 20,
include: { moderator: { select: { id: true, fullName: true } } },
});
res.json({ user, moderationLogs });
} catch (error) {
next(error);
}
});
// PATCH /api/admin/users/:id/role - Change role
router.patch('/:id/role', requireSuperAdmin, validate(changeRoleSchema), async (req, res, next) => {
try {
if (req.params.id === req.userId) {
throw new AppError(400, 'Cannot change your own role');
}
const user = await prisma.user.update({
where: { id: req.params.id },
data: { role: req.body.role },
select: { id: true, fullName: true, role: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'WARNING',
reason: `Role changed to ${req.body.role}`,
},
});
res.json(user);
} catch (error) {
next(error);
}
});
// POST /api/admin/users/:id/ban - Ban user
router.post('/:id/ban', requireAdmin, validate(banUserSchema), async (req, res, next) => {
try {
if (req.params.id === req.userId) {
throw new AppError(400, 'Cannot ban yourself');
}
const target = await prisma.user.findUnique({ where: { id: req.params.id }, select: { role: true } });
if (!target) throw new AppError(404, 'User not found');
if (target.role === 'SUPER_ADMIN') throw new AppError(403, 'Cannot ban a super admin');
const user = await prisma.user.update({
where: { id: req.params.id },
data: {
isBanned: true,
banReason: req.body.reason,
bannedAt: new Date(),
bannedBy: req.userId,
},
select: { id: true, fullName: true, isBanned: true, banReason: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'BAN',
reason: req.body.reason,
},
});
await prisma.notification.create({
data: {
userId: req.params.id,
type: 'ACCOUNT_BANNED',
title: 'Account Suspended',
body: `Your account has been suspended. Reason: ${req.body.reason}`,
},
});
res.json(user);
} catch (error) {
next(error);
}
});
// POST /api/admin/users/:id/unban - Unban user
router.post('/:id/unban', requireAdmin, async (req, res, next) => {
try {
const user = await prisma.user.update({
where: { id: req.params.id },
data: {
isBanned: false,
banReason: null,
bannedAt: null,
bannedBy: null,
},
select: { id: true, fullName: true, isBanned: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'UNBAN',
reason: 'Account unbanned',
},
});
await prisma.notification.create({
data: {
userId: req.params.id,
type: 'ACCOUNT_UNBANNED',
title: 'Account Restored',
body: 'Your account has been restored. You can now use the platform again.',
},
});
res.json(user);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -20,7 +20,7 @@ router.post('/register', validate(registerSchema), async (req, res, next) => {
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: { fullName, email, passwordHash },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
const accessToken = generateAccessToken(user.id);
@@ -56,6 +56,7 @@ router.post('/login', validate(loginSchema), async (req, res, next) => {
const fullUser = await prisma.user.findUnique({ where: { email } });
if (!fullUser) throw new AppError(401, 'Invalid email or password');
if (!fullUser.isActive) throw new AppError(403, 'Account is disabled');
if (fullUser.isBanned) throw new AppError(403, `Account suspended: ${fullUser.banReason || 'Contact support for details'}`);
const valid = await comparePassword(password, fullUser.passwordHash);
if (!valid) throw new AppError(401, 'Invalid email or password');
@@ -82,7 +83,7 @@ router.post('/login', validate(loginSchema), async (req, res, next) => {
const user = await prisma.user.findUnique({
where: { id: fullUser.id },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
res.json({ user, accessToken });
} catch (error) {
@@ -127,7 +128,7 @@ router.get('/me', authenticate, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
if (!user) throw new AppError(404, 'User not found');
res.json({ user });

View File

@@ -0,0 +1,357 @@
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';
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 subtotal = pricePerPeriod * totalPeriods;
const commissionRate = config.rentalCommissionPercent;
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;

View File

@@ -6,12 +6,14 @@ import { upload } from '../middleware/upload.js';
import { createListingSchema, updateListingSchema } from '../validators/listing.js';
import { AppError } from '../middleware/errorHandler.js';
import { getBlockedUserIds } from '../utils/blocked.js';
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
const router = Router();
const listingSelect = {
id: true, title: true, description: true, price: true, obo: true,
category: true, condition: true, status: true, location: true, viewCount: true,
isFeatured: true,
createdAt: true, updatedAt: true, sellerId: true,
seller: { select: { id: true, fullName: true, nickname: true, avatar: true, rating: true, location: true, createdAt: true, showEmail: true, showPhone: true, showLocation: true } },
images: { orderBy: { order: 'asc' as const } },
@@ -214,8 +216,12 @@ router.get('/:id', optionalAuth, async (req, res, next) => {
// --- Create listing ---
router.post('/', authenticate, validate(createListingSchema), async (req, res, next) => {
try {
const config = await getPlatformConfig();
const textToCheck = `${req.body.title} ${req.body.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const listing = await prisma.listing.create({
data: { ...req.body, sellerId: req.userId!, status: 'DRAFT' },
data: { ...req.body, sellerId: req.userId!, status: blockedWord ? 'PENDING_REVIEW' : 'DRAFT' },
select: listingSelect,
});
res.status(201).json(listing);
@@ -250,11 +256,16 @@ router.post('/:id/activate', authenticate, async (req, res, next) => {
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Listing not found');
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'DRAFT') throw new AppError(400, 'Listing is not in draft status');
if (existing.status !== 'DRAFT' && existing.status !== 'PENDING_REVIEW') throw new AppError(400, 'Listing cannot be activated');
const config = await getPlatformConfig();
const textToCheck = `${existing.title} ${existing.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const newStatus = (!config.autoApprove || blockedWord) ? 'PENDING_REVIEW' : 'ACTIVE';
const listing = await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE' },
data: { status: newStatus },
select: listingSelect,
});
res.json(listing);

View File

@@ -198,6 +198,26 @@ router.patch('/:id', authenticate, validate(respondOfferSchema), async (req, res
where: { listingId: existing.listingId, id: { not: existing.id }, status: 'PENDING' },
data: { status: 'DECLINED' },
});
// Create commission payment
try {
const { getPlatformConfig } = await import('../utils/moderation.js');
const config = await getPlatformConfig();
const saleAmount = existing.status === 'COUNTERED' ? (existing.counterAmount || existing.amount) : existing.amount;
const commission = saleAmount * (config.commissionPercent / 100);
if (commission > 0) {
await prisma.payment.create({
data: {
userId: existing.sellerId,
listingId: existing.listingId,
amount: commission,
status: 'COMPLETED',
type: 'COMMISSION',
description: `${config.commissionPercent}% commission on sale`,
},
});
}
} catch {}
}
const recipientId = existing.buyerId === req.userId ? existing.sellerId : existing.buyerId;

View File

@@ -4,6 +4,7 @@ import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
import { getPlatformConfig } from '../utils/moderation.js';
const router = Router();
@@ -63,8 +64,11 @@ router.post('/create-intent', authenticate, async (req, res, next) => {
});
if (existingPayment) throw new AppError(400, 'Listing already paid for');
const config = await getPlatformConfig();
const feeInCents = Math.round(config.listingFee * 100);
const paymentIntent = await stripe.paymentIntents.create({
amount: 500,
amount: feeInCents,
currency: 'usd',
metadata: { listingId, userId: req.userId! },
});
@@ -74,8 +78,9 @@ router.post('/create-intent', authenticate, async (req, res, next) => {
userId: req.userId!,
listingId,
stripePaymentId: paymentIntent.id,
amount: 5,
amount: config.listingFee,
status: 'PENDING',
type: 'LISTING_FEE',
},
});

116
server/src/routes/payout.ts Normal file
View File

@@ -0,0 +1,116 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- List payouts ---
router.get('/', authenticate, async (req, res, next) => {
try {
const payouts = await prisma.payout.findMany({
where: { landlordId: req.userId },
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
startDate: true, endDate: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
res.json(payouts);
} catch (error) {
next(error);
}
});
// --- Get payout details ---
router.get('/:id', authenticate, async (req, res, next) => {
try {
const payout = await prisma.payout.findUnique({
where: { id: req.params.id },
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
startDate: true, endDate: true, totalAmount: true, subtotal: true,
},
},
},
});
if (!payout) throw new AppError(404, 'Payout not found');
if (payout.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
res.json(payout);
} catch (error) {
next(error);
}
});
// --- Setup Stripe Connect account ---
router.post('/setup-account', authenticate, async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user) throw new AppError(404, 'User not found');
let accountId = user.stripeAccountId;
if (!accountId) {
const account = await stripe.accounts.create({
type: 'express',
email: user.email,
metadata: { userId: user.id },
});
accountId = account.id;
await prisma.user.update({ where: { id: req.userId }, data: { stripeAccountId: accountId } });
}
const accountLink = await stripe.accountLinks.create({
account: accountId,
refresh_url: `${env.CLIENT_URL}/landlord/payouts`,
return_url: `${env.CLIENT_URL}/landlord/payouts`,
type: 'account_onboarding',
});
res.json({ url: accountLink.url });
} catch (error) {
next(error);
}
});
// --- Check Stripe Connect account status ---
router.get('/account-status', authenticate, async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user?.stripeAccountId) {
return res.json({ connected: false, detailsSubmitted: false, chargesEnabled: false, payoutsEnabled: false });
}
const account = await stripe.accounts.retrieve(user.stripeAccountId);
res.json({
connected: true,
detailsSubmitted: account.details_submitted,
chargesEnabled: account.charges_enabled,
payoutsEnabled: account.payouts_enabled,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,72 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { getPlatformConfig } from '../utils/moderation.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
// POST /api/listings/:id/promote
router.post('/:id/promote', authenticate, async (req, res, next) => {
try {
const { days } = req.body;
if (!days || days < 1 || days > 30) {
throw new AppError(400, 'Days must be between 1 and 30');
}
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
if (listing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
if (listing.status !== 'ACTIVE') throw new AppError(400, 'Listing must be active');
const config = await getPlatformConfig();
const amountPaid = config.promotionDayPrice * days;
const promotion = await prisma.promotedListing.upsert({
where: { listingId: req.params.id },
update: {
endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
amountPaid: { increment: amountPaid },
isActive: true,
},
create: {
listingId: req.params.id,
userId: req.userId!,
endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
amountPaid,
isActive: true,
},
});
// Record payment
await prisma.payment.create({
data: {
userId: req.userId!,
listingId: req.params.id,
amount: amountPaid,
status: 'COMPLETED',
type: 'PROMOTION',
description: `${days}-day listing promotion`,
},
});
res.json(promotion);
} catch (error) {
next(error);
}
});
// GET /api/listings/:id/promotion
router.get('/:id/promotion', authenticate, async (req, res, next) => {
try {
const promotion = await prisma.promotedListing.findUnique({
where: { listingId: req.params.id },
});
res.json(promotion || { isActive: false });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,127 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- Create payment intent for confirmed booking ---
router.post('/create-intent', authenticate, async (req, res, next) => {
try {
const { bookingId } = req.body;
if (!stripe) throw new AppError(500, 'Stripe not configured');
if (!bookingId) throw new AppError(400, 'Booking ID required');
const booking = await prisma.booking.findUnique({ where: { id: bookingId } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'CONFIRMED') throw new AppError(400, 'Booking must be confirmed before payment');
// Check if already paid
const existingPayment = await prisma.bookingPayment.findFirst({
where: { bookingId, status: 'COMPLETED', type: 'RENTAL_BOOKING' },
});
if (existingPayment) throw new AppError(400, 'Booking already paid');
const amountInCents = Math.round(booking.totalAmount * 100);
const paymentIntent = await stripe.paymentIntents.create({
amount: amountInCents,
currency: 'usd',
metadata: { bookingId, tenantId: req.userId!, landlordId: booking.landlordId },
});
await prisma.bookingPayment.create({
data: {
bookingId,
stripePaymentId: paymentIntent.id,
amount: booking.totalAmount,
type: 'RENTAL_BOOKING',
status: 'PENDING',
},
});
await prisma.booking.update({
where: { id: bookingId },
data: { stripePaymentIntentId: paymentIntent.id },
});
res.json({ clientSecret: paymentIntent.client_secret });
} catch (error) {
next(error);
}
});
// --- Stripe webhook ---
router.post('/webhook', async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const sig = req.headers['stripe-signature'] as string;
const event = stripe.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET);
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
const { bookingId } = paymentIntent.metadata;
if (bookingId) {
await prisma.bookingPayment.updateMany({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'COMPLETED' },
});
// Move booking to ACTIVE if start date has passed, otherwise keep CONFIRMED
const booking = await prisma.booking.findUnique({ where: { id: bookingId } });
if (booking && booking.status === 'CONFIRMED') {
const newStatus = booking.startDate <= new Date() ? 'ACTIVE' : 'CONFIRMED';
await prisma.booking.update({ where: { id: bookingId }, data: { status: newStatus } });
}
}
} else if (event.type === 'payment_intent.payment_failed') {
const paymentIntent = event.data.object;
await prisma.bookingPayment.updateMany({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'FAILED' },
});
}
res.json({ received: true });
} catch (error) {
next(error);
}
});
// --- Payment history ---
router.get('/history', authenticate, async (req, res, next) => {
try {
const payments = await prisma.bookingPayment.findMany({
where: {
booking: {
OR: [
{ tenantId: req.userId },
{ landlordId: req.userId },
],
},
},
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true, images: { take: 1, orderBy: { order: 'asc' } } } },
},
},
},
orderBy: { createdAt: 'desc' },
});
res.json(payments);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,103 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createReviewSchema, respondReviewSchema } from '../validators/rental-review.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
// --- Create review (tenant only, on completed booking) ---
router.post('/', authenticate, validate(createReviewSchema), async (req, res, next) => {
try {
const { bookingId, rating, comment } = req.body;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: { review: true },
});
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'COMPLETED') throw new AppError(400, 'Can only review completed bookings');
if (booking.review) throw new AppError(409, 'Review already exists for this booking');
const review = await prisma.rentalReview.create({
data: {
bookingId,
rentalListingId: booking.rentalListingId,
tenantId: req.userId!,
landlordId: booking.landlordId,
rating,
comment,
},
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
},
});
// Notify landlord
const notification = await prisma.notification.create({
data: {
userId: booking.landlordId,
type: 'RENTAL_REVIEW',
title: 'New Review',
body: `You received a ${rating}-star review`,
data: { reviewId: review.id, bookingId },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.landlordId}`).emit('new_notification', notification);
}
res.status(201).json(review);
} catch (error) {
next(error);
}
});
// --- Landlord respond to review ---
router.patch('/:id/respond', authenticate, validate(respondReviewSchema), async (req, res, next) => {
try {
const review = await prisma.rentalReview.findUnique({ where: { id: req.params.id } });
if (!review) throw new AppError(404, 'Review not found');
if (review.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const updated = await prisma.rentalReview.update({
where: { id: req.params.id },
data: { landlordResponse: req.body.response },
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Get all reviews for a landlord ---
router.get('/landlord/:id', async (req, res, next) => {
try {
const reviews = await prisma.rentalReview.findMany({
where: { landlordId: req.params.id },
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
rentalListing: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
});
const avgRating = reviews.length > 0
? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
: 0;
res.json({ reviews, avgRating, totalReviews: reviews.length });
} catch (error) {
next(error);
}
});
export default router;

406
server/src/routes/rental.ts Normal file
View File

@@ -0,0 +1,406 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate, optionalAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { upload } from '../middleware/upload.js';
import { createRentalSchema, updateRentalSchema } from '../validators/rental.js';
import { AppError } from '../middleware/errorHandler.js';
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
import { checkAvailability } from '../utils/rental.js';
const router = Router();
const rentalSelect = {
id: true, title: true, description: true, category: true, location: true,
dailyPrice: true, monthlyPrice: true, depositAmount: true, details: true,
amenities: true, rules: true, cancellationPolicy: true,
minDays: true, maxDays: true, minMonths: true, maxMonths: true,
status: true, viewCount: true, isFeatured: true, isVerified: true,
rejectionReason: true,
createdAt: true, updatedAt: true, landlordId: true,
landlord: { select: { id: true, fullName: true, nickname: true, avatar: true, rating: true, location: true, createdAt: true, landlordVerified: true, showEmail: true, showPhone: true, showLocation: true } },
images: { orderBy: { order: 'asc' as const } },
_count: { select: { favorites: true, bookings: true, reviews: true } },
};
// --- My rental listings ---
router.get('/mine', authenticate, async (req, res, next) => {
try {
const { status } = req.query;
const where: Record<string, unknown> = { landlordId: req.userId };
if (status && typeof status === 'string') {
where.status = status;
} else {
where.status = { not: 'DELETED' };
}
const listings = await prisma.rentalListing.findMany({
where,
select: rentalSelect,
orderBy: { createdAt: 'desc' },
});
res.json(listings);
} catch (error) {
next(error);
}
});
// --- Favorites list ---
router.get('/favorites', authenticate, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [favorites, total] = await Promise.all([
prisma.rentalFavorite.findMany({
where: { userId: req.userId! },
include: { rentalListing: { select: rentalSelect } },
orderBy: { createdAt: 'desc' },
skip, take,
}),
prisma.rentalFavorite.count({ where: { userId: req.userId! } }),
]);
const data = favorites
.filter(f => f.rentalListing.status === 'ACTIVE')
.map(f => ({ ...f.rentalListing, isFavorited: true }));
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Search/list active rentals ---
router.get('/', optionalAuth, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', category, search, sort = 'newest', priceMin, priceMax, periodType, location } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = { status: 'ACTIVE' };
if (category) where.category = category;
if (location && typeof location === 'string') {
where.location = { contains: location, mode: 'insensitive' };
}
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
{ location: { contains: search as string, mode: 'insensitive' } },
];
}
// Price filters
if (periodType === 'DAILY' || priceMin || priceMax) {
const priceField = periodType === 'MONTHLY' ? 'monthlyPrice' : 'dailyPrice';
const priceFilter: Record<string, unknown> = {};
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
if (Object.keys(priceFilter).length > 0) {
where[priceField] = priceFilter;
}
}
const orderBy = sort === 'price_asc' ? { dailyPrice: 'asc' as const }
: sort === 'price_desc' ? { dailyPrice: 'desc' as const }
: sort === 'popular' ? { viewCount: 'desc' as const }
: { createdAt: 'desc' as const };
const [data, total] = await Promise.all([
prisma.rentalListing.findMany({ where, select: rentalSelect, skip, take, orderBy }),
prisma.rentalListing.count({ where }),
]);
let favIds: Set<string> = new Set();
if (req.userId) {
const favs = await prisma.rentalFavorite.findMany({
where: { userId: req.userId, rentalListingId: { in: data.map(l => l.id) } },
select: { rentalListingId: true },
});
favIds = new Set(favs.map(f => f.rentalListingId));
}
const listings = data.map(l => ({ ...l, isFavorited: favIds.has(l.id) }));
res.json({ data: listings, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Get single rental ---
router.get('/:id', optionalAuth, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({
where: { id: req.params.id },
select: {
...rentalSelect,
landlord: { select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, landlordVerified: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true } },
reviews: {
select: {
id: true, rating: true, comment: true, landlordResponse: true, createdAt: true,
tenant: { select: { id: true, fullName: true, avatar: true } },
},
orderBy: { createdAt: 'desc' as const },
take: 20,
},
},
});
if (!rental || rental.status === 'DELETED') throw new AppError(404, 'Rental not found');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { viewCount: { increment: 1 } } });
let isFavorited = false;
if (req.userId) {
const fav = await prisma.rentalFavorite.findUnique({
where: { userId_rentalListingId: { userId: req.userId, rentalListingId: rental.id } },
});
isFavorited = !!fav;
}
// Average rating
const avgRating = rental.reviews.length > 0
? rental.reviews.reduce((sum, r) => sum + r.rating, 0) / rental.reviews.length
: 0;
// Privacy
const landlord: Record<string, unknown> = { ...rental.landlord };
if (!rental.landlord.showEmail) delete landlord.email;
if (!rental.landlord.showPhone) delete landlord.phone;
if (!rental.landlord.showLocation) delete landlord.location;
res.json({ ...rental, landlord, isFavorited, avgRating });
} catch (error) {
next(error);
}
});
// --- Create rental ---
router.post('/', authenticate, validate(createRentalSchema), async (req, res, next) => {
try {
const config = await getPlatformConfig();
const textToCheck = `${req.body.title} ${req.body.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
// Set user as landlord
await prisma.user.update({ where: { id: req.userId }, data: { isLandlord: true } });
const rental = await prisma.rentalListing.create({
data: { ...req.body, landlordId: req.userId!, status: blockedWord ? 'PENDING_REVIEW' : 'DRAFT' },
select: rentalSelect,
});
res.status(201).json(rental);
} catch (error) {
next(error);
}
});
// --- Update rental ---
router.put('/:id', authenticate, validate(updateRentalSchema), async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status === 'DELETED') throw new AppError(400, 'Cannot edit a deleted rental');
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: req.body,
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Delete rental (soft) ---
router.delete('/:id', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { status: 'DELETED' } });
res.json({ message: 'Rental deleted' });
} catch (error) {
next(error);
}
});
// --- Activate / submit for review ---
router.post('/:id/activate', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'DRAFT' && existing.status !== 'PAUSED' && existing.status !== 'PENDING_REVIEW') {
throw new AppError(400, 'Rental cannot be activated from current status');
}
const config = await getPlatformConfig();
const textToCheck = `${existing.title} ${existing.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const newStatus = (!config.rentalAutoApprove || blockedWord) ? 'PENDING_REVIEW' : 'ACTIVE';
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: newStatus },
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Pause rental ---
router.post('/:id/pause', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'ACTIVE') throw new AppError(400, 'Can only pause active rentals');
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'PAUSED' },
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Upload images ---
router.post('/:id/images', authenticate, upload.array('images', 10), async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const files = req.files as Express.Multer.File[];
if (!files?.length) throw new AppError(400, 'No files uploaded');
const existingImages = await prisma.rentalImage.count({ where: { rentalListingId: req.params.id } });
const images = await Promise.all(
files.map((file, i) =>
prisma.rentalImage.create({
data: {
url: `/uploads/${file.filename}`,
order: existingImages + i,
rentalListingId: req.params.id!,
},
})
)
);
res.status(201).json(images);
} catch (error) {
next(error);
}
});
// --- Toggle favorite ---
router.post('/:id/favorite', authenticate, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
const existing = await prisma.rentalFavorite.findUnique({
where: { userId_rentalListingId: { userId: req.userId!, rentalListingId: req.params.id! } },
});
if (existing) {
await prisma.rentalFavorite.delete({ where: { id: existing.id } });
res.json({ isFavorited: false });
} else {
await prisma.rentalFavorite.create({ data: { userId: req.userId!, rentalListingId: req.params.id! } });
res.json({ isFavorited: true });
}
} catch (error) {
next(error);
}
});
// --- Availability ---
router.get('/:id/availability', async (req, res, next) => {
try {
const { start, end } = req.query;
const startDate = start ? new Date(start as string) : new Date();
const endDate = end ? new Date(end as string) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
const [blocks, bookings] = await Promise.all([
prisma.availabilityBlock.findMany({
where: {
rentalListingId: req.params.id,
OR: [
{ startDate: { lte: endDate }, endDate: { gte: startDate } },
],
},
orderBy: { startDate: 'asc' },
}),
prisma.booking.findMany({
where: {
rentalListingId: req.params.id,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { id: true, startDate: true, endDate: true, status: true },
orderBy: { startDate: 'asc' },
}),
]);
res.json({ blocks, bookings });
} catch (error) {
next(error);
}
});
router.post('/:id/availability', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const { startDate, endDate, reason } = req.body;
if (!startDate || !endDate) throw new AppError(400, 'Start and end dates required');
const block = await prisma.availabilityBlock.create({
data: {
rentalListingId: req.params.id!,
startDate: new Date(startDate),
endDate: new Date(endDate),
isBlocked: true,
reason,
},
});
res.status(201).json(block);
} catch (error) {
next(error);
}
});
router.delete('/:id/availability/:blockId', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const block = await prisma.availabilityBlock.findUnique({ where: { id: req.params.blockId } });
if (!block || block.rentalListingId !== req.params.id) throw new AppError(404, 'Block not found');
await prisma.availabilityBlock.delete({ where: { id: req.params.blockId } });
res.json({ message: 'Block deleted' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createReportSchema } from '../validators/report.js';
const router = Router();
// POST /api/reports - Create report
router.post('/', authenticate, validate(createReportSchema), async (req, res, next) => {
try {
const { targetType, targetId, reason, description } = req.body;
// Verify target exists
if (targetType === 'LISTING') {
const listing = await prisma.listing.findUnique({ where: { id: targetId } });
if (!listing) {
res.status(404).json({ message: 'Listing not found' });
return;
}
} else {
const user = await prisma.user.findUnique({ where: { id: targetId } });
if (!user) {
res.status(404).json({ message: 'User not found' });
return;
}
}
const report = await prisma.report.create({
data: {
reporterId: req.userId!,
targetType,
targetId,
reason,
description,
},
});
res.status(201).json(report);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,76 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
const router = Router();
// GET /api/subscriptions/current
router.get('/current', authenticate, async (req, res, next) => {
try {
const subscription = await prisma.subscription.findUnique({
where: { userId: req.userId! },
});
res.json(subscription || { tier: 'BASIC', status: 'ACTIVE', userId: req.userId });
} catch (error) {
next(error);
}
});
// POST /api/subscriptions/create
router.post('/create', authenticate, async (req, res, next) => {
try {
const { tier } = req.body;
if (!tier || !['PRO', 'BUSINESS'].includes(tier)) {
res.status(400).json({ message: 'Invalid tier' });
return;
}
const existing = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
if (existing && existing.status === 'ACTIVE' && existing.tier !== 'BASIC') {
res.status(400).json({ message: 'Already have an active subscription' });
return;
}
const subscription = await prisma.subscription.upsert({
where: { userId: req.userId! },
update: {
tier,
status: 'ACTIVE',
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
create: {
userId: req.userId!,
tier,
status: 'ACTIVE',
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
res.json(subscription);
} catch (error) {
next(error);
}
});
// POST /api/subscriptions/cancel
router.post('/cancel', authenticate, async (req, res, next) => {
try {
const subscription = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
if (!subscription) {
res.status(404).json({ message: 'No subscription found' });
return;
}
const updated = await prisma.subscription.update({
where: { userId: req.userId! },
data: { status: 'CANCELLED' },
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,82 @@
import { prisma } from '../config/database.js';
interface PlatformConfigCache {
data: {
blockedKeywords: string[];
autoApprove: boolean;
listingFee: number;
commissionPercent: number;
maxImagesPerListing: number;
maxListingsFreeTier: number;
promotionDayPrice: number;
rentalCommissionPercent: number;
rentalAutoApprove: boolean;
maxRentalImagesPerListing: number;
bookingExpiryHours: number;
rentalPromotionDayPrice: number;
} | null;
timestamp: number;
}
const cache: PlatformConfigCache = { data: null, timestamp: 0 };
const CACHE_TTL = 60 * 1000; // 60 seconds
export async function getPlatformConfig() {
const now = Date.now();
if (cache.data && now - cache.timestamp < CACHE_TTL) {
return cache.data;
}
const config = await prisma.platformConfig.findFirst();
if (!config) {
const defaults = {
blockedKeywords: [] as string[],
autoApprove: true,
listingFee: 5.0,
commissionPercent: 5.0,
maxImagesPerListing: 6,
maxListingsFreeTier: 5,
promotionDayPrice: 2.99,
rentalCommissionPercent: 10.0,
rentalAutoApprove: false,
maxRentalImagesPerListing: 10,
bookingExpiryHours: 48,
rentalPromotionDayPrice: 3.99,
};
cache.data = defaults;
cache.timestamp = now;
return defaults;
}
cache.data = {
blockedKeywords: config.blockedKeywords,
autoApprove: config.autoApprove,
listingFee: config.listingFee,
commissionPercent: config.commissionPercent,
maxImagesPerListing: config.maxImagesPerListing,
maxListingsFreeTier: config.maxListingsFreeTier,
promotionDayPrice: config.promotionDayPrice,
rentalCommissionPercent: config.rentalCommissionPercent,
rentalAutoApprove: config.rentalAutoApprove,
maxRentalImagesPerListing: config.maxRentalImagesPerListing,
bookingExpiryHours: config.bookingExpiryHours,
rentalPromotionDayPrice: config.rentalPromotionDayPrice,
};
cache.timestamp = now;
return cache.data;
}
export function invalidateConfigCache() {
cache.data = null;
cache.timestamp = 0;
}
export function checkBlockedKeywords(text: string, blockedKeywords: string[]): string | null {
const lowerText = text.toLowerCase();
for (const keyword of blockedKeywords) {
if (keyword && lowerText.includes(keyword.toLowerCase())) {
return keyword;
}
}
return null;
}

134
server/src/utils/rental.ts Normal file
View File

@@ -0,0 +1,134 @@
import { prisma } from '../config/database.js';
import type { Booking, CancellationPolicy, BookingStatus } from '@prisma/client';
/**
* Checks whether a rental listing is available for the given date range.
* Returns true if no blocked AvailabilityBlock and no CONFIRMED/ACTIVE booking overlaps.
*/
export async function checkAvailability(
rentalListingId: string,
startDate: Date,
endDate: Date
): Promise<boolean> {
const [blockedCount, bookingCount] = await Promise.all([
prisma.availabilityBlock.count({
where: {
rentalListingId,
isBlocked: true,
startDate: { lt: endDate },
endDate: { gt: startDate },
},
}),
prisma.booking.count({
where: {
rentalListingId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: endDate },
endDate: { gt: startDate },
},
}),
]);
return blockedCount === 0 && bookingCount === 0;
}
/**
* Calculates cancellation refund amounts based on the listing's cancellation policy.
*
* - FLEXIBLE: >7 days = 100%, 1-7 days = 100%, <24h = 50%. Deposit always returned.
* - MODERATE: >7 days = 100%, 1-7 days = 50%, <24h = 0%. Deposit always returned.
* - STRICT: >7 days = 50%, 1-7 days = 0%, <24h = 0%. Deposit always returned.
*/
export function calculateCancellationRefund(
policy: CancellationPolicy,
startDate: Date,
subtotal: number,
depositAmount: number
): { refundAmount: number; depositRefund: number } {
const now = new Date();
const msUntilStart = startDate.getTime() - now.getTime();
const hoursUntilStart = msUntilStart / (1000 * 60 * 60);
const daysUntilStart = hoursUntilStart / 24;
let refundPercent: number;
switch (policy) {
case 'FLEXIBLE':
if (hoursUntilStart < 24) {
refundPercent = 50;
} else {
refundPercent = 100;
}
break;
case 'MODERATE':
if (hoursUntilStart < 24) {
refundPercent = 0;
} else if (daysUntilStart <= 7) {
refundPercent = 50;
} else {
refundPercent = 100;
}
break;
case 'STRICT':
if (daysUntilStart > 7) {
refundPercent = 50;
} else {
refundPercent = 0;
}
break;
}
const refundAmount = Math.round((subtotal * refundPercent) / 100 * 100) / 100;
const depositRefund = depositAmount;
return { refundAmount, depositRefund };
}
/**
* Calculates the price breakdown for a booking.
*/
export function calculateBookingPrice(
pricePerPeriod: number,
totalPeriods: number,
commissionRate: number,
depositAmount: number
): { subtotal: number; commissionAmount: number; totalAmount: number } {
const subtotal = Math.round(pricePerPeriod * totalPeriods * 100) / 100;
const commissionAmount = Math.round(subtotal * (commissionRate / 100) * 100) / 100;
const totalAmount = Math.round((subtotal + commissionAmount + depositAmount) * 100) / 100;
return { subtotal, commissionAmount, totalAmount };
}
/**
* Performs lazy status transitions on a booking:
*
* - PENDING + expiresAt < now -> EXPIRED
* - CONFIRMED + startDate <= now -> ACTIVE
* - ACTIVE + endDate <= now -> COMPLETED
*
* Returns the updated booking if a transition occurred, or the original booking otherwise.
*/
export async function autoTransitionBooking(booking: Booking): Promise<Booking> {
const now = new Date();
let newStatus: BookingStatus | null = null;
if (booking.status === 'PENDING' && booking.expiresAt && booking.expiresAt < now) {
newStatus = 'EXPIRED';
} else if (booking.status === 'CONFIRMED' && booking.startDate <= now) {
newStatus = 'ACTIVE';
} else if (booking.status === 'ACTIVE' && booking.endDate <= now) {
newStatus = 'COMPLETED';
}
if (newStatus) {
return prisma.booking.update({
where: { id: booking.id },
data: { status: newStatus },
});
}
return booking;
}

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
export const banUserSchema = z.object({
reason: z.string().min(1, 'Ban reason is required').max(500),
});
export const changeRoleSchema = z.object({
role: z.enum(['USER', 'MODERATOR', 'ADMIN', 'SUPER_ADMIN']),
});
export const rejectListingSchema = z.object({
reason: z.string().min(1, 'Rejection reason is required').max(500),
});
export const resolveReportSchema = z.object({
status: z.enum(['RESOLVED', 'DISMISSED']),
resolution: z.string().max(500).optional(),
});
export const updateSettingsSchema = z.object({
listingFee: z.number().min(0).optional(),
commissionPercent: z.number().min(0).max(100).optional(),
autoApprove: z.boolean().optional(),
maxImagesPerListing: z.number().int().min(1).max(20).optional(),
maxListingsFreeTier: z.number().int().min(1).optional(),
proPrice: z.number().min(0).optional(),
businessPrice: z.number().min(0).optional(),
promotionDayPrice: z.number().min(0).optional(),
blockedKeywords: z.array(z.string()).optional(),
rentalCommissionPercent: z.number().min(0).max(100).optional(),
rentalAutoApprove: z.boolean().optional(),
maxRentalImagesPerListing: z.number().int().min(1).max(30).optional(),
bookingExpiryHours: z.number().int().min(1).max(720).optional(),
rentalPromotionDayPrice: z.number().min(0).optional(),
});

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createBookingSchema = z.object({
rentalListingId: z.string().min(1),
periodType: z.enum(['DAILY', 'MONTHLY']),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
message: z.string().max(1000).optional(),
});
export const rejectBookingSchema = z.object({
reason: z.string().min(1).max(500),
});
export const cancelBookingSchema = z.object({
reason: z.string().min(1).max(500),
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const createReviewSchema = z.object({
bookingId: z.string().min(1),
rating: z.number().int().min(1).max(5),
comment: z.string().max(2000).optional(),
});
export const respondReviewSchema = z.object({
response: z.string().min(1).max(1000),
});

View File

@@ -0,0 +1,39 @@
import { z } from 'zod';
export const createRentalSchema = z.object({
title: z.string().min(3).max(100),
description: z.string().min(10).max(5000),
category: z.enum(['APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE']),
location: z.string().min(1).max(200),
dailyPrice: z.number().positive().optional(),
monthlyPrice: z.number().positive().optional(),
depositAmount: z.number().min(0).optional(),
details: z.record(z.any()).optional(),
amenities: z.array(z.string()).optional(),
rules: z.array(z.string()).optional(),
cancellationPolicy: z.enum(['FLEXIBLE', 'MODERATE', 'STRICT']).optional(),
minDays: z.number().int().positive().optional(),
maxDays: z.number().int().positive().optional(),
minMonths: z.number().int().positive().optional(),
maxMonths: z.number().int().positive().optional(),
}).refine(data => data.dailyPrice || data.monthlyPrice, {
message: 'At least one price (daily or monthly) is required',
});
export const updateRentalSchema = z.object({
title: z.string().min(3).max(100).optional(),
description: z.string().min(10).max(5000).optional(),
category: z.enum(['APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE']).optional(),
location: z.string().min(1).max(200).optional(),
dailyPrice: z.number().positive().nullable().optional(),
monthlyPrice: z.number().positive().nullable().optional(),
depositAmount: z.number().min(0).nullable().optional(),
details: z.record(z.any()).optional(),
amenities: z.array(z.string()).optional(),
rules: z.array(z.string()).optional(),
cancellationPolicy: z.enum(['FLEXIBLE', 'MODERATE', 'STRICT']).optional(),
minDays: z.number().int().positive().nullable().optional(),
maxDays: z.number().int().positive().nullable().optional(),
minMonths: z.number().int().positive().nullable().optional(),
maxMonths: z.number().int().positive().nullable().optional(),
});

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const createReportSchema = z.object({
targetType: z.enum(['LISTING', 'USER']),
targetId: z.string().min(1),
reason: z.enum(['SPAM', 'INAPPROPRIATE', 'SCAM', 'COUNTERFEIT', 'PROHIBITED_ITEM', 'HARASSMENT', 'OTHER']),
description: z.string().max(1000).optional(),
});