Phase 2 and Demo

This commit is contained in:
2026-05-02 20:14:15 -05:00
parent d909cb7c30
commit 056bd27f89
36 changed files with 3867 additions and 299 deletions
+2
View File
@@ -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}`);
+240 -15
View File
@@ -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." });
}
});
+140 -9
View File
@@ -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) });
});
+128 -4
View File
@@ -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}`);
}
}
+117
View File
@@ -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})`);
}
}
+26 -3
View File
@@ -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(),
});