- Add resolveVendorId() helper — admin can pass ?vendorId= to scope catalog operations to any vendor; other roles locked to JWT vendorId - Thread ?vendorId= through products, categories, taxes, events routes - Add DELETE /vendors/:id (admin only) with cascade-safe guard: blocks if vendor has users or transactions; otherwise cascade-deletes EventProduct → EventTax → Event → Product → Tax → Category → Vendor - Rewrite VendorPage: admin gets full CRUD list, vendor gets own settings - Add VendorFilter shared component (admin-only dropdown) - Integrate VendorFilter into Catalog, Users, and Events pages so admin can switch vendor context for all create/read operations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
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<string, unknown>);
|
|
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<string, unknown>);
|
|
|
|
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<string, unknown>);
|
|
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;
|