Scaffold and Phase 1
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user