Phase 3 and 4

This commit is contained in:
2026-05-04 14:23:10 -05:00
parent 056bd27f89
commit 884043cf22
24 changed files with 1847 additions and 175 deletions
+45
View File
@@ -142,3 +142,48 @@ auctionsRouter.post("/:id/windows", requireAuth, STAFF_WRITE, async (req, res) =
});
res.status(201).json(window);
});
const UpdateWindowSchema = z.object({
name: z.string().min(1).optional(),
opensAt: z.string().datetime().optional(),
closesAt: z.string().datetime().optional(),
softCloseEnabled: z.boolean().optional(),
softCloseExtendMinutes: z.number().int().min(1).max(60).optional(),
status: z.enum(["pending", "open", "closed"]).optional(),
});
auctionsRouter.patch("/:id/windows/:windowId", requireAuth, AUCTIONEER, async (req, res) => {
const parse = UpdateWindowSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const window = await prisma.silentAuctionWindow.updateMany({
where: { id: req.params["windowId"], auctionId: req.params["id"] },
data: parse.data,
});
if (window.count === 0) {
res.status(404).json({ error: "Window not found" });
return;
}
const updated = await prisma.silentAuctionWindow.findUnique({
where: { id: req.params["windowId"] },
});
res.json(updated);
});
auctionsRouter.delete("/:id/windows/:windowId", requireAuth, STAFF_WRITE, async (req, res) => {
const window = await prisma.silentAuctionWindow.findFirst({
where: { id: req.params["windowId"], auctionId: req.params["id"] },
});
if (!window) {
res.status(404).json({ error: "Window not found" });
return;
}
if (window.status !== "pending") {
res.status(409).json({ error: "Cannot delete a window that has been opened" });
return;
}
await prisma.silentAuctionWindow.delete({ where: { id: window.id } });
res.json({ ok: true });
});
+26 -2
View File
@@ -259,7 +259,31 @@ biddersRouter.get("/:id/bids", requireAuth, async (req, res) => {
const bids = await prisma.bid.findMany({
where: { bidderId: req.params["id"] },
orderBy: { createdAt: "desc" },
include: { item: { select: { title: true, lotNumber: true, state: true } } },
include: {
item: {
select: {
title: true,
lotNumber: true,
state: true,
currentHighBid: true,
currentHighBidderId: true,
},
},
},
});
res.json(bids);
// Serialize Decimal fields
const serialized = bids.map((b) => ({
...b,
amount: Number(b.amount),
clientCreatedAt: b.clientCreatedAt.toISOString(),
serverReceivedAt: b.serverReceivedAt.toISOString(),
createdAt: b.createdAt.toISOString(),
item: {
...b.item,
currentHighBid: b.item.currentHighBid ? Number(b.item.currentHighBid) : null,
},
}));
res.json(serialized);
});
+6 -5
View File
@@ -20,7 +20,7 @@ export interface PlaceBidInput {
}
export type BidResult =
| { ok: true; bid: Awaited<ReturnType<typeof prisma.bid.create>>; item: Awaited<ReturnType<typeof prisma.auctionItem.findUniqueOrThrow>> }
| { ok: true; bid: Awaited<ReturnType<typeof prisma.bid.create>>; item: Awaited<ReturnType<typeof prisma.auctionItem.findUniqueOrThrow>>; windowExtendedTo?: string }
| { ok: false; error: string; code: "ITEM_NOT_FOUND" | "WINDOW_CLOSED" | "ITEM_STATE" | "AMOUNT_TOO_LOW" | "DUPLICATE" };
/**
@@ -121,6 +121,7 @@ export async function placeBid(input: PlaceBidInput): Promise<BidResult> {
});
// 8. Soft-close extension for silent auction
let windowExtendedTo: string | undefined;
if (
auction.type === "silent" &&
updatedItem.softCloseEnabled &&
@@ -133,16 +134,16 @@ export async function placeBid(input: PlaceBidInput): Promise<BidResult> {
const msRemaining = window.closesAt.getTime() - Date.now();
const extendThresholdMs = updatedItem.softCloseExtendMinutes * 60 * 1000;
if (msRemaining < extendThresholdMs) {
const newClosesAt = new Date(Date.now() + extendThresholdMs);
await tx.silentAuctionWindow.update({
where: { id: window.id },
data: {
closesAt: new Date(Date.now() + extendThresholdMs),
},
data: { closesAt: newClosesAt },
});
windowExtendedTo = newClosesAt.toISOString();
}
}
}
return { ok: true, bid, item: updatedItem };
return { ok: true, bid, item: updatedItem, windowExtendedTo };
});
}
+18
View File
@@ -19,6 +19,7 @@ import type {
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
const POLL_INTERVAL_MS = 10_000;
const CLOSING_WARN_MS = 5 * 60 * 1000; // warn 5 minutes before close
export function startScheduler(io: IO): void {
console.log("[scheduler] starting silent auction window poller");
@@ -107,6 +108,7 @@ async function closeExpiredWindows(io: IO): Promise<void> {
data: { status: "open" },
});
// Tell clients the close time so they can start their countdowns
io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", {
windowId: window.id,
closesAt: window.closesAt.toISOString(),
@@ -114,4 +116,20 @@ async function closeExpiredWindows(io: IO): Promise<void> {
console.log(`[scheduler] opened window ${window.id} (${window.name})`);
}
// Emit a closing-soon warning for open windows within the 5-minute threshold
const closingSoonWindows = await prisma.silentAuctionWindow.findMany({
where: {
status: "open",
closesAt: { gt: now, lte: new Date(now.getTime() + CLOSING_WARN_MS) },
},
include: { auction: { select: { eventId: true } } },
});
for (const window of closingSoonWindows) {
io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", {
windowId: window.id,
closesAt: window.closesAt.toISOString(),
});
}
}
+7
View File
@@ -45,6 +45,13 @@ export function registerSocketHandlers(io: IO): void {
void socket.join(`bidder:${socket.data.bidderId}`);
}
// Tell the client which connectivity path is active.
// The x-origin-mode handshake header is set by the client's connection manager.
const originHint = socket.handshake.headers["x-origin-mode"] as string | undefined;
const syncStatus: "connected" | "local" | "offline" =
originHint === "local_dns" || originHint === "local_ip" ? "local" : "connected";
socket.emit("sync_status_changed", { status: syncStatus });
// Room join/leave for event-scoped broadcasts
socket.on("join_event", (eventId) => {
void socket.join(`event:${eventId}`);
@@ -70,6 +70,14 @@ export function registerSilentAuctionHandlers(io: IO, socket: Sock): void {
item: serializedItem,
});
// If a soft-close extension happened, tell all clients the new close time
if (result.windowExtendedTo && serializedItem.silentWindowId) {
io.to(`event:${item.auction.eventId}`).emit("silent_window_extended", {
windowId: serializedItem.silentWindowId,
newClosesAt: result.windowExtendedTo,
});
}
// Notify the previously winning bidder that they've been outbid.
// We find the second-highest bid for this item.
const previousBid = await prisma.bid.findFirst({