Phase 2 and Demo
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
||||
|
||||
import { app } from "./app.js";
|
||||
import { registerSocketHandlers } from "./socket/index.js";
|
||||
import { startScheduler } from "./services/scheduler.js";
|
||||
import { prisma } from "./lib/prisma.js";
|
||||
|
||||
const PORT = parseInt(process.env["PORT"] ?? "3001", 10);
|
||||
@@ -31,6 +32,7 @@ export const io = new Server<
|
||||
});
|
||||
|
||||
registerSocketHandlers(io);
|
||||
startScheduler(io);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`[server] listening on http://localhost:${PORT}`);
|
||||
|
||||
@@ -1,31 +1,256 @@
|
||||
/**
|
||||
* GET /api/checkout/:bidderId – get invoice for bidder
|
||||
* POST /api/checkout/:bidderId/pay – create Stripe Payment Intent
|
||||
* POST /api/checkout/:bidderId/capture – capture/finalize payment
|
||||
* POST /api/checkout/donate – one-time donation
|
||||
* POST /api/checkout/paddle-raise – paddle raise donation
|
||||
* GET /api/checkout/:bidderId – get or create invoice for a bidder + event
|
||||
* POST /api/checkout/:bidderId/intent – create Stripe Payment Intent, return client_secret
|
||||
* POST /api/checkout/:bidderId/complete – mark invoice paid after webhook confirms
|
||||
* POST /api/checkout/donate – one-time donation Payment Intent
|
||||
* POST /api/checkout/paddle-raise – paddle raise donation Payment Intent
|
||||
*/
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import Stripe from "stripe";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||
|
||||
export const checkoutRouter = Router();
|
||||
|
||||
checkoutRouter.get("/:bidderId", requireAuth, (_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
function getStripe(): Stripe {
|
||||
const key = process.env["STRIPE_SECRET_KEY"];
|
||||
if (!key) throw new Error("STRIPE_SECRET_KEY is not configured");
|
||||
return new Stripe(key, { apiVersion: "2024-04-10" });
|
||||
}
|
||||
|
||||
// ── Get / create invoice ───────────────────────────────────────────────────────
|
||||
|
||||
checkoutRouter.get("/:bidderId", requireAuth, async (req, res) => {
|
||||
const { bidderId } = req.params;
|
||||
const { eventId } = req.query;
|
||||
|
||||
const isOwn = req.auth!.sub === bidderId;
|
||||
const isStaff = ["admin", "event_manager"].includes(req.auth!.role);
|
||||
if (!isOwn && !isStaff) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof eventId !== "string") {
|
||||
res.status(400).json({ error: "eventId query param required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find existing open invoice or build a new draft from winning bids
|
||||
let invoice = await prisma.invoice.findFirst({
|
||||
where: { bidderId, eventId, status: { notIn: ["void"] } },
|
||||
include: { payments: true },
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
// Tally all items where this bidder is the current high bidder + item is sold/closed
|
||||
const wonItems = await prisma.auctionItem.findMany({
|
||||
where: {
|
||||
currentHighBidderId: bidderId,
|
||||
state: { in: ["sold", "closed"] },
|
||||
auction: { eventId },
|
||||
},
|
||||
select: { id: true, title: true, currentHighBid: true },
|
||||
});
|
||||
|
||||
const totalAmount = wonItems.reduce(
|
||||
(sum, i) => sum + Number(i.currentHighBid ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
invoice = await prisma.invoice.create({
|
||||
data: {
|
||||
bidderId,
|
||||
eventId,
|
||||
totalAmount,
|
||||
status: totalAmount > 0 ? "open" : "draft",
|
||||
},
|
||||
include: { payments: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Attach won item summary
|
||||
const wonItems = await prisma.auctionItem.findMany({
|
||||
where: {
|
||||
currentHighBidderId: bidderId,
|
||||
state: { in: ["sold", "closed"] },
|
||||
auction: { eventId },
|
||||
},
|
||||
select: { id: true, title: true, lotNumber: true, currentHighBid: true },
|
||||
});
|
||||
|
||||
res.json({ invoice, wonItems });
|
||||
});
|
||||
|
||||
checkoutRouter.post("/:bidderId/pay", requireAuth, (_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
// ── Create Payment Intent ──────────────────────────────────────────────────────
|
||||
|
||||
const IntentSchema = z.object({ eventId: z.string() });
|
||||
|
||||
checkoutRouter.post("/:bidderId/intent", requireAuth, async (req, res) => {
|
||||
const { bidderId } = req.params;
|
||||
|
||||
const isOwn = req.auth!.sub === bidderId;
|
||||
const isStaff = ["admin", "event_manager"].includes(req.auth!.role);
|
||||
if (!isOwn && !isStaff) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parse = IntentSchema.safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const invoice = await prisma.invoice.findFirst({
|
||||
where: { bidderId, eventId: parse.data.eventId, status: "open" },
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
res.status(404).json({ error: "No open invoice found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(invoice.totalAmount) <= 0) {
|
||||
res.status(400).json({ error: "Invoice total is zero" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
|
||||
// Amount in cents
|
||||
const amountCents = Math.round(Number(invoice.totalAmount) * 100);
|
||||
|
||||
const bidder = await prisma.bidder.findUniqueOrThrow({
|
||||
where: { id: bidderId },
|
||||
});
|
||||
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount: amountCents,
|
||||
currency: "usd",
|
||||
metadata: {
|
||||
invoiceId: invoice.id,
|
||||
bidderId,
|
||||
eventId: parse.data.eventId,
|
||||
},
|
||||
description: `Auction invoice ${invoice.id}`,
|
||||
receipt_email: bidder.email ?? undefined,
|
||||
});
|
||||
|
||||
// Persist the Payment Intent ID on the invoice
|
||||
await prisma.invoice.update({
|
||||
where: { id: invoice.id },
|
||||
data: { stripeInvoiceId: intent.id },
|
||||
});
|
||||
|
||||
// Create a pending payment record
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
invoiceId: invoice.id,
|
||||
stripePaymentIntentId: intent.id,
|
||||
amount: invoice.totalAmount,
|
||||
currency: "usd",
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ clientSecret: intent.client_secret, invoiceId: invoice.id });
|
||||
} catch (err) {
|
||||
console.error("[checkout] Stripe error", err);
|
||||
res.status(502).json({ error: "Payment provider error. Please try again." });
|
||||
}
|
||||
});
|
||||
|
||||
checkoutRouter.post("/:bidderId/capture", requireAuth, requireRole("admin", "event_manager"), (_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
// ── Donation ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const DonateSchema = z.object({
|
||||
eventId: z.string(),
|
||||
amount: z.number().positive(),
|
||||
campaignId: z.string().optional(),
|
||||
anonymous: z.boolean().default(false),
|
||||
});
|
||||
|
||||
checkoutRouter.post("/donate", requireAuth, (_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
checkoutRouter.post("/donate", requireAuth, async (req, res) => {
|
||||
const parse = DonateSchema.safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const { eventId, amount, campaignId, anonymous } = parse.data;
|
||||
const bidderId = req.auth!.role === "bidder" ? req.auth!.sub : null;
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
const amountCents = Math.round(amount * 100);
|
||||
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount: amountCents,
|
||||
currency: "usd",
|
||||
metadata: { eventId, bidderId: bidderId ?? "guest", campaignId: campaignId ?? "" },
|
||||
description: campaignId ? `Paddle raise donation` : `General donation`,
|
||||
});
|
||||
|
||||
// Stage donation record — confirmed by webhook
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
eventId,
|
||||
bidderId,
|
||||
campaignId: campaignId ?? null,
|
||||
amount,
|
||||
anonymous,
|
||||
stripePaymentIntentId: intent.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ clientSecret: intent.client_secret });
|
||||
} catch (err) {
|
||||
console.error("[checkout] donation Stripe error", err);
|
||||
res.status(502).json({ error: "Payment provider error. Please try again." });
|
||||
}
|
||||
});
|
||||
|
||||
checkoutRouter.post("/paddle-raise", requireAuth, (_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
// ── Paddle Raise ───────────────────────────────────────────────────────────────
|
||||
// Paddle raise uses the same donation flow but we update the campaign total on success.
|
||||
// Reuse /donate with a campaignId — the webhook handles incrementing totalRaised.
|
||||
|
||||
checkoutRouter.post("/paddle-raise", requireAuth, async (req, res) => {
|
||||
// Alias to /donate — campaignId required
|
||||
const parse = DonateSchema.extend({ campaignId: z.string() }).safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.flatten() });
|
||||
return;
|
||||
}
|
||||
req.body = parse.data;
|
||||
// Forward to donate handler logic inline
|
||||
const { eventId, amount, campaignId, anonymous } = parse.data;
|
||||
const bidderId = req.auth!.role === "bidder" ? req.auth!.sub : null;
|
||||
|
||||
try {
|
||||
const stripe = getStripe();
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(amount * 100),
|
||||
currency: "usd",
|
||||
metadata: { eventId, bidderId: bidderId ?? "guest", campaignId },
|
||||
description: "Paddle raise donation",
|
||||
});
|
||||
|
||||
await prisma.donation.create({
|
||||
data: {
|
||||
eventId,
|
||||
bidderId,
|
||||
campaignId,
|
||||
amount,
|
||||
anonymous,
|
||||
stripePaymentIntentId: intent.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ clientSecret: intent.client_secret });
|
||||
} catch (err) {
|
||||
console.error("[checkout] paddle raise Stripe error", err);
|
||||
res.status(502).json({ error: "Payment provider error. Please try again." });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,23 +1,154 @@
|
||||
/**
|
||||
* GET /api/reporting/events/:id/summary – event revenue & sell-through
|
||||
* GET /api/reporting/events/:id/bidders – bidder activity report
|
||||
* GET /api/reporting/events/:id/audit-log – full audit log
|
||||
* GET /api/reporting/events/:id/summary – revenue, sell-through, item count
|
||||
* GET /api/reporting/events/:id/bidders – per-bidder activity + invoice status
|
||||
* GET /api/reporting/events/:id/audit-log – full audit log with pagination
|
||||
*/
|
||||
import { Router } from "express";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||
|
||||
export const reportingRouter = Router();
|
||||
|
||||
const adminOnly = requireRole("admin", "event_manager");
|
||||
|
||||
reportingRouter.get("/events/:id/summary", requireAuth, adminOnly, (_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
// ── Event summary ──────────────────────────────────────────────────────────────
|
||||
reportingRouter.get("/events/:id/summary", requireAuth, adminOnly, async (req, res) => {
|
||||
const eventId = req.params["id"]!;
|
||||
|
||||
const [event, auctions, invoices, donations] = await Promise.all([
|
||||
prisma.auctionEvent.findFirst({
|
||||
where: { id: eventId, organizationId: req.auth!.organizationId },
|
||||
}),
|
||||
prisma.auction.findMany({
|
||||
where: { eventId },
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
state: true,
|
||||
currentHighBid: true,
|
||||
fairMarketValue: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.invoice.findMany({ where: { eventId } }),
|
||||
prisma.donation.findMany({ where: { eventId } }),
|
||||
]);
|
||||
|
||||
if (!event) {
|
||||
res.status(404).json({ error: "Event not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const allItems = auctions.flatMap((a) => a.items);
|
||||
const soldItems = allItems.filter((i) => i.state === "sold" || i.state === "closed");
|
||||
|
||||
const grossRevenue = soldItems.reduce(
|
||||
(sum, i) => sum + Number(i.currentHighBid ?? 0),
|
||||
0,
|
||||
);
|
||||
const totalDonations = donations.reduce((sum, d) => sum + Number(d.amount), 0);
|
||||
const totalPaid = invoices.reduce((sum, inv) => sum + Number(inv.paidAmount), 0);
|
||||
const totalOutstanding = invoices
|
||||
.filter((inv) => inv.status !== "paid" && inv.status !== "void")
|
||||
.reduce((sum, inv) => sum + (Number(inv.totalAmount) - Number(inv.paidAmount)), 0);
|
||||
|
||||
res.json({
|
||||
event,
|
||||
items: {
|
||||
total: allItems.length,
|
||||
sold: soldItems.length,
|
||||
sellThroughPct:
|
||||
allItems.length > 0
|
||||
? Math.round((soldItems.length / allItems.length) * 100)
|
||||
: 0,
|
||||
},
|
||||
revenue: {
|
||||
gross: grossRevenue,
|
||||
donations: totalDonations,
|
||||
total: grossRevenue + totalDonations,
|
||||
collected: totalPaid,
|
||||
outstanding: totalOutstanding,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
reportingRouter.get("/events/:id/bidders", requireAuth, adminOnly, (_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
// ── Bidder activity ────────────────────────────────────────────────────────────
|
||||
reportingRouter.get("/events/:id/bidders", requireAuth, adminOnly, async (req, res) => {
|
||||
const eventId = req.params["id"]!;
|
||||
|
||||
const enrollments = await prisma.bidderEventEnrollment.findMany({
|
||||
where: { eventId },
|
||||
include: {
|
||||
bidder: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { bidder: { lastName: "asc" } },
|
||||
});
|
||||
|
||||
const invoices = await prisma.invoice.findMany({
|
||||
where: { eventId },
|
||||
select: { bidderId: true, totalAmount: true, paidAmount: true, status: true },
|
||||
});
|
||||
|
||||
const invoiceByBidder = Object.fromEntries(
|
||||
invoices.map((inv) => [inv.bidderId, inv]),
|
||||
);
|
||||
|
||||
const wonItemsByBidder = await prisma.auctionItem.groupBy({
|
||||
by: ["currentHighBidderId"],
|
||||
where: {
|
||||
state: { in: ["sold", "closed"] },
|
||||
auction: { eventId },
|
||||
currentHighBidderId: { not: null },
|
||||
},
|
||||
_count: { id: true },
|
||||
_sum: { currentHighBid: true },
|
||||
});
|
||||
|
||||
const wonMap = Object.fromEntries(
|
||||
wonItemsByBidder.map((row) => [
|
||||
row.currentHighBidderId,
|
||||
{ count: row._count.id, total: Number(row._sum.currentHighBid ?? 0) },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = enrollments.map((e) => ({
|
||||
...e,
|
||||
invoice: invoiceByBidder[e.bidderId] ?? null,
|
||||
won: wonMap[e.bidderId] ?? { count: 0, total: 0 },
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
reportingRouter.get("/events/:id/audit-log", requireAuth, adminOnly, (_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
// ── Audit log ──────────────────────────────────────────────────────────────────
|
||||
reportingRouter.get("/events/:id/audit-log", requireAuth, adminOnly, async (req, res) => {
|
||||
const eventId = req.params["id"]!;
|
||||
const page = parseInt(String(req.query["page"] ?? "1"), 10);
|
||||
const limit = Math.min(parseInt(String(req.query["limit"] ?? "100"), 10), 500);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where: { eventId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
include: {
|
||||
staffUser: { select: { name: true, email: true, role: true } },
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({ where: { eventId } }),
|
||||
]);
|
||||
|
||||
res.json({ logs, total, page, pages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
@@ -1,16 +1,140 @@
|
||||
/**
|
||||
* POST /api/webhooks/stripe – Stripe webhook handler (raw body required)
|
||||
* POST /api/webhooks/stripe
|
||||
*
|
||||
* Handles payment_intent.succeeded and payment_intent.payment_failed.
|
||||
* Raw body is required for signature verification (mounted before express.json()).
|
||||
*/
|
||||
import { Router } from "express";
|
||||
import express from "express";
|
||||
import Stripe from "stripe";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
|
||||
export const webhooksRouter = Router();
|
||||
|
||||
// Raw body needed for Stripe signature verification
|
||||
webhooksRouter.post(
|
||||
"/stripe",
|
||||
express.raw({ type: "application/json" }),
|
||||
(_req, res) => {
|
||||
res.status(501).json({ error: "Not implemented" });
|
||||
async (req, res) => {
|
||||
const sig = req.headers["stripe-signature"];
|
||||
const secret = process.env["STRIPE_WEBHOOK_SECRET"];
|
||||
|
||||
if (!sig || !secret) {
|
||||
res.status(400).json({ error: "Missing signature or webhook secret" });
|
||||
return;
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
const stripe = new Stripe(process.env["STRIPE_SECRET_KEY"] ?? "", {
|
||||
apiVersion: "2024-04-10",
|
||||
});
|
||||
event = stripe.webhooks.constructEvent(req.body as Buffer, sig, secret);
|
||||
} catch (err) {
|
||||
console.error("[webhook] signature verification failed", err);
|
||||
res.status(400).json({ error: "Invalid signature" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "payment_intent.succeeded":
|
||||
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
|
||||
break;
|
||||
|
||||
case "payment_intent.payment_failed":
|
||||
await handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Ignore other event types
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[webhook] error handling ${event.type}`, err);
|
||||
// Return 200 so Stripe doesn't retry — log and investigate separately
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handlePaymentSucceeded(intent: Stripe.PaymentIntent): Promise<void> {
|
||||
const { invoiceId, bidderId, eventId, campaignId } = intent.metadata;
|
||||
|
||||
// ── Invoice payment ─────────────────────────────────────────────────────────
|
||||
if (invoiceId) {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: { stripePaymentIntentId: intent.id },
|
||||
});
|
||||
|
||||
if (payment) {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: "succeeded" },
|
||||
});
|
||||
}
|
||||
|
||||
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||
if (invoice) {
|
||||
const newPaid = Number(invoice.paidAmount) + Number(intent.amount) / 100;
|
||||
const total = Number(invoice.totalAmount);
|
||||
const status = newPaid >= total ? "paid" : "partially_paid";
|
||||
|
||||
await prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: { paidAmount: newPaid, status },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
eventId,
|
||||
action: "invoice_paid",
|
||||
entityType: "Invoice",
|
||||
entityId: invoiceId,
|
||||
payload: { amount: intent.amount / 100, status },
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[webhook] invoice ${invoiceId} → ${status} ($${newPaid})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Donation / paddle raise ─────────────────────────────────────────────────
|
||||
if (!invoiceId && bidderId) {
|
||||
const donation = await prisma.donation.findFirst({
|
||||
where: { stripePaymentIntentId: intent.id },
|
||||
});
|
||||
|
||||
if (donation && campaignId) {
|
||||
await prisma.paddleRaiseCampaign.update({
|
||||
where: { id: campaignId },
|
||||
data: { totalRaised: { increment: Number(intent.amount) / 100 } },
|
||||
});
|
||||
|
||||
const campaign = await prisma.paddleRaiseCampaign.findUnique({
|
||||
where: { id: campaignId },
|
||||
});
|
||||
|
||||
if (campaign) {
|
||||
console.log(
|
||||
`[webhook] paddle raise ${campaignId} total → $${Number(campaign.totalRaised)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaymentFailed(intent: Stripe.PaymentIntent): Promise<void> {
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: { stripePaymentIntentId: intent.id },
|
||||
});
|
||||
if (payment) {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: "failed" },
|
||||
});
|
||||
console.warn(`[webhook] payment failed for intent ${intent.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Silent auction window scheduler.
|
||||
*
|
||||
* Polls every 10 seconds for windows whose closesAt has passed, closes them,
|
||||
* marks each item as "closed", and broadcasts silent_item_closed to the event room.
|
||||
*
|
||||
* This runs entirely on the local server — no external dependencies — so it
|
||||
* continues working when the internet is unavailable.
|
||||
*/
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import type { Server } from "socket.io";
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
InterServerEvents,
|
||||
SocketData,
|
||||
} from "@storybid/shared";
|
||||
|
||||
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
|
||||
export function startScheduler(io: IO): void {
|
||||
console.log("[scheduler] starting silent auction window poller");
|
||||
|
||||
setInterval(() => {
|
||||
void closeExpiredWindows(io);
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function closeExpiredWindows(io: IO): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
// Find open windows whose close time has passed
|
||||
const expiredWindows = await prisma.silentAuctionWindow.findMany({
|
||||
where: {
|
||||
status: "open",
|
||||
closesAt: { lte: now },
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
where: { state: { notIn: ["sold", "passed", "closed"] } },
|
||||
include: { auction: { select: { eventId: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const window of expiredWindows) {
|
||||
// Mark the window closed
|
||||
await prisma.silentAuctionWindow.update({
|
||||
where: { id: window.id },
|
||||
data: { status: "closed" },
|
||||
});
|
||||
|
||||
// Close each item still in the window
|
||||
for (const item of window.items) {
|
||||
const closed = await prisma.auctionItem.update({
|
||||
where: { id: item.id },
|
||||
data: { state: "closed" },
|
||||
});
|
||||
|
||||
// Write audit log entry
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
eventId: item.auction.eventId,
|
||||
action: "item_auto_closed",
|
||||
entityType: "AuctionItem",
|
||||
entityId: item.id,
|
||||
payload: {
|
||||
windowId: window.id,
|
||||
winnerId: closed.currentHighBidderId,
|
||||
finalAmount: closed.currentHighBid
|
||||
? Number(closed.currentHighBid)
|
||||
: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Broadcast to event room
|
||||
io.to(`event:${item.auction.eventId}`).emit("silent_item_closed", {
|
||||
itemId: item.id,
|
||||
winnerId: closed.currentHighBidderId,
|
||||
finalAmount: closed.currentHighBid ? Number(closed.currentHighBid) : null,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[scheduler] closed item ${item.id} (lot ${item.lotNumber}) ` +
|
||||
`winner=${closed.currentHighBidderId ?? "none"} ` +
|
||||
`amount=${closed.currentHighBid ?? 0}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Also open any windows whose opensAt has arrived
|
||||
const pendingWindows = await prisma.silentAuctionWindow.findMany({
|
||||
where: {
|
||||
status: "pending",
|
||||
opensAt: { lte: now },
|
||||
closesAt: { gt: now },
|
||||
},
|
||||
include: { auction: { select: { eventId: true } } },
|
||||
});
|
||||
|
||||
for (const window of pendingWindows) {
|
||||
await prisma.silentAuctionWindow.update({
|
||||
where: { id: window.id },
|
||||
data: { status: "open" },
|
||||
});
|
||||
|
||||
io.to(`event:${window.auction.eventId}`).emit("silent_window_closing", {
|
||||
windowId: window.id,
|
||||
closesAt: window.closesAt.toISOString(),
|
||||
});
|
||||
|
||||
console.log(`[scheduler] opened window ${window.id} (${window.name})`);
|
||||
}
|
||||
}
|
||||
@@ -128,13 +128,36 @@ export function registerLiveAuctionHandlers(io: IO, socket: Sock): void {
|
||||
socket.on("auctioneer_accept_bid", async (payload) => {
|
||||
if (!isStaff(socket.data.role)) return;
|
||||
|
||||
// Resolve paddle:<number> → real bidderId via event enrollment
|
||||
let bidderId = payload.bidderId;
|
||||
if (bidderId.startsWith("paddle:")) {
|
||||
const paddleNumber = bidderId.slice(7);
|
||||
const item = await prisma.auctionItem.findUnique({
|
||||
where: { id: payload.itemId },
|
||||
include: { auction: { select: { eventId: true } } },
|
||||
});
|
||||
if (item) {
|
||||
const enrollment = await prisma.bidderEventEnrollment.findFirst({
|
||||
where: {
|
||||
eventId: item.auction.eventId,
|
||||
paddleNumber,
|
||||
},
|
||||
});
|
||||
if (!enrollment) {
|
||||
console.warn(`[live] spotter: paddle ${paddleNumber} not found in event`);
|
||||
return;
|
||||
}
|
||||
bidderId = enrollment.bidderId;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await placeBid({
|
||||
itemId: payload.itemId,
|
||||
bidderId: payload.bidderId,
|
||||
bidderId,
|
||||
amount: payload.amount,
|
||||
originMode: "public",
|
||||
deviceId: socket.id, // spotter device = socket id
|
||||
clientSeq: Date.now(), // floor bids use server timestamp as seq
|
||||
deviceId: socket.id,
|
||||
clientSeq: Date.now(),
|
||||
clientCreatedAt: new Date(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user