187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
/**
|
||
* 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 });
|
||
});
|