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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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