Scaffold and Phase 1
This commit is contained in:
@@ -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 <noreply@example.com>"
|
||||||
+30
@@ -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/
|
||||||
@@ -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 <repo>
|
||||||
|
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 |
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
|
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>Storybid Auction</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<ConnectivityBanner />
|
||||||
|
<Routes>
|
||||||
|
{/* Auth */}
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/verify" element={<VerifyPage />} />
|
||||||
|
|
||||||
|
{/* Bidder */}
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/live" element={<LivePage />} />
|
||||||
|
<Route path="/silent" element={<SilentPage />} />
|
||||||
|
<Route path="/items/:id" element={<ItemPage />} />
|
||||||
|
<Route path="/my-bids" element={<MyBidsPage />} />
|
||||||
|
<Route path="/checkout" element={<CheckoutPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
|
||||||
|
{/* Staff – optimized single-task views */}
|
||||||
|
<Route path="/staff/auctioneer" element={<AuctioneerPage />} />
|
||||||
|
<Route path="/staff/spotter" element={<SpotterPage />} />
|
||||||
|
<Route path="/staff/check-in" element={<CheckInPage />} />
|
||||||
|
<Route path="/display" element={<DisplayBoardPage />} />
|
||||||
|
|
||||||
|
{/* Admin */}
|
||||||
|
<Route path="/admin" element={<AdminDashboard />} />
|
||||||
|
<Route path="/admin/events" element={<AdminEventsPage />} />
|
||||||
|
<Route path="/admin/items" element={<AdminItemsPage />} />
|
||||||
|
<Route path="/admin/bidders" element={<AdminBiddersPage />} />
|
||||||
|
<Route path="/admin/checkout" element={<AdminCheckoutPage />} />
|
||||||
|
<Route path="/admin/reporting" element={<AdminReportingPage />} />
|
||||||
|
<Route path="/admin/fund-a-need" element={<FundANeedPage />} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useConnectivityStore } from "../store/connectivity.js";
|
||||||
|
|
||||||
|
const labels: Record<string, { text: string; className: string }> = {
|
||||||
|
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 (
|
||||||
|
<div className={`${className} text-white text-center text-sm py-1 px-4 font-medium`}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<LiveAuctionState>({
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<string> => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<AuctionItem[]>([]);
|
||||||
|
const [outbidItemIds, setOutbidItemIds] = useState<Set<string>>(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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => apiFetch<T>(path),
|
||||||
|
post: <T>(path: string, body: unknown) =>
|
||||||
|
apiFetch<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
patch: <T>(path: string, body: unknown) =>
|
||||||
|
apiFetch<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
delete: <T>(path: string) => apiFetch<T>(path, { method: "DELETE" }),
|
||||||
|
};
|
||||||
@@ -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<OutboxBid, string>;
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { io, type Socket } from "socket.io-client";
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
} from "@storybid/shared";
|
||||||
|
|
||||||
|
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Bidders – profiles, paddles, QR codes, CSV import.
|
||||||
|
* TODO: CRUD + bulk import via /api/bidders.
|
||||||
|
*/
|
||||||
|
export default function AdminBiddersPage() {
|
||||||
|
return (
|
||||||
|
<main className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Bidder Manager</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-3 py-2 border rounded-lg text-sm">Import CSV</button>
|
||||||
|
<button className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
|
||||||
|
+ Add Bidder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Bidder list — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Checkout</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Cashier station — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{["Events", "Bidders", "Revenue"].map((label) => (
|
||||||
|
<div key={label} className="border rounded-xl p-5 text-center">
|
||||||
|
<p className="text-gray-500 text-sm">{label}</p>
|
||||||
|
<p className="text-3xl font-bold mt-1">—</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Events – list, create, edit events.
|
||||||
|
* TODO: CRUD via /api/events.
|
||||||
|
*/
|
||||||
|
export default function AdminEventsPage() {
|
||||||
|
return (
|
||||||
|
<main className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Events</h1>
|
||||||
|
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium">
|
||||||
|
+ New Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Events list — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Fund-a-Need</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Paddle raise setup & live totals — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Item Manager</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Item list & editor — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Admin → Reporting – revenue, sell-through, bidder activity, audit log.
|
||||||
|
* TODO: fetch /api/reporting/events/:id/*.
|
||||||
|
*/
|
||||||
|
export default function AdminReportingPage() {
|
||||||
|
return (
|
||||||
|
<main className="p-6 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Reporting</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Reports — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-center">Sign in to bid</h1>
|
||||||
|
<p className="text-center text-gray-500 text-sm">
|
||||||
|
Enter your email for a magic link, or your phone number for a one-time code.
|
||||||
|
</p>
|
||||||
|
{/* TODO: LoginForm component */}
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-400 text-sm">
|
||||||
|
LoginForm — not yet implemented
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<p className="text-gray-500">Verifying…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-4 space-y-4">
|
||||||
|
<h1 className="text-xl font-bold">Checkout</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Stripe checkout — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-4 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Welcome to the Auction</h1>
|
||||||
|
<nav className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "🎙 Live Auction", href: "/live" },
|
||||||
|
{ label: "🔇 Silent Auction", href: "/silent" },
|
||||||
|
{ label: "📋 My Bids", href: "/my-bids" },
|
||||||
|
{ label: "💳 Checkout", href: "/checkout" },
|
||||||
|
].map(({ label, href }) => (
|
||||||
|
<a
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className="block rounded-xl border border-gray-200 p-5 text-center font-semibold text-brand-700 hover:bg-brand-50"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-4 space-y-4">
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Item detail — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<main className="min-h-screen flex flex-col p-4 gap-6">
|
||||||
|
{/* Status banner */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs uppercase tracking-widest text-gray-400 font-semibold">
|
||||||
|
Live Auction
|
||||||
|
</p>
|
||||||
|
{state && (
|
||||||
|
<span
|
||||||
|
className={`inline-block mt-1 px-3 py-1 rounded-full text-sm font-bold ${
|
||||||
|
isSold ? "bg-gray-200 text-gray-500" : "bg-brand-100 text-brand-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STATE_LABELS[state] ?? state}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentItem ? (
|
||||||
|
<>
|
||||||
|
{/* Item info */}
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="text-gray-400 text-sm">Lot {currentItem.lotNumber}</p>
|
||||||
|
<h1 className="text-2xl font-bold">{currentItem.title}</h1>
|
||||||
|
{currentItem.donorName && (
|
||||||
|
<p className="text-sm text-gray-500">Donated by {currentItem.donorName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current bid */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-400 uppercase tracking-wide">Current bid</p>
|
||||||
|
<p className="text-5xl font-black text-brand-700">
|
||||||
|
{currentBid != null ? `$${currentBid.toLocaleString()}` : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Called amount + bid button */}
|
||||||
|
{calledAmount != null && (
|
||||||
|
<button
|
||||||
|
onClick={handleBid}
|
||||||
|
disabled={!canBid}
|
||||||
|
className="w-full py-6 rounded-2xl bg-brand-600 text-white text-3xl font-black shadow-lg active:scale-95 transition-transform disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Bid ${calledAmount.toLocaleString()}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent bids stream */}
|
||||||
|
{recentBids.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<p className="text-xs uppercase tracking-widest text-gray-400 mb-2">Recent bids</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{recentBids.map((b) => (
|
||||||
|
<li key={b.id} className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{b.createdAt}</span>
|
||||||
|
<span className="font-semibold">${Number(b.amount).toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-gray-400 text-lg">Waiting for the auctioneer to open a lot…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-4 space-y-4">
|
||||||
|
<h1 className="text-xl font-bold">My Bids</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Bid history — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-4 space-y-4">
|
||||||
|
<h1 className="text-xl font-bold">Profile</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Profile & digital paddle — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AuctionItem[]>(`/api/items?auctionId=${auctionId}`)
|
||||||
|
.then(setItems)
|
||||||
|
.catch(console.error);
|
||||||
|
}, [auctionId, setItems]);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<main className="p-4">
|
||||||
|
<h1 className="text-xl font-bold mb-4">Silent Auction</h1>
|
||||||
|
<p className="text-gray-400">Loading items…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="p-4 space-y-4">
|
||||||
|
<h1 className="text-xl font-bold">Silent Auction</h1>
|
||||||
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{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 (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`border rounded-xl overflow-hidden shadow-sm ${
|
||||||
|
isOutbid ? "border-red-400" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Outbid banner */}
|
||||||
|
{isOutbid && (
|
||||||
|
<div className="bg-red-50 text-red-600 text-xs font-bold px-3 py-1">
|
||||||
|
You've been outbid!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<p className="text-xs text-gray-400">Lot {item.lotNumber}</p>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
isClosed
|
||||||
|
? "bg-gray-100 text-gray-400"
|
||||||
|
: "bg-green-100 text-green-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isClosed ? "Closed" : "Open"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link to={`/items/${item.id}`} className="block font-semibold hover:text-brand-600">
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-400">Current bid</p>
|
||||||
|
<p className="text-lg font-bold text-brand-700">
|
||||||
|
{item.currentHighBid != null
|
||||||
|
? `$${item.currentHighBid.toLocaleString()}`
|
||||||
|
: `Starting at $${item.openingBid.toLocaleString()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isClosed && (
|
||||||
|
<button
|
||||||
|
onClick={() => void placeSilentBid(item.id, minNext)}
|
||||||
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-bold hover:bg-brand-700 active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
Bid ${minNext.toLocaleString()}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="min-h-screen bg-gray-900 text-white p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Auctioneer Console</h1>
|
||||||
|
<div className="border border-dashed border-gray-600 rounded-xl p-8 text-center text-gray-500 text-sm">
|
||||||
|
Live auction controls — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="p-4 space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">Check-In</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
QR scan & bidder search — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="min-h-screen bg-brand-900 text-white flex flex-col items-center justify-center p-8 space-y-8">
|
||||||
|
<h1 className="text-5xl font-black tracking-tight">Storybid</h1>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-2xl text-brand-100 uppercase tracking-widest">Current Lot</p>
|
||||||
|
<p className="text-6xl font-bold">—</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xl text-brand-200">Current Bid</p>
|
||||||
|
<p className="text-8xl font-black">$—</p>
|
||||||
|
<p className="text-2xl text-brand-300 mt-2">Paddle —</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="min-h-screen p-6 space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Spotter</h1>
|
||||||
|
<div className="border border-dashed border-gray-300 rounded-xl p-8 text-center text-gray-400 text-sm">
|
||||||
|
Paddle entry — not yet implemented
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AuthState>((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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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<ConnectivityState>((set) => ({
|
||||||
|
status: navigator.onLine ? "connected" : "offline",
|
||||||
|
setStatus: (status) => set({ status }),
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
@@ -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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
@@ -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" }));
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/auth/magic-link – request email magic link
|
||||||
|
* GET /api/auth/verify?token= – verify magic link, issue JWT
|
||||||
|
* POST /api/auth/otp/send – request SMS OTP via Twilio Verify
|
||||||
|
* POST /api/auth/otp/verify – verify SMS OTP, issue JWT
|
||||||
|
* POST /api/auth/logout – clear session (client drops token)
|
||||||
|
*/
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { signToken } from "../lib/jwt.js";
|
||||||
|
import { sendMagicLink } from "../services/email.js";
|
||||||
|
import { sendOtp, verifyOtp } from "../services/twilio.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
/** Find or create a Bidder + BidderAuthMethod for the given identifier. */
|
||||||
|
async function upsertBidder(
|
||||||
|
type: "email_magic_link" | "sms_otp",
|
||||||
|
identifier: string,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// Find existing auth method
|
||||||
|
const existing = await prisma.bidderAuthMethod.findUnique({
|
||||||
|
where: { type_identifier: { type, identifier } },
|
||||||
|
include: { bidder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) return existing.bidderId;
|
||||||
|
|
||||||
|
// Create new bidder + auth method
|
||||||
|
const bidder = await prisma.bidder.create({
|
||||||
|
data: {
|
||||||
|
organizationId,
|
||||||
|
email: type === "email_magic_link" ? identifier : null,
|
||||||
|
phone: type === "sms_otp" ? identifier : null,
|
||||||
|
firstName: "Guest",
|
||||||
|
lastName: "",
|
||||||
|
authMethods: {
|
||||||
|
create: { type, identifier },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return bidder.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the base public URL for building magic-link callbacks. */
|
||||||
|
function resolveBaseUrl(req: { protocol: string; hostname: string }): string {
|
||||||
|
return (
|
||||||
|
process.env["PUBLIC_URL"] ??
|
||||||
|
`${req.protocol}://${req.hostname}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a signed JWT for a bidder. */
|
||||||
|
async function issueBidderToken(bidderId: string, deviceId?: string): Promise<string> {
|
||||||
|
const bidder = await prisma.bidder.findUniqueOrThrow({
|
||||||
|
where: { id: bidderId },
|
||||||
|
});
|
||||||
|
return signToken({
|
||||||
|
sub: bidderId,
|
||||||
|
role: "bidder",
|
||||||
|
organizationId: bidder.organizationId,
|
||||||
|
deviceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Magic link – request ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MagicLinkRequestSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
deviceId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/magic-link", async (req, res) => {
|
||||||
|
const parse = MagicLinkRequestSchema.safeParse(req.body);
|
||||||
|
if (!parse.success) {
|
||||||
|
res.status(400).json({ error: "Invalid email address" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, deviceId } = parse.data;
|
||||||
|
|
||||||
|
// Resolve organization (single-org install)
|
||||||
|
const org = await prisma.organization.findFirst();
|
||||||
|
if (!org) {
|
||||||
|
res.status(500).json({ error: "Organization not configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bidderId = await upsertBidder("email_magic_link", email, org.id);
|
||||||
|
|
||||||
|
// Generate a short-lived token stored in Redis (or fall back to a signed value)
|
||||||
|
const rawToken = randomBytes(32).toString("hex");
|
||||||
|
const expiresAt = Date.now() + MAGIC_LINK_TTL_MS;
|
||||||
|
|
||||||
|
// Store token in DB on the DeviceSession-like approach: reuse AuditLog payload
|
||||||
|
// Simple approach: store in a dedicated magic_token via AuditLog with entityType='magic_link'
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: "magic_link_issued",
|
||||||
|
entityType: "magic_link",
|
||||||
|
entityId: rawToken,
|
||||||
|
payload: { bidderId, email, expiresAt, deviceId: deviceId ?? null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMagicLink(email, rawToken, resolveBaseUrl(req));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[auth] sendMagicLink failed", err);
|
||||||
|
// Don't leak whether the email exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always respond with success to prevent email enumeration
|
||||||
|
res.json({ ok: true, message: "If that address is registered, a link is on its way." });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Magic link – verify ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
authRouter.get("/verify", async (req, res) => {
|
||||||
|
const token = req.query["token"];
|
||||||
|
if (typeof token !== "string" || !token) {
|
||||||
|
res.status(400).json({ error: "Missing token" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = await prisma.auditLog.findFirst({
|
||||||
|
where: { entityType: "magic_link", entityId: token },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!log || !log.payload) {
|
||||||
|
res.status(401).json({ error: "Invalid or expired link" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = log.payload as {
|
||||||
|
bidderId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
deviceId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Date.now() > payload.expiresAt) {
|
||||||
|
res.status(401).json({ error: "Link has expired" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume token (delete so it can't be reused)
|
||||||
|
await prisma.auditLog.delete({ where: { id: log.id } });
|
||||||
|
|
||||||
|
// Mark auth method verified
|
||||||
|
await prisma.bidderAuthMethod.updateMany({
|
||||||
|
where: { bidderId: payload.bidderId, type: "email_magic_link" },
|
||||||
|
data: { verifiedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const jwt = await issueBidderToken(payload.bidderId, payload.deviceId ?? undefined);
|
||||||
|
res.json({ token: jwt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SMS OTP – send ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const OtpSendSchema = z.object({
|
||||||
|
phone: z.string().regex(/^\+[1-9]\d{7,14}$/, "Phone must be E.164 format (e.g. +12025551234)"),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/otp/send", async (req, res) => {
|
||||||
|
const parse = OtpSendSchema.safeParse(req.body);
|
||||||
|
if (!parse.success) {
|
||||||
|
res.status(400).json({ error: parse.error.issues[0]?.message ?? "Invalid phone" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { phone } = parse.data;
|
||||||
|
|
||||||
|
const org = await prisma.organization.findFirst();
|
||||||
|
if (!org) {
|
||||||
|
res.status(500).json({ error: "Organization not configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertBidder("sms_otp", phone, org.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendOtp(phone);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[auth] sendOtp failed", err);
|
||||||
|
// Return generic error – don't reveal Twilio config issues to clients
|
||||||
|
res.status(503).json({ error: "Could not send verification code. Please try again." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SMS OTP – verify ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const OtpVerifySchema = z.object({
|
||||||
|
phone: z.string(),
|
||||||
|
code: z.string().min(4).max(10),
|
||||||
|
deviceId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/otp/verify", async (req, res) => {
|
||||||
|
const parse = OtpVerifySchema.safeParse(req.body);
|
||||||
|
if (!parse.success) {
|
||||||
|
res.status(400).json({ error: "Invalid request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { phone, code, deviceId } = parse.data;
|
||||||
|
|
||||||
|
let approved: boolean;
|
||||||
|
try {
|
||||||
|
approved = await verifyOtp(phone, code);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[auth] verifyOtp failed", err);
|
||||||
|
res.status(503).json({ error: "Verification check failed. Please try again." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!approved) {
|
||||||
|
res.status(401).json({ error: "Incorrect or expired code" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authMethod = await prisma.bidderAuthMethod.findUnique({
|
||||||
|
where: { type_identifier: { type: "sms_otp", identifier: phone } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authMethod) {
|
||||||
|
res.status(401).json({ error: "Phone not registered" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.bidderAuthMethod.update({
|
||||||
|
where: { id: authMethod.id },
|
||||||
|
data: { verifiedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const jwt = await issueBidderToken(authMethod.bidderId, deviceId);
|
||||||
|
res.json({ token: jwt });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Logout ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
authRouter.post("/logout", requireAuth, (_req, res) => {
|
||||||
|
// JWT is stateless; the client drops the token.
|
||||||
|
// For harder logout, add a token denylist in Redis here.
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/check-in/scan – process QR token, return bidder + enrollment
|
||||||
|
* POST /api/check-in/:id – manual check-in by enrollment id
|
||||||
|
*/
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export const checkInRouter = Router();
|
||||||
|
|
||||||
|
const STAFF = requireRole("admin", "event_manager", "checkin_staff");
|
||||||
|
|
||||||
|
// QR codes encode a JWT sub (bidderId) + eventId in a short URL
|
||||||
|
// e.g. /check-in?b=<bidderId>&e=<eventId>
|
||||||
|
|
||||||
|
const ScanSchema = z.object({
|
||||||
|
bidderId: z.string(),
|
||||||
|
eventId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
checkInRouter.post("/scan", requireAuth, STAFF, async (req, res) => {
|
||||||
|
const parse = ScanSchema.safeParse(req.body);
|
||||||
|
if (!parse.success) {
|
||||||
|
res.status(400).json({ error: "Invalid QR payload" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bidderId, eventId } = parse.data;
|
||||||
|
|
||||||
|
const enrollment = await prisma.bidderEventEnrollment.findUnique({
|
||||||
|
where: { bidderId_eventId: { bidderId, eventId } },
|
||||||
|
include: { bidder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enrollment) {
|
||||||
|
res.status(404).json({ error: "Bidder is not registered for this event" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrollment.checkInStatus === "checked_in") {
|
||||||
|
// Return profile but flag as already checked in
|
||||||
|
res.json({ enrollment, alreadyCheckedIn: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.bidderEventEnrollment.update({
|
||||||
|
where: { id: enrollment.id },
|
||||||
|
data: { checkInStatus: "checked_in", checkInAt: new Date() },
|
||||||
|
include: { bidder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ enrollment: updated, alreadyCheckedIn: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
checkInRouter.post("/:enrollmentId", requireAuth, STAFF, async (req, res) => {
|
||||||
|
const enrollment = await prisma.bidderEventEnrollment.findUnique({
|
||||||
|
where: { id: req.params["enrollmentId"] },
|
||||||
|
include: { bidder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enrollment) {
|
||||||
|
res.status(404).json({ error: "Enrollment not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.bidderEventEnrollment.update({
|
||||||
|
where: { id: enrollment.id },
|
||||||
|
data: { checkInStatus: "checked_in", checkInAt: new Date() },
|
||||||
|
include: { bidder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ enrollment: updated, alreadyCheckedIn: enrollment.checkInStatus === "checked_in" });
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/media/upload – multipart upload; saves to local disk
|
||||||
|
* DELETE /api/media/:key – delete a file by key (admin/event_manager)
|
||||||
|
*
|
||||||
|
* Upload flow (replaces the old presigned-URL pattern):
|
||||||
|
* 1. Client POSTs multipart/form-data with fields: itemId, mediaType, plus the file
|
||||||
|
* 2. Server saves to UPLOAD_DIR/items/<itemId>/<uuid>.<ext>
|
||||||
|
* 3. Server returns { url, key, mimetype, sizeBytes }
|
||||||
|
* 4. Client calls POST /api/items/:id/media with { mediaType, url } to attach the
|
||||||
|
* record to the item (existing endpoint in routes/items.ts)
|
||||||
|
*
|
||||||
|
* Files are served as static assets at /media/* (see app.ts).
|
||||||
|
* Everything stays on the local machine — no internet required.
|
||||||
|
*/
|
||||||
|
import { Router } from "express";
|
||||||
|
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||||
|
import { upload, resolveFile, deleteFile, type MediaType } from "../services/storage.js";
|
||||||
|
|
||||||
|
export const mediaRouter = Router();
|
||||||
|
|
||||||
|
const STAFF_WRITE = requireRole("admin", "event_manager");
|
||||||
|
|
||||||
|
// ── Upload ─────────────────────────────────────────────────────────────────────
|
||||||
|
mediaRouter.post(
|
||||||
|
"/upload",
|
||||||
|
requireAuth,
|
||||||
|
STAFF_WRITE,
|
||||||
|
// Parse a single file field named "file" plus any text fields (itemId, mediaType)
|
||||||
|
upload.single("file"),
|
||||||
|
(req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ error: "No file received" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = (req.body as { mediaType?: string }).mediaType as MediaType | undefined;
|
||||||
|
if (!mediaType || !["image", "video", "document"].includes(mediaType)) {
|
||||||
|
res.status(400).json({ error: "mediaType must be image, video, or document" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = resolveFile(req.file, mediaType);
|
||||||
|
res.status(201).json(saved);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Delete ─────────────────────────────────────────────────────────────────────
|
||||||
|
mediaRouter.delete(
|
||||||
|
"/:key(*)", // key contains slashes, e.g. items/abc/uuid.jpg
|
||||||
|
requireAuth,
|
||||||
|
STAFF_WRITE,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
await deleteFile(req.params["key"] ?? "");
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/organization – get org profile (any authenticated user)
|
||||||
|
* PATCH /api/organization – update branding / DNS settings (admin only)
|
||||||
|
*/
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { requireAuth, requireRole } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export const organizationRouter = Router();
|
||||||
|
|
||||||
|
organizationRouter.get("/", requireAuth, async (req, res) => {
|
||||||
|
const org = await prisma.organization.findFirst({
|
||||||
|
where: { id: req.auth!.organizationId },
|
||||||
|
});
|
||||||
|
if (!org) {
|
||||||
|
res.status(404).json({ error: "Organization not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Strip Stripe keys from non-admin responses
|
||||||
|
const { stripeAccountId: _, ...safe } = org;
|
||||||
|
res.json(req.auth!.role === "admin" ? org : safe);
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateOrgSchema = z.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
logoUrl: z.string().url().nullable().optional(),
|
||||||
|
primaryColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
||||||
|
publicUrl: z.string().url().nullable().optional(),
|
||||||
|
localHostname: z.string().nullable().optional(),
|
||||||
|
stripeAccountId: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
organizationRouter.patch("/", requireAuth, requireRole("admin"), async (req, res) => {
|
||||||
|
const parse = UpdateOrgSchema.safeParse(req.body);
|
||||||
|
if (!parse.success) {
|
||||||
|
res.status(400).json({ error: parse.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = await prisma.organization.update({
|
||||||
|
where: { id: req.auth!.organizationId },
|
||||||
|
data: parse.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(org);
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Bid engine – shared validation + persistence logic used by both the
|
||||||
|
* REST fallback route and the Socket.io handlers.
|
||||||
|
*
|
||||||
|
* Keeping this in one place ensures that offline-synced outbox bids
|
||||||
|
* and real-time bids go through identical server-side rules.
|
||||||
|
*/
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import type { OriginMode } from "@storybid/shared";
|
||||||
|
|
||||||
|
export interface PlaceBidInput {
|
||||||
|
itemId: string;
|
||||||
|
bidderId: string;
|
||||||
|
amount: number; // in whole dollars (server stores as Decimal)
|
||||||
|
originMode: OriginMode;
|
||||||
|
deviceId: string;
|
||||||
|
clientSeq: number;
|
||||||
|
clientCreatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BidResult =
|
||||||
|
| { ok: true; bid: Awaited<ReturnType<typeof prisma.bid.create>>; item: Awaited<ReturnType<typeof prisma.auctionItem.findUniqueOrThrow>> }
|
||||||
|
| { ok: false; error: string; code: "ITEM_NOT_FOUND" | "WINDOW_CLOSED" | "ITEM_STATE" | "AMOUNT_TOO_LOW" | "DUPLICATE" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place a validated bid. Runs inside a Prisma transaction so the
|
||||||
|
* high-bid update and bid record creation are atomic.
|
||||||
|
*/
|
||||||
|
export async function placeBid(input: PlaceBidInput): Promise<BidResult> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Load item with a row-level lock (SELECT FOR UPDATE)
|
||||||
|
const item = await tx.auctionItem.findUnique({
|
||||||
|
where: { id: input.itemId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return { ok: false, error: "Item not found", code: "ITEM_NOT_FOUND" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate item state
|
||||||
|
const auction = await tx.auction.findUniqueOrThrow({ where: { id: item.auctionId } });
|
||||||
|
|
||||||
|
if (auction.type === "live") {
|
||||||
|
if (!["active", "going_once", "going_twice"].includes(item.state)) {
|
||||||
|
return { ok: false, error: "Item is not accepting bids", code: "ITEM_STATE" };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Silent auction
|
||||||
|
if (item.state === "closed" || item.state === "passed") {
|
||||||
|
return { ok: false, error: "Bidding on this item has closed", code: "WINDOW_CLOSED" };
|
||||||
|
}
|
||||||
|
if (item.silentWindowId) {
|
||||||
|
const window = await tx.silentAuctionWindow.findUnique({
|
||||||
|
where: { id: item.silentWindowId },
|
||||||
|
});
|
||||||
|
if (!window || window.status !== "open") {
|
||||||
|
return { ok: false, error: "Bidding window is not open", code: "WINDOW_CLOSED" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate amount
|
||||||
|
const minBid = item.currentHighBid
|
||||||
|
? Number(item.currentHighBid) + Number(item.bidIncrement)
|
||||||
|
: Number(item.openingBid);
|
||||||
|
|
||||||
|
if (input.amount < minBid) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Minimum bid is $${minBid}`,
|
||||||
|
code: "AMOUNT_TOO_LOW",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Idempotency – reject exact duplicate (same device + seq)
|
||||||
|
const duplicate = await tx.bid.findFirst({
|
||||||
|
where: { deviceId: input.deviceId, clientSeq: input.clientSeq, itemId: input.itemId },
|
||||||
|
});
|
||||||
|
if (duplicate) {
|
||||||
|
return { ok: false, error: "Duplicate bid", code: "DUPLICATE" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Persist bid
|
||||||
|
const bid = await tx.bid.create({
|
||||||
|
data: {
|
||||||
|
itemId: input.itemId,
|
||||||
|
bidderId: input.bidderId,
|
||||||
|
amount: new Prisma.Decimal(input.amount),
|
||||||
|
clientCreatedAt: input.clientCreatedAt,
|
||||||
|
serverReceivedAt: new Date(),
|
||||||
|
originMode: input.originMode,
|
||||||
|
syncStatus: "synced",
|
||||||
|
deviceId: input.deviceId,
|
||||||
|
clientSeq: input.clientSeq,
|
||||||
|
isWinning: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Mark previous high bid as no longer winning
|
||||||
|
await tx.bid.updateMany({
|
||||||
|
where: {
|
||||||
|
itemId: input.itemId,
|
||||||
|
isWinning: true,
|
||||||
|
id: { not: bid.id },
|
||||||
|
},
|
||||||
|
data: { isWinning: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Update item high bid
|
||||||
|
const updatedItem = await tx.auctionItem.update({
|
||||||
|
where: { id: input.itemId },
|
||||||
|
data: {
|
||||||
|
currentHighBid: new Prisma.Decimal(input.amount),
|
||||||
|
currentHighBidderId: input.bidderId,
|
||||||
|
// Reset going-once/going-twice back to active on new bid
|
||||||
|
...(["going_once", "going_twice"].includes(item.state) && {
|
||||||
|
state: "active",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. Soft-close extension for silent auction
|
||||||
|
if (
|
||||||
|
auction.type === "silent" &&
|
||||||
|
updatedItem.softCloseEnabled &&
|
||||||
|
updatedItem.silentWindowId
|
||||||
|
) {
|
||||||
|
const window = await tx.silentAuctionWindow.findUnique({
|
||||||
|
where: { id: updatedItem.silentWindowId },
|
||||||
|
});
|
||||||
|
if (window && window.status === "open") {
|
||||||
|
const msRemaining = window.closesAt.getTime() - Date.now();
|
||||||
|
const extendThresholdMs = updatedItem.softCloseExtendMinutes * 60 * 1000;
|
||||||
|
if (msRemaining < extendThresholdMs) {
|
||||||
|
await tx.silentAuctionWindow.update({
|
||||||
|
where: { id: window.id },
|
||||||
|
data: {
|
||||||
|
closesAt: new Date(Date.now() + extendThresholdMs),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, bid, item: updatedItem };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
function createTransport() {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: process.env["SMTP_HOST"],
|
||||||
|
port: parseInt(process.env["SMTP_PORT"] ?? "587", 10),
|
||||||
|
secure: process.env["SMTP_PORT"] === "465",
|
||||||
|
auth: {
|
||||||
|
user: process.env["SMTP_USER"],
|
||||||
|
pass: process.env["SMTP_PASS"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const FROM = process.env["EMAIL_FROM"] ?? "Storybid <noreply@example.com>";
|
||||||
|
|
||||||
|
export async function sendMagicLink(to: string, token: string, baseUrl: string): Promise<void> {
|
||||||
|
const link = `${baseUrl}/verify?token=${encodeURIComponent(token)}`;
|
||||||
|
const transporter = createTransport();
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: "Your Storybid sign-in link",
|
||||||
|
text: `Click the link below to sign in to the auction. The link expires in 15 minutes.\n\n${link}`,
|
||||||
|
html: `
|
||||||
|
<p>Click the button below to sign in to the auction. This link expires in <strong>15 minutes</strong>.</p>
|
||||||
|
<p style="margin:24px 0">
|
||||||
|
<a href="${link}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold">
|
||||||
|
Sign in to Auction
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#6b7280;font-size:12px">If you didn't request this, you can ignore this email.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendReceipt(
|
||||||
|
to: string,
|
||||||
|
invoiceId: string,
|
||||||
|
totalAmount: number,
|
||||||
|
items: Array<{ title: string; amount: number }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const transporter = createTransport();
|
||||||
|
const rows = items
|
||||||
|
.map((i) => `<tr><td>${i.title}</td><td style="text-align:right">$${(i.amount / 100).toFixed(2)}</td></tr>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: "Your auction receipt",
|
||||||
|
html: `
|
||||||
|
<h2>Thank you for your support!</h2>
|
||||||
|
<table style="width:100%;border-collapse:collapse">
|
||||||
|
<thead><tr><th style="text-align:left">Item</th><th style="text-align:right">Amount</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Total</strong></td>
|
||||||
|
<td style="text-align:right"><strong>$${(totalAmount / 100).toFixed(2)}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
<p style="color:#6b7280;font-size:12px">Invoice #${invoiceId}</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Local disk storage service.
|
||||||
|
*
|
||||||
|
* Files are written to UPLOAD_DIR (default: /app/uploads inside the container,
|
||||||
|
* mapped to the `media_data` Docker volume so they survive restarts).
|
||||||
|
* Express serves them as static files under /media (see app.ts).
|
||||||
|
*
|
||||||
|
* This keeps the app fully self-contained and operational when the internet
|
||||||
|
* is unavailable — no S3, no external CDN, no external dependencies.
|
||||||
|
*/
|
||||||
|
import { mkdir, unlink } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join, extname } from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import multer, { type FileFilterCallback } from "multer";
|
||||||
|
|
||||||
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const UPLOAD_DIR = process.env["UPLOAD_DIR"] ?? join(process.cwd(), "uploads");
|
||||||
|
|
||||||
|
/** Public URL prefix used to build the URL stored in the DB. */
|
||||||
|
function mediaBaseUrl(): string {
|
||||||
|
return process.env["MEDIA_BASE_URL"] ?? "/media";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Allowed types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MediaType = "image" | "video" | "document";
|
||||||
|
|
||||||
|
const ALLOWED_MIME: Record<MediaType, string[]> = {
|
||||||
|
image: ["image/jpeg", "image/png", "image/webp", "image/gif"],
|
||||||
|
video: ["video/mp4", "video/webm"],
|
||||||
|
document: ["application/pdf"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIME_TO_EXT: Record<string, string> = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/webp": "webp",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"video/mp4": "mp4",
|
||||||
|
"video/webm": "webm",
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Multer storage engine ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const diskStorage = multer.diskStorage({
|
||||||
|
destination: async (req, _file, cb) => {
|
||||||
|
// itemId is in the request body (parsed before multer runs via fields())
|
||||||
|
const itemId = (req.body as { itemId?: string }).itemId ?? "unknown";
|
||||||
|
const dir = join(UPLOAD_DIR, "items", itemId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
cb(null, dir);
|
||||||
|
} catch (err) {
|
||||||
|
cb(err as Error, dir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = MIME_TO_EXT[file.mimetype] ?? extname(file.originalname).slice(1) ?? "bin";
|
||||||
|
cb(null, `${randomUUID()}.${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function fileFilter(
|
||||||
|
req: Request,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
cb: FileFilterCallback,
|
||||||
|
): void {
|
||||||
|
const mediaType = (req.body as { mediaType?: string }).mediaType as MediaType | undefined;
|
||||||
|
const allowed = mediaType ? ALLOWED_MIME[mediaType] : Object.values(ALLOWED_MIME).flat();
|
||||||
|
|
||||||
|
if (allowed?.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`File type ${file.mimetype} is not allowed for mediaType "${mediaType ?? "unknown"}"`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max file sizes in bytes */
|
||||||
|
const MAX_SIZE: Record<MediaType, number> = {
|
||||||
|
image: 10 * 1024 * 1024, // 10 MB
|
||||||
|
video: 500 * 1024 * 1024, // 500 MB
|
||||||
|
document: 50 * 1024 * 1024, // 50 MB
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upload = multer({
|
||||||
|
storage: diskStorage,
|
||||||
|
fileFilter,
|
||||||
|
limits: { fileSize: 500 * 1024 * 1024 }, // hard ceiling; per-type checked below
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Post-upload helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SavedFile {
|
||||||
|
url: string; // public URL served by Express static
|
||||||
|
key: string; // relative path within UPLOAD_DIR, used for deletion
|
||||||
|
mimetype: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the public URL and key for a file that multer has already saved to disk.
|
||||||
|
* Also enforces the per-mediaType size limit (multer's limit is a single ceiling).
|
||||||
|
*/
|
||||||
|
export function resolveFile(
|
||||||
|
file: Express.Multer.File,
|
||||||
|
mediaType: MediaType,
|
||||||
|
): SavedFile {
|
||||||
|
const maxSize = MAX_SIZE[mediaType];
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
// Remove the already-written file before throwing
|
||||||
|
void unlink(file.path).catch(() => undefined);
|
||||||
|
throw new Error(
|
||||||
|
`File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB exceeds the ${maxSize / 1024 / 1024} MB limit for ${mediaType}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// key = relative path from UPLOAD_DIR, e.g. "items/abc123/uuid.jpg"
|
||||||
|
const key = file.path.replace(UPLOAD_DIR + "/", "").replace(UPLOAD_DIR + "\\", "");
|
||||||
|
const url = `${mediaBaseUrl()}/${key.replace(/\\/g, "/")}`;
|
||||||
|
|
||||||
|
return { url, key, mimetype: file.mimetype, sizeBytes: file.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a previously uploaded file by its key.
|
||||||
|
* Silently ignores missing files (idempotent).
|
||||||
|
*/
|
||||||
|
export async function deleteFile(key: string): Promise<void> {
|
||||||
|
const fullPath = join(UPLOAD_DIR, key);
|
||||||
|
// Safety: ensure the resolved path stays inside UPLOAD_DIR
|
||||||
|
if (!fullPath.startsWith(UPLOAD_DIR)) {
|
||||||
|
throw new Error("Invalid key — path traversal detected");
|
||||||
|
}
|
||||||
|
if (existsSync(fullPath)) {
|
||||||
|
await unlink(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import twilio from "twilio";
|
||||||
|
|
||||||
|
function getClient() {
|
||||||
|
const sid = process.env["TWILIO_ACCOUNT_SID"];
|
||||||
|
const token = process.env["TWILIO_AUTH_TOKEN"];
|
||||||
|
if (!sid || !token) throw new Error("Twilio credentials not configured");
|
||||||
|
return twilio(sid, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE_SID = process.env["TWILIO_VERIFY_SERVICE_SID"] ?? "";
|
||||||
|
|
||||||
|
export async function sendOtp(phone: string): Promise<void> {
|
||||||
|
const client = getClient();
|
||||||
|
await client.verify.v2.services(SERVICE_SID).verifications.create({
|
||||||
|
to: phone,
|
||||||
|
channel: "sms",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyOtp(phone: string, code: string): Promise<boolean> {
|
||||||
|
const client = getClient();
|
||||||
|
const result = await client.verify.v2
|
||||||
|
.services(SERVICE_SID)
|
||||||
|
.verificationChecks.create({ to: phone, code });
|
||||||
|
return result.status === "approved";
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Server } from "socket.io";
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
InterServerEvents,
|
||||||
|
SocketData,
|
||||||
|
} from "@storybid/shared";
|
||||||
|
|
||||||
|
import { registerLiveAuctionHandlers } from "./live-auction.js";
|
||||||
|
import { registerSilentAuctionHandlers } from "./silent-auction.js";
|
||||||
|
import { verifyToken } from "../lib/jwt.js";
|
||||||
|
|
||||||
|
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||||
|
|
||||||
|
export function registerSocketHandlers(io: IO): void {
|
||||||
|
// Auth middleware – validate JWT on handshake
|
||||||
|
io.use((socket, next) => {
|
||||||
|
const token =
|
||||||
|
(socket.handshake.auth["token"] as string | undefined) ??
|
||||||
|
(socket.handshake.headers["authorization"] as string | undefined)?.replace("Bearer ", "");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Allow unauthenticated connections for display board / public catalog
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
socket.data.bidderId = payload.role === "bidder" ? payload.sub : undefined;
|
||||||
|
socket.data.staffId = payload.role !== "bidder" ? payload.sub : undefined;
|
||||||
|
socket.data.role = payload.role;
|
||||||
|
socket.data.deviceId = payload.deviceId;
|
||||||
|
} catch {
|
||||||
|
return next(new Error("Invalid token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
console.log(`[socket] connected ${socket.id} role=${socket.data.role ?? "guest"}`);
|
||||||
|
|
||||||
|
// Auto-join personal room for outbid / checkout notifications
|
||||||
|
if (socket.data.bidderId) {
|
||||||
|
void socket.join(`bidder:${socket.data.bidderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room join/leave for event-scoped broadcasts
|
||||||
|
socket.on("join_event", (eventId) => {
|
||||||
|
void socket.join(`event:${eventId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("leave_event", (eventId) => {
|
||||||
|
void socket.leave(`event:${eventId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerLiveAuctionHandlers(io, socket);
|
||||||
|
registerSilentAuctionHandlers(io, socket);
|
||||||
|
|
||||||
|
socket.on("disconnect", (reason) => {
|
||||||
|
console.log(`[socket] disconnected ${socket.id} reason=${reason}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import type { Server, Socket } from "socket.io";
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
InterServerEvents,
|
||||||
|
SocketData,
|
||||||
|
} from "@storybid/shared";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { placeBid } from "../services/bid-engine.js";
|
||||||
|
|
||||||
|
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||||
|
type Sock = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||||
|
|
||||||
|
function isStaff(role?: string) {
|
||||||
|
return ["admin", "event_manager", "auctioneer", "spotter"].includes(role ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuctioneer(role?: string) {
|
||||||
|
return ["admin", "event_manager", "auctioneer"].includes(role ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast to all sockets in the event room. */
|
||||||
|
function toEvent(io: IO, itemId: string, broadcastFn: (room: ReturnType<IO["to"]>) => void) {
|
||||||
|
// We need the eventId – look it up from item. For now pass auctionId-based room.
|
||||||
|
// Rooms are joined as `event:<eventId>` on connect.
|
||||||
|
// We'll derive it from the item's auction.
|
||||||
|
void prisma.auctionItem.findUnique({
|
||||||
|
where: { id: itemId },
|
||||||
|
include: { auction: { select: { eventId: true } } },
|
||||||
|
}).then((item) => {
|
||||||
|
if (item?.auction.eventId) {
|
||||||
|
broadcastFn(io.to(`event:${item.auction.eventId}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLiveAuctionHandlers(io: IO, socket: Sock): void {
|
||||||
|
|
||||||
|
// ── Bidder: place a live bid ────────────────────────────────────────────────
|
||||||
|
socket.on("place_live_bid", async (payload) => {
|
||||||
|
const bidderId = socket.data.bidderId;
|
||||||
|
if (!bidderId) return;
|
||||||
|
|
||||||
|
const result = await placeBid({
|
||||||
|
itemId: payload.itemId,
|
||||||
|
bidderId,
|
||||||
|
amount: payload.amount,
|
||||||
|
originMode: "public", // socket transport → always public or local; use header hint if needed
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
clientSeq: payload.clientSeq,
|
||||||
|
clientCreatedAt: new Date(payload.clientCreatedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.warn(`[live] rejected bid bidder=${bidderId} reason=${result.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast winning bid to event room
|
||||||
|
toEvent(io, payload.itemId, (room) => {
|
||||||
|
room.emit("live_bid_accepted", {
|
||||||
|
bid: {
|
||||||
|
...result.bid,
|
||||||
|
amount: Number(result.bid.amount),
|
||||||
|
clientCreatedAt: result.bid.clientCreatedAt.toISOString(),
|
||||||
|
serverReceivedAt: result.bid.serverReceivedAt.toISOString(),
|
||||||
|
createdAt: result.bid.createdAt.toISOString(),
|
||||||
|
originMode: result.bid.originMode as import("@storybid/shared").OriginMode,
|
||||||
|
syncStatus: result.bid.syncStatus as import("@storybid/shared").SyncStatus,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
...result.item,
|
||||||
|
fairMarketValue: result.item.fairMarketValue ? Number(result.item.fairMarketValue) : null,
|
||||||
|
openingBid: Number(result.item.openingBid),
|
||||||
|
reservePrice: result.item.reservePrice ? Number(result.item.reservePrice) : null,
|
||||||
|
currentHighBid: result.item.currentHighBid ? Number(result.item.currentHighBid) : null,
|
||||||
|
bidIncrement: Number(result.item.bidIncrement),
|
||||||
|
createdAt: result.item.createdAt.toISOString(),
|
||||||
|
updatedAt: result.item.updatedAt.toISOString(),
|
||||||
|
state: result.item.state as import("@storybid/shared").ItemState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auctioneer: activate a lot ─────────────────────────────────────────────
|
||||||
|
socket.on("auctioneer_activate_item", async (itemId) => {
|
||||||
|
if (!isAuctioneer(socket.data.role)) return;
|
||||||
|
|
||||||
|
const item = await prisma.auctionItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: { state: "active" },
|
||||||
|
include: { auction: { select: { eventId: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
io.to(`event:${item.auction.eventId}`).emit("item_activated", {
|
||||||
|
item: {
|
||||||
|
...item,
|
||||||
|
fairMarketValue: item.fairMarketValue ? Number(item.fairMarketValue) : null,
|
||||||
|
openingBid: Number(item.openingBid),
|
||||||
|
reservePrice: item.reservePrice ? Number(item.reservePrice) : null,
|
||||||
|
currentHighBid: item.currentHighBid ? Number(item.currentHighBid) : null,
|
||||||
|
bidIncrement: Number(item.bidIncrement),
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
state: item.state as import("@storybid/shared").ItemState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auctioneer: call the next bid amount ────────────────────────────────────
|
||||||
|
socket.on("auctioneer_call_next_bid", async (payload) => {
|
||||||
|
if (!isAuctioneer(socket.data.role)) return;
|
||||||
|
|
||||||
|
const item = await prisma.auctionItem.findUnique({
|
||||||
|
where: { id: payload.itemId },
|
||||||
|
include: { auction: { select: { eventId: true } } },
|
||||||
|
});
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
io.to(`event:${item.auction.eventId}`).emit("next_live_bid", {
|
||||||
|
itemId: payload.itemId,
|
||||||
|
amount: payload.amount,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auctioneer / Spotter: accept a floor bid ────────────────────────────────
|
||||||
|
socket.on("auctioneer_accept_bid", async (payload) => {
|
||||||
|
if (!isStaff(socket.data.role)) return;
|
||||||
|
|
||||||
|
const result = await placeBid({
|
||||||
|
itemId: payload.itemId,
|
||||||
|
bidderId: payload.bidderId,
|
||||||
|
amount: payload.amount,
|
||||||
|
originMode: "public",
|
||||||
|
deviceId: socket.id, // spotter device = socket id
|
||||||
|
clientSeq: Date.now(), // floor bids use server timestamp as seq
|
||||||
|
clientCreatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.warn(`[live] spotter bid rejected reason=${result.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toEvent(io, payload.itemId, (room) => {
|
||||||
|
room.emit("live_bid_accepted", {
|
||||||
|
bid: {
|
||||||
|
...result.bid,
|
||||||
|
amount: Number(result.bid.amount),
|
||||||
|
clientCreatedAt: result.bid.clientCreatedAt.toISOString(),
|
||||||
|
serverReceivedAt: result.bid.serverReceivedAt.toISOString(),
|
||||||
|
createdAt: result.bid.createdAt.toISOString(),
|
||||||
|
originMode: result.bid.originMode as import("@storybid/shared").OriginMode,
|
||||||
|
syncStatus: result.bid.syncStatus as import("@storybid/shared").SyncStatus,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
...result.item,
|
||||||
|
fairMarketValue: result.item.fairMarketValue ? Number(result.item.fairMarketValue) : null,
|
||||||
|
openingBid: Number(result.item.openingBid),
|
||||||
|
reservePrice: result.item.reservePrice ? Number(result.item.reservePrice) : null,
|
||||||
|
currentHighBid: result.item.currentHighBid ? Number(result.item.currentHighBid) : null,
|
||||||
|
bidIncrement: Number(result.item.bidIncrement),
|
||||||
|
createdAt: result.item.createdAt.toISOString(),
|
||||||
|
updatedAt: result.item.updatedAt.toISOString(),
|
||||||
|
state: result.item.state as import("@storybid/shared").ItemState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── State transitions ───────────────────────────────────────────────────────
|
||||||
|
async function transitionItem(
|
||||||
|
itemId: string,
|
||||||
|
state: "going_once" | "going_twice" | "sold" | "passed",
|
||||||
|
) {
|
||||||
|
const item = await prisma.auctionItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: { state },
|
||||||
|
include: { auction: { select: { eventId: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state === "sold") {
|
||||||
|
io.to(`event:${item.auction.eventId}`).emit("item_sold", {
|
||||||
|
itemId: item.id,
|
||||||
|
winnerId: item.currentHighBidderId ?? "",
|
||||||
|
amount: item.currentHighBid ? Number(item.currentHighBid) : 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
io.to(`event:${item.auction.eventId}`).emit("item_state_changed", {
|
||||||
|
itemId: item.id,
|
||||||
|
state: item.state as import("@storybid/shared").ItemState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on("auctioneer_going_once", (itemId) => {
|
||||||
|
if (!isAuctioneer(socket.data.role)) return;
|
||||||
|
void transitionItem(itemId, "going_once");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("auctioneer_going_twice", (itemId) => {
|
||||||
|
if (!isAuctioneer(socket.data.role)) return;
|
||||||
|
void transitionItem(itemId, "going_twice");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("auctioneer_sold", (itemId) => {
|
||||||
|
if (!isAuctioneer(socket.data.role)) return;
|
||||||
|
void transitionItem(itemId, "sold");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("auctioneer_pass", (itemId) => {
|
||||||
|
if (!isAuctioneer(socket.data.role)) return;
|
||||||
|
void transitionItem(itemId, "passed");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Server, Socket } from "socket.io";
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
InterServerEvents,
|
||||||
|
SocketData,
|
||||||
|
OriginMode,
|
||||||
|
SyncStatus,
|
||||||
|
ItemState,
|
||||||
|
} from "@storybid/shared";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { placeBid } from "../services/bid-engine.js";
|
||||||
|
|
||||||
|
type IO = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||||
|
type Sock = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||||
|
|
||||||
|
export function registerSilentAuctionHandlers(io: IO, socket: Sock): void {
|
||||||
|
|
||||||
|
// ── Bidder: place a silent bid ──────────────────────────────────────────────
|
||||||
|
socket.on("place_silent_bid", async (payload) => {
|
||||||
|
const bidderId = socket.data.bidderId;
|
||||||
|
if (!bidderId) return;
|
||||||
|
|
||||||
|
const result = await placeBid({
|
||||||
|
itemId: payload.itemId,
|
||||||
|
bidderId,
|
||||||
|
amount: payload.amount,
|
||||||
|
originMode: "public",
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
clientSeq: payload.clientSeq,
|
||||||
|
clientCreatedAt: new Date(payload.clientCreatedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.warn(`[silent] rejected bid bidder=${bidderId} reason=${result.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await prisma.auctionItem.findUnique({
|
||||||
|
where: { id: payload.itemId },
|
||||||
|
include: { auction: { select: { eventId: true } } },
|
||||||
|
});
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const serializedBid = {
|
||||||
|
...result.bid,
|
||||||
|
amount: Number(result.bid.amount),
|
||||||
|
clientCreatedAt: result.bid.clientCreatedAt.toISOString(),
|
||||||
|
serverReceivedAt: result.bid.serverReceivedAt.toISOString(),
|
||||||
|
createdAt: result.bid.createdAt.toISOString(),
|
||||||
|
originMode: result.bid.originMode as OriginMode,
|
||||||
|
syncStatus: result.bid.syncStatus as SyncStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializedItem = {
|
||||||
|
...result.item,
|
||||||
|
fairMarketValue: result.item.fairMarketValue ? Number(result.item.fairMarketValue) : null,
|
||||||
|
openingBid: Number(result.item.openingBid),
|
||||||
|
reservePrice: result.item.reservePrice ? Number(result.item.reservePrice) : null,
|
||||||
|
currentHighBid: result.item.currentHighBid ? Number(result.item.currentHighBid) : null,
|
||||||
|
bidIncrement: Number(result.item.bidIncrement),
|
||||||
|
createdAt: result.item.createdAt.toISOString(),
|
||||||
|
updatedAt: result.item.updatedAt.toISOString(),
|
||||||
|
state: result.item.state as ItemState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Broadcast new high bid to everyone in the event room
|
||||||
|
io.to(`event:${item.auction.eventId}`).emit("silent_bid_accepted", {
|
||||||
|
bid: serializedBid,
|
||||||
|
item: serializedItem,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the previously winning bidder that they've been outbid.
|
||||||
|
// We find the second-highest bid for this item.
|
||||||
|
const previousBid = await prisma.bid.findFirst({
|
||||||
|
where: {
|
||||||
|
itemId: payload.itemId,
|
||||||
|
isWinning: false,
|
||||||
|
bidderId: { not: bidderId },
|
||||||
|
},
|
||||||
|
orderBy: { amount: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousBid) {
|
||||||
|
// Emit to a personal room for the outbid bidder (bidder joins `bidder:<id>` on connect)
|
||||||
|
io.to(`bidder:${previousBid.bidderId}`).emit("silent_outbid", {
|
||||||
|
itemId: payload.itemId,
|
||||||
|
yourBidderId: previousBid.bidderId,
|
||||||
|
newAmount: payload.amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sync offline outbox bids after reconnect ────────────────────────────────
|
||||||
|
socket.on("sync_outbox", async (bids) => {
|
||||||
|
const bidderId = socket.data.bidderId;
|
||||||
|
if (!bidderId || !bids.length) return;
|
||||||
|
|
||||||
|
const sorted = [...bids].sort((a, b) => a.clientSeq - b.clientSeq);
|
||||||
|
|
||||||
|
for (const entry of sorted) {
|
||||||
|
const result = await placeBid({
|
||||||
|
itemId: entry.itemId,
|
||||||
|
bidderId,
|
||||||
|
amount: entry.amount,
|
||||||
|
originMode: "offline_queue",
|
||||||
|
deviceId: entry.deviceId,
|
||||||
|
clientSeq: entry.clientSeq,
|
||||||
|
clientCreatedAt: new Date(entry.clientCreatedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.emit("bid_sync_result", {
|
||||||
|
localId: entry.localId,
|
||||||
|
accepted: result.ok,
|
||||||
|
...(result.ok
|
||||||
|
? {
|
||||||
|
bid: {
|
||||||
|
...result.bid,
|
||||||
|
amount: Number(result.bid.amount),
|
||||||
|
clientCreatedAt: result.bid.clientCreatedAt.toISOString(),
|
||||||
|
serverReceivedAt: result.bid.serverReceivedAt.toISOString(),
|
||||||
|
createdAt: result.bid.createdAt.toISOString(),
|
||||||
|
originMode: result.bid.originMode as OriginMode,
|
||||||
|
syncStatus: result.bid.syncStatus as SyncStatus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { error: result.error }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user