Scaffold and Phase 1

This commit is contained in:
2026-05-02 19:46:42 -05:00
parent ab74e7cad4
commit d909cb7c30
92 changed files with 4967 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
/**
* 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 });
});