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:
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;
|
||||
Reference in New Issue
Block a user