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