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 });
|
|||
|
|
});
|