/** * GET /api/items?auctionId= – catalog (bidders see active/preview only) * POST /api/items – create item * GET /api/items/:id – get item with media + bid history * PATCH /api/items/:id – update item * DELETE /api/items/:id – delete item (draft only) * POST /api/items/:id/media – attach media record after S3 upload * DELETE /api/items/:id/media/:mediaId – remove media */ import { Router } from "express"; import { z } from "zod"; import { prisma } from "../lib/prisma.js"; import { requireAuth, requireRole } from "../middleware/auth.js"; export const itemsRouter = Router(); const STAFF_WRITE = requireRole("admin", "event_manager"); // ── List / catalog ───────────────────────────────────────────────────────────── itemsRouter.get("/", requireAuth, async (req, res) => { const { auctionId } = req.query; if (typeof auctionId !== "string") { res.status(400).json({ error: "auctionId query param required" }); return; } const isStaff = ["admin", "event_manager", "auctioneer", "spotter"].includes( req.auth!.role, ); const items = await prisma.auctionItem.findMany({ where: { auctionId, // Bidders only see preview/active/going_once/going_twice/sold/closed ...(!isStaff && { state: { notIn: ["passed"] } }), }, orderBy: { sortOrder: "asc" }, include: { media: { orderBy: { sortOrder: "asc" } }, _count: { select: { bids: true } }, }, }); res.json(items); }); // ── Create ───────────────────────────────────────────────────────────────────── const CreateItemSchema = z.object({ auctionId: z.string(), lotNumber: z.string().min(1), title: z.string().min(1), description: z.string().nullable().optional(), donorName: z.string().nullable().optional(), category: z.string().nullable().optional(), fairMarketValue: z.number().positive().nullable().optional(), openingBid: z.number().min(0).default(0), reservePrice: z.number().positive().nullable().optional(), bidIncrement: z.number().positive().default(10), pickupNotes: z.string().nullable().optional(), sortOrder: z.number().int().default(0), silentWindowId: z.string().nullable().optional(), softCloseEnabled: z.boolean().default(false), softCloseExtendMinutes: z.number().int().min(1).max(60).default(2), }); itemsRouter.post("/", requireAuth, STAFF_WRITE, async (req, res) => { const parse = CreateItemSchema.safeParse(req.body); if (!parse.success) { res.status(400).json({ error: parse.error.flatten() }); return; } // Check lot number uniqueness within auction const dup = await prisma.auctionItem.findUnique({ where: { auctionId_lotNumber: { auctionId: parse.data.auctionId, lotNumber: parse.data.lotNumber, }, }, }); if (dup) { res.status(409).json({ error: "Lot number already exists in this auction" }); return; } const item = await prisma.auctionItem.create({ data: parse.data }); res.status(201).json(item); }); // ── Get ──────────────────────────────────────────────────────────────────────── itemsRouter.get("/:id", requireAuth, async (req, res) => { const item = await prisma.auctionItem.findUnique({ where: { id: req.params["id"] }, include: { media: { orderBy: { sortOrder: "asc" } }, bids: { orderBy: { createdAt: "desc" }, take: 20, include: { bidder: { select: { paddleNumber: true } } }, }, }, }); if (!item) { res.status(404).json({ error: "Item not found" }); return; } // Bidders see abbreviated bid history (no paddleNumbers of others) if (req.auth!.role === "bidder") { const safe = { ...item, bids: item.bids.map((b) => ({ id: b.id, amount: b.amount, isWinning: b.isWinning, createdAt: b.createdAt, isMine: b.bidderId === req.auth!.sub, })), }; res.json(safe); return; } res.json(item); }); // ── Update ───────────────────────────────────────────────────────────────────── const UpdateItemSchema = CreateItemSchema.omit({ auctionId: true }).partial().extend({ state: z.enum(["preview", "active", "going_once", "going_twice", "sold", "passed", "closed"]).optional(), }); itemsRouter.patch("/:id", requireAuth, STAFF_WRITE, async (req, res) => { const parse = UpdateItemSchema.safeParse(req.body); if (!parse.success) { res.status(400).json({ error: parse.error.flatten() }); return; } const item = await prisma.auctionItem.update({ where: { id: req.params["id"] }, data: parse.data, }); res.json(item); }); // ── Delete ───────────────────────────────────────────────────────────────────── itemsRouter.delete("/:id", requireAuth, STAFF_WRITE, async (req, res) => { const item = await prisma.auctionItem.findUnique({ where: { id: req.params["id"] } }); if (!item) { res.status(404).json({ error: "Item not found" }); return; } if (item.state !== "preview") { res.status(409).json({ error: "Cannot delete an item that has been activated" }); return; } await prisma.auctionItem.delete({ where: { id: item.id } }); res.json({ ok: true }); }); // ── Attach media (after client uploads to S3) ────────────────────────────────── const AttachMediaSchema = z.object({ mediaType: z.enum(["image", "video", "document", "embed"]), url: z.string().url(), thumbnailUrl: z.string().url().nullable().optional(), caption: z.string().nullable().optional(), sortOrder: z.number().int().default(0), }); itemsRouter.post("/:id/media", requireAuth, STAFF_WRITE, async (req, res) => { const parse = AttachMediaSchema.safeParse(req.body); if (!parse.success) { res.status(400).json({ error: parse.error.flatten() }); return; } const media = await prisma.itemMedia.create({ data: { ...parse.data, itemId: req.params["id"] }, }); res.status(201).json(media); }); itemsRouter.delete("/:id/media/:mediaId", requireAuth, STAFF_WRITE, async (req, res) => { await prisma.itemMedia.deleteMany({ where: { id: req.params["mediaId"], itemId: req.params["id"] }, }); res.json({ ok: true }); });