import { Router, Request, Response, NextFunction } from "express"; import { z } from "zod"; import { prisma } from "../lib/prisma.js"; import { requireAuth, requireRole } from "../middleware/auth.js"; import { AppError } from "../middleware/errorHandler.js"; import { parsePage, paginatedResponse } from "../lib/pagination.js"; import { AuthenticatedRequest } from "../types/index.js"; import { resolveVendorId } from "../lib/vendorScope.js"; const router = Router(); const auth = requireAuth as unknown as (r: Request, s: Response, n: NextFunction) => void; const vendorUp = requireRole("admin", "vendor") as unknown as (r: Request, s: Response, n: NextFunction) => void; const EventSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(1000).optional(), startsAt: z.string().datetime(), endsAt: z.string().datetime(), isActive: z.boolean().default(true), }); const EventTaxSchema = z.object({ taxId: z.string().min(1), rate: z.number().min(0).max(100), }); const EventProductSchema = z.object({ productId: z.string().min(1), }); // Helper: resolve vendorId scope (admin sees all, vendor sees own) function vendorScope(authReq: AuthenticatedRequest) { return authReq.auth.roleName === "admin" ? {} : { vendorId: authReq.auth.vendorId }; } // ─── GET /api/v1/events ──────────────────────────────────────────────────── router.get("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const { page, limit, skip } = parsePage(req.query as Record); const where = vendorScope(authReq); const [data, total] = await Promise.all([ prisma.event.findMany({ where, skip, take: limit, orderBy: { startsAt: "asc" }, include: { vendor: { select: { id: true, name: true } }, _count: { select: { products: true, taxOverrides: true, transactions: true } }, }, }), prisma.event.count({ where }), ]); res.json(paginatedResponse(data, total, { page, limit, skip })); } catch (err) { next(err); } }); // ─── POST /api/v1/events ─────────────────────────────────────────────────── router.post("/", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const body = EventSchema.parse(req.body); if (new Date(body.endsAt) <= new Date(body.startsAt)) { throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt"); } const targetVendorId = resolveVendorId(authReq, req.query as Record); const event = await prisma.event.create({ data: { ...body, startsAt: new Date(body.startsAt), endsAt: new Date(body.endsAt), vendorId: targetVendorId, }, include: { vendor: { select: { id: true, name: true } } }, }); res.status(201).json(event); } catch (err) { next(err); } }); // ─── GET /api/v1/events/:id ──────────────────────────────────────────────── router.get("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const event = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, include: { vendor: { select: { id: true, name: true } }, taxOverrides: { include: { tax: true } }, products: { include: { product: { select: { id: true, name: true, price: true, sku: true } } } }, }, }); if (!event) throw new AppError(404, "NOT_FOUND", "Event not found"); res.json(event); } catch (err) { next(err); } }); // ─── PUT /api/v1/events/:id ──────────────────────────────────────────────── router.put("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const existing = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!existing) throw new AppError(404, "NOT_FOUND", "Event not found"); const body = EventSchema.parse(req.body); if (new Date(body.endsAt) <= new Date(body.startsAt)) { throw new AppError(400, "BAD_REQUEST", "endsAt must be after startsAt"); } const event = await prisma.event.update({ where: { id: req.params.id }, data: { ...body, startsAt: new Date(body.startsAt), endsAt: new Date(body.endsAt) }, include: { vendor: { select: { id: true, name: true } } }, }); res.json(event); } catch (err) { next(err); } }); // ─── DELETE /api/v1/events/:id ───────────────────────────────────────────── router.delete("/:id", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const existing = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!existing) throw new AppError(404, "NOT_FOUND", "Event not found"); await prisma.event.delete({ where: { id: req.params.id } }); res.status(204).send(); } catch (err) { next(err); } }); // ─── Tax overrides ───────────────────────────────────────────────────────── // PUT /api/v1/events/:id/taxes — upsert a tax override (idempotent) router.put("/:id/taxes", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const event = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!event) throw new AppError(404, "NOT_FOUND", "Event not found"); const { taxId, rate } = EventTaxSchema.parse(req.body); // Verify tax belongs to same vendor const tax = await prisma.tax.findFirst({ where: { id: taxId, vendorId: event.vendorId } }); if (!tax) throw new AppError(404, "NOT_FOUND", "Tax not found"); const override = await prisma.eventTax.upsert({ where: { eventId_taxId: { eventId: event.id, taxId } }, create: { eventId: event.id, taxId, rate }, update: { rate }, include: { tax: true }, }); res.json(override); } catch (err) { next(err); } }); // DELETE /api/v1/events/:id/taxes/:taxId router.delete("/:id/taxes/:taxId", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const event = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!event) throw new AppError(404, "NOT_FOUND", "Event not found"); const existing = await prisma.eventTax.findUnique({ where: { eventId_taxId: { eventId: event.id, taxId: req.params.taxId } }, }); if (!existing) throw new AppError(404, "NOT_FOUND", "Tax override not found"); await prisma.eventTax.delete({ where: { eventId_taxId: { eventId: event.id, taxId: req.params.taxId } }, }); res.status(204).send(); } catch (err) { next(err); } }); // ─── Product allowlist ───────────────────────────────────────────────────── // GET /api/v1/events/:id/products router.get("/:id/products", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const event = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!event) throw new AppError(404, "NOT_FOUND", "Event not found"); const items = await prisma.eventProduct.findMany({ where: { eventId: event.id }, include: { product: { select: { id: true, name: true, price: true, sku: true } } }, }); res.json(items); } catch (err) { next(err); } }); // POST /api/v1/events/:id/products — add product to allowlist router.post("/:id/products", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const event = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!event) throw new AppError(404, "NOT_FOUND", "Event not found"); const { productId } = EventProductSchema.parse(req.body); // Verify product belongs to same vendor const product = await prisma.product.findFirst({ where: { id: productId, vendorId: event.vendorId } }); if (!product) throw new AppError(404, "NOT_FOUND", "Product not found"); const item = await prisma.eventProduct.upsert({ where: { eventId_productId: { eventId: event.id, productId } }, create: { eventId: event.id, productId }, update: {}, include: { product: { select: { id: true, name: true, price: true, sku: true } } }, }); res.status(201).json(item); } catch (err) { next(err); } }); // DELETE /api/v1/events/:id/products/:productId router.delete("/:id/products/:productId", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const event = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!event) throw new AppError(404, "NOT_FOUND", "Event not found"); const existing = await prisma.eventProduct.findUnique({ where: { eventId_productId: { eventId: event.id, productId: req.params.productId } }, }); if (!existing) throw new AppError(404, "NOT_FOUND", "Product not in event allowlist"); await prisma.eventProduct.delete({ where: { eventId_productId: { eventId: event.id, productId: req.params.productId } }, }); res.status(204).send(); } catch (err) { next(err); } }); // ─── GET /api/v1/events/:id/transactions ────────────────────────────────── router.get("/:id/transactions", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const event = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!event) throw new AppError(404, "NOT_FOUND", "Event not found"); const { page, limit, skip } = parsePage(req.query as Record); const where = { eventId: event.id }; const [data, total] = await Promise.all([ prisma.transaction.findMany({ where, skip, take: limit, orderBy: { createdAt: "desc" }, include: { user: { select: { id: true, name: true, email: true } }, items: true }, }), prisma.transaction.count({ where }), ]); res.json(paginatedResponse(data, total, { page, limit, skip })); } catch (err) { next(err); } }); // ─── GET /api/v1/events/:id/reports/summary ─────────────────────────────── router.get("/:id/reports/summary", auth, vendorUp, async (req: Request, res: Response, next: NextFunction) => { try { const authReq = req as AuthenticatedRequest; const event = await prisma.event.findFirst({ where: { id: req.params.id, ...vendorScope(authReq) }, }); if (!event) throw new AppError(404, "NOT_FOUND", "Event not found"); const where = { eventId: event.id, status: "completed" }; const [totals, byPayment, topProducts] = await Promise.all([ prisma.transaction.aggregate({ where, _sum: { total: true, taxTotal: true, discountTotal: true, subtotal: true }, _count: { id: true }, _avg: { total: true }, }), prisma.transaction.groupBy({ by: ["paymentMethod"], where, _sum: { total: true }, _count: { id: true }, }), prisma.transactionItem.groupBy({ by: ["productId", "productName"], where: { transaction: where }, _sum: { total: true, quantity: true }, orderBy: { _sum: { total: "desc" } }, take: 10, }), ]); res.json({ event: { id: event.id, name: event.name, startsAt: event.startsAt, endsAt: event.endsAt }, totals: { revenue: totals._sum.total ?? 0, subtotal: totals._sum.subtotal ?? 0, tax: totals._sum.taxTotal ?? 0, discounts: totals._sum.discountTotal ?? 0, transactionCount: totals._count.id, averageTransaction: totals._avg.total ?? 0, }, byPaymentMethod: byPayment.map((r) => ({ method: r.paymentMethod, revenue: r._sum.total ?? 0, count: r._count.id, })), topProducts: topProducts.map((r) => ({ productId: r.productId, productName: r.productName, revenue: r._sum.total ?? 0, unitsSold: r._sum.quantity ?? 0, })), }); } catch (err) { next(err); } }); export default router;