Scaffold and Phase 1

This commit is contained in:
2026-05-02 19:46:42 -05:00
parent ab74e7cad4
commit d909cb7c30
92 changed files with 4967 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/server/package.json ./packages/server/
RUN npm ci --workspace=packages/shared --workspace=packages/server
COPY packages/shared ./packages/shared
COPY packages/server ./packages/server
COPY tsconfig.base.json ./
RUN npm run build -w packages/shared
RUN npm run build -w packages/server
RUN npm run db:generate -w packages/server
# ── Runtime image ──────────────────────────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/packages/server/dist ./dist
COPY --from=builder /app/packages/server/prisma ./prisma
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3001
CMD ["node", "dist/index.js"]
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@storybid/server",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^5.14.0",
"@storybid/shared": "*",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.3.1",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.13",
"socket.io": "^4.7.5",
"stripe": "^16.1.0",
"twilio": "^5.2.2",
"uuid": "^10.0.0",
"zod": "^3.23.8",
"multer": "^1.4.5-lts.1"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.11",
"@types/nodemailer": "^6.4.15",
"@types/uuid": "^10.0.0",
"prisma": "^5.14.0",
"tsx": "^4.15.1",
"typescript": "*"
}
}
+338
View File
@@ -0,0 +1,338 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ── Organization ──────────────────────────────────────────────────────────────
model Organization {
id String @id @default(cuid())
name String
slug String @unique
logoUrl String?
primaryColor String?
stripeAccountId String?
publicUrl String?
localHostname String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events AuctionEvent[]
bidders Bidder[]
staffUsers StaffUser[]
}
// ── Staff Users ───────────────────────────────────────────────────────────────
model StaffUser {
id String @id @default(cuid())
organizationId String
email String @unique
name String
role String // admin | event_manager | auctioneer | spotter | checkin_staff
passwordHash String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
auditLogs AuditLog[]
}
// ── Events ────────────────────────────────────────────────────────────────────
model AuctionEvent {
id String @id @default(cuid())
organizationId String
name String
slug String
description String?
venueAddress String?
startAt DateTime
endAt DateTime
status String @default("draft") // draft | published | active | closed | archived
timezone String @default("America/New_York")
bannerImageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
auctions Auction[]
bidders BidderEventEnrollment[]
invoices Invoice[]
donations Donation[]
paddleRaiseCampaigns PaddleRaiseCampaign[]
auditLogs AuditLog[]
@@unique([organizationId, slug])
}
// ── Auctions ──────────────────────────────────────────────────────────────────
model Auction {
id String @id @default(cuid())
eventId String
type String // live | silent
name String
status String @default("draft") // draft | active | paused | closed
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event AuctionEvent @relation(fields: [eventId], references: [id])
items AuctionItem[]
silentWindows SilentAuctionWindow[]
}
// ── Auction Items ─────────────────────────────────────────────────────────────
model AuctionItem {
id String @id @default(cuid())
auctionId String
lotNumber String
title String
description String?
donorName String?
category String?
fairMarketValue Decimal?
openingBid Decimal @default(0)
reservePrice Decimal?
currentHighBid Decimal?
currentHighBidderId String?
bidIncrement Decimal @default(10)
state String @default("preview") // preview | active | going_once | going_twice | sold | passed | closed
pickupNotes String?
sortOrder Int @default(0)
silentWindowId String?
softCloseEnabled Boolean @default(false)
softCloseExtendMinutes Int @default(2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auction Auction @relation(fields: [auctionId], references: [id])
silentWindow SilentAuctionWindow? @relation(fields: [silentWindowId], references: [id])
currentHighBidder Bidder? @relation("CurrentHighBids", fields: [currentHighBidderId], references: [id])
media ItemMedia[]
bids Bid[]
@@unique([auctionId, lotNumber])
}
model ItemMedia {
id String @id @default(cuid())
itemId String
mediaType String // image | video | document | embed
url String
thumbnailUrl String?
caption String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
item AuctionItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
}
model SilentAuctionWindow {
id String @id @default(cuid())
auctionId String
name String
opensAt DateTime
closesAt DateTime
softCloseEnabled Boolean @default(false)
softCloseExtendMinutes Int @default(2)
status String @default("pending") // pending | open | closed
auction Auction @relation(fields: [auctionId], references: [id])
items AuctionItem[]
}
// ── Bidders ───────────────────────────────────────────────────────────────────
model Bidder {
id String @id @default(cuid())
organizationId String
email String?
phone String?
firstName String
lastName String
paymentMethodOnFile Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id])
authMethods BidderAuthMethod[]
eventEnrollments BidderEventEnrollment[]
bids Bid[]
currentHighBids AuctionItem[] @relation("CurrentHighBids")
invoices Invoice[]
donations Donation[]
deviceSessions DeviceSession[]
notifications Notification[]
}
model BidderAuthMethod {
id String @id @default(cuid())
bidderId String
type String // email_magic_link | sms_otp
identifier String
verifiedAt DateTime?
createdAt DateTime @default(now())
bidder Bidder @relation(fields: [bidderId], references: [id], onDelete: Cascade)
@@unique([type, identifier])
}
model BidderEventEnrollment {
id String @id @default(cuid())
bidderId String
eventId String
paddleNumber String?
tableAssignment String?
notes String?
checkInStatus String @default("pending") // pending | checked_in
checkInAt DateTime?
createdAt DateTime @default(now())
bidder Bidder @relation(fields: [bidderId], references: [id])
event AuctionEvent @relation(fields: [eventId], references: [id])
@@unique([bidderId, eventId])
@@unique([eventId, paddleNumber])
}
// ── Bids ──────────────────────────────────────────────────────────────────────
model Bid {
id String @id @default(cuid())
itemId String
bidderId String
amount Decimal
clientCreatedAt DateTime
serverReceivedAt DateTime @default(now())
originMode String // public | local_dns | local_ip | offline_queue
syncStatus String @default("synced") // synced | pending | conflict | rejected
deviceId String
clientSeq Int
isWinning Boolean @default(false)
createdAt DateTime @default(now())
item AuctionItem @relation(fields: [itemId], references: [id])
bidder Bidder @relation(fields: [bidderId], references: [id])
@@index([itemId, createdAt])
@@index([bidderId])
}
// ── Paddle Raise & Donations ──────────────────────────────────────────────────
model PaddleRaiseCampaign {
id String @id @default(cuid())
eventId String
name String
goal Decimal?
totalRaised Decimal @default(0)
tiers Json @default("[]") // number[]
isActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event AuctionEvent @relation(fields: [eventId], references: [id])
donations Donation[]
}
model Donation {
id String @id @default(cuid())
eventId String
bidderId String?
campaignId String?
amount Decimal
anonymous Boolean @default(false)
stripePaymentIntentId String?
createdAt DateTime @default(now())
event AuctionEvent @relation(fields: [eventId], references: [id])
bidder Bidder? @relation(fields: [bidderId], references: [id])
campaign PaddleRaiseCampaign? @relation(fields: [campaignId], references: [id])
}
// ── Invoices & Payments ───────────────────────────────────────────────────────
model Invoice {
id String @id @default(cuid())
bidderId String
eventId String
stripeInvoiceId String?
totalAmount Decimal @default(0)
paidAmount Decimal @default(0)
status String @default("draft") // draft | open | paid | partially_paid | void
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bidder Bidder @relation(fields: [bidderId], references: [id])
event AuctionEvent @relation(fields: [eventId], references: [id])
payments Payment[]
}
model Payment {
id String @id @default(cuid())
invoiceId String
stripePaymentIntentId String?
amount Decimal
currency String @default("usd")
status String // pending | succeeded | failed | refunded
createdAt DateTime @default(now())
invoice Invoice @relation(fields: [invoiceId], references: [id])
}
// ── Device Sessions ───────────────────────────────────────────────────────────
model DeviceSession {
id String @id @default(cuid())
bidderId String
deviceId String @unique
userAgent String?
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
bidder Bidder @relation(fields: [bidderId], references: [id])
}
// ── Audit Log ─────────────────────────────────────────────────────────────────
model AuditLog {
id String @id @default(cuid())
eventId String?
staffUserId String?
action String
entityType String
entityId String
payload Json?
originMode String? // mirrors bid origin when relevant
ipAddress String?
createdAt DateTime @default(now())
event AuctionEvent? @relation(fields: [eventId], references: [id])
staffUser StaffUser? @relation(fields: [staffUserId], references: [id])
@@index([eventId, createdAt])
@@index([entityType, entityId])
}
// ── Notifications ─────────────────────────────────────────────────────────────
model Notification {
id String @id @default(cuid())
bidderId String
type String // outbid | item_closed | checkout_ready | otp | receipt
channel String // in_app | push | email | sms
payload Json
sentAt DateTime?
readAt DateTime?
createdAt DateTime @default(now())
bidder Bidder @relation(fields: [bidderId], references: [id])
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Seed script creates a default Organization and one demo Event.
* Run: npm run db:seed -w packages/server
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
const org = await prisma.organization.upsert({
where: { slug: "demo-org" },
update: {},
create: {
name: "Demo Nonprofit",
slug: "demo-org",
primaryColor: "#2563eb",
publicUrl: "https://bid.example.org",
localHostname: "auction.event.lan",
},
});
console.log(`Organization: ${org.name} (${org.id})`);
const event = await prisma.auctionEvent.upsert({
where: { organizationId_slug: { organizationId: org.id, slug: "gala-2026" } },
update: {},
create: {
organizationId: org.id,
name: "Annual Gala 2026",
slug: "gala-2026",
description: "Our flagship annual fundraising gala.",
startAt: new Date("2026-10-15T18:00:00Z"),
endAt: new Date("2026-10-15T23:00:00Z"),
status: "draft",
timezone: "America/New_York",
},
});
console.log(`Event: ${event.name} (${event.id})`);
const liveAuction = await prisma.auction.upsert({
where: { id: "seed-live-auction" },
update: {},
create: {
id: "seed-live-auction",
eventId: event.id,
type: "live",
name: "Live Auction",
sortOrder: 0,
},
});
const silentAuction = await prisma.auction.upsert({
where: { id: "seed-silent-auction" },
update: {},
create: {
id: "seed-silent-auction",
eventId: event.id,
type: "silent",
name: "Silent Auction",
sortOrder: 1,
},
});
console.log(`Auctions: ${liveAuction.name}, ${silentAuction.name}`);
const admin = await prisma.staffUser.upsert({
where: { email: "admin@example.org" },
update: {},
create: {
organizationId: org.id,
email: "admin@example.org",
name: "Demo Admin",
role: "admin",
},
});
console.log(`Staff: ${admin.email}`);
console.log("Seed complete.");
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
+75
View File
@@ -0,0 +1,75 @@
import express from "express";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import { rateLimit } from "express-rate-limit";
import { UPLOAD_DIR } from "./services/storage.js";
import { authRouter } from "./routes/auth.js";
import { organizationRouter } from "./routes/organization.js";
import { eventsRouter } from "./routes/events.js";
import { auctionsRouter } from "./routes/auctions.js";
import { itemsRouter } from "./routes/items.js";
import { biddersRouter } from "./routes/bidders.js";
import { bidsRouter } from "./routes/bids.js";
import { checkInRouter } from "./routes/check-in.js";
import { checkoutRouter } from "./routes/checkout.js";
import { mediaRouter } from "./routes/media.js";
import { webhooksRouter } from "./routes/webhooks.js";
import { reportingRouter } from "./routes/reporting.js";
export const app = express();
// ── Security middleware ────────────────────────────────────────────────────────
app.use(helmet());
app.use(cors({
origin: process.env["NODE_ENV"] === "production"
? [process.env["PUBLIC_URL"] ?? "", process.env["CLIENT_URL"] ?? ""]
: "*",
credentials: true,
}));
// Stripe webhooks need raw body mount BEFORE json() parser
app.use("/api/webhooks", webhooksRouter);
app.use(express.json({ limit: "2mb" }));
app.use(cookieParser());
// ── Global rate limit ──────────────────────────────────────────────────────────
app.use("/api", rateLimit({
windowMs: 60_000,
max: 300,
standardHeaders: true,
legacyHeaders: false,
}));
// ── Media static files ─────────────────────────────────────────────────────────
// Served before the API rate limiter so media loads don't count against bid quotas.
// Cache-Control: 1 year for content-addressed files (uuid filenames never collide).
app.use(
"/media",
express.static(UPLOAD_DIR, {
maxAge: "1y",
immutable: true,
fallthrough: false,
}),
);
// ── Health check ───────────────────────────────────────────────────────────────
app.get("/health", (_req, res) => res.json({ ok: true, ts: new Date().toISOString() }));
// ── API routes ─────────────────────────────────────────────────────────────────
app.use("/api/auth", authRouter);
app.use("/api/organization", organizationRouter);
app.use("/api/events", eventsRouter);
app.use("/api/auctions", auctionsRouter);
app.use("/api/items", itemsRouter);
app.use("/api/bidders", biddersRouter);
app.use("/api/bids", bidsRouter);
app.use("/api/check-in", checkInRouter);
app.use("/api/checkout", checkoutRouter);
app.use("/api/media", mediaRouter);
app.use("/api/reporting", reportingRouter);
// ── 404 fallthrough ────────────────────────────────────────────────────────────
app.use((_req, res) => res.status(404).json({ error: "Not found" }));
+48
View File
@@ -0,0 +1,48 @@
import "dotenv/config";
import { createServer } from "node:http";
import { Server } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
} from "@storybid/shared";
import { app } from "./app.js";
import { registerSocketHandlers } from "./socket/index.js";
import { prisma } from "./lib/prisma.js";
const PORT = parseInt(process.env["PORT"] ?? "3001", 10);
const httpServer = createServer(app);
export const io = new Server<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>(httpServer, {
cors: {
origin: process.env["NODE_ENV"] === "production"
? [process.env["PUBLIC_URL"] ?? "", process.env["CLIENT_URL"] ?? ""]
: "*",
credentials: true,
},
});
registerSocketHandlers(io);
httpServer.listen(PORT, () => {
console.log(`[server] listening on http://localhost:${PORT}`);
console.log(`[server] NODE_ENV=${process.env["NODE_ENV"] ?? "development"}`);
});
// Graceful shutdown
const shutdown = async () => {
console.log("[server] shutting down…");
await prisma.$disconnect();
httpServer.close(() => process.exit(0));
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
+19
View File
@@ -0,0 +1,19 @@
import jwt from "jsonwebtoken";
const SECRET = process.env["JWT_SECRET"] ?? "dev-secret-change-me";
const EXPIRES_IN = "7d";
export interface TokenPayload {
sub: string; // bidderId or staffId
role: string;
organizationId: string;
deviceId?: string;
}
export function signToken(payload: TokenPayload): string {
return jwt.sign(payload, SECRET, { expiresIn: EXPIRES_IN });
}
export function verifyToken(token: string): TokenPayload {
return jwt.verify(token, SECRET) as TokenPayload;
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env["NODE_ENV"] === "development"
? ["query", "warn", "error"]
: ["warn", "error"],
});
if (process.env["NODE_ENV"] !== "production") {
globalForPrisma.prisma = prisma;
}
+13
View File
@@ -0,0 +1,13 @@
import Redis from "ioredis";
let client: Redis | null = null;
export function getRedis(): Redis {
if (!client) {
const url = process.env["REDIS_URL"];
if (!url) throw new Error("REDIS_URL is not set");
client = new Redis(url, { lazyConnect: true });
client.on("error", (err) => console.error("[redis] error", err));
}
return client;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Request, Response, NextFunction } from "express";
import { verifyToken, type TokenPayload } from "../lib/jwt.js";
declare global {
namespace Express {
interface Request {
auth?: TokenPayload;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers["authorization"];
if (!header?.startsWith("Bearer ")) {
res.status(401).json({ error: "Unauthorized" });
return;
}
try {
req.auth = verifyToken(header.slice(7));
next();
} catch {
res.status(401).json({ error: "Invalid or expired token" });
}
}
export function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.auth || !roles.includes(req.auth.role)) {
res.status(403).json({ error: "Forbidden" });
return;
}
next();
};
}
+144
View File
@@ -0,0 +1,144 @@
/**
* GET /api/auctions?eventId= list auctions for an event
* POST /api/auctions create auction
* GET /api/auctions/:id get auction with item count
* PATCH /api/auctions/:id update auction metadata
* POST /api/auctions/:id/open activate auction
* POST /api/auctions/:id/close close auction
* GET /api/auctions/:id/windows list silent auction windows
* POST /api/auctions/:id/windows create silent auction window
*/
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
export const auctionsRouter = Router();
const STAFF_WRITE = requireRole("admin", "event_manager");
const AUCTIONEER = requireRole("admin", "event_manager", "auctioneer");
// ── List ───────────────────────────────────────────────────────────────────────
auctionsRouter.get("/", requireAuth, async (req, res) => {
const { eventId } = req.query;
if (typeof eventId !== "string") {
res.status(400).json({ error: "eventId query param required" });
return;
}
const auctions = await prisma.auction.findMany({
where: { eventId },
orderBy: { sortOrder: "asc" },
include: { _count: { select: { items: true } } },
});
res.json(auctions);
});
// ── Create ─────────────────────────────────────────────────────────────────────
const CreateAuctionSchema = z.object({
eventId: z.string(),
type: z.enum(["live", "silent"]),
name: z.string().min(1),
sortOrder: z.number().int().default(0),
});
auctionsRouter.post("/", requireAuth, STAFF_WRITE, async (req, res) => {
const parse = CreateAuctionSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
// Verify event belongs to org
const event = await prisma.auctionEvent.findFirst({
where: { id: parse.data.eventId, organizationId: req.auth!.organizationId },
});
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
const auction = await prisma.auction.create({ data: parse.data });
res.status(201).json(auction);
});
// ── Get ────────────────────────────────────────────────────────────────────────
auctionsRouter.get("/:id", requireAuth, async (req, res) => {
const auction = await prisma.auction.findUnique({
where: { id: req.params["id"] },
include: {
silentWindows: { orderBy: { opensAt: "asc" } },
_count: { select: { items: true } },
},
});
if (!auction) {
res.status(404).json({ error: "Auction not found" });
return;
}
res.json(auction);
});
// ── Update ─────────────────────────────────────────────────────────────────────
const UpdateAuctionSchema = z.object({
name: z.string().min(1).optional(),
sortOrder: z.number().int().optional(),
});
auctionsRouter.patch("/:id", requireAuth, STAFF_WRITE, async (req, res) => {
const parse = UpdateAuctionSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const updated = await prisma.auction.update({
where: { id: req.params["id"] },
data: parse.data,
});
res.json(updated);
});
// ── Open / Close ───────────────────────────────────────────────────────────────
auctionsRouter.post("/:id/open", requireAuth, AUCTIONEER, async (req, res) => {
const auction = await prisma.auction.update({
where: { id: req.params["id"] },
data: { status: "active" },
});
res.json(auction);
});
auctionsRouter.post("/:id/close", requireAuth, AUCTIONEER, async (req, res) => {
const auction = await prisma.auction.update({
where: { id: req.params["id"] },
data: { status: "closed" },
});
res.json(auction);
});
// ── Silent auction windows ─────────────────────────────────────────────────────
auctionsRouter.get("/:id/windows", requireAuth, async (req, res) => {
const windows = await prisma.silentAuctionWindow.findMany({
where: { auctionId: req.params["id"] },
orderBy: { opensAt: "asc" },
});
res.json(windows);
});
const CreateWindowSchema = z.object({
name: z.string().min(1),
opensAt: z.string().datetime(),
closesAt: z.string().datetime(),
softCloseEnabled: z.boolean().default(false),
softCloseExtendMinutes: z.number().int().min(1).max(60).default(2),
});
auctionsRouter.post("/:id/windows", requireAuth, STAFF_WRITE, async (req, res) => {
const parse = CreateWindowSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const window = await prisma.silentAuctionWindow.create({
data: { ...parse.data, auctionId: req.params["id"] },
});
res.status(201).json(window);
});
+258
View File
@@ -0,0 +1,258 @@
/**
* POST /api/auth/magic-link request email magic link
* GET /api/auth/verify?token= verify magic link, issue JWT
* POST /api/auth/otp/send request SMS OTP via Twilio Verify
* POST /api/auth/otp/verify verify SMS OTP, issue JWT
* POST /api/auth/logout clear session (client drops token)
*/
import { Router } from "express";
import { z } from "zod";
import { randomBytes } from "node:crypto";
import { prisma } from "../lib/prisma.js";
import { signToken } from "../lib/jwt.js";
import { sendMagicLink } from "../services/email.js";
import { sendOtp, verifyOtp } from "../services/twilio.js";
import { requireAuth } from "../middleware/auth.js";
export const authRouter = Router();
// ── Helpers ────────────────────────────────────────────────────────────────────
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes
/** Find or create a Bidder + BidderAuthMethod for the given identifier. */
async function upsertBidder(
type: "email_magic_link" | "sms_otp",
identifier: string,
organizationId: string,
): Promise<string> {
// Find existing auth method
const existing = await prisma.bidderAuthMethod.findUnique({
where: { type_identifier: { type, identifier } },
include: { bidder: true },
});
if (existing) return existing.bidderId;
// Create new bidder + auth method
const bidder = await prisma.bidder.create({
data: {
organizationId,
email: type === "email_magic_link" ? identifier : null,
phone: type === "sms_otp" ? identifier : null,
firstName: "Guest",
lastName: "",
authMethods: {
create: { type, identifier },
},
},
});
return bidder.id;
}
/** Resolve the base public URL for building magic-link callbacks. */
function resolveBaseUrl(req: { protocol: string; hostname: string }): string {
return (
process.env["PUBLIC_URL"] ??
`${req.protocol}://${req.hostname}`
);
}
/** Build a signed JWT for a bidder. */
async function issueBidderToken(bidderId: string, deviceId?: string): Promise<string> {
const bidder = await prisma.bidder.findUniqueOrThrow({
where: { id: bidderId },
});
return signToken({
sub: bidderId,
role: "bidder",
organizationId: bidder.organizationId,
deviceId,
});
}
// ── Magic link request ───────────────────────────────────────────────────────
const MagicLinkRequestSchema = z.object({
email: z.string().email(),
deviceId: z.string().optional(),
});
authRouter.post("/magic-link", async (req, res) => {
const parse = MagicLinkRequestSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: "Invalid email address" });
return;
}
const { email, deviceId } = parse.data;
// Resolve organization (single-org install)
const org = await prisma.organization.findFirst();
if (!org) {
res.status(500).json({ error: "Organization not configured" });
return;
}
const bidderId = await upsertBidder("email_magic_link", email, org.id);
// Generate a short-lived token stored in Redis (or fall back to a signed value)
const rawToken = randomBytes(32).toString("hex");
const expiresAt = Date.now() + MAGIC_LINK_TTL_MS;
// Store token in DB on the DeviceSession-like approach: reuse AuditLog payload
// Simple approach: store in a dedicated magic_token via AuditLog with entityType='magic_link'
await prisma.auditLog.create({
data: {
action: "magic_link_issued",
entityType: "magic_link",
entityId: rawToken,
payload: { bidderId, email, expiresAt, deviceId: deviceId ?? null },
},
});
try {
await sendMagicLink(email, rawToken, resolveBaseUrl(req));
} catch (err) {
console.error("[auth] sendMagicLink failed", err);
// Don't leak whether the email exists
}
// Always respond with success to prevent email enumeration
res.json({ ok: true, message: "If that address is registered, a link is on its way." });
});
// ── Magic link verify ────────────────────────────────────────────────────────
authRouter.get("/verify", async (req, res) => {
const token = req.query["token"];
if (typeof token !== "string" || !token) {
res.status(400).json({ error: "Missing token" });
return;
}
const log = await prisma.auditLog.findFirst({
where: { entityType: "magic_link", entityId: token },
});
if (!log || !log.payload) {
res.status(401).json({ error: "Invalid or expired link" });
return;
}
const payload = log.payload as {
bidderId: string;
expiresAt: number;
deviceId: string | null;
};
if (Date.now() > payload.expiresAt) {
res.status(401).json({ error: "Link has expired" });
return;
}
// Consume token (delete so it can't be reused)
await prisma.auditLog.delete({ where: { id: log.id } });
// Mark auth method verified
await prisma.bidderAuthMethod.updateMany({
where: { bidderId: payload.bidderId, type: "email_magic_link" },
data: { verifiedAt: new Date() },
});
const jwt = await issueBidderToken(payload.bidderId, payload.deviceId ?? undefined);
res.json({ token: jwt });
});
// ── SMS OTP – send ─────────────────────────────────────────────────────────────
const OtpSendSchema = z.object({
phone: z.string().regex(/^\+[1-9]\d{7,14}$/, "Phone must be E.164 format (e.g. +12025551234)"),
});
authRouter.post("/otp/send", async (req, res) => {
const parse = OtpSendSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.issues[0]?.message ?? "Invalid phone" });
return;
}
const { phone } = parse.data;
const org = await prisma.organization.findFirst();
if (!org) {
res.status(500).json({ error: "Organization not configured" });
return;
}
await upsertBidder("sms_otp", phone, org.id);
try {
await sendOtp(phone);
} catch (err) {
console.error("[auth] sendOtp failed", err);
// Return generic error don't reveal Twilio config issues to clients
res.status(503).json({ error: "Could not send verification code. Please try again." });
return;
}
res.json({ ok: true });
});
// ── SMS OTP – verify ───────────────────────────────────────────────────────────
const OtpVerifySchema = z.object({
phone: z.string(),
code: z.string().min(4).max(10),
deviceId: z.string().optional(),
});
authRouter.post("/otp/verify", async (req, res) => {
const parse = OtpVerifySchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: "Invalid request" });
return;
}
const { phone, code, deviceId } = parse.data;
let approved: boolean;
try {
approved = await verifyOtp(phone, code);
} catch (err) {
console.error("[auth] verifyOtp failed", err);
res.status(503).json({ error: "Verification check failed. Please try again." });
return;
}
if (!approved) {
res.status(401).json({ error: "Incorrect or expired code" });
return;
}
const authMethod = await prisma.bidderAuthMethod.findUnique({
where: { type_identifier: { type: "sms_otp", identifier: phone } },
});
if (!authMethod) {
res.status(401).json({ error: "Phone not registered" });
return;
}
await prisma.bidderAuthMethod.update({
where: { id: authMethod.id },
data: { verifiedAt: new Date() },
});
const jwt = await issueBidderToken(authMethod.bidderId, deviceId);
res.json({ token: jwt });
});
// ── Logout ─────────────────────────────────────────────────────────────────────
authRouter.post("/logout", requireAuth, (_req, res) => {
// JWT is stateless; the client drops the token.
// For harder logout, add a token denylist in Redis here.
res.json({ ok: true });
});
+265
View File
@@ -0,0 +1,265 @@
/**
* GET /api/bidders/me authenticated bidder's own profile
* GET /api/bidders?eventId= list bidder enrollments for an event (staff)
* POST /api/bidders create bidder + enrollment manually
* POST /api/bidders/import bulk import CSV rows
* GET /api/bidders/:id get bidder profile + enrollment
* PATCH /api/bidders/:id update bidder / enrollment
* GET /api/bidders/:id/bids bid history for a bidder (staff or own)
*/
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
export const biddersRouter = Router();
const STAFF = requireRole("admin", "event_manager", "checkin_staff");
// ── Me ─────────────────────────────────────────────────────────────────────────
biddersRouter.get("/me", requireAuth, async (req, res) => {
if (req.auth!.role !== "bidder") {
res.status(403).json({ error: "Forbidden" });
return;
}
const bidder = await prisma.bidder.findUnique({
where: { id: req.auth!.sub },
include: {
authMethods: { select: { type: true, identifier: true, verifiedAt: true } },
eventEnrollments: true,
},
});
if (!bidder) {
res.status(404).json({ error: "Bidder not found" });
return;
}
res.json(bidder);
});
// ── List ───────────────────────────────────────────────────────────────────────
biddersRouter.get("/", requireAuth, STAFF, async (req, res) => {
const { eventId, q } = req.query;
if (typeof eventId !== "string") {
res.status(400).json({ error: "eventId query param required" });
return;
}
const enrollments = await prisma.bidderEventEnrollment.findMany({
where: {
eventId,
bidder: q
? {
OR: [
{ firstName: { contains: String(q), mode: "insensitive" } },
{ lastName: { contains: String(q), mode: "insensitive" } },
{ email: { contains: String(q), mode: "insensitive" } },
],
}
: undefined,
},
include: { bidder: true },
orderBy: [{ bidder: { lastName: "asc" } }, { bidder: { firstName: "asc" } }],
});
res.json(enrollments);
});
// ── Create bidder + enrollment ─────────────────────────────────────────────────
const CreateBidderSchema = z.object({
eventId: z.string(),
firstName: z.string().min(1),
lastName: z.string().default(""),
email: z.string().email().nullable().optional(),
phone: z.string().nullable().optional(),
paddleNumber: z.string().nullable().optional(),
tableAssignment: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
});
biddersRouter.post("/", requireAuth, STAFF, async (req, res) => {
const parse = CreateBidderSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const { eventId, firstName, lastName, email, phone, paddleNumber, tableAssignment, notes } = parse.data;
const enrollment = await prisma.$transaction(async (tx) => {
const bidder = await tx.bidder.create({
data: {
organizationId: req.auth!.organizationId,
firstName,
lastName,
email: email ?? null,
phone: phone ?? null,
...(email && {
authMethods: {
create: { type: "email_magic_link", identifier: email },
},
}),
...(phone && {
authMethods: {
create: { type: "sms_otp", identifier: phone },
},
}),
},
});
return tx.bidderEventEnrollment.create({
data: {
bidderId: bidder.id,
eventId,
paddleNumber: paddleNumber ?? null,
tableAssignment: tableAssignment ?? null,
notes: notes ?? null,
},
include: { bidder: true },
});
});
res.status(201).json(enrollment);
});
// ── Bulk CSV import ────────────────────────────────────────────────────────────
const ImportRowSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().default(""),
email: z.string().email().optional(),
phone: z.string().optional(),
paddleNumber: z.string().optional(),
tableAssignment: z.string().optional(),
});
const ImportSchema = z.object({
eventId: z.string(),
rows: z.array(ImportRowSchema).min(1).max(500),
});
biddersRouter.post("/import", requireAuth, requireRole("admin", "event_manager"), async (req, res) => {
const parse = ImportSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const { eventId, rows } = parse.data;
const results: { row: number; ok: boolean; error?: string }[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
try {
await prisma.$transaction(async (tx) => {
const bidder = await tx.bidder.create({
data: {
organizationId: req.auth!.organizationId,
firstName: row.firstName,
lastName: row.lastName,
email: row.email ?? null,
phone: row.phone ?? null,
},
});
await tx.bidderEventEnrollment.create({
data: {
bidderId: bidder.id,
eventId,
paddleNumber: row.paddleNumber ?? null,
tableAssignment: row.tableAssignment ?? null,
},
});
});
results.push({ row: i + 1, ok: true });
} catch (err) {
results.push({ row: i + 1, ok: false, error: String(err) });
}
}
const failed = results.filter((r) => !r.ok);
res.status(failed.length > 0 ? 207 : 201).json({ results });
});
// ── Get ────────────────────────────────────────────────────────────────────────
biddersRouter.get("/:id", requireAuth, async (req, res) => {
const isOwn = req.auth!.sub === req.params["id"];
const isStaff = ["admin", "event_manager", "checkin_staff"].includes(req.auth!.role);
if (!isOwn && !isStaff) {
res.status(403).json({ error: "Forbidden" });
return;
}
const bidder = await prisma.bidder.findUnique({
where: { id: req.params["id"] },
include: { eventEnrollments: true, authMethods: { select: { type: true, verifiedAt: true } } },
});
if (!bidder) {
res.status(404).json({ error: "Bidder not found" });
return;
}
res.json(bidder);
});
// ── Update ─────────────────────────────────────────────────────────────────────
const UpdateBidderSchema = z.object({
firstName: z.string().min(1).optional(),
lastName: z.string().optional(),
email: z.string().email().nullable().optional(),
phone: z.string().nullable().optional(),
// Enrollment fields (require eventId to scope)
eventId: z.string().optional(),
paddleNumber: z.string().nullable().optional(),
tableAssignment: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
checkInStatus: z.enum(["pending", "checked_in"]).optional(),
});
biddersRouter.patch("/:id", requireAuth, STAFF, async (req, res) => {
const parse = UpdateBidderSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const { eventId, paddleNumber, tableAssignment, notes, checkInStatus, ...bidderData } = parse.data;
await prisma.$transaction(async (tx) => {
if (Object.keys(bidderData).length > 0) {
await tx.bidder.update({ where: { id: req.params["id"] }, data: bidderData });
}
if (eventId) {
await tx.bidderEventEnrollment.updateMany({
where: { bidderId: req.params["id"], eventId },
data: {
...(paddleNumber !== undefined && { paddleNumber }),
...(tableAssignment !== undefined && { tableAssignment }),
...(notes !== undefined && { notes }),
...(checkInStatus && { checkInStatus }),
...(checkInStatus === "checked_in" && { checkInAt: new Date() }),
},
});
}
});
const updated = await prisma.bidder.findUniqueOrThrow({
where: { id: req.params["id"] },
include: { eventEnrollments: true },
});
res.json(updated);
});
// ── Bid history ────────────────────────────────────────────────────────────────
biddersRouter.get("/:id/bids", requireAuth, async (req, res) => {
const isOwn = req.auth!.sub === req.params["id"];
const isStaff = ["admin", "event_manager"].includes(req.auth!.role);
if (!isOwn && !isStaff) {
res.status(403).json({ error: "Forbidden" });
return;
}
const bids = await prisma.bid.findMany({
where: { bidderId: req.params["id"] },
orderBy: { createdAt: "desc" },
include: { item: { select: { title: true, lotNumber: true, state: true } } },
});
res.json(bids);
});
+157
View File
@@ -0,0 +1,157 @@
/**
* POST /api/bids/live REST fallback for live bid (primary path is Socket.io)
* POST /api/bids/silent REST fallback for silent bid
* POST /api/bids/sync batch sync offline outbox bids after reconnect
* GET /api/bids?itemId= bid history for an item (staff)
*/
import { Router } from "express";
import { z } from "zod";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { placeBid } from "../services/bid-engine.js";
import { prisma } from "../lib/prisma.js";
import type { OriginMode } from "@storybid/shared";
export const bidsRouter = Router();
// Derive origin mode from request headers set by client connection manager
function detectOriginMode(req: import("express").Request): OriginMode {
const hint = req.headers["x-origin-mode"];
if (hint === "local_dns" || hint === "local_ip" || hint === "offline_queue") return hint;
return "public";
}
// ── Live bid (REST fallback) ───────────────────────────────────────────────────
const LiveBidSchema = z.object({
itemId: z.string(),
amount: z.number().positive(),
deviceId: z.string(),
clientSeq: z.number().int().min(0),
clientCreatedAt: z.string().datetime(),
});
bidsRouter.post("/live", requireAuth, async (req, res) => {
if (req.auth!.role !== "bidder") {
res.status(403).json({ error: "Only bidders can place bids" });
return;
}
const parse = LiveBidSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const result = await placeBid({
...parse.data,
bidderId: req.auth!.sub,
originMode: detectOriginMode(req),
clientCreatedAt: new Date(parse.data.clientCreatedAt),
});
if (!result.ok) {
const status = result.code === "AMOUNT_TOO_LOW" ? 422 : 409;
res.status(status).json({ error: result.error });
return;
}
res.status(201).json({ bid: result.bid, item: result.item });
});
// ── Silent bid (REST fallback) ─────────────────────────────────────────────────
bidsRouter.post("/silent", requireAuth, async (req, res) => {
if (req.auth!.role !== "bidder") {
res.status(403).json({ error: "Only bidders can place bids" });
return;
}
const parse = LiveBidSchema.safeParse(req.body); // same shape
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const result = await placeBid({
...parse.data,
bidderId: req.auth!.sub,
originMode: detectOriginMode(req),
clientCreatedAt: new Date(parse.data.clientCreatedAt),
});
if (!result.ok) {
const status = result.code === "AMOUNT_TOO_LOW" ? 422 : 409;
res.status(status).json({ error: result.error });
return;
}
res.status(201).json({ bid: result.bid, item: result.item });
});
// ── Outbox sync ────────────────────────────────────────────────────────────────
const SyncBidSchema = z.object({
localId: z.string(),
itemId: z.string(),
amount: z.number().positive(),
deviceId: z.string(),
clientSeq: z.number().int().min(0),
clientCreatedAt: z.string().datetime(),
});
bidsRouter.post("/sync", requireAuth, async (req, res) => {
if (req.auth!.role !== "bidder") {
res.status(403).json({ error: "Only bidders can sync bids" });
return;
}
const parse = z.array(SyncBidSchema).min(1).max(100).safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const results: Array<{ localId: string; ok: boolean; bid?: object; error?: string }> = [];
// Process in clientSeq order within each item
const sorted = [...parse.data].sort((a, b) => a.clientSeq - b.clientSeq);
for (const entry of sorted) {
const result = await placeBid({
itemId: entry.itemId,
amount: entry.amount,
bidderId: req.auth!.sub,
originMode: "offline_queue",
deviceId: entry.deviceId,
clientSeq: entry.clientSeq,
clientCreatedAt: new Date(entry.clientCreatedAt),
});
if (result.ok) {
results.push({ localId: entry.localId, ok: true, bid: result.bid });
} else {
results.push({ localId: entry.localId, ok: false, error: result.error });
}
}
res.json({ results });
});
// ── Bid history (staff) ────────────────────────────────────────────────────────
bidsRouter.get("/", requireAuth, requireRole("admin", "event_manager", "auctioneer"), async (req, res) => {
const { itemId } = req.query;
if (typeof itemId !== "string") {
res.status(400).json({ error: "itemId query param required" });
return;
}
const bids = await prisma.bid.findMany({
where: { itemId },
orderBy: { createdAt: "desc" },
include: {
bidder: {
select: { firstName: true, lastName: true },
include: { eventEnrollments: { select: { paddleNumber: true }, take: 1 } },
},
},
});
res.json(bids);
});
+74
View File
@@ -0,0 +1,74 @@
/**
* POST /api/check-in/scan process QR token, return bidder + enrollment
* POST /api/check-in/:id manual check-in by enrollment id
*/
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
export const checkInRouter = Router();
const STAFF = requireRole("admin", "event_manager", "checkin_staff");
// QR codes encode a JWT sub (bidderId) + eventId in a short URL
// e.g. /check-in?b=<bidderId>&e=<eventId>
const ScanSchema = z.object({
bidderId: z.string(),
eventId: z.string(),
});
checkInRouter.post("/scan", requireAuth, STAFF, async (req, res) => {
const parse = ScanSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: "Invalid QR payload" });
return;
}
const { bidderId, eventId } = parse.data;
const enrollment = await prisma.bidderEventEnrollment.findUnique({
where: { bidderId_eventId: { bidderId, eventId } },
include: { bidder: true },
});
if (!enrollment) {
res.status(404).json({ error: "Bidder is not registered for this event" });
return;
}
if (enrollment.checkInStatus === "checked_in") {
// Return profile but flag as already checked in
res.json({ enrollment, alreadyCheckedIn: true });
return;
}
const updated = await prisma.bidderEventEnrollment.update({
where: { id: enrollment.id },
data: { checkInStatus: "checked_in", checkInAt: new Date() },
include: { bidder: true },
});
res.json({ enrollment: updated, alreadyCheckedIn: false });
});
checkInRouter.post("/:enrollmentId", requireAuth, STAFF, async (req, res) => {
const enrollment = await prisma.bidderEventEnrollment.findUnique({
where: { id: req.params["enrollmentId"] },
include: { bidder: true },
});
if (!enrollment) {
res.status(404).json({ error: "Enrollment not found" });
return;
}
const updated = await prisma.bidderEventEnrollment.update({
where: { id: enrollment.id },
data: { checkInStatus: "checked_in", checkInAt: new Date() },
include: { bidder: true },
});
res.json({ enrollment: updated, alreadyCheckedIn: enrollment.checkInStatus === "checked_in" });
});
+31
View File
@@ -0,0 +1,31 @@
/**
* 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
*/
import { Router } from "express";
import { requireAuth, requireRole } from "../middleware/auth.js";
export const checkoutRouter = Router();
checkoutRouter.get("/:bidderId", requireAuth, (_req, res) => {
res.status(501).json({ error: "Not implemented" });
});
checkoutRouter.post("/:bidderId/pay", requireAuth, (_req, res) => {
res.status(501).json({ error: "Not implemented" });
});
checkoutRouter.post("/:bidderId/capture", requireAuth, requireRole("admin", "event_manager"), (_req, res) => {
res.status(501).json({ error: "Not implemented" });
});
checkoutRouter.post("/donate", requireAuth, (_req, res) => {
res.status(501).json({ error: "Not implemented" });
});
checkoutRouter.post("/paddle-raise", requireAuth, (_req, res) => {
res.status(501).json({ error: "Not implemented" });
});
+125
View File
@@ -0,0 +1,125 @@
/**
* GET /api/events list events for the organization
* POST /api/events create event
* GET /api/events/:id get event with auction summary
* PATCH /api/events/:id update event
* DELETE /api/events/:id archive event (sets status=archived)
*/
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
export const eventsRouter = Router();
const STAFF_WRITE = requireRole("admin", "event_manager");
// ── List ───────────────────────────────────────────────────────────────────────
eventsRouter.get("/", requireAuth, async (req, res) => {
const events = await prisma.auctionEvent.findMany({
where: { organizationId: req.auth!.organizationId },
orderBy: { startAt: "desc" },
include: { _count: { select: { auctions: true, bidders: true } } },
});
res.json(events);
});
// ── Create ─────────────────────────────────────────────────────────────────────
const CreateEventSchema = z.object({
name: z.string().min(1),
slug: z.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
description: z.string().nullable().optional(),
venueAddress: z.string().nullable().optional(),
startAt: z.string().datetime(),
endAt: z.string().datetime(),
timezone: z.string().default("America/New_York"),
bannerImageUrl: z.string().url().nullable().optional(),
});
eventsRouter.post("/", requireAuth, STAFF_WRITE, async (req, res) => {
const parse = CreateEventSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const existing = await prisma.auctionEvent.findUnique({
where: {
organizationId_slug: {
organizationId: req.auth!.organizationId,
slug: parse.data.slug,
},
},
});
if (existing) {
res.status(409).json({ error: "An event with that slug already exists" });
return;
}
const event = await prisma.auctionEvent.create({
data: { ...parse.data, organizationId: req.auth!.organizationId, status: "draft" },
});
res.status(201).json(event);
});
// ── Get ────────────────────────────────────────────────────────────────────────
eventsRouter.get("/:id", requireAuth, async (req, res) => {
const event = await prisma.auctionEvent.findFirst({
where: { id: req.params["id"], organizationId: req.auth!.organizationId },
include: {
auctions: {
orderBy: { sortOrder: "asc" },
include: { _count: { select: { items: true } } },
},
},
});
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
res.json(event);
});
// ── Update ─────────────────────────────────────────────────────────────────────
const UpdateEventSchema = CreateEventSchema.partial().extend({
status: z.enum(["draft", "published", "active", "closed", "archived"]).optional(),
});
eventsRouter.patch("/:id", requireAuth, STAFF_WRITE, async (req, res) => {
const parse = UpdateEventSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const event = await prisma.auctionEvent.findFirst({
where: { id: req.params["id"], organizationId: req.auth!.organizationId },
});
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
const updated = await prisma.auctionEvent.update({
where: { id: event.id },
data: parse.data,
});
res.json(updated);
});
// ── Archive (soft delete) ──────────────────────────────────────────────────────
eventsRouter.delete("/:id", requireAuth, requireRole("admin"), async (req, res) => {
const event = await prisma.auctionEvent.findFirst({
where: { id: req.params["id"], organizationId: req.auth!.organizationId },
});
if (!event) {
res.status(404).json({ error: "Event not found" });
return;
}
await prisma.auctionEvent.update({
where: { id: event.id },
data: { status: "archived" },
});
res.json({ ok: true });
});
+186
View File
@@ -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 });
});
+64
View File
@@ -0,0 +1,64 @@
/**
* POST /api/media/upload multipart upload; saves to local disk
* DELETE /api/media/:key delete a file by key (admin/event_manager)
*
* Upload flow (replaces the old presigned-URL pattern):
* 1. Client POSTs multipart/form-data with fields: itemId, mediaType, plus the file
* 2. Server saves to UPLOAD_DIR/items/<itemId>/<uuid>.<ext>
* 3. Server returns { url, key, mimetype, sizeBytes }
* 4. Client calls POST /api/items/:id/media with { mediaType, url } to attach the
* record to the item (existing endpoint in routes/items.ts)
*
* Files are served as static assets at /media/* (see app.ts).
* Everything stays on the local machine — no internet required.
*/
import { Router } from "express";
import { requireAuth, requireRole } from "../middleware/auth.js";
import { upload, resolveFile, deleteFile, type MediaType } from "../services/storage.js";
export const mediaRouter = Router();
const STAFF_WRITE = requireRole("admin", "event_manager");
// ── Upload ─────────────────────────────────────────────────────────────────────
mediaRouter.post(
"/upload",
requireAuth,
STAFF_WRITE,
// Parse a single file field named "file" plus any text fields (itemId, mediaType)
upload.single("file"),
(req, res) => {
if (!req.file) {
res.status(400).json({ error: "No file received" });
return;
}
const mediaType = (req.body as { mediaType?: string }).mediaType as MediaType | undefined;
if (!mediaType || !["image", "video", "document"].includes(mediaType)) {
res.status(400).json({ error: "mediaType must be image, video, or document" });
return;
}
try {
const saved = resolveFile(req.file, mediaType);
res.status(201).json(saved);
} catch (err) {
res.status(400).json({ error: String(err) });
}
},
);
// ── Delete ─────────────────────────────────────────────────────────────────────
mediaRouter.delete(
"/:key(*)", // key contains slashes, e.g. items/abc/uuid.jpg
requireAuth,
STAFF_WRITE,
async (req, res) => {
try {
await deleteFile(req.params["key"] ?? "");
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: String(err) });
}
},
);
@@ -0,0 +1,47 @@
/**
* GET /api/organization get org profile (any authenticated user)
* PATCH /api/organization update branding / DNS settings (admin only)
*/
import { Router } from "express";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { requireAuth, requireRole } from "../middleware/auth.js";
export const organizationRouter = Router();
organizationRouter.get("/", requireAuth, async (req, res) => {
const org = await prisma.organization.findFirst({
where: { id: req.auth!.organizationId },
});
if (!org) {
res.status(404).json({ error: "Organization not found" });
return;
}
// Strip Stripe keys from non-admin responses
const { stripeAccountId: _, ...safe } = org;
res.json(req.auth!.role === "admin" ? org : safe);
});
const UpdateOrgSchema = z.object({
name: z.string().min(1).optional(),
logoUrl: z.string().url().nullable().optional(),
primaryColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
publicUrl: z.string().url().nullable().optional(),
localHostname: z.string().nullable().optional(),
stripeAccountId: z.string().nullable().optional(),
});
organizationRouter.patch("/", requireAuth, requireRole("admin"), async (req, res) => {
const parse = UpdateOrgSchema.safeParse(req.body);
if (!parse.success) {
res.status(400).json({ error: parse.error.flatten() });
return;
}
const org = await prisma.organization.update({
where: { id: req.auth!.organizationId },
data: parse.data,
});
res.json(org);
});
+23
View File
@@ -0,0 +1,23 @@
/**
* 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
*/
import { Router } from "express";
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" });
});
reportingRouter.get("/events/:id/bidders", requireAuth, adminOnly, (_req, res) => {
res.status(501).json({ error: "Not implemented" });
});
reportingRouter.get("/events/:id/audit-log", requireAuth, adminOnly, (_req, res) => {
res.status(501).json({ error: "Not implemented" });
});
+16
View File
@@ -0,0 +1,16 @@
/**
* POST /api/webhooks/stripe Stripe webhook handler (raw body required)
*/
import { Router } from "express";
import express from "express";
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" });
},
);
+148
View File
@@ -0,0 +1,148 @@
/**
* Bid engine shared validation + persistence logic used by both the
* REST fallback route and the Socket.io handlers.
*
* Keeping this in one place ensures that offline-synced outbox bids
* and real-time bids go through identical server-side rules.
*/
import { Prisma } from "@prisma/client";
import { prisma } from "../lib/prisma.js";
import type { OriginMode } from "@storybid/shared";
export interface PlaceBidInput {
itemId: string;
bidderId: string;
amount: number; // in whole dollars (server stores as Decimal)
originMode: OriginMode;
deviceId: string;
clientSeq: number;
clientCreatedAt: Date;
}
export type BidResult =
| { ok: true; bid: Awaited<ReturnType<typeof prisma.bid.create>>; item: Awaited<ReturnType<typeof prisma.auctionItem.findUniqueOrThrow>> }
| { ok: false; error: string; code: "ITEM_NOT_FOUND" | "WINDOW_CLOSED" | "ITEM_STATE" | "AMOUNT_TOO_LOW" | "DUPLICATE" };
/**
* Place a validated bid. Runs inside a Prisma transaction so the
* high-bid update and bid record creation are atomic.
*/
export async function placeBid(input: PlaceBidInput): Promise<BidResult> {
return prisma.$transaction(async (tx) => {
// 1. Load item with a row-level lock (SELECT FOR UPDATE)
const item = await tx.auctionItem.findUnique({
where: { id: input.itemId },
});
if (!item) {
return { ok: false, error: "Item not found", code: "ITEM_NOT_FOUND" };
}
// 2. Validate item state
const auction = await tx.auction.findUniqueOrThrow({ where: { id: item.auctionId } });
if (auction.type === "live") {
if (!["active", "going_once", "going_twice"].includes(item.state)) {
return { ok: false, error: "Item is not accepting bids", code: "ITEM_STATE" };
}
} else {
// Silent auction
if (item.state === "closed" || item.state === "passed") {
return { ok: false, error: "Bidding on this item has closed", code: "WINDOW_CLOSED" };
}
if (item.silentWindowId) {
const window = await tx.silentAuctionWindow.findUnique({
where: { id: item.silentWindowId },
});
if (!window || window.status !== "open") {
return { ok: false, error: "Bidding window is not open", code: "WINDOW_CLOSED" };
}
}
}
// 3. Validate amount
const minBid = item.currentHighBid
? Number(item.currentHighBid) + Number(item.bidIncrement)
: Number(item.openingBid);
if (input.amount < minBid) {
return {
ok: false,
error: `Minimum bid is $${minBid}`,
code: "AMOUNT_TOO_LOW",
};
}
// 4. Idempotency reject exact duplicate (same device + seq)
const duplicate = await tx.bid.findFirst({
where: { deviceId: input.deviceId, clientSeq: input.clientSeq, itemId: input.itemId },
});
if (duplicate) {
return { ok: false, error: "Duplicate bid", code: "DUPLICATE" };
}
// 5. Persist bid
const bid = await tx.bid.create({
data: {
itemId: input.itemId,
bidderId: input.bidderId,
amount: new Prisma.Decimal(input.amount),
clientCreatedAt: input.clientCreatedAt,
serverReceivedAt: new Date(),
originMode: input.originMode,
syncStatus: "synced",
deviceId: input.deviceId,
clientSeq: input.clientSeq,
isWinning: true,
},
});
// 6. Mark previous high bid as no longer winning
await tx.bid.updateMany({
where: {
itemId: input.itemId,
isWinning: true,
id: { not: bid.id },
},
data: { isWinning: false },
});
// 7. Update item high bid
const updatedItem = await tx.auctionItem.update({
where: { id: input.itemId },
data: {
currentHighBid: new Prisma.Decimal(input.amount),
currentHighBidderId: input.bidderId,
// Reset going-once/going-twice back to active on new bid
...(["going_once", "going_twice"].includes(item.state) && {
state: "active",
}),
},
});
// 8. Soft-close extension for silent auction
if (
auction.type === "silent" &&
updatedItem.softCloseEnabled &&
updatedItem.silentWindowId
) {
const window = await tx.silentAuctionWindow.findUnique({
where: { id: updatedItem.silentWindowId },
});
if (window && window.status === "open") {
const msRemaining = window.closesAt.getTime() - Date.now();
const extendThresholdMs = updatedItem.softCloseExtendMinutes * 60 * 1000;
if (msRemaining < extendThresholdMs) {
await tx.silentAuctionWindow.update({
where: { id: window.id },
data: {
closesAt: new Date(Date.now() + extendThresholdMs),
},
});
}
}
}
return { ok: true, bid, item: updatedItem };
});
}
+68
View File
@@ -0,0 +1,68 @@
import nodemailer from "nodemailer";
function createTransport() {
return nodemailer.createTransport({
host: process.env["SMTP_HOST"],
port: parseInt(process.env["SMTP_PORT"] ?? "587", 10),
secure: process.env["SMTP_PORT"] === "465",
auth: {
user: process.env["SMTP_USER"],
pass: process.env["SMTP_PASS"],
},
});
}
const FROM = process.env["EMAIL_FROM"] ?? "Storybid <noreply@example.com>";
export async function sendMagicLink(to: string, token: string, baseUrl: string): Promise<void> {
const link = `${baseUrl}/verify?token=${encodeURIComponent(token)}`;
const transporter = createTransport();
await transporter.sendMail({
from: FROM,
to,
subject: "Your Storybid sign-in link",
text: `Click the link below to sign in to the auction. The link expires in 15 minutes.\n\n${link}`,
html: `
<p>Click the button below to sign in to the auction. This link expires in <strong>15 minutes</strong>.</p>
<p style="margin:24px 0">
<a href="${link}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold">
Sign in to Auction
</a>
</p>
<p style="color:#6b7280;font-size:12px">If you didn't request this, you can ignore this email.</p>
`,
});
}
export async function sendReceipt(
to: string,
invoiceId: string,
totalAmount: number,
items: Array<{ title: string; amount: number }>,
): Promise<void> {
const transporter = createTransport();
const rows = items
.map((i) => `<tr><td>${i.title}</td><td style="text-align:right">$${(i.amount / 100).toFixed(2)}</td></tr>`)
.join("");
await transporter.sendMail({
from: FROM,
to,
subject: "Your auction receipt",
html: `
<h2>Thank you for your support!</h2>
<table style="width:100%;border-collapse:collapse">
<thead><tr><th style="text-align:left">Item</th><th style="text-align:right">Amount</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td><strong>Total</strong></td>
<td style="text-align:right"><strong>$${(totalAmount / 100).toFixed(2)}</strong></td>
</tr>
</tfoot>
</table>
<p style="color:#6b7280;font-size:12px">Invoice #${invoiceId}</p>
`,
});
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Local disk storage service.
*
* Files are written to UPLOAD_DIR (default: /app/uploads inside the container,
* mapped to the `media_data` Docker volume so they survive restarts).
* Express serves them as static files under /media (see app.ts).
*
* This keeps the app fully self-contained and operational when the internet
* is unavailable — no S3, no external CDN, no external dependencies.
*/
import { mkdir, unlink } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, extname } from "node:path";
import { randomUUID } from "node:crypto";
import type { Request } from "express";
import multer, { type FileFilterCallback } from "multer";
// ── Config ────────────────────────────────────────────────────────────────────
export const UPLOAD_DIR = process.env["UPLOAD_DIR"] ?? join(process.cwd(), "uploads");
/** Public URL prefix used to build the URL stored in the DB. */
function mediaBaseUrl(): string {
return process.env["MEDIA_BASE_URL"] ?? "/media";
}
// ── Allowed types ──────────────────────────────────────────────────────────────
export type MediaType = "image" | "video" | "document";
const ALLOWED_MIME: Record<MediaType, string[]> = {
image: ["image/jpeg", "image/png", "image/webp", "image/gif"],
video: ["video/mp4", "video/webm"],
document: ["application/pdf"],
};
const MIME_TO_EXT: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"video/mp4": "mp4",
"video/webm": "webm",
"application/pdf": "pdf",
};
// ── Multer storage engine ──────────────────────────────────────────────────────
const diskStorage = multer.diskStorage({
destination: async (req, _file, cb) => {
// itemId is in the request body (parsed before multer runs via fields())
const itemId = (req.body as { itemId?: string }).itemId ?? "unknown";
const dir = join(UPLOAD_DIR, "items", itemId);
try {
await mkdir(dir, { recursive: true });
cb(null, dir);
} catch (err) {
cb(err as Error, dir);
}
},
filename: (_req, file, cb) => {
const ext = MIME_TO_EXT[file.mimetype] ?? extname(file.originalname).slice(1) ?? "bin";
cb(null, `${randomUUID()}.${ext}`);
},
});
function fileFilter(
req: Request,
file: Express.Multer.File,
cb: FileFilterCallback,
): void {
const mediaType = (req.body as { mediaType?: string }).mediaType as MediaType | undefined;
const allowed = mediaType ? ALLOWED_MIME[mediaType] : Object.values(ALLOWED_MIME).flat();
if (allowed?.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type ${file.mimetype} is not allowed for mediaType "${mediaType ?? "unknown"}"`));
}
}
/** Max file sizes in bytes */
const MAX_SIZE: Record<MediaType, number> = {
image: 10 * 1024 * 1024, // 10 MB
video: 500 * 1024 * 1024, // 500 MB
document: 50 * 1024 * 1024, // 50 MB
};
export const upload = multer({
storage: diskStorage,
fileFilter,
limits: { fileSize: 500 * 1024 * 1024 }, // hard ceiling; per-type checked below
});
// ── Post-upload helpers ────────────────────────────────────────────────────────
export interface SavedFile {
url: string; // public URL served by Express static
key: string; // relative path within UPLOAD_DIR, used for deletion
mimetype: string;
sizeBytes: number;
}
/**
* Build the public URL and key for a file that multer has already saved to disk.
* Also enforces the per-mediaType size limit (multer's limit is a single ceiling).
*/
export function resolveFile(
file: Express.Multer.File,
mediaType: MediaType,
): SavedFile {
const maxSize = MAX_SIZE[mediaType];
if (file.size > maxSize) {
// Remove the already-written file before throwing
void unlink(file.path).catch(() => undefined);
throw new Error(
`File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB exceeds the ${maxSize / 1024 / 1024} MB limit for ${mediaType}`,
);
}
// key = relative path from UPLOAD_DIR, e.g. "items/abc123/uuid.jpg"
const key = file.path.replace(UPLOAD_DIR + "/", "").replace(UPLOAD_DIR + "\\", "");
const url = `${mediaBaseUrl()}/${key.replace(/\\/g, "/")}`;
return { url, key, mimetype: file.mimetype, sizeBytes: file.size };
}
/**
* Delete a previously uploaded file by its key.
* Silently ignores missing files (idempotent).
*/
export async function deleteFile(key: string): Promise<void> {
const fullPath = join(UPLOAD_DIR, key);
// Safety: ensure the resolved path stays inside UPLOAD_DIR
if (!fullPath.startsWith(UPLOAD_DIR)) {
throw new Error("Invalid key — path traversal detected");
}
if (existsSync(fullPath)) {
await unlink(fullPath);
}
}
+26
View File
@@ -0,0 +1,26 @@
import twilio from "twilio";
function getClient() {
const sid = process.env["TWILIO_ACCOUNT_SID"];
const token = process.env["TWILIO_AUTH_TOKEN"];
if (!sid || !token) throw new Error("Twilio credentials not configured");
return twilio(sid, token);
}
const SERVICE_SID = process.env["TWILIO_VERIFY_SERVICE_SID"] ?? "";
export async function sendOtp(phone: string): Promise<void> {
const client = getClient();
await client.verify.v2.services(SERVICE_SID).verifications.create({
to: phone,
channel: "sms",
});
}
export async function verifyOtp(phone: string, code: string): Promise<boolean> {
const client = getClient();
const result = await client.verify.v2
.services(SERVICE_SID)
.verificationChecks.create({ to: phone, code });
return result.status === "approved";
}
+64
View File
@@ -0,0 +1,64 @@
import type { Server } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
} from "@storybid/shared";
import { registerLiveAuctionHandlers } from "./live-auction.js";
import { registerSilentAuctionHandlers } from "./silent-auction.js";
import { verifyToken } from "../lib/jwt.js";
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
export function registerSocketHandlers(io: IO): void {
// Auth middleware validate JWT on handshake
io.use((socket, next) => {
const token =
(socket.handshake.auth["token"] as string | undefined) ??
(socket.handshake.headers["authorization"] as string | undefined)?.replace("Bearer ", "");
if (!token) {
// Allow unauthenticated connections for display board / public catalog
return next();
}
try {
const payload = verifyToken(token);
socket.data.bidderId = payload.role === "bidder" ? payload.sub : undefined;
socket.data.staffId = payload.role !== "bidder" ? payload.sub : undefined;
socket.data.role = payload.role;
socket.data.deviceId = payload.deviceId;
} catch {
return next(new Error("Invalid token"));
}
next();
});
io.on("connection", (socket) => {
console.log(`[socket] connected ${socket.id} role=${socket.data.role ?? "guest"}`);
// Auto-join personal room for outbid / checkout notifications
if (socket.data.bidderId) {
void socket.join(`bidder:${socket.data.bidderId}`);
}
// Room join/leave for event-scoped broadcasts
socket.on("join_event", (eventId) => {
void socket.join(`event:${eventId}`);
});
socket.on("leave_event", (eventId) => {
void socket.leave(`event:${eventId}`);
});
registerLiveAuctionHandlers(io, socket);
registerSilentAuctionHandlers(io, socket);
socket.on("disconnect", (reason) => {
console.log(`[socket] disconnected ${socket.id} reason=${reason}`);
});
});
}
+216
View File
@@ -0,0 +1,216 @@
import type { Server, Socket } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
} from "@storybid/shared";
import { prisma } from "../lib/prisma.js";
import { placeBid } from "../services/bid-engine.js";
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
type Sock = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
function isStaff(role?: string) {
return ["admin", "event_manager", "auctioneer", "spotter"].includes(role ?? "");
}
function isAuctioneer(role?: string) {
return ["admin", "event_manager", "auctioneer"].includes(role ?? "");
}
/** Broadcast to all sockets in the event room. */
function toEvent(io: IO, itemId: string, broadcastFn: (room: ReturnType<IO["to"]>) => void) {
// We need the eventId look it up from item. For now pass auctionId-based room.
// Rooms are joined as `event:<eventId>` on connect.
// We'll derive it from the item's auction.
void prisma.auctionItem.findUnique({
where: { id: itemId },
include: { auction: { select: { eventId: true } } },
}).then((item) => {
if (item?.auction.eventId) {
broadcastFn(io.to(`event:${item.auction.eventId}`));
}
});
}
export function registerLiveAuctionHandlers(io: IO, socket: Sock): void {
// ── Bidder: place a live bid ────────────────────────────────────────────────
socket.on("place_live_bid", async (payload) => {
const bidderId = socket.data.bidderId;
if (!bidderId) return;
const result = await placeBid({
itemId: payload.itemId,
bidderId,
amount: payload.amount,
originMode: "public", // socket transport → always public or local; use header hint if needed
deviceId: payload.deviceId,
clientSeq: payload.clientSeq,
clientCreatedAt: new Date(payload.clientCreatedAt),
});
if (!result.ok) {
console.warn(`[live] rejected bid bidder=${bidderId} reason=${result.error}`);
return;
}
// Broadcast winning bid to event room
toEvent(io, payload.itemId, (room) => {
room.emit("live_bid_accepted", {
bid: {
...result.bid,
amount: Number(result.bid.amount),
clientCreatedAt: result.bid.clientCreatedAt.toISOString(),
serverReceivedAt: result.bid.serverReceivedAt.toISOString(),
createdAt: result.bid.createdAt.toISOString(),
originMode: result.bid.originMode as import("@storybid/shared").OriginMode,
syncStatus: result.bid.syncStatus as import("@storybid/shared").SyncStatus,
},
item: {
...result.item,
fairMarketValue: result.item.fairMarketValue ? Number(result.item.fairMarketValue) : null,
openingBid: Number(result.item.openingBid),
reservePrice: result.item.reservePrice ? Number(result.item.reservePrice) : null,
currentHighBid: result.item.currentHighBid ? Number(result.item.currentHighBid) : null,
bidIncrement: Number(result.item.bidIncrement),
createdAt: result.item.createdAt.toISOString(),
updatedAt: result.item.updatedAt.toISOString(),
state: result.item.state as import("@storybid/shared").ItemState,
},
});
});
});
// ── Auctioneer: activate a lot ─────────────────────────────────────────────
socket.on("auctioneer_activate_item", async (itemId) => {
if (!isAuctioneer(socket.data.role)) return;
const item = await prisma.auctionItem.update({
where: { id: itemId },
data: { state: "active" },
include: { auction: { select: { eventId: true } } },
});
io.to(`event:${item.auction.eventId}`).emit("item_activated", {
item: {
...item,
fairMarketValue: item.fairMarketValue ? Number(item.fairMarketValue) : null,
openingBid: Number(item.openingBid),
reservePrice: item.reservePrice ? Number(item.reservePrice) : null,
currentHighBid: item.currentHighBid ? Number(item.currentHighBid) : null,
bidIncrement: Number(item.bidIncrement),
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
state: item.state as import("@storybid/shared").ItemState,
},
});
});
// ── Auctioneer: call the next bid amount ────────────────────────────────────
socket.on("auctioneer_call_next_bid", async (payload) => {
if (!isAuctioneer(socket.data.role)) return;
const item = await prisma.auctionItem.findUnique({
where: { id: payload.itemId },
include: { auction: { select: { eventId: true } } },
});
if (!item) return;
io.to(`event:${item.auction.eventId}`).emit("next_live_bid", {
itemId: payload.itemId,
amount: payload.amount,
});
});
// ── Auctioneer / Spotter: accept a floor bid ────────────────────────────────
socket.on("auctioneer_accept_bid", async (payload) => {
if (!isStaff(socket.data.role)) return;
const result = await placeBid({
itemId: payload.itemId,
bidderId: payload.bidderId,
amount: payload.amount,
originMode: "public",
deviceId: socket.id, // spotter device = socket id
clientSeq: Date.now(), // floor bids use server timestamp as seq
clientCreatedAt: new Date(),
});
if (!result.ok) {
console.warn(`[live] spotter bid rejected reason=${result.error}`);
return;
}
toEvent(io, payload.itemId, (room) => {
room.emit("live_bid_accepted", {
bid: {
...result.bid,
amount: Number(result.bid.amount),
clientCreatedAt: result.bid.clientCreatedAt.toISOString(),
serverReceivedAt: result.bid.serverReceivedAt.toISOString(),
createdAt: result.bid.createdAt.toISOString(),
originMode: result.bid.originMode as import("@storybid/shared").OriginMode,
syncStatus: result.bid.syncStatus as import("@storybid/shared").SyncStatus,
},
item: {
...result.item,
fairMarketValue: result.item.fairMarketValue ? Number(result.item.fairMarketValue) : null,
openingBid: Number(result.item.openingBid),
reservePrice: result.item.reservePrice ? Number(result.item.reservePrice) : null,
currentHighBid: result.item.currentHighBid ? Number(result.item.currentHighBid) : null,
bidIncrement: Number(result.item.bidIncrement),
createdAt: result.item.createdAt.toISOString(),
updatedAt: result.item.updatedAt.toISOString(),
state: result.item.state as import("@storybid/shared").ItemState,
},
});
});
});
// ── State transitions ───────────────────────────────────────────────────────
async function transitionItem(
itemId: string,
state: "going_once" | "going_twice" | "sold" | "passed",
) {
const item = await prisma.auctionItem.update({
where: { id: itemId },
data: { state },
include: { auction: { select: { eventId: true } } },
});
if (state === "sold") {
io.to(`event:${item.auction.eventId}`).emit("item_sold", {
itemId: item.id,
winnerId: item.currentHighBidderId ?? "",
amount: item.currentHighBid ? Number(item.currentHighBid) : 0,
});
} else {
io.to(`event:${item.auction.eventId}`).emit("item_state_changed", {
itemId: item.id,
state: item.state as import("@storybid/shared").ItemState,
});
}
}
socket.on("auctioneer_going_once", (itemId) => {
if (!isAuctioneer(socket.data.role)) return;
void transitionItem(itemId, "going_once");
});
socket.on("auctioneer_going_twice", (itemId) => {
if (!isAuctioneer(socket.data.role)) return;
void transitionItem(itemId, "going_twice");
});
socket.on("auctioneer_sold", (itemId) => {
if (!isAuctioneer(socket.data.role)) return;
void transitionItem(itemId, "sold");
});
socket.on("auctioneer_pass", (itemId) => {
if (!isAuctioneer(socket.data.role)) return;
void transitionItem(itemId, "passed");
});
}
@@ -0,0 +1,131 @@
import type { Server, Socket } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
OriginMode,
SyncStatus,
ItemState,
} from "@storybid/shared";
import { prisma } from "../lib/prisma.js";
import { placeBid } from "../services/bid-engine.js";
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
type Sock = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
export function registerSilentAuctionHandlers(io: IO, socket: Sock): void {
// ── Bidder: place a silent bid ──────────────────────────────────────────────
socket.on("place_silent_bid", async (payload) => {
const bidderId = socket.data.bidderId;
if (!bidderId) return;
const result = await placeBid({
itemId: payload.itemId,
bidderId,
amount: payload.amount,
originMode: "public",
deviceId: payload.deviceId,
clientSeq: payload.clientSeq,
clientCreatedAt: new Date(payload.clientCreatedAt),
});
if (!result.ok) {
console.warn(`[silent] rejected bid bidder=${bidderId} reason=${result.error}`);
return;
}
const item = await prisma.auctionItem.findUnique({
where: { id: payload.itemId },
include: { auction: { select: { eventId: true } } },
});
if (!item) return;
const serializedBid = {
...result.bid,
amount: Number(result.bid.amount),
clientCreatedAt: result.bid.clientCreatedAt.toISOString(),
serverReceivedAt: result.bid.serverReceivedAt.toISOString(),
createdAt: result.bid.createdAt.toISOString(),
originMode: result.bid.originMode as OriginMode,
syncStatus: result.bid.syncStatus as SyncStatus,
};
const serializedItem = {
...result.item,
fairMarketValue: result.item.fairMarketValue ? Number(result.item.fairMarketValue) : null,
openingBid: Number(result.item.openingBid),
reservePrice: result.item.reservePrice ? Number(result.item.reservePrice) : null,
currentHighBid: result.item.currentHighBid ? Number(result.item.currentHighBid) : null,
bidIncrement: Number(result.item.bidIncrement),
createdAt: result.item.createdAt.toISOString(),
updatedAt: result.item.updatedAt.toISOString(),
state: result.item.state as ItemState,
};
// Broadcast new high bid to everyone in the event room
io.to(`event:${item.auction.eventId}`).emit("silent_bid_accepted", {
bid: serializedBid,
item: serializedItem,
});
// 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({
where: {
itemId: payload.itemId,
isWinning: false,
bidderId: { not: bidderId },
},
orderBy: { amount: "desc" },
});
if (previousBid) {
// Emit to a personal room for the outbid bidder (bidder joins `bidder:<id>` on connect)
io.to(`bidder:${previousBid.bidderId}`).emit("silent_outbid", {
itemId: payload.itemId,
yourBidderId: previousBid.bidderId,
newAmount: payload.amount,
});
}
});
// ── Sync offline outbox bids after reconnect ────────────────────────────────
socket.on("sync_outbox", async (bids) => {
const bidderId = socket.data.bidderId;
if (!bidderId || !bids.length) return;
const sorted = [...bids].sort((a, b) => a.clientSeq - b.clientSeq);
for (const entry of sorted) {
const result = await placeBid({
itemId: entry.itemId,
bidderId,
amount: entry.amount,
originMode: "offline_queue",
deviceId: entry.deviceId,
clientSeq: entry.clientSeq,
clientCreatedAt: new Date(entry.clientCreatedAt),
});
socket.emit("bid_sync_result", {
localId: entry.localId,
accepted: result.ok,
...(result.ok
? {
bid: {
...result.bid,
amount: Number(result.bid.amount),
clientCreatedAt: result.bid.clientCreatedAt.toISOString(),
serverReceivedAt: result.bid.serverReceivedAt.toISOString(),
createdAt: result.bid.createdAt.toISOString(),
originMode: result.bid.originMode as OriginMode,
syncStatus: result.bid.syncStatus as SyncStatus,
},
}
: { error: result.error }),
});
}
});
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}