Phase 3 and 4
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user