Files
storybid/packages/server/src/routes/items.ts
T
2026-05-02 19:46:42 -05:00

187 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 });
});