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:
@@ -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) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
userId?: string;
|
||||
userRole?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
server/src/middleware/checkBanned.ts
Normal file
24
server/src/middleware/checkBanned.ts
Normal 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();
|
||||
}
|
||||
47
server/src/middleware/requireRole.ts
Normal file
47
server/src/middleware/requireRole.ts
Normal 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');
|
||||
38
server/src/routes/admin/bookings.ts
Normal file
38
server/src/routes/admin/bookings.ts
Normal 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;
|
||||
30
server/src/routes/admin/index.ts
Normal file
30
server/src/routes/admin/index.ts
Normal 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;
|
||||
187
server/src/routes/admin/listings.ts
Normal file
187
server/src/routes/admin/listings.ts
Normal 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;
|
||||
73
server/src/routes/admin/moderation.ts
Normal file
73
server/src/routes/admin/moderation.ts
Normal 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;
|
||||
65
server/src/routes/admin/payments.ts
Normal file
65
server/src/routes/admin/payments.ts
Normal 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;
|
||||
109
server/src/routes/admin/rental-payouts.ts
Normal file
109
server/src/routes/admin/rental-payouts.ts
Normal 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;
|
||||
150
server/src/routes/admin/rentals.ts
Normal file
150
server/src/routes/admin/rentals.ts
Normal 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;
|
||||
107
server/src/routes/admin/reports.ts
Normal file
107
server/src/routes/admin/reports.ts
Normal 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;
|
||||
43
server/src/routes/admin/settings.ts
Normal file
43
server/src/routes/admin/settings.ts
Normal 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;
|
||||
83
server/src/routes/admin/stats.ts
Normal file
83
server/src/routes/admin/stats.ts
Normal 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;
|
||||
195
server/src/routes/admin/users.ts
Normal file
195
server/src/routes/admin/users.ts
Normal 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;
|
||||
@@ -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 });
|
||||
|
||||
357
server/src/routes/booking.ts
Normal file
357
server/src/routes/booking.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
116
server/src/routes/payout.ts
Normal 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;
|
||||
72
server/src/routes/promotion.ts
Normal file
72
server/src/routes/promotion.ts
Normal 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;
|
||||
127
server/src/routes/rental-payment.ts
Normal file
127
server/src/routes/rental-payment.ts
Normal 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;
|
||||
103
server/src/routes/rental-review.ts
Normal file
103
server/src/routes/rental-review.ts
Normal 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
406
server/src/routes/rental.ts
Normal 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;
|
||||
45
server/src/routes/report.ts
Normal file
45
server/src/routes/report.ts
Normal 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;
|
||||
76
server/src/routes/subscription.ts
Normal file
76
server/src/routes/subscription.ts
Normal 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;
|
||||
82
server/src/utils/moderation.ts
Normal file
82
server/src/utils/moderation.ts
Normal 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
134
server/src/utils/rental.ts
Normal 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;
|
||||
}
|
||||
35
server/src/validators/admin.ts
Normal file
35
server/src/validators/admin.ts
Normal 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(),
|
||||
});
|
||||
17
server/src/validators/booking.ts
Normal file
17
server/src/validators/booking.ts
Normal 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),
|
||||
});
|
||||
11
server/src/validators/rental-review.ts
Normal file
11
server/src/validators/rental-review.ts
Normal 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),
|
||||
});
|
||||
39
server/src/validators/rental.ts
Normal file
39
server/src/validators/rental.ts
Normal 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(),
|
||||
});
|
||||
8
server/src/validators/report.ts
Normal file
8
server/src/validators/report.ts
Normal 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(),
|
||||
});
|
||||
Reference in New Issue
Block a user