From d909cb7c30f88743e84a811e4c7bd0a719969b82 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 2 May 2026 19:46:42 -0500 Subject: [PATCH] Scaffold and Phase 1 --- .env.example | 39 ++ .gitignore | 30 ++ README.md | 76 ++++ docker-compose.dev.yml | 24 ++ docker-compose.yml | 69 ++++ event-runbook/preflight.md | 46 +++ event-runbook/staff-roles.md | 32 ++ ops/README.md | 61 ++++ ops/unifi-dns.md | 49 +++ package.json | 24 ++ packages/client/Dockerfile | 18 + packages/client/index.html | 16 + packages/client/nginx.conf | 26 ++ packages/client/package.json | 34 ++ packages/client/postcss.config.ts | 6 + packages/client/src/App.tsx | 69 ++++ .../src/components/ConnectivityBanner.tsx | 21 ++ packages/client/src/hooks/useLiveAuction.ts | 91 +++++ packages/client/src/hooks/useOfflineBids.ts | 116 ++++++ packages/client/src/hooks/useSilentAuction.ts | 80 +++++ packages/client/src/index.css | 14 + packages/client/src/lib/api.ts | 46 +++ packages/client/src/lib/db.ts | 20 ++ packages/client/src/lib/socket.ts | 44 +++ packages/client/src/main.tsx | 13 + .../client/src/pages/admin/BiddersPage.tsx | 22 ++ .../client/src/pages/admin/CheckoutPage.tsx | 14 + .../client/src/pages/admin/DashboardPage.tsx | 19 + .../client/src/pages/admin/EventsPage.tsx | 19 + .../client/src/pages/admin/FundANeedPage.tsx | 14 + packages/client/src/pages/admin/ItemsPage.tsx | 14 + .../client/src/pages/admin/ReportingPage.tsx | 14 + packages/client/src/pages/auth/LoginPage.tsx | 20 ++ packages/client/src/pages/auth/VerifyPage.tsx | 11 + .../client/src/pages/bidder/CheckoutPage.tsx | 14 + packages/client/src/pages/bidder/HomePage.tsx | 27 ++ packages/client/src/pages/bidder/ItemPage.tsx | 18 + packages/client/src/pages/bidder/LivePage.tsx | 108 ++++++ .../client/src/pages/bidder/MyBidsPage.tsx | 14 + .../client/src/pages/bidder/ProfilePage.tsx | 14 + .../client/src/pages/bidder/SilentPage.tsx | 106 ++++++ .../client/src/pages/staff/AuctioneerPage.tsx | 20 ++ .../client/src/pages/staff/CheckInPage.tsx | 19 + .../src/pages/staff/DisplayBoardPage.tsx | 26 ++ .../client/src/pages/staff/SpotterPage.tsx | 19 + packages/client/src/store/auth.ts | 24 ++ packages/client/src/store/connectivity.ts | 13 + packages/client/tailwind.config.ts | 20 ++ packages/client/tsconfig.json | 13 + packages/client/vite.config.ts | 52 +++ packages/server/Dockerfile | 27 ++ packages/server/package.json | 49 +++ packages/server/prisma/schema.prisma | 338 ++++++++++++++++++ packages/server/prisma/seed.ts | 84 +++++ packages/server/src/app.ts | 75 ++++ packages/server/src/index.ts | 48 +++ packages/server/src/lib/jwt.ts | 19 + packages/server/src/lib/prisma.ts | 15 + packages/server/src/lib/redis.ts | 13 + packages/server/src/middleware/auth.ts | 34 ++ packages/server/src/routes/auctions.ts | 144 ++++++++ packages/server/src/routes/auth.ts | 258 +++++++++++++ packages/server/src/routes/bidders.ts | 265 ++++++++++++++ packages/server/src/routes/bids.ts | 157 ++++++++ packages/server/src/routes/check-in.ts | 74 ++++ packages/server/src/routes/checkout.ts | 31 ++ packages/server/src/routes/events.ts | 125 +++++++ packages/server/src/routes/items.ts | 186 ++++++++++ packages/server/src/routes/media.ts | 64 ++++ packages/server/src/routes/organization.ts | 47 +++ packages/server/src/routes/reporting.ts | 23 ++ packages/server/src/routes/webhooks.ts | 16 + packages/server/src/services/bid-engine.ts | 148 ++++++++ packages/server/src/services/email.ts | 68 ++++ packages/server/src/services/storage.ts | 142 ++++++++ packages/server/src/services/twilio.ts | 26 ++ packages/server/src/socket/index.ts | 64 ++++ packages/server/src/socket/live-auction.ts | 216 +++++++++++ packages/server/src/socket/silent-auction.ts | 131 +++++++ packages/server/tsconfig.json | 12 + packages/shared/package.json | 22 ++ packages/shared/src/index.ts | 8 + packages/shared/src/types/auction.ts | 72 ++++ packages/shared/src/types/bid.ts | 38 ++ packages/shared/src/types/bidder.ts | 24 ++ packages/shared/src/types/event.ts | 17 + packages/shared/src/types/organization.ts | 12 + packages/shared/src/types/payment.ts | 51 +++ packages/shared/src/types/roles.ts | 15 + packages/shared/src/types/socket-events.ts | 68 ++++ packages/shared/tsconfig.json | 8 + tsconfig.base.json | 15 + 92 files changed, 4967 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 event-runbook/preflight.md create mode 100644 event-runbook/staff-roles.md create mode 100644 ops/README.md create mode 100644 ops/unifi-dns.md create mode 100644 package.json create mode 100644 packages/client/Dockerfile create mode 100644 packages/client/index.html create mode 100644 packages/client/nginx.conf create mode 100644 packages/client/package.json create mode 100644 packages/client/postcss.config.ts create mode 100644 packages/client/src/App.tsx create mode 100644 packages/client/src/components/ConnectivityBanner.tsx create mode 100644 packages/client/src/hooks/useLiveAuction.ts create mode 100644 packages/client/src/hooks/useOfflineBids.ts create mode 100644 packages/client/src/hooks/useSilentAuction.ts create mode 100644 packages/client/src/index.css create mode 100644 packages/client/src/lib/api.ts create mode 100644 packages/client/src/lib/db.ts create mode 100644 packages/client/src/lib/socket.ts create mode 100644 packages/client/src/main.tsx create mode 100644 packages/client/src/pages/admin/BiddersPage.tsx create mode 100644 packages/client/src/pages/admin/CheckoutPage.tsx create mode 100644 packages/client/src/pages/admin/DashboardPage.tsx create mode 100644 packages/client/src/pages/admin/EventsPage.tsx create mode 100644 packages/client/src/pages/admin/FundANeedPage.tsx create mode 100644 packages/client/src/pages/admin/ItemsPage.tsx create mode 100644 packages/client/src/pages/admin/ReportingPage.tsx create mode 100644 packages/client/src/pages/auth/LoginPage.tsx create mode 100644 packages/client/src/pages/auth/VerifyPage.tsx create mode 100644 packages/client/src/pages/bidder/CheckoutPage.tsx create mode 100644 packages/client/src/pages/bidder/HomePage.tsx create mode 100644 packages/client/src/pages/bidder/ItemPage.tsx create mode 100644 packages/client/src/pages/bidder/LivePage.tsx create mode 100644 packages/client/src/pages/bidder/MyBidsPage.tsx create mode 100644 packages/client/src/pages/bidder/ProfilePage.tsx create mode 100644 packages/client/src/pages/bidder/SilentPage.tsx create mode 100644 packages/client/src/pages/staff/AuctioneerPage.tsx create mode 100644 packages/client/src/pages/staff/CheckInPage.tsx create mode 100644 packages/client/src/pages/staff/DisplayBoardPage.tsx create mode 100644 packages/client/src/pages/staff/SpotterPage.tsx create mode 100644 packages/client/src/store/auth.ts create mode 100644 packages/client/src/store/connectivity.ts create mode 100644 packages/client/tailwind.config.ts create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/vite.config.ts create mode 100644 packages/server/Dockerfile create mode 100644 packages/server/package.json create mode 100644 packages/server/prisma/schema.prisma create mode 100644 packages/server/prisma/seed.ts create mode 100644 packages/server/src/app.ts create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/src/lib/jwt.ts create mode 100644 packages/server/src/lib/prisma.ts create mode 100644 packages/server/src/lib/redis.ts create mode 100644 packages/server/src/middleware/auth.ts create mode 100644 packages/server/src/routes/auctions.ts create mode 100644 packages/server/src/routes/auth.ts create mode 100644 packages/server/src/routes/bidders.ts create mode 100644 packages/server/src/routes/bids.ts create mode 100644 packages/server/src/routes/check-in.ts create mode 100644 packages/server/src/routes/checkout.ts create mode 100644 packages/server/src/routes/events.ts create mode 100644 packages/server/src/routes/items.ts create mode 100644 packages/server/src/routes/media.ts create mode 100644 packages/server/src/routes/organization.ts create mode 100644 packages/server/src/routes/reporting.ts create mode 100644 packages/server/src/routes/webhooks.ts create mode 100644 packages/server/src/services/bid-engine.ts create mode 100644 packages/server/src/services/email.ts create mode 100644 packages/server/src/services/storage.ts create mode 100644 packages/server/src/services/twilio.ts create mode 100644 packages/server/src/socket/index.ts create mode 100644 packages/server/src/socket/live-auction.ts create mode 100644 packages/server/src/socket/silent-auction.ts create mode 100644 packages/server/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/types/auction.ts create mode 100644 packages/shared/src/types/bid.ts create mode 100644 packages/shared/src/types/bidder.ts create mode 100644 packages/shared/src/types/event.ts create mode 100644 packages/shared/src/types/organization.ts create mode 100644 packages/shared/src/types/payment.ts create mode 100644 packages/shared/src/types/roles.ts create mode 100644 packages/shared/src/types/socket-events.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8fd284 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# ── Database ────────────────────────────────────────────────────────────────── +DATABASE_URL="postgresql://storybid:storybid@localhost:5432/storybid" + +# ── Redis (optional – queues, rate limiting, Socket.io scaling) ─────────────── +REDIS_URL="redis://localhost:6379" + +# ── App ─────────────────────────────────────────────────────────────────────── +NODE_ENV=development +PORT=3001 +# Public FQDN used as first-choice endpoint; fall back to LOCAL_HOSTNAME +PUBLIC_URL="https://bid.example.org" +# Local DNS hostname on event Wi-Fi (UniFi local DNS record) +LOCAL_HOSTNAME="auction.event.lan" +JWT_SECRET="change-me-in-production" + +# ── Stripe ──────────────────────────────────────────────────────────────────── +STRIPE_SECRET_KEY="sk_test_..." +STRIPE_WEBHOOK_SECRET="whsec_..." + +# ── Twilio Verify (SMS OTP) ─────────────────────────────────────────────────── +TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +TWILIO_VERIFY_SERVICE_SID="VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# ── Media storage (local disk) ──────────────────────────────────────────────── +# Absolute path where uploaded files are stored on the server. +# In Docker this is the mount point of the media_data volume (/app/uploads). +# In dev it defaults to packages/server/uploads/ (relative to server cwd). +UPLOAD_DIR=/app/uploads +# Public URL prefix that the server serves files under. +# With the default nginx/proxy setup this is just /media (same origin). +MEDIA_BASE_URL=/media + +# ── Email (magic links + receipts) ─────────────────────────────────────────── +SMTP_HOST="smtp.example.com" +SMTP_PORT=587 +SMTP_USER="noreply@example.com" +SMTP_PASS="..." +EMAIL_FROM="Storybid " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2c2bb5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +node_modules/ +dist/ +build/ +.env +.env.local +*.local + +# Prisma +packages/server/prisma/migrations/*.sql.shadow + +# Logs +*.log +npm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Test coverage +coverage/ + +# Vite +packages/client/.vite/ + +# Local media uploads (dev only; production uses Docker volume) +packages/server/uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc811de --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Storybid + +Self-hosted charity auction platform supporting live and silent auctions in the +same event, with offline-first PWA bidding and automatic LAN failover for +event-night resilience. + +## Stack + +| Layer | Choice | +|------------|---------------------------------------------| +| Client | React 18 + TypeScript + Vite + Tailwind | +| PWA/Offline| Workbox (vite-plugin-pwa) + Dexie/IndexedDB | +| Real-time | Socket.io | +| Server | Node.js + Express + TypeScript | +| ORM | Prisma | +| Database | PostgreSQL (SQLite optional for dev) | +| Cache/Queue| Redis (optional) | +| Auth | Email magic links + Twilio Verify SMS OTP | +| Payments | Stripe Payment Element / Payment Intents | +| Media | S3-compatible presigned uploads | +| Deploy | Docker Compose (Unraid / Linux VM) | + +## Quick Start (development) + +```bash +# 1. Clone and install +git clone +cd storybid +npm install + +# 2. Start DB + Redis +docker compose -f docker-compose.dev.yml up -d + +# 3. Configure environment +cp .env.example .env +# Edit .env with your Stripe, Twilio, SMTP, and storage keys + +# 4. Migrate database and seed demo data +npm run db:migrate +npm run db:seed + +# 5. Start dev servers +npm run dev +# Client → http://localhost:5173 +# Server → http://localhost:3001 +``` + +## Production Deployment + +See [`ops/README.md`](./ops/README.md). + +## Event-Night Network + +See [`ops/unifi-dns.md`](./ops/unifi-dns.md) for UniFi local DNS setup and +WAN-failover testing. + +## Staff Runbook + +See [`event-runbook/preflight.md`](./event-runbook/preflight.md) for the +pre-event checklist and [`event-runbook/staff-roles.md`](./event-runbook/staff-roles.md) +for per-role instructions. + +## Project Plan + +The full product specification lives in [`STORYBID.md`](./STORYBID.md). + +## Build Roadmap + +| Phase | Focus | Status | +|-------|------------------------------------------------|---------| +| 1 | Foundation – monorepo, auth, org/event models | 🏗 scaffold | +| 2 | Live Auction – auctioneer console, bidder view | ⬜ todo | +| 3 | Silent Auction – catalog, timers, outbid | ⬜ todo | +| 4 | Offline Resilience – PWA, outbox, failover | ⬜ todo | +| 5 | Event Ops – check-in, checkout, fund-a-need | ⬜ todo | +| 6 | Hardening – load test, a11y, backups, docs | ⬜ todo | diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..92a95b9 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,24 @@ +version: "3.9" +# Development override – only runs DB + Redis; app servers run via `npm run dev` +# Media uploads land in packages/server/uploads/ by default (UPLOAD_DIR env var). +# That directory is gitignored; Docker volume is not needed in dev. + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: storybid + POSTGRES_PASSWORD: storybid + POSTGRES_DB: storybid + volumes: + - postgres_data_dev:/var/lib/postgresql/data + ports: + - "5432:5432" + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + +volumes: + postgres_data_dev: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..07ffc42 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +version: "3.9" + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: storybid + POSTGRES_PASSWORD: storybid + POSTGRES_DB: storybid + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U storybid"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + server: + build: + context: . + dockerfile: packages/server/Dockerfile + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: postgresql://storybid:storybid@db:5432/storybid + REDIS_URL: redis://redis:6379 + NODE_ENV: production + UPLOAD_DIR: /app/uploads + MEDIA_BASE_URL: /media + volumes: + - media_data:/app/uploads + ports: + - "3001:3001" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + client: + build: + context: . + dockerfile: packages/client/Dockerfile + restart: unless-stopped + ports: + - "8080:80" + depends_on: + - server + +volumes: + postgres_data: + redis_data: + media_data: diff --git a/event-runbook/preflight.md b/event-runbook/preflight.md new file mode 100644 index 0000000..d00097b --- /dev/null +++ b/event-runbook/preflight.md @@ -0,0 +1,46 @@ +# Event-Day Preflight Checklist + +Complete this list at least 60 minutes before doors open. + +## Server & Network + +- [ ] Server is powered on and accessible at the public URL +- [ ] `docker compose ps` shows all containers healthy +- [ ] Local DNS record resolves: `ping auction.event.lan` responds from server LAN IP +- [ ] Event Wi-Fi SSID is broadcasting; test device can connect +- [ ] Failover test: disconnect WAN, confirm app loads via local hostname, reconnect WAN +- [ ] UPS is charged and protecting server + network gear + +## Application + +- [ ] Log in as admin at `/admin` +- [ ] Correct event is set to **published** status +- [ ] All auction items are entered with opening bids and increments +- [ ] Silent auction windows have correct open/close times and timezone +- [ ] Soft-close is configured on silent items +- [ ] Stripe keys are set; run a test payment in Stripe test mode +- [ ] Twilio Verify is working: send a test OTP to your phone + +## Staff Devices + +- [ ] Auctioneer tablet loaded at `/staff/auctioneer`, logged in with `auctioneer` role +- [ ] Spotter phone(s) loaded at `/staff/spotter`, logged in with `spotter` role +- [ ] Check-in tablet(s) loaded at `/staff/check-in`, logged in with `checkin_staff` role +- [ ] Display board TV/projector loaded at `/display` (no login required) +- [ ] All staff devices are connected to the event SSID + +## Bidder Registration + +- [ ] CSV import completed (or manual bidders entered) +- [ ] Paddle numbers assigned for pre-registered bidders +- [ ] QR code registration link / printed QR codes ready at check-in desk +- [ ] Test bidder account works: log in, view silent catalog, place a test bid, see outbid notification + +## Backups + +- [ ] Database backup triggered: `docker compose exec db pg_dump -U storybid storybid > backup-$(date +%F).sql` +- [ ] Backup file copied off the event server (USB drive or remote location) + +## Go/No-Go + +All items checked → **Go**. Any blocker → resolve before opening bidding. diff --git a/event-runbook/staff-roles.md b/event-runbook/staff-roles.md new file mode 100644 index 0000000..35ff23d --- /dev/null +++ b/event-runbook/staff-roles.md @@ -0,0 +1,32 @@ +# Staff Roles Quick Reference + +| Role | URL | Can do | +|------------------|-----------------------|-----------------------------------------------------------| +| `admin` | `/admin` | Everything | +| `event_manager` | `/admin/events` | Create/edit events, items, bidders; view reports | +| `auctioneer` | `/staff/auctioneer` | Activate lots, call bids, mark sold/pass | +| `spotter` | `/staff/spotter` | Enter floor bids by paddle number | +| `checkin_staff` | `/staff/check-in` | Search bidders, scan QR, mark checked-in | +| `bidder` | `/` | View catalog, place bids, checkout | + +## Auctioneer Workflow (Live Auction) + +1. Open `/staff/auctioneer` on your tablet. +2. Tap **Activate** on the first lot — this makes it live to all bidder devices. +3. Call the opening bid from the podium; tap **Call $X** to push the amount to devices. +4. As bids come in (phone or floor), tap **Accept** with the paddle number. +5. Tap **Going Once** → **Going Twice** → **Sold** to close the lot. +6. Tap **Next Lot** to advance. + +## Spotter Workflow + +1. Open `/staff/spotter` on your phone. +2. When you see a floor bid, enter the paddle number and tap **Bid**. +3. The auctioneer console receives it immediately. + +## Check-In Workflow + +1. Open `/staff/check-in` on your tablet. +2. Scan the bidder's QR code **or** search by name/email. +3. Confirm paddle number and assign a table if relevant. +4. Tap **Check In** — the bidder's device receives their digital paddle. diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..789e3ad --- /dev/null +++ b/ops/README.md @@ -0,0 +1,61 @@ +# Ops – Deployment & Network Guide + +## Docker Compose (production) + +```bash +cp .env.example .env +# Fill in all values in .env + +docker compose up -d +# Server: http://localhost:3001 +# Client: http://localhost:8080 +``` + +Run migrations after first boot: + +```bash +docker compose exec server node -e "require('./dist/lib/prisma').prisma.\$executeRaw\`SELECT 1\`" +# or directly: +docker compose run --rm server npx prisma migrate deploy +``` + +## Development (local) + +```bash +# Start DB + Redis only +docker compose -f docker-compose.dev.yml up -d + +# Install dependencies +npm install + +# Generate Prisma client +npm run db:generate + +# Run first migration +npm run db:migrate + +# Seed demo data +npm run db:seed + +# Start both server and client with hot-reload +npm run dev +``` + +Client: http://localhost:5173 +Server: http://localhost:3001 +Prisma Studio: `npm run db:studio` + +## Unraid Deployment + +1. Place the repo in `/mnt/user/appdata/storybid/`. +2. Copy `.env.example` → `.env` and fill in all values. +3. From the Unraid terminal: `docker compose up -d` +4. Optionally add a **Community Applications** custom app pointing to `docker-compose.yml`. + +## UniFi Event-Network Setup + +See [unifi-dns.md](./unifi-dns.md) for step-by-step instructions to: + +- Create a dedicated event SSID. +- Add a local DNS record (`auction.event.lan` → server LAN IP). +- Configure the `LOCAL_HOSTNAME` env var so the app can fail over to it. diff --git a/ops/unifi-dns.md b/ops/unifi-dns.md new file mode 100644 index 0000000..03c9f00 --- /dev/null +++ b/ops/unifi-dns.md @@ -0,0 +1,49 @@ +# UniFi Event-Network Setup + +## Goal + +When the WAN link degrades during an event, bidder devices on the event Wi-Fi +should automatically fall back to the on-site server over the local LAN, with +no visible interruption beyond the connectivity banner turning yellow. + +## Steps + +### 1 – Note the server's LAN IP + +Find the static or DHCP-reserved IP for the event server on your UniFi network +(e.g. `192.168.1.50`). Set a DHCP reservation so it never changes. + +### 2 – Create a local DNS record + +In **UniFi Network → Settings → Networks → DNS Records** (or **Local DNS** in +older firmware), add: + +| Hostname | IP | +|-----------------------|----------------| +| `auction.event.lan` | `192.168.1.50` | + +Set this same hostname in `.env`: + +``` +LOCAL_HOSTNAME=auction.event.lan +``` + +### 3 – Create a dedicated event SSID + +- SSID name: e.g. `GalaAuction` +- Password: share on the event program or check-in desk +- VLAN: optional; keep on the same LAN as the server +- Band steering: enable for cleaner 5 GHz preference + +### 4 – Test failover + +1. Join `GalaAuction` on a test device. +2. Confirm `https://bid.example.org` loads normally (WAN path). +3. Disconnect the WAN cable or block WAN traffic in UniFi. +4. Confirm the connectivity banner turns yellow ("Local network – offline-capable"). +5. Confirm bidding still works — bids are accepted via `auction.event.lan`. + +### 5 – Battery backup + +Plug the server, UniFi gateway, and all access points into a UPS. A 10-minute +runtime is enough to survive a generator switchover or brief power blip. diff --git a/package.json b/package.json new file mode 100644 index 0000000..cc1c53f --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "storybid", + "private": true, + "workspaces": [ + "packages/shared", + "packages/server", + "packages/client" + ], + "scripts": { + "dev": "concurrently \"npm run dev -w packages/server\" \"npm run dev -w packages/client\"", + "build": "npm run build -w packages/shared && npm run build -w packages/server && npm run build -w packages/client", + "typecheck": "npm run typecheck -w packages/shared && npm run typecheck -w packages/server && npm run typecheck -w packages/client", + "db:migrate": "npm run db:migrate -w packages/server", + "db:generate": "npm run db:generate -w packages/server", + "db:studio": "npm run db:studio -w packages/server" + }, + "devDependencies": { + "concurrently": "^8.2.2", + "typescript": "^5.4.5" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/client/Dockerfile b/packages/client/Dockerfile new file mode 100644 index 0000000..4135787 --- /dev/null +++ b/packages/client/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/client/package.json ./packages/client/ +RUN npm ci --workspace=packages/shared --workspace=packages/client + +COPY packages/shared ./packages/shared +COPY packages/client ./packages/client +COPY tsconfig.base.json ./ + +RUN npm run build -w packages/shared +RUN npm run build -w packages/client + +FROM nginx:1.27-alpine AS runtime +COPY --from=builder /app/packages/client/dist /usr/share/nginx/html +COPY packages/client/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/packages/client/index.html b/packages/client/index.html new file mode 100644 index 0000000..082f320 --- /dev/null +++ b/packages/client/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Storybid Auction + + +
+ + + diff --git a/packages/client/nginx.conf b/packages/client/nginx.conf new file mode 100644 index 0000000..dc3eb01 --- /dev/null +++ b/packages/client/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Gzip + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + + # PWA – all routes fall back to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache hashed assets aggressively + location ~* \.(js|css|woff2|png|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Never cache the service worker or manifest + location ~* (service-worker\.js|manifest\.webmanifest)$ { + expires off; + add_header Cache-Control "no-store"; + } +} diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..c85f4dd --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,34 @@ +{ + "name": "@storybid/client", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@storybid/shared": "*", + "dexie": "^3.2.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "socket.io-client": "^4.7.5", + "zustand": "^4.5.2", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "*", + "vite": "^5.2.13", + "vite-plugin-pwa": "^0.20.0" + } +} diff --git a/packages/client/postcss.config.ts b/packages/client/postcss.config.ts new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/packages/client/postcss.config.ts @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx new file mode 100644 index 0000000..e6a40d5 --- /dev/null +++ b/packages/client/src/App.tsx @@ -0,0 +1,69 @@ +import { Routes, Route, Navigate } from "react-router-dom"; +import { ConnectivityBanner } from "./components/ConnectivityBanner.js"; + +// Bidder-facing pages +import HomePage from "./pages/bidder/HomePage.js"; +import LivePage from "./pages/bidder/LivePage.js"; +import SilentPage from "./pages/bidder/SilentPage.js"; +import ItemPage from "./pages/bidder/ItemPage.js"; +import MyBidsPage from "./pages/bidder/MyBidsPage.js"; +import CheckoutPage from "./pages/bidder/CheckoutPage.js"; +import ProfilePage from "./pages/bidder/ProfilePage.js"; + +// Auth pages +import LoginPage from "./pages/auth/LoginPage.js"; +import VerifyPage from "./pages/auth/VerifyPage.js"; + +// Staff pages +import AuctioneerPage from "./pages/staff/AuctioneerPage.js"; +import SpotterPage from "./pages/staff/SpotterPage.js"; +import CheckInPage from "./pages/staff/CheckInPage.js"; +import DisplayBoardPage from "./pages/staff/DisplayBoardPage.js"; + +// Admin pages +import AdminDashboard from "./pages/admin/DashboardPage.js"; +import AdminEventsPage from "./pages/admin/EventsPage.js"; +import AdminItemsPage from "./pages/admin/ItemsPage.js"; +import AdminBiddersPage from "./pages/admin/BiddersPage.js"; +import AdminCheckoutPage from "./pages/admin/CheckoutPage.js"; +import AdminReportingPage from "./pages/admin/ReportingPage.js"; +import FundANeedPage from "./pages/admin/FundANeedPage.js"; + +export default function App() { + return ( + <> + + + {/* Auth */} + } /> + } /> + + {/* Bidder */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Staff – optimized single-task views */} + } /> + } /> + } /> + } /> + + {/* Admin */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ); +} diff --git a/packages/client/src/components/ConnectivityBanner.tsx b/packages/client/src/components/ConnectivityBanner.tsx new file mode 100644 index 0000000..56eaccc --- /dev/null +++ b/packages/client/src/components/ConnectivityBanner.tsx @@ -0,0 +1,21 @@ +import { useConnectivityStore } from "../store/connectivity.js"; + +const labels: Record = { + connected: { text: "Connected", className: "bg-green-500" }, + local: { text: "Local network – offline-capable", className: "bg-yellow-500" }, + offline: { text: "Offline – bids will sync when reconnected", className: "bg-red-500" }, +}; + +export function ConnectivityBanner() { + const status = useConnectivityStore((s) => s.status); + + if (status === "connected") return null; + + const { text, className } = labels[status]!; + + return ( +
+ {text} +
+ ); +} diff --git a/packages/client/src/hooks/useLiveAuction.ts b/packages/client/src/hooks/useLiveAuction.ts new file mode 100644 index 0000000..3a4cc2c --- /dev/null +++ b/packages/client/src/hooks/useLiveAuction.ts @@ -0,0 +1,91 @@ +/** + * Real-time state hook for the live auction bidder view. + * Subscribes to item_activated, next_live_bid, live_bid_accepted, + * item_state_changed, item_sold. + */ +import { useState, useEffect } from "react"; +import { getSocket } from "../lib/socket.js"; +import type { AuctionItem, Bid, ItemState } from "@storybid/shared"; + +export interface LiveAuctionState { + currentItem: AuctionItem | null; + currentBid: number | null; + calledAmount: number | null; + state: ItemState | null; + recentBids: Bid[]; +} + +export function useLiveAuction(eventId: string) { + const [state, setState] = useState({ + currentItem: null, + currentBid: null, + calledAmount: null, + state: null, + recentBids: [], + }); + + useEffect(() => { + const socket = getSocket(); + socket.emit("join_event", eventId); + + socket.on("item_activated", ({ item }) => { + setState({ + currentItem: item, + currentBid: item.currentHighBid, + calledAmount: item.openingBid, + state: item.state, + recentBids: [], + }); + }); + + socket.on("next_live_bid", ({ amount }) => { + setState((prev) => ({ ...prev, calledAmount: amount })); + }); + + socket.on("live_bid_accepted", ({ bid, item }) => { + setState((prev) => ({ + ...prev, + currentBid: item.currentHighBid, + state: item.state, + currentItem: item, + recentBids: [bid, ...prev.recentBids].slice(0, 10), + })); + }); + + socket.on("item_state_changed", ({ itemId, state: newState }) => { + setState((prev) => { + if (prev.currentItem?.id !== itemId) return prev; + return { ...prev, state: newState }; + }); + }); + + socket.on("item_sold", ({ itemId, amount }) => { + setState((prev) => { + if (prev.currentItem?.id !== itemId) return prev; + return { ...prev, currentBid: amount, state: "sold" }; + }); + }); + + return () => { + socket.emit("leave_event", eventId); + socket.off("item_activated"); + socket.off("next_live_bid"); + socket.off("live_bid_accepted"); + socket.off("item_state_changed"); + socket.off("item_sold"); + }; + }, [eventId]); + + const placeBid = (itemId: string, amount: number, deviceId: string, clientSeq: number) => { + const socket = getSocket(); + socket.emit("place_live_bid", { + itemId, + amount, + deviceId, + clientSeq, + clientCreatedAt: new Date().toISOString(), + }); + }; + + return { ...state, placeBid }; +} diff --git a/packages/client/src/hooks/useOfflineBids.ts b/packages/client/src/hooks/useOfflineBids.ts new file mode 100644 index 0000000..ae2f758 --- /dev/null +++ b/packages/client/src/hooks/useOfflineBids.ts @@ -0,0 +1,116 @@ +/** + * Manages the IndexedDB outbox queue. + * + * - Adds bids to the outbox when offline or when the server rejects the socket call. + * - Watches navigator.onLine + socket reconnect events to trigger sync. + * - Emits `sync_outbox` via Socket.io and removes successfully synced entries. + */ +import { useEffect, useCallback } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { db } from "../lib/db.js"; +import { getSocket } from "../lib/socket.js"; +import { useConnectivityStore } from "../store/connectivity.js"; +import type { OutboxBid } from "@storybid/shared"; + +const DEVICE_ID_KEY = "sb_device_id"; + +function getDeviceId(): string { + let id = localStorage.getItem(DEVICE_ID_KEY); + if (!id) { + id = uuidv4(); + localStorage.setItem(DEVICE_ID_KEY, id); + } + return id; +} + +let clientSeq = 0; + +export function useOfflineBids() { + const setStatus = useConnectivityStore((s) => s.setStatus); + + const syncOutbox = useCallback(async () => { + const pending = await db.outbox.toArray(); + if (!pending.length) return; + + const socket = getSocket(); + if (!socket.connected) return; + + socket.emit( + "sync_outbox", + pending.map((b) => ({ + localId: b.localId, + itemId: b.itemId, + amount: b.amount, + deviceId: b.deviceId, + clientSeq: b.clientSeq, + clientCreatedAt: b.clientCreatedAt, + })), + ); + }, []); + + useEffect(() => { + const socket = getSocket(); + + // Listen for sync results and clear acknowledged entries + const onSyncResult = (result: { localId: string; accepted: boolean }) => { + if (result.accepted) { + void db.outbox.delete(result.localId); + } + }; + + const onReconnect = () => { + setStatus("connected"); + void syncOutbox(); + }; + + const onDisconnect = () => { + setStatus(navigator.onLine ? "local" : "offline"); + }; + + const onOnline = () => { + setStatus("connected"); + void syncOutbox(); + }; + + const onOffline = () => setStatus("offline"); + + socket.on("bid_sync_result", onSyncResult); + socket.on("connect", onReconnect); + socket.on("disconnect", onDisconnect); + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + + return () => { + socket.off("bid_sync_result", onSyncResult); + socket.off("connect", onReconnect); + socket.off("disconnect", onDisconnect); + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + }; + }, [setStatus, syncOutbox]); + + /** + * Queue a bid in IndexedDB. Call this when the socket is disconnected + * or when you want to guarantee delivery before the network confirms. + */ + const queueBid = useCallback( + async (itemId: string, bidderId: string, amount: number): Promise => { + const entry: OutboxBid = { + localId: uuidv4(), + itemId, + bidderId, + amount, + clientCreatedAt: new Date().toISOString(), + deviceId: getDeviceId(), + clientSeq: ++clientSeq, + attempts: 0, + lastAttemptAt: null, + }; + await db.outbox.add(entry); + return entry.localId; + }, + [], + ); + + return { queueBid, syncOutbox, getDeviceId }; +} diff --git a/packages/client/src/hooks/useSilentAuction.ts b/packages/client/src/hooks/useSilentAuction.ts new file mode 100644 index 0000000..afe2833 --- /dev/null +++ b/packages/client/src/hooks/useSilentAuction.ts @@ -0,0 +1,80 @@ +/** + * Real-time state hook for the silent auction catalog. + * Subscribes to silent_bid_accepted, silent_outbid, silent_window_closing, + * silent_window_extended, silent_item_closed. + */ +import { useState, useEffect, useCallback } from "react"; +import { getSocket } from "../lib/socket.js"; +import { useOfflineBids } from "./useOfflineBids.js"; +import { useAuthStore } from "../store/auth.js"; +import type { AuctionItem } from "@storybid/shared"; + +export function useSilentAuction(eventId: string) { + const [items, setItems] = useState([]); + const [outbidItemIds, setOutbidItemIds] = useState>(new Set()); + const bidderId = useAuthStore((s) => s.bidder?.id); + const { queueBid, getDeviceId } = useOfflineBids(); + + let clientSeq = 0; + + useEffect(() => { + const socket = getSocket(); + socket.emit("join_event", eventId); + + socket.on("silent_bid_accepted", ({ item }) => { + setItems((prev) => + prev.map((i) => (i.id === item.id ? item : i)), + ); + // Clear outbid flag if we just won + if (item.currentHighBidderId === bidderId) { + setOutbidItemIds((prev) => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + } + }); + + socket.on("silent_outbid", ({ itemId }) => { + setOutbidItemIds((prev) => new Set([...prev, itemId])); + }); + + socket.on("silent_item_closed", ({ itemId }) => { + setItems((prev) => + prev.map((i) => (i.id === itemId ? { ...i, state: "closed" } : i)), + ); + }); + + return () => { + socket.emit("leave_event", eventId); + socket.off("silent_bid_accepted"); + socket.off("silent_outbid"); + socket.off("silent_item_closed"); + }; + }, [eventId, bidderId]); + + const placeSilentBid = useCallback( + async (itemId: string, amount: number) => { + if (!bidderId) return; + const socket = getSocket(); + const deviceId = getDeviceId(); + const seq = ++clientSeq; + + if (socket.connected) { + socket.emit("place_silent_bid", { + itemId, + amount, + deviceId, + clientSeq: seq, + clientCreatedAt: new Date().toISOString(), + }); + } else { + // Offline – write to IndexedDB outbox + await queueBid(itemId, bidderId, amount); + } + }, + [bidderId, getDeviceId, queueBid], + ); + + return { items, setItems, outbidItemIds, placeSilentBid }; +} diff --git a/packages/client/src/index.css b/packages/client/src/index.css new file mode 100644 index 0000000..942337e --- /dev/null +++ b/packages/client/src/index.css @@ -0,0 +1,14 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + /* Prevent text-size inflation on mobile */ + -webkit-text-size-adjust: 100%; + } + + body { + @apply bg-white text-gray-900 antialiased; + } +} diff --git a/packages/client/src/lib/api.ts b/packages/client/src/lib/api.ts new file mode 100644 index 0000000..0cd41d0 --- /dev/null +++ b/packages/client/src/lib/api.ts @@ -0,0 +1,46 @@ +/** + * Thin fetch wrapper – attaches the auth token, handles JSON, and throws + * typed errors. All API modules import from here. + */ + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +function getToken(): string | null { + return localStorage.getItem("sb_token"); +} + +export async function apiFetch( + path: string, + init: RequestInit = {}, +): Promise { + const token = getToken(); + const headers = new Headers(init.headers); + headers.set("Content-Type", "application/json"); + if (token) headers.set("Authorization", `Bearer ${token}`); + + const res = await fetch(path, { ...init, headers }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})) as { error?: string }; + throw new ApiError(res.status, body.error ?? res.statusText); + } + + return res.json() as Promise; +} + +export const api = { + get: (path: string) => apiFetch(path), + post: (path: string, body: unknown) => + apiFetch(path, { method: "POST", body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + apiFetch(path, { method: "PATCH", body: JSON.stringify(body) }), + delete: (path: string) => apiFetch(path, { method: "DELETE" }), +}; diff --git a/packages/client/src/lib/db.ts b/packages/client/src/lib/db.ts new file mode 100644 index 0000000..cdcfe9a --- /dev/null +++ b/packages/client/src/lib/db.ts @@ -0,0 +1,20 @@ +/** + * IndexedDB via Dexie – persists the offline bid outbox and cached event data. + */ +import Dexie, { type Table } from "dexie"; +import type { OutboxBid } from "@storybid/shared"; + +export class StorybidDB extends Dexie { + outbox!: Table; + + constructor() { + super("storybid"); + + this.version(1).stores({ + // localId is the primary key; index itemId for item-scoped queries + outbox: "localId, itemId, bidderId, clientCreatedAt", + }); + } +} + +export const db = new StorybidDB(); diff --git a/packages/client/src/lib/socket.ts b/packages/client/src/lib/socket.ts new file mode 100644 index 0000000..00dc772 --- /dev/null +++ b/packages/client/src/lib/socket.ts @@ -0,0 +1,44 @@ +import { io, type Socket } from "socket.io-client"; +import type { + ServerToClientEvents, + ClientToServerEvents, +} from "@storybid/shared"; + +export type AppSocket = Socket; + +let socket: AppSocket | null = null; + +/** + * Returns (or lazily creates) the singleton Socket.io client. + * + * The connection manager tries the public URL first, then the local-LAN + * hostname injected at build-time or from org settings. The server emits + * `sync_status_changed` once the transport is established so the UI can + * show which path is in use. + */ +export function getSocket(token?: string): AppSocket { + if (socket) return socket; + + socket = io({ + auth: token ? { token } : undefined, + // Reconnect aggressively – events are high-stakes + reconnectionAttempts: Infinity, + reconnectionDelay: 500, + reconnectionDelayMax: 5000, + }); + + socket.on("connect", () => { + console.log("[socket] connected via", socket?.io.engine.transport.name); + }); + + socket.on("disconnect", (reason) => { + console.warn("[socket] disconnected:", reason); + }); + + return socket; +} + +export function disconnectSocket(): void { + socket?.disconnect(); + socket = null; +} diff --git a/packages/client/src/main.tsx b/packages/client/src/main.tsx new file mode 100644 index 0000000..2d5e640 --- /dev/null +++ b/packages/client/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App.js"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/packages/client/src/pages/admin/BiddersPage.tsx b/packages/client/src/pages/admin/BiddersPage.tsx new file mode 100644 index 0000000..06ecc43 --- /dev/null +++ b/packages/client/src/pages/admin/BiddersPage.tsx @@ -0,0 +1,22 @@ +/** + * Admin → Bidders – profiles, paddles, QR codes, CSV import. + * TODO: CRUD + bulk import via /api/bidders. + */ +export default function AdminBiddersPage() { + return ( +
+
+

Bidder Manager

+
+ + +
+
+
+ Bidder list — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/admin/CheckoutPage.tsx b/packages/client/src/pages/admin/CheckoutPage.tsx new file mode 100644 index 0000000..fa658c4 --- /dev/null +++ b/packages/client/src/pages/admin/CheckoutPage.tsx @@ -0,0 +1,14 @@ +/** + * Admin → Checkout – cashier station; find bidder, take payment, print receipt. + * TODO: search bidders, show invoice, call /api/checkout/:bidderId/capture. + */ +export default function AdminCheckoutPage() { + return ( +
+

Checkout

+
+ Cashier station — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/admin/DashboardPage.tsx b/packages/client/src/pages/admin/DashboardPage.tsx new file mode 100644 index 0000000..b9e1828 --- /dev/null +++ b/packages/client/src/pages/admin/DashboardPage.tsx @@ -0,0 +1,19 @@ +/** + * Admin dashboard – overview of events, recent bids, revenue snapshot. + * TODO: fetch org summary from /api/reporting. + */ +export default function AdminDashboard() { + return ( +
+

Admin Dashboard

+
+ {["Events", "Bidders", "Revenue"].map((label) => ( +
+

{label}

+

+
+ ))} +
+
+ ); +} diff --git a/packages/client/src/pages/admin/EventsPage.tsx b/packages/client/src/pages/admin/EventsPage.tsx new file mode 100644 index 0000000..5606082 --- /dev/null +++ b/packages/client/src/pages/admin/EventsPage.tsx @@ -0,0 +1,19 @@ +/** + * Admin → Events – list, create, edit events. + * TODO: CRUD via /api/events. + */ +export default function AdminEventsPage() { + return ( +
+
+

Events

+ +
+
+ Events list — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/admin/FundANeedPage.tsx b/packages/client/src/pages/admin/FundANeedPage.tsx new file mode 100644 index 0000000..2d3668d --- /dev/null +++ b/packages/client/src/pages/admin/FundANeedPage.tsx @@ -0,0 +1,14 @@ +/** + * Admin → Fund-a-Need / Paddle Raise – set tiers, open campaign, show live total. + * TODO: configure PaddleRaiseCampaign, subscribe to paddle_raise_update events. + */ +export default function FundANeedPage() { + return ( +
+

Fund-a-Need

+
+ Paddle raise setup & live totals — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/admin/ItemsPage.tsx b/packages/client/src/pages/admin/ItemsPage.tsx new file mode 100644 index 0000000..58e942e --- /dev/null +++ b/packages/client/src/pages/admin/ItemsPage.tsx @@ -0,0 +1,14 @@ +/** + * Admin → Items – manage lots, categories, media, donor info, increments. + * TODO: CRUD via /api/items; file uploads via POST /api/media/upload (multipart). + */ +export default function AdminItemsPage() { + return ( +
+

Item Manager

+
+ Item list & editor — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/admin/ReportingPage.tsx b/packages/client/src/pages/admin/ReportingPage.tsx new file mode 100644 index 0000000..9d03739 --- /dev/null +++ b/packages/client/src/pages/admin/ReportingPage.tsx @@ -0,0 +1,14 @@ +/** + * Admin → Reporting – revenue, sell-through, bidder activity, audit log. + * TODO: fetch /api/reporting/events/:id/*. + */ +export default function AdminReportingPage() { + return ( +
+

Reporting

+
+ Reports — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/auth/LoginPage.tsx b/packages/client/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..f2942f3 --- /dev/null +++ b/packages/client/src/pages/auth/LoginPage.tsx @@ -0,0 +1,20 @@ +/** + * Login – email magic link or SMS OTP entry point. + * TODO: implement magic-link request form and OTP flow. + */ +export default function LoginPage() { + return ( +
+
+

Sign in to bid

+

+ Enter your email for a magic link, or your phone number for a one-time code. +

+ {/* TODO: LoginForm component */} +
+ LoginForm — not yet implemented +
+
+
+ ); +} diff --git a/packages/client/src/pages/auth/VerifyPage.tsx b/packages/client/src/pages/auth/VerifyPage.tsx new file mode 100644 index 0000000..7ca2844 --- /dev/null +++ b/packages/client/src/pages/auth/VerifyPage.tsx @@ -0,0 +1,11 @@ +/** + * Verify – handles magic-link ?token= callback and OTP confirmation. + * TODO: read token from URL, call /api/auth/verify, redirect to /. + */ +export default function VerifyPage() { + return ( +
+

Verifying…

+
+ ); +} diff --git a/packages/client/src/pages/bidder/CheckoutPage.tsx b/packages/client/src/pages/bidder/CheckoutPage.tsx new file mode 100644 index 0000000..b25ed10 --- /dev/null +++ b/packages/client/src/pages/bidder/CheckoutPage.tsx @@ -0,0 +1,14 @@ +/** + * Bidder checkout – shows won lots, total, and Stripe Payment Element. + * TODO: fetch /api/checkout/:bidderId, render Stripe Elements, handle success. + */ +export default function CheckoutPage() { + return ( +
+

Checkout

+
+ Stripe checkout — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/bidder/HomePage.tsx b/packages/client/src/pages/bidder/HomePage.tsx new file mode 100644 index 0000000..3370ead --- /dev/null +++ b/packages/client/src/pages/bidder/HomePage.tsx @@ -0,0 +1,27 @@ +/** + * Bidder home – event welcome screen, quick nav to Live / Silent / My Bids. + * TODO: fetch event details, show upcoming lots, paddle number, QR code. + */ +export default function HomePage() { + return ( +
+

Welcome to the Auction

+ +
+ ); +} diff --git a/packages/client/src/pages/bidder/ItemPage.tsx b/packages/client/src/pages/bidder/ItemPage.tsx new file mode 100644 index 0000000..c7248ee --- /dev/null +++ b/packages/client/src/pages/bidder/ItemPage.tsx @@ -0,0 +1,18 @@ +/** + * Individual silent auction item detail page. + * Shows media gallery, description, bid history, and bid form. + * + * TODO: + * - Load item by :id param + * - Media carousel (images, video embed, documents) + * - Place bid form with offline-outbox fallback via db.outbox + */ +export default function ItemPage() { + return ( +
+
+ Item detail — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/bidder/LivePage.tsx b/packages/client/src/pages/bidder/LivePage.tsx new file mode 100644 index 0000000..1297b05 --- /dev/null +++ b/packages/client/src/pages/bidder/LivePage.tsx @@ -0,0 +1,108 @@ +/** + * Live auction bidder view. + * + * Shows the current lot, current bid, and a single "Bid $X" button for the + * auctioneer-called amount. Real-time updates via Socket.io. + * Falls back gracefully when no lot is active. + */ +import { useParams } from "react-router-dom"; +import { useLiveAuction } from "../../hooks/useLiveAuction.js"; +import { useOfflineBids } from "../../hooks/useOfflineBids.js"; + +const STATE_LABELS: Record = { + preview: "Up next", + active: "Bidding open", + going_once: "Going once…", + going_twice: "Going twice…", + sold: "SOLD", + passed: "Passed", +}; + +export default function LivePage() { + // eventId comes from route or a global store; use param or fallback + const { eventId = "" } = useParams<{ eventId?: string }>(); + const { currentItem, currentBid, calledAmount, state, recentBids, placeBid } = + useLiveAuction(eventId); + const { getDeviceId } = useOfflineBids(); + + let clientSeq = 0; + + const handleBid = () => { + if (!currentItem || calledAmount == null) return; + placeBid(currentItem.id, calledAmount, getDeviceId(), ++clientSeq); + }; + + const isSold = state === "sold" || state === "passed"; + const canBid = state === "active" || state === "going_once" || state === "going_twice"; + + return ( +
+ {/* Status banner */} +
+

+ Live Auction +

+ {state && ( + + {STATE_LABELS[state] ?? state} + + )} +
+ + {currentItem ? ( + <> + {/* Item info */} +
+

Lot {currentItem.lotNumber}

+

{currentItem.title}

+ {currentItem.donorName && ( +

Donated by {currentItem.donorName}

+ )} +
+ + {/* Current bid */} +
+

Current bid

+

+ {currentBid != null ? `$${currentBid.toLocaleString()}` : "—"} +

+
+ + {/* Called amount + bid button */} + {calledAmount != null && ( + + )} + + {/* Recent bids stream */} + {recentBids.length > 0 && ( +
+

Recent bids

+
    + {recentBids.map((b) => ( +
  • + {b.createdAt} + ${Number(b.amount).toLocaleString()} +
  • + ))} +
+
+ )} + + ) : ( +
+

Waiting for the auctioneer to open a lot…

+
+ )} +
+ ); +} diff --git a/packages/client/src/pages/bidder/MyBidsPage.tsx b/packages/client/src/pages/bidder/MyBidsPage.tsx new file mode 100644 index 0000000..1a70944 --- /dev/null +++ b/packages/client/src/pages/bidder/MyBidsPage.tsx @@ -0,0 +1,14 @@ +/** + * Bidder's personal bid history and watchlist. + * TODO: fetch /api/bidders/me/bids, show winning / outbid status per item. + */ +export default function MyBidsPage() { + return ( +
+

My Bids

+
+ Bid history — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/bidder/ProfilePage.tsx b/packages/client/src/pages/bidder/ProfilePage.tsx new file mode 100644 index 0000000..37d33a3 --- /dev/null +++ b/packages/client/src/pages/bidder/ProfilePage.tsx @@ -0,0 +1,14 @@ +/** + * Bidder profile – paddle number, contact info, digital paddle QR, notifications prefs. + * TODO: fetch /api/bidders/me, render paddle QR code. + */ +export default function ProfilePage() { + return ( +
+

Profile

+
+ Profile & digital paddle — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/bidder/SilentPage.tsx b/packages/client/src/pages/bidder/SilentPage.tsx new file mode 100644 index 0000000..08489db --- /dev/null +++ b/packages/client/src/pages/bidder/SilentPage.tsx @@ -0,0 +1,106 @@ +/** + * Silent auction catalog. + * Loads items from the API, then keeps them live via Socket.io. + * Outbid items are highlighted; offline bids queue to IndexedDB. + */ +import { useEffect } from "react"; +import { Link } from "react-router-dom"; +import { useSilentAuction } from "../../hooks/useSilentAuction.js"; +import { api } from "../../lib/api.js"; +import type { AuctionItem } from "@storybid/shared"; + +interface Props { + eventId: string; + auctionId: string; +} + +export default function SilentPage({ eventId, auctionId }: Props) { + const { items, setItems, outbidItemIds, placeSilentBid } = useSilentAuction(eventId); + + // Initial load from REST catalog + useEffect(() => { + api + .get(`/api/items?auctionId=${auctionId}`) + .then(setItems) + .catch(console.error); + }, [auctionId, setItems]); + + if (!items.length) { + return ( +
+

Silent Auction

+

Loading items…

+
+ ); + } + + return ( +
+

Silent Auction

+
    + {items.map((item) => { + const isOutbid = outbidItemIds.has(item.id); + const isClosed = item.state === "closed" || item.state === "passed"; + const minNext = item.currentHighBid != null + ? item.currentHighBid + item.bidIncrement + : item.openingBid; + + return ( +
  • + {/* Outbid banner */} + {isOutbid && ( +
    + You've been outbid! +
    + )} + +
    +
    +

    Lot {item.lotNumber}

    + + {isClosed ? "Closed" : "Open"} + +
    + + + {item.title} + + +
    +
    +

    Current bid

    +

    + {item.currentHighBid != null + ? `$${item.currentHighBid.toLocaleString()}` + : `Starting at $${item.openingBid.toLocaleString()}`} +

    +
    + + {!isClosed && ( + + )} +
    +
    +
  • + ); + })} +
+
+ ); +} diff --git a/packages/client/src/pages/staff/AuctioneerPage.tsx b/packages/client/src/pages/staff/AuctioneerPage.tsx new file mode 100644 index 0000000..d1b6852 --- /dev/null +++ b/packages/client/src/pages/staff/AuctioneerPage.tsx @@ -0,0 +1,20 @@ +/** + * Auctioneer console – optimised for tablet in landscape. + * Shows: current lot, current bid, next callable bid, recent bid stream, + * and controls: Activate / Call Next Bid / Going Once / Going Twice / Sold / Pass. + * + * TODO: + * - Subscribe to all live auction socket events + * - Emit auctioneer_* events on button press + * - Display large-format current bid and paddle number + */ +export default function AuctioneerPage() { + return ( +
+

Auctioneer Console

+
+ Live auction controls — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/staff/CheckInPage.tsx b/packages/client/src/pages/staff/CheckInPage.tsx new file mode 100644 index 0000000..14be9b5 --- /dev/null +++ b/packages/client/src/pages/staff/CheckInPage.tsx @@ -0,0 +1,19 @@ +/** + * Check-in station – search bidders, scan QR, assign paddle, confirm payment readiness. + * + * TODO: + * - Search /api/bidders?eventId=&q= + * - QR scanner via device camera + * - POST /api/check-in/:id on confirm + * - Show payment-on-file indicator + */ +export default function CheckInPage() { + return ( +
+

Check-In

+
+ QR scan & bidder search — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/pages/staff/DisplayBoardPage.tsx b/packages/client/src/pages/staff/DisplayBoardPage.tsx new file mode 100644 index 0000000..457f763 --- /dev/null +++ b/packages/client/src/pages/staff/DisplayBoardPage.tsx @@ -0,0 +1,26 @@ +/** + * Display board – read-only fullscreen view for projector / TV. + * Shows: current item, current bid, bidder paddle, org branding, + * and optionally a fundraising thermometer. + * + * TODO: + * - Subscribe to live auction events (read-only socket connection) + * - Fullscreen CSS layout with large typography + * - Paddle raise thermometer via paddle_raise_update events + */ +export default function DisplayBoardPage() { + return ( +
+

Storybid

+
+

Current Lot

+

+
+
+

Current Bid

+

$—

+

Paddle —

+
+
+ ); +} diff --git a/packages/client/src/pages/staff/SpotterPage.tsx b/packages/client/src/pages/staff/SpotterPage.tsx new file mode 100644 index 0000000..80e459f --- /dev/null +++ b/packages/client/src/pages/staff/SpotterPage.tsx @@ -0,0 +1,19 @@ +/** + * Spotter mode – floor volunteer enters bids by paddle number. + * Simple: paddle number input + confirm button. Emits auctioneer_accept_bid. + * + * TODO: + * - Show current item and called amount (read-only) + * - Large paddle number input with numeric keyboard + * - Emit place_live_bid (spotter path) on confirm + */ +export default function SpotterPage() { + return ( +
+

Spotter

+
+ Paddle entry — not yet implemented +
+
+ ); +} diff --git a/packages/client/src/store/auth.ts b/packages/client/src/store/auth.ts new file mode 100644 index 0000000..52d2dc4 --- /dev/null +++ b/packages/client/src/store/auth.ts @@ -0,0 +1,24 @@ +import { create } from "zustand"; +import type { Bidder } from "@storybid/shared"; + +interface AuthState { + token: string | null; + bidder: Bidder | null; + role: string | null; + setAuth: (token: string, bidder: Bidder, role: string) => void; + clearAuth: () => void; +} + +export const useAuthStore = create((set) => ({ + token: localStorage.getItem("sb_token"), + bidder: null, + role: null, + setAuth(token, bidder, role) { + localStorage.setItem("sb_token", token); + set({ token, bidder, role }); + }, + clearAuth() { + localStorage.removeItem("sb_token"); + set({ token: null, bidder: null, role: null }); + }, +})); diff --git a/packages/client/src/store/connectivity.ts b/packages/client/src/store/connectivity.ts new file mode 100644 index 0000000..9ea2e71 --- /dev/null +++ b/packages/client/src/store/connectivity.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +export type ConnectivityStatus = "connected" | "local" | "offline"; + +interface ConnectivityState { + status: ConnectivityStatus; + setStatus: (status: ConnectivityStatus) => void; +} + +export const useConnectivityStore = create((set) => ({ + status: navigator.onLine ? "connected" : "offline", + setStatus: (status) => set({ status }), +})); diff --git a/packages/client/tailwind.config.ts b/packages/client/tailwind.config.ts new file mode 100644 index 0000000..cc25822 --- /dev/null +++ b/packages/client/tailwind.config.ts @@ -0,0 +1,20 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + brand: { + 50: "#eff6ff", + 100: "#dbeafe", + 500: "#3b82f6", + 600: "#2563eb", + 700: "#1d4ed8", + 900: "#1e3a8a", + }, + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..2c4397d --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "outDir": "./dist", + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts new file mode 100644 index 0000000..179bb71 --- /dev/null +++ b/packages/client/vite.config.ts @@ -0,0 +1,52 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { VitePWA } from "vite-plugin-pwa"; + +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: "autoUpdate", + workbox: { + globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], + runtimeCaching: [ + { + // Cache API catalog responses (silent auction items) for offline browsing + urlPattern: /\/api\/items/, + handler: "NetworkFirst", + options: { + cacheName: "api-items", + expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 }, + }, + }, + { + // Always network-first for live bidding endpoints + urlPattern: /\/api\/bids/, + handler: "NetworkOnly", + }, + ], + }, + manifest: { + name: "Storybid Auction", + short_name: "Storybid", + description: "Live and silent charity auction bidding", + theme_color: "#2563eb", + background_color: "#ffffff", + display: "standalone", + orientation: "portrait", + start_url: "/", + icons: [ + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }, + ], + }, + }), + ], + server: { + port: 5173, + proxy: { + "/api": { target: "http://localhost:3001", changeOrigin: true }, + "/socket.io": { target: "http://localhost:3001", ws: true }, + }, + }, +}); diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile new file mode 100644 index 0000000..deaa4a7 --- /dev/null +++ b/packages/server/Dockerfile @@ -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"] diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..ae018ff --- /dev/null +++ b/packages/server/package.json @@ -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": "*" + } +} diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma new file mode 100644 index 0000000..9852d3e --- /dev/null +++ b/packages/server/prisma/schema.prisma @@ -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]) +} diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts new file mode 100644 index 0000000..82f1811 --- /dev/null +++ b/packages/server/prisma/seed.ts @@ -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()); diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts new file mode 100644 index 0000000..2aa99bf --- /dev/null +++ b/packages/server/src/app.ts @@ -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" })); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..fb16d6f --- /dev/null +++ b/packages/server/src/index.ts @@ -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); diff --git a/packages/server/src/lib/jwt.ts b/packages/server/src/lib/jwt.ts new file mode 100644 index 0000000..afdfcdc --- /dev/null +++ b/packages/server/src/lib/jwt.ts @@ -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; +} diff --git a/packages/server/src/lib/prisma.ts b/packages/server/src/lib/prisma.ts new file mode 100644 index 0000000..c15958c --- /dev/null +++ b/packages/server/src/lib/prisma.ts @@ -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; +} diff --git a/packages/server/src/lib/redis.ts b/packages/server/src/lib/redis.ts new file mode 100644 index 0000000..8edc39c --- /dev/null +++ b/packages/server/src/lib/redis.ts @@ -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; +} diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 0000000..cf7d6d9 --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -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(); + }; +} diff --git a/packages/server/src/routes/auctions.ts b/packages/server/src/routes/auctions.ts new file mode 100644 index 0000000..19b92e8 --- /dev/null +++ b/packages/server/src/routes/auctions.ts @@ -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); +}); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 index 0000000..85a1ae1 --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -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 { + // 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 { + 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 }); +}); diff --git a/packages/server/src/routes/bidders.ts b/packages/server/src/routes/bidders.ts new file mode 100644 index 0000000..1ec57ef --- /dev/null +++ b/packages/server/src/routes/bidders.ts @@ -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); +}); diff --git a/packages/server/src/routes/bids.ts b/packages/server/src/routes/bids.ts new file mode 100644 index 0000000..9fda121 --- /dev/null +++ b/packages/server/src/routes/bids.ts @@ -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); +}); diff --git a/packages/server/src/routes/check-in.ts b/packages/server/src/routes/check-in.ts new file mode 100644 index 0000000..e8d5877 --- /dev/null +++ b/packages/server/src/routes/check-in.ts @@ -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=&e= + +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" }); +}); diff --git a/packages/server/src/routes/checkout.ts b/packages/server/src/routes/checkout.ts new file mode 100644 index 0000000..e6befeb --- /dev/null +++ b/packages/server/src/routes/checkout.ts @@ -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" }); +}); diff --git a/packages/server/src/routes/events.ts b/packages/server/src/routes/events.ts new file mode 100644 index 0000000..ed260e3 --- /dev/null +++ b/packages/server/src/routes/events.ts @@ -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 }); +}); diff --git a/packages/server/src/routes/items.ts b/packages/server/src/routes/items.ts new file mode 100644 index 0000000..e167710 --- /dev/null +++ b/packages/server/src/routes/items.ts @@ -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 }); +}); diff --git a/packages/server/src/routes/media.ts b/packages/server/src/routes/media.ts new file mode 100644 index 0000000..83df98a --- /dev/null +++ b/packages/server/src/routes/media.ts @@ -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//. + * 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) }); + } + }, +); diff --git a/packages/server/src/routes/organization.ts b/packages/server/src/routes/organization.ts new file mode 100644 index 0000000..90bc239 --- /dev/null +++ b/packages/server/src/routes/organization.ts @@ -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); +}); diff --git a/packages/server/src/routes/reporting.ts b/packages/server/src/routes/reporting.ts new file mode 100644 index 0000000..bf79ce9 --- /dev/null +++ b/packages/server/src/routes/reporting.ts @@ -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" }); +}); diff --git a/packages/server/src/routes/webhooks.ts b/packages/server/src/routes/webhooks.ts new file mode 100644 index 0000000..4057aaa --- /dev/null +++ b/packages/server/src/routes/webhooks.ts @@ -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" }); + }, +); diff --git a/packages/server/src/services/bid-engine.ts b/packages/server/src/services/bid-engine.ts new file mode 100644 index 0000000..f84b067 --- /dev/null +++ b/packages/server/src/services/bid-engine.ts @@ -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>; item: Awaited> } + | { 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 { + 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 }; + }); +} diff --git a/packages/server/src/services/email.ts b/packages/server/src/services/email.ts new file mode 100644 index 0000000..eb1143b --- /dev/null +++ b/packages/server/src/services/email.ts @@ -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 "; + +export async function sendMagicLink(to: string, token: string, baseUrl: string): Promise { + 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: ` +

Click the button below to sign in to the auction. This link expires in 15 minutes.

+

+ + Sign in to Auction + +

+

If you didn't request this, you can ignore this email.

+ `, + }); +} + +export async function sendReceipt( + to: string, + invoiceId: string, + totalAmount: number, + items: Array<{ title: string; amount: number }>, +): Promise { + const transporter = createTransport(); + const rows = items + .map((i) => `${i.title}$${(i.amount / 100).toFixed(2)}`) + .join(""); + + await transporter.sendMail({ + from: FROM, + to, + subject: "Your auction receipt", + html: ` +

Thank you for your support!

+ + + ${rows} + + + + + + +
ItemAmount
Total$${(totalAmount / 100).toFixed(2)}
+

Invoice #${invoiceId}

+ `, + }); +} diff --git a/packages/server/src/services/storage.ts b/packages/server/src/services/storage.ts new file mode 100644 index 0000000..523aee5 --- /dev/null +++ b/packages/server/src/services/storage.ts @@ -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 = { + image: ["image/jpeg", "image/png", "image/webp", "image/gif"], + video: ["video/mp4", "video/webm"], + document: ["application/pdf"], +}; + +const MIME_TO_EXT: Record = { + "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 = { + 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 { + 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); + } +} diff --git a/packages/server/src/services/twilio.ts b/packages/server/src/services/twilio.ts new file mode 100644 index 0000000..59060eb --- /dev/null +++ b/packages/server/src/services/twilio.ts @@ -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 { + 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 { + const client = getClient(); + const result = await client.verify.v2 + .services(SERVICE_SID) + .verificationChecks.create({ to: phone, code }); + return result.status === "approved"; +} diff --git a/packages/server/src/socket/index.ts b/packages/server/src/socket/index.ts new file mode 100644 index 0000000..156f323 --- /dev/null +++ b/packages/server/src/socket/index.ts @@ -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; + +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}`); + }); + }); +} diff --git a/packages/server/src/socket/live-auction.ts b/packages/server/src/socket/live-auction.ts new file mode 100644 index 0000000..bfe01b1 --- /dev/null +++ b/packages/server/src/socket/live-auction.ts @@ -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; +type Sock = Socket; + +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) => void) { + // We need the eventId – look it up from item. For now pass auctionId-based room. + // Rooms are joined as `event:` 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"); + }); +} diff --git a/packages/server/src/socket/silent-auction.ts b/packages/server/src/socket/silent-auction.ts new file mode 100644 index 0000000..b8463bb --- /dev/null +++ b/packages/server/src/socket/silent-auction.ts @@ -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; +type Sock = Socket; + +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:` 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 }), + }); + } + }); +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..b5128b8 --- /dev/null +++ b/packages/server/tsconfig.json @@ -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"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..d3bc16b --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,22 @@ +{ + "name": "@storybid/shared", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "dev": "tsc --watch" + }, + "devDependencies": { + "typescript": "*" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..75014ea --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,8 @@ +export * from "./types/auction.js"; +export * from "./types/bidder.js"; +export * from "./types/bid.js"; +export * from "./types/event.js"; +export * from "./types/organization.js"; +export * from "./types/payment.js"; +export * from "./types/socket-events.js"; +export * from "./types/roles.js"; diff --git a/packages/shared/src/types/auction.ts b/packages/shared/src/types/auction.ts new file mode 100644 index 0000000..2d0aa79 --- /dev/null +++ b/packages/shared/src/types/auction.ts @@ -0,0 +1,72 @@ +export type AuctionType = "live" | "silent"; + +export type AuctionStatus = "draft" | "active" | "paused" | "closed"; + +export interface Auction { + id: string; + eventId: string; + type: AuctionType; + name: string; + status: AuctionStatus; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +// ── Auction Item ────────────────────────────────────────────────────────────── + +export type ItemState = + | "preview" + | "active" + | "going_once" + | "going_twice" + | "sold" + | "passed" + | "closed"; // silent auction final state + +export interface AuctionItem { + id: string; + auctionId: string; + lotNumber: string; + title: string; + description: string | null; + donorName: string | null; + category: string | null; + fairMarketValue: number | null; + openingBid: number; + reservePrice: number | null; + currentHighBid: number | null; + currentHighBidderId: string | null; + bidIncrement: number; + state: ItemState; + pickupNotes: string | null; + sortOrder: number; + // Silent-auction specific + silentWindowId: string | null; + softCloseEnabled: boolean; + softCloseExtendMinutes: number; + createdAt: string; + updatedAt: string; +} + +export interface ItemMedia { + id: string; + itemId: string; + mediaType: "image" | "video" | "document" | "embed"; + url: string; + thumbnailUrl: string | null; + caption: string | null; + sortOrder: number; + createdAt: string; +} + +export interface SilentAuctionWindow { + id: string; + auctionId: string; + name: string; + opensAt: string; + closesAt: string; + softCloseEnabled: boolean; + softCloseExtendMinutes: number; + status: "pending" | "open" | "closed"; +} diff --git a/packages/shared/src/types/bid.ts b/packages/shared/src/types/bid.ts new file mode 100644 index 0000000..3abd077 --- /dev/null +++ b/packages/shared/src/types/bid.ts @@ -0,0 +1,38 @@ +export type OriginMode = + | "public" // bid arrived via public FQDN + | "local_dns" // bid arrived via event-LAN hostname + | "local_ip" // bid arrived via raw local IP + | "offline_queue"; // bid was queued client-side and synced later + +export type SyncStatus = "synced" | "pending" | "conflict" | "rejected"; + +export interface Bid { + id: string; + itemId: string; + bidderId: string; + amount: number; + /** ISO-8601 timestamp from the client clock at intent time */ + clientCreatedAt: string; + /** ISO-8601 timestamp when the server accepted the bid */ + serverReceivedAt: string; + originMode: OriginMode; + syncStatus: SyncStatus; + deviceId: string; + /** Client-side monotonic sequence within the device session */ + clientSeq: number; + isWinning: boolean; + createdAt: string; +} + +// Outbox entry stored in IndexedDB before network sync +export interface OutboxBid { + localId: string; // UUID generated client-side + itemId: string; + bidderId: string; + amount: number; + clientCreatedAt: string; + deviceId: string; + clientSeq: number; + attempts: number; + lastAttemptAt: string | null; +} diff --git a/packages/shared/src/types/bidder.ts b/packages/shared/src/types/bidder.ts new file mode 100644 index 0000000..c4226d6 --- /dev/null +++ b/packages/shared/src/types/bidder.ts @@ -0,0 +1,24 @@ +export interface Bidder { + id: string; + organizationId: string; + email: string | null; + phone: string | null; + firstName: string; + lastName: string; + paddleNumber: string | null; + tableAssignment: string | null; + notes: string | null; + paymentMethodOnFile: boolean; + checkInStatus: "pending" | "checked_in"; + createdAt: string; + updatedAt: string; +} + +export interface BidderAuthMethod { + id: string; + bidderId: string; + type: "email_magic_link" | "sms_otp"; + identifier: string; // email address or E.164 phone number + verifiedAt: string | null; + createdAt: string; +} diff --git a/packages/shared/src/types/event.ts b/packages/shared/src/types/event.ts new file mode 100644 index 0000000..a1c08d7 --- /dev/null +++ b/packages/shared/src/types/event.ts @@ -0,0 +1,17 @@ +export type EventStatus = "draft" | "published" | "active" | "closed" | "archived"; + +export interface AuctionEvent { + id: string; + organizationId: string; + name: string; + slug: string; + description: string | null; + venueAddress: string | null; + startAt: string; + endAt: string; + status: EventStatus; + timezone: string; + bannerImageUrl: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/packages/shared/src/types/organization.ts b/packages/shared/src/types/organization.ts new file mode 100644 index 0000000..2d7cf14 --- /dev/null +++ b/packages/shared/src/types/organization.ts @@ -0,0 +1,12 @@ +export interface Organization { + id: string; + name: string; + slug: string; + logoUrl: string | null; + primaryColor: string | null; + stripeAccountId: string | null; + publicUrl: string | null; + localHostname: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/packages/shared/src/types/payment.ts b/packages/shared/src/types/payment.ts new file mode 100644 index 0000000..0a4cdd8 --- /dev/null +++ b/packages/shared/src/types/payment.ts @@ -0,0 +1,51 @@ +export type InvoiceStatus = + | "draft" + | "open" + | "paid" + | "partially_paid" + | "void"; + +export interface Invoice { + id: string; + bidderId: string; + eventId: string; + stripeInvoiceId: string | null; + totalAmount: number; + paidAmount: number; + status: InvoiceStatus; + createdAt: string; + updatedAt: string; +} + +export interface Payment { + id: string; + invoiceId: string; + stripePaymentIntentId: string | null; + amount: number; + currency: string; + status: "pending" | "succeeded" | "failed" | "refunded"; + createdAt: string; +} + +export interface PaddleRaiseCampaign { + id: string; + eventId: string; + name: string; + goal: number | null; + totalRaised: number; + tiers: number[]; // suggested donation amounts + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface Donation { + id: string; + eventId: string; + bidderId: string | null; + campaignId: string | null; + amount: number; + anonymous: boolean; + stripePaymentIntentId: string | null; + createdAt: string; +} diff --git a/packages/shared/src/types/roles.ts b/packages/shared/src/types/roles.ts new file mode 100644 index 0000000..a41bb2a --- /dev/null +++ b/packages/shared/src/types/roles.ts @@ -0,0 +1,15 @@ +export type UserRole = + | "admin" + | "event_manager" + | "auctioneer" + | "spotter" + | "checkin_staff" + | "bidder"; + +export const STAFF_ROLES: UserRole[] = [ + "admin", + "event_manager", + "auctioneer", + "spotter", + "checkin_staff", +]; diff --git a/packages/shared/src/types/socket-events.ts b/packages/shared/src/types/socket-events.ts new file mode 100644 index 0000000..7a27b5b --- /dev/null +++ b/packages/shared/src/types/socket-events.ts @@ -0,0 +1,68 @@ +import type { ItemState, AuctionItem } from "./auction.js"; +import type { Bid } from "./bid.js"; + +// ── Events emitted by the SERVER ────────────────────────────────────────────── + +export interface ServerToClientEvents { + // Live auction + item_activated: (payload: { item: AuctionItem }) => void; + next_live_bid: (payload: { itemId: string; amount: number }) => void; + live_bid_accepted: (payload: { bid: Bid; item: AuctionItem }) => void; + item_state_changed: (payload: { itemId: string; state: ItemState }) => void; + item_sold: (payload: { itemId: string; winnerId: string; amount: number }) => void; + + // Silent auction + silent_bid_accepted: (payload: { bid: Bid; item: AuctionItem }) => void; + silent_outbid: (payload: { itemId: string; yourBidderId: string; newAmount: number }) => void; + silent_window_closing: (payload: { windowId: string; closesAt: string }) => void; + silent_window_extended: (payload: { windowId: string; newClosesAt: string }) => void; + silent_item_closed: (payload: { itemId: string; winnerId: string | null; finalAmount: number | null }) => void; + + // Paddle raise + paddle_raise_update: (payload: { campaignId: string; totalRaised: number }) => void; + + // Connectivity / sync + sync_status_changed: (payload: { status: "connected" | "local" | "offline" }) => void; + bid_sync_result: (payload: { localId: string; accepted: boolean; bid?: Bid; error?: string }) => void; +} + +// ── Events emitted by the CLIENT ────────────────────────────────────────────── + +export interface ClientToServerEvents { + // Bidder joins/leaves a room scoped to an event + join_event: (eventId: string) => void; + leave_event: (eventId: string) => void; + + // Live bid (amount is the auctioneer-called amount shown in UI) + place_live_bid: (payload: { itemId: string; amount: number; deviceId: string; clientSeq: number; clientCreatedAt: string }) => void; + + // Silent bid + place_silent_bid: (payload: { itemId: string; amount: number; deviceId: string; clientSeq: number; clientCreatedAt: string }) => void; + + // Sync queued outbox bids after reconnect + sync_outbox: (bids: Array<{ localId: string; itemId: string; amount: number; deviceId: string; clientSeq: number; clientCreatedAt: string }>) => void; + + // Auctioneer controls + auctioneer_activate_item: (itemId: string) => void; + auctioneer_call_next_bid: (payload: { itemId: string; amount: number }) => void; + auctioneer_accept_bid: (payload: { itemId: string; bidderId: string; amount: number }) => void; + auctioneer_going_once: (itemId: string) => void; + auctioneer_going_twice: (itemId: string) => void; + auctioneer_sold: (itemId: string) => void; + auctioneer_pass: (itemId: string) => void; +} + +// ── Shared inter-server events (for Redis adapter) ─────────────────────────── + +export interface InterServerEvents { + ping: () => void; +} + +// ── Per-socket data ─────────────────────────────────────────────────────────── + +export interface SocketData { + bidderId?: string; + staffId?: string; + role?: string; + deviceId?: string; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..792172f --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..95361b8 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + } +}