Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9087650e4c | ||
|
|
88e7325099 | ||
|
|
29b3d5d4e5 | ||
|
|
b3584e6c4d | ||
|
|
e203fb6c74 | ||
|
|
b51c1360e2 | ||
|
|
ef32d5a917 | ||
|
|
515707c9e4 | ||
|
|
a4945a87cf | ||
|
|
0b7f0a474e | ||
|
|
a70333f5e4 | ||
|
|
9cc330030b | ||
|
|
59acc32a46 | ||
|
|
183a2c99ed | ||
|
|
745af4184f | ||
|
|
279cd19308 | ||
|
|
02a12ee9e0 | ||
|
|
de791fd17d | ||
|
|
ed1d3a8c12 | ||
|
|
87bf9a6151 | ||
|
|
3f7b2d51db | ||
|
|
5970d707af | ||
|
|
e296e80e48 | ||
|
|
a0161a67d2 | ||
|
|
d06f80e572 | ||
|
|
be4dd5b47f | ||
|
|
e0f0b2f1e9 | ||
|
|
390e1dc0ea | ||
|
|
fa8686276d | ||
|
|
498d7d8a7d | ||
|
|
82857b5aa8 | ||
|
|
dafa3711f8 | ||
|
|
5b478c327d | ||
|
|
733d2459df | ||
|
|
4afa5d6fa0 | ||
|
|
617c884012 | ||
|
|
93476ac72a | ||
|
|
c4094630d9 | ||
|
|
6ec8b8400f | ||
|
|
9b2668f498 | ||
|
|
e38d14970d | ||
|
|
71c277045b | ||
|
|
c9a600b6d6 | ||
|
|
19461098a0 | ||
|
|
4772b85f3d | ||
|
|
74dcc8e5ac | ||
|
|
0d47156071 | ||
|
|
bd1043a3f4 | ||
|
|
75e317d73e | ||
|
|
d8c207c995 | ||
|
|
cb422332c8 | ||
|
|
8d5fbf18b4 | ||
|
|
78ffd0ae52 | ||
|
|
09bfb17f03 | ||
|
|
cacc52ec24 | ||
|
|
107cd9753e | ||
|
|
38526f17b5 | ||
|
|
194604004e | ||
|
|
f5fe87613f | ||
|
|
613bd7be1d | ||
|
|
f0e2de2834 | ||
|
|
a6840ea953 | ||
|
|
bb920ce6f0 | ||
|
|
c0315a705d | ||
|
|
fbc437a670 | ||
|
|
e0f0700398 | ||
|
|
defd9685e0 | ||
|
|
1ed62b0d3f | ||
|
|
91de6df04d | ||
|
|
a5d97d65e1 | ||
|
|
f0128f66b0 | ||
|
|
b33c68feb0 | ||
|
|
15655e3987 | ||
|
|
d8b3864411 | ||
|
|
194cbd6ca8 | ||
|
|
d5445c2282 | ||
|
|
dcfefc8371 | ||
|
|
b5f14335c4 | ||
|
|
d44ac949b5 | ||
|
|
a5e939221d | ||
|
|
833e3e5a9c | ||
|
|
ba1975dd6d | ||
|
|
3025ef3d21 | ||
|
|
8564f8af83 |
@@ -7,6 +7,7 @@ package-lock.json
|
||||
# Build outputs
|
||||
dist/
|
||||
.next/
|
||||
.next-build/
|
||||
out/
|
||||
build/
|
||||
|
||||
@@ -56,6 +57,10 @@ yarn-error.log*
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Go photo-api service
|
||||
photo-api/bin/
|
||||
photo-api/data/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
|
||||
@@ -4,17 +4,24 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
||||
|
||||
## Features
|
||||
|
||||
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
|
||||
- **User dashboard**: profile, tickets, payments, sessions/security
|
||||
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
|
||||
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
|
||||
- Stable `/next` and `/featured` URLs that redirect to the current event
|
||||
- Human-readable event URL slugs (with legacy-ID redirect support)
|
||||
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
|
||||
- Lightning invoice reuse and re-payment straight from the dashboard
|
||||
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
|
||||
- One unified ticket-creation modal with first-class payment status
|
||||
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
|
||||
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
|
||||
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
||||
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
|
||||
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
|
||||
- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
|
||||
- **Email**: `nodemailer` (SMTP) with optional provider config
|
||||
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, SWR, Heroicons
|
||||
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -22,6 +29,8 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- Go 1.26+ (for the `photo-api` service)
|
||||
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -29,12 +38,14 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
||||
npm install
|
||||
cp backend/.env.example backend/.env
|
||||
cp frontend/.env.example frontend/.env
|
||||
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
|
||||
```
|
||||
|
||||
### Initialize database (SQLite by default)
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
npm run db:migrate # backend (Drizzle) tables
|
||||
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
|
||||
```
|
||||
|
||||
### Run
|
||||
@@ -43,10 +54,13 @@ npm run db:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
|
||||
|
||||
Default URLs:
|
||||
|
||||
- Frontend: `http://localhost:3002`
|
||||
- Backend API: `http://localhost:3001`
|
||||
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
|
||||
- API docs: `http://localhost:3001/api-docs`
|
||||
|
||||
### First user becomes admin
|
||||
@@ -58,16 +72,37 @@ The first user to register becomes the **admin**. Register at `/register`.
|
||||
Run these from the repo root:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run dev # backend + frontend + photo-api
|
||||
npm run build # build all workspaces
|
||||
npm run start
|
||||
npm run db:generate
|
||||
npm run db:migrate
|
||||
npm run db:studio
|
||||
npm run db:export # Backup database
|
||||
npm run db:import # Restore from backup
|
||||
|
||||
# Photo service (Go)
|
||||
npm run dev:photos # go run ./cmd/photo-api
|
||||
npm run build:photos # go build -o bin/photo-api
|
||||
npm run migrate:photos # apply photos_* migrations
|
||||
npm run test:photos # go test ./...
|
||||
|
||||
# Move the photo library between storage backends (both must be configured
|
||||
# in photo-api/.env; the source is left untouched, reruns skip what is there)
|
||||
npm run sync:photos:to-s3 # local disk -> S3
|
||||
npm run sync:photos:to-local # S3 -> local disk
|
||||
npm run sync:photos -- to-s3 --dry-run # flags: --dry-run --overwrite
|
||||
# --concurrency=N --gallery=<id>
|
||||
|
||||
# Uploads are deduplicated per gallery by content hash. Photos uploaded before
|
||||
# that existed need hashing once (idempotent, deletes nothing):
|
||||
npm run backfill:photos:checksums
|
||||
```
|
||||
|
||||
After a sync, set `STORAGE_BACKEND=s3` (or `local`) in `photo-api/.env` and
|
||||
restart the service to serve from the new backend. See
|
||||
[`photo-api/README.md`](photo-api/README.md#move-the-library-between-backends).
|
||||
|
||||
You can also run per workspace:
|
||||
|
||||
```bash
|
||||
@@ -82,10 +117,23 @@ npm run dev --workspace=frontend
|
||||
Key settings (see `backend/.env.example` for the full list):
|
||||
|
||||
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
|
||||
- **Auth**: `JWT_SECRET` (change in production)
|
||||
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
|
||||
- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development.
|
||||
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
||||
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
||||
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
|
||||
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
|
||||
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
|
||||
|
||||
### Photo service (`photo-api/.env`)
|
||||
|
||||
Key settings (see `photo-api/.env.example`):
|
||||
|
||||
- **Port**: `PORT=3003` (dev)
|
||||
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
|
||||
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
|
||||
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
|
||||
- **Storage switch**: `STORAGE_BACKEND=auto|local|s3` (`auto` = S3 when it is configured). Keep both sides configured and flip this one line to move between them; `npm run sync:photos:to-s3` / `:to-local` copies the existing library first
|
||||
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
|
||||
|
||||
### Frontend (`frontend/.env`)
|
||||
|
||||
@@ -93,8 +141,10 @@ Key settings (see `frontend/.env.example`):
|
||||
|
||||
- **Server port**: `PORT=3002`
|
||||
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
|
||||
- Leave empty to use same-origin `/api` (recommended when running behind nginx)
|
||||
- In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend
|
||||
- Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port.
|
||||
- Inlined at build time: changing it requires a rebuild, not just a restart.
|
||||
- Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well.
|
||||
- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`).
|
||||
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
|
||||
|
||||
## Database
|
||||
@@ -142,12 +192,13 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
|
||||
|
||||
This repo includes example configs in `deploy/`:
|
||||
|
||||
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
|
||||
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
|
||||
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
|
||||
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
|
||||
- Backend needs write access to `backend/data` and `backend/uploads`
|
||||
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
|
||||
- **nginx**:
|
||||
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
|
||||
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
|
||||
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
|
||||
- `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
|
||||
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
|
||||
|
||||
Typical production flow:
|
||||
@@ -155,11 +206,93 @@ Typical production flow:
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
npm run build:photos
|
||||
npm run db:migrate
|
||||
npm run migrate:photos
|
||||
```
|
||||
|
||||
Then install/enable the systemd services and nginx configs for your server.
|
||||
|
||||
## Horizontal scaling
|
||||
|
||||
The backend can run as a single instance with zero extra configuration (the
|
||||
default), or as multiple replicas behind a load balancer. Scaling support is
|
||||
fully optional and backward compatible: if you set none of the variables below,
|
||||
the app behaves exactly as before with in-memory state and local-disk uploads.
|
||||
|
||||
### Requirements for multiple instances
|
||||
|
||||
- **Use PostgreSQL.** Set `DB_TYPE=postgres`. SQLite is a single local file and
|
||||
cannot be shared safely across instances.
|
||||
- **Set `REDIS_URL`.** This makes the following subsystems shared across
|
||||
instances instead of per process:
|
||||
- distributed cache
|
||||
- rate limiting (shared sliding/fixed window)
|
||||
- pub/sub for real-time payment events, so an SSE client connected to one
|
||||
instance still receives an event when the LNbits webhook lands on another
|
||||
- distributed locks (so only one instance seeds email templates per boot and
|
||||
only one instance polls LNbits per pending ticket)
|
||||
- the email hourly cap (`MAX_EMAILS_PER_HOUR`) becomes a global cap
|
||||
- **Tune the DB pool.** `DB_POOL_MAX` is the max Postgres connections per
|
||||
instance (default 10). Keep `DB_POOL_MAX * replicas` below the Postgres
|
||||
`max_connections` setting (default 100). For example, 5 replicas at
|
||||
`DB_POOL_MAX=15` uses up to 75 connections.
|
||||
|
||||
If Redis is configured but becomes unreachable at runtime, each subsystem
|
||||
degrades gracefully (rate limiter fails open, cache misses fall through to the
|
||||
DB, locks proceed) and the API keeps serving rather than crashing.
|
||||
|
||||
### Uploads across instances
|
||||
|
||||
Media uploads default to local disk (`./uploads`). With more than one instance
|
||||
you must use shared storage so a file uploaded on one instance is readable on
|
||||
the others. Two options:
|
||||
|
||||
- **S3-compatible storage (recommended):** set `S3_ENDPOINT`, `S3_BUCKET`,
|
||||
`S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (and optionally `S3_PUBLIC_URL`,
|
||||
`S3_REGION`, `S3_FORCE_PATH_STYLE`). Works with Garage, MinIO, or AWS S3.
|
||||
- **Shared volume:** mount the same `./uploads` directory (e.g. NFS) into every
|
||||
instance.
|
||||
|
||||
### Real-time payment SSE behind a load balancer
|
||||
|
||||
The payment status stream (`/api/lnbits/stream/:ticketId`) is a long-lived SSE
|
||||
connection. With Redis pub/sub enabled, any instance can deliver the payment
|
||||
event regardless of which instance holds the socket, so sticky sessions are not
|
||||
strictly required. Enabling sticky sessions (IP hash) for the SSE path is still
|
||||
a reasonable optimization.
|
||||
|
||||
### Health and observability
|
||||
|
||||
`GET /health` always returns 200 and reports Redis connectivity and which
|
||||
backend each subsystem selected, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"redis": { "enabled": true, "healthy": true },
|
||||
"backends": {
|
||||
"cache": "redis",
|
||||
"rateLimiter": "redis",
|
||||
"pubsub": "redis",
|
||||
"lock": "redis",
|
||||
"storage": "s3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The same selection is logged once at startup.
|
||||
|
||||
### docker-compose example (N replicas + Redis)
|
||||
|
||||
A ready-to-edit snippet lives at `deploy/docker-compose.scale.yml`. It runs
|
||||
Postgres, Redis, and the API scaled to multiple replicas behind nginx. Bring it
|
||||
up with:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Specs / notes**: `about/`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+91
-1
@@ -8,13 +8,83 @@ DATABASE_URL=./data/spanglish.db
|
||||
# For PostgreSQL
|
||||
# DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
|
||||
|
||||
# JWT Secret (change in production!)
|
||||
# Max PostgreSQL connections per instance (default 10). When running multiple
|
||||
# replicas, keep DB_POOL_MAX * replicas below the Postgres max_connections limit.
|
||||
# DB_POOL_MAX=10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Horizontal scaling (all optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leave everything below UNSET to run as a single instance with in-memory
|
||||
# backends and local-disk uploads (zero-config, identical to the original
|
||||
# behavior). Set them to run multiple API replicas behind a load balancer.
|
||||
#
|
||||
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
|
||||
# single local file and cannot be shared safely across instances.
|
||||
|
||||
# Redis connection URL. When set, the cache, rate limiter, login lockout,
|
||||
# pub/sub (real-time payment events), distributed locks, and the email hourly
|
||||
# cap are shared across all instances. When unset, each instance uses in-memory
|
||||
# equivalents.
|
||||
#
|
||||
# In production always set a password (requirepass on the server) and put it in
|
||||
# the URL. Use the rediss:// scheme for TLS (handled natively by the client),
|
||||
# and an optional /N path to select a DB index when sharing a Redis instance
|
||||
# with other applications.
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# REDIS_URL=redis://:your-redis-password@redis.internal:6379
|
||||
# REDIS_URL=rediss://:your-redis-password@redis.example.com:6380/1
|
||||
|
||||
# Optional S3-compatible object storage for media uploads (e.g. Garage, MinIO,
|
||||
# AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and
|
||||
# are shared across instances. When unset, uploads are written to ./uploads on
|
||||
# local disk (the default).
|
||||
# S3_ENDPOINT=https://garage.example.com
|
||||
# S3_REGION=garage
|
||||
# S3_BUCKET=spanglish-media
|
||||
# S3_ACCESS_KEY_ID=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# Public base URL used to build fileUrl for stored objects (CDN or web endpoint).
|
||||
# If unset, a path-style URL against S3_ENDPOINT/S3_BUCKET is used.
|
||||
# S3_PUBLIC_URL=https://media.example.com
|
||||
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
|
||||
# S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Better Auth session secret. REQUIRED in production, 32+ characters.
|
||||
# Generate one with: openssl rand -base64 48
|
||||
# Rotating it signs everyone out (cookie signatures invalidate).
|
||||
BETTER_AUTH_SECRET=
|
||||
|
||||
# Public site origin Better Auth builds its URLs against (falls back to
|
||||
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
|
||||
BETTER_AUTH_URL=
|
||||
|
||||
# Session cookie domain, shared across subdomains. Set this when the API is served
|
||||
# from a different host than the site (e.g. api.spanglishcommunity.com vs
|
||||
# spanglishcommunity.com): without it the cookie is host-only and the frontend's
|
||||
# Next middleware cannot see it, so /dashboard and /admin bounce back to /login.
|
||||
# Must start with a dot. Leave EMPTY in development (localhost is single-host).
|
||||
# E.g. .spanglishcommunity.com
|
||||
AUTH_COOKIE_DOMAIN=
|
||||
|
||||
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
|
||||
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
|
||||
# trusted; anything else is treated as a client and rate-limited by its
|
||||
# actual socket address.
|
||||
# TRUSTED_PROXIES=
|
||||
|
||||
# DEPRECATED: no longer used for API auth (Better Auth replaced the JWTs).
|
||||
# Still read by photo-api as the fallback secret for gallery view tokens
|
||||
# until PHOTO_VIEW_SECRET is set there; safe to remove after that.
|
||||
JWT_SECRET=your-super-secret-key-change-in-production
|
||||
|
||||
# Google OAuth (optional - for Google Sign-In)
|
||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||
# Note: The same Client ID should be used in frontend/.env
|
||||
GOOGLE_CLIENT_ID=
|
||||
# Only needed for the redirect OAuth flow; the Google Identity Services
|
||||
# button (ID token sign-in) works with the Client ID alone.
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
@@ -73,3 +143,23 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||
# If the limit is reached, queued emails will pause and resume automatically
|
||||
MAX_EMAILS_PER_HOUR=30
|
||||
|
||||
# Pending Booking Cleanup
|
||||
# Pending bookings whose payment is still unpaid (not awaiting admin approval)
|
||||
# are cancelled after this many minutes, freeing the seats (default: 30)
|
||||
PENDING_BOOKING_TTL_MINUTES=30
|
||||
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
|
||||
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000
|
||||
|
||||
# Auto-Hold Sweep
|
||||
# Bookings awaiting admin payment approval are put on hold (seat released) after
|
||||
# this many hours with no confirmation (default: 72)
|
||||
HOLD_THRESHOLD_HOURS=72
|
||||
# How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min)
|
||||
HOLD_SWEEP_INTERVAL_MS=900000
|
||||
|
||||
# Ended-Event Payment Sweep
|
||||
# Once an event is over, any unconfirmed payment (pending / pending_approval /
|
||||
# on_hold) is auto-rejected and its ticket cancelled. No email is sent.
|
||||
# How often this sweep runs, in milliseconds (default: 900000 = 15 min)
|
||||
EVENT_END_SWEEP_INTERVAL_MS=900000
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+11
-4
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
@@ -13,16 +14,20 @@
|
||||
"db:import": "tsx src/db/import.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@hono/node-server": "^1.11.4",
|
||||
"@hono/swagger-ui": "^0.4.0",
|
||||
"@hono/zod-openapi": "^0.14.4",
|
||||
"argon2": "^0.44.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"better-auth": "1.6.25",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.31.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.4.7",
|
||||
"ioredis": "^5.11.1",
|
||||
"jose": "^5.4.0",
|
||||
"moment-timezone": "^0.6.2",
|
||||
"nanoid": "^5.0.7",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pdfkit": "^0.17.2",
|
||||
@@ -38,8 +43,10 @@
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"drizzle-kit": "^0.22.8",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"ioredis-mock": "^8.13.1",
|
||||
"tsx": "^4.15.7",
|
||||
"typescript": "^5.5.2"
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
text as pgText,
|
||||
timestamp,
|
||||
boolean as pgBoolean,
|
||||
bigint,
|
||||
customType as pgCustomType,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// Better Auth table definitions for both dialects.
|
||||
//
|
||||
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
|
||||
// foreign key that references them) survive the auth migration untouched. Only
|
||||
// the columns Better Auth reads/writes are declared here; legacy columns
|
||||
// (password, google_id, token_version) stay physically present but invisible
|
||||
// to Better Auth. The full application-facing definition lives in schema.ts —
|
||||
// two Drizzle table objects can safely describe the same SQL table.
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
|
||||
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
|
||||
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
|
||||
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
|
||||
dataType() {
|
||||
return 'text';
|
||||
},
|
||||
toDriver(value: Date): string {
|
||||
return (value instanceof Date ? value : new Date(value)).toISOString();
|
||||
},
|
||||
fromDriver(value: string): Date {
|
||||
return new Date(value);
|
||||
},
|
||||
});
|
||||
|
||||
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
|
||||
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
|
||||
dataType() {
|
||||
return 'integer';
|
||||
},
|
||||
toDriver(value: boolean): number {
|
||||
return value ? 1 : 0;
|
||||
},
|
||||
fromDriver(value: number | boolean): boolean {
|
||||
return Boolean(value);
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== SQLite ====================
|
||||
|
||||
export const sqliteAuthUsers = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||
image: text('image'),
|
||||
createdAt: isoText('created_at').notNull(),
|
||||
updatedAt: isoText('updated_at').notNull(),
|
||||
// admin plugin fields
|
||||
role: text('role').notNull().default('user'),
|
||||
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
||||
// application additionalFields
|
||||
phone: text('phone'),
|
||||
languagePreference: text('language_preference'),
|
||||
rucNumber: text('ruc_number'),
|
||||
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
|
||||
accountStatus: text('account_status').notNull().default('active'),
|
||||
});
|
||||
|
||||
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
|
||||
token: text('token').notNull().unique(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
// admin plugin (impersonation)
|
||||
impersonatedBy: text('impersonated_by'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
|
||||
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
|
||||
id: text('id').primaryKey(),
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
|
||||
id: text('id').primaryKey(),
|
||||
key: text('key'),
|
||||
count: integer('count'),
|
||||
lastRequest: integer('last_request'),
|
||||
});
|
||||
|
||||
// ==================== PostgreSQL ====================
|
||||
|
||||
export const pgAuthUsers = pgTable('users', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
emailVerified: pgBoolean('email_verified').notNull().default(false),
|
||||
image: pgText('image'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
// admin plugin fields
|
||||
role: varchar('role', { length: 20 }).notNull().default('user'),
|
||||
banned: pgBoolean('banned').notNull().default(false),
|
||||
banReason: pgText('ban_reason'),
|
||||
banExpires: timestamp('ban_expires'),
|
||||
// application additionalFields
|
||||
phone: varchar('phone', { length: 50 }),
|
||||
languagePreference: varchar('language_preference', { length: 10 }),
|
||||
rucNumber: varchar('ruc_number', { length: 15 }),
|
||||
isClaimed: pgIntBool('is_claimed').notNull(),
|
||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||
});
|
||||
|
||||
export const pgAuthSessions = pgTable('auth_sessions', {
|
||||
id: uuid('id').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
|
||||
token: varchar('token', { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
ipAddress: varchar('ip_address', { length: 45 }),
|
||||
userAgent: pgText('user_agent'),
|
||||
// admin plugin (impersonation)
|
||||
impersonatedBy: uuid('impersonated_by'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgAuthAccounts = pgTable('auth_accounts', {
|
||||
id: uuid('id').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
|
||||
accountId: varchar('account_id', { length: 255 }).notNull(),
|
||||
providerId: varchar('provider_id', { length: 100 }).notNull(),
|
||||
accessToken: pgText('access_token'),
|
||||
refreshToken: pgText('refresh_token'),
|
||||
idToken: pgText('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||
scope: pgText('scope'),
|
||||
password: pgText('password'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgAuthVerifications = pgTable('auth_verifications', {
|
||||
id: uuid('id').primaryKey(),
|
||||
identifier: varchar('identifier', { length: 255 }).notNull(),
|
||||
value: pgText('value').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
|
||||
id: varchar('id', { length: 64 }).primaryKey(),
|
||||
key: varchar('key', { length: 255 }),
|
||||
count: bigint('count', { mode: 'number' }),
|
||||
lastRequest: bigint('last_request', { mode: 'number' }),
|
||||
});
|
||||
|
||||
// ==================== Runtime-switched exports ====================
|
||||
|
||||
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
|
||||
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
|
||||
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
|
||||
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
|
||||
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
|
||||
@@ -12,8 +12,11 @@ const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
|
||||
|
||||
if (dbType === 'postgres') {
|
||||
// Cap connections per instance so that, when running multiple replicas,
|
||||
// DB_POOL_MAX * replicas stays below the Postgres max_connections limit.
|
||||
const pool = new pg.Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
|
||||
max: Number(process.env.DB_POOL_MAX || 10),
|
||||
});
|
||||
db = drizzlePg(pool, { schema });
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
// Migration idempotency for the Better Auth backfill: seed legacy-shaped user
|
||||
// rows, run migrate repeatedly, and assert the backfill is correct and never
|
||||
// duplicates.
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-'));
|
||||
const dbPath = join(dir, 'migrate.db');
|
||||
|
||||
function runMigrate() {
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
|
||||
env: {
|
||||
...process.env,
|
||||
DB_TYPE: 'sqlite',
|
||||
DATABASE_URL: dbPath,
|
||||
REDIS_URL: '',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
beforeAll(() => {
|
||||
// First run creates the schema
|
||||
runMigrate();
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Seed legacy-shaped users (pre-Better-Auth): password lives on users,
|
||||
// google_id links Google, '' marks guest accounts, suspended via status.
|
||||
const now = new Date().toISOString();
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
||||
);
|
||||
insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now);
|
||||
insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now);
|
||||
insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now);
|
||||
insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now);
|
||||
insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now);
|
||||
insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now);
|
||||
|
||||
// Second run performs the backfill against the seeded rows
|
||||
runMigrate();
|
||||
}, 240_000);
|
||||
|
||||
describe('Better Auth migration backfill', () => {
|
||||
it('creates credential accounts for users with real passwords only', () => {
|
||||
const rows = db
|
||||
.prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id")
|
||||
.all() as any[];
|
||||
const byUser = new Map(rows.map((r) => [r.user_id, r.password]));
|
||||
|
||||
expect(byUser.get('legacy-argon')).toContain('$argon2id$');
|
||||
expect(byUser.get('legacy-bcrypt')).toContain('$2a$');
|
||||
expect(byUser.get('legacy-both')).toContain('$argon2id$');
|
||||
expect(byUser.get('legacy-suspended')).toBeTruthy();
|
||||
// Guests ('' password) and Google-only users get no credential account
|
||||
expect(byUser.has('legacy-guest')).toBe(false);
|
||||
expect(byUser.has('legacy-google')).toBe(false);
|
||||
});
|
||||
|
||||
it('creates google accounts from google_id', () => {
|
||||
const rows = db
|
||||
.prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id")
|
||||
.all() as any[];
|
||||
expect(rows).toEqual([
|
||||
{ user_id: 'legacy-both', account_id: 'google-sub-456' },
|
||||
{ user_id: 'legacy-google', account_id: 'google-sub-123' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks claimed legacy users email-verified, guests not', () => {
|
||||
const verified = (email: string) =>
|
||||
(db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified;
|
||||
expect(verified('argon@old.py')).toBe(1);
|
||||
expect(verified('google@old.py')).toBe(1);
|
||||
expect(verified('guest@old.py')).toBe(0);
|
||||
});
|
||||
|
||||
it('mirrors suspended accounts to banned', () => {
|
||||
const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any;
|
||||
expect(row.banned).toBe(1);
|
||||
expect(row.ban_reason).toContain('suspended');
|
||||
const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any;
|
||||
expect(active.banned).toBe(0);
|
||||
});
|
||||
|
||||
it('is idempotent: a third run adds nothing', () => {
|
||||
const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n;
|
||||
const before = count();
|
||||
runMigrate();
|
||||
expect(count()).toBe(before);
|
||||
}, 60_000);
|
||||
});
|
||||
+704
-6
@@ -1,11 +1,12 @@
|
||||
import 'dotenv/config';
|
||||
import { db, dbAll, events } from './index.js';
|
||||
import { sql, eq } from 'drizzle-orm';
|
||||
import { db, dbAll, dbGet, events, users, expenseCategories } from './index.js';
|
||||
import { sql, eq, ne } from 'drizzle-orm';
|
||||
import { uniqueSlug } from '../lib/slugify.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
console.log(`Database type: ${dbType}`);
|
||||
console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`);
|
||||
// Do not log DATABASE_URL: it may contain credentials.
|
||||
|
||||
async function migrate() {
|
||||
console.log('Running migrations...');
|
||||
@@ -43,6 +44,9 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Magic link tokens table
|
||||
await (db as any).run(sql`
|
||||
@@ -91,6 +95,8 @@ async function migrate() {
|
||||
banner_url TEXT,
|
||||
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
external_booking_url TEXT,
|
||||
presale_closure_enabled INTEGER,
|
||||
presale_close_minutes_before INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
@@ -104,6 +110,19 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN walk_in_price REAL`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add short description columns to events
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
|
||||
@@ -130,17 +149,25 @@ async function migrate() {
|
||||
`);
|
||||
|
||||
await (db as any).run(sql`
|
||||
-- Matches db/schema.ts. The legacy attendee_name / NOT NULL email+phone
|
||||
-- shape only survives in databases created before the split into
|
||||
-- first/last name, where the ALTERs below relaxed it; a fresh database
|
||||
-- must not recreate constraints the app no longer satisfies (door
|
||||
-- walk-ins have neither an email nor a phone).
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
event_id TEXT NOT NULL REFERENCES events(id),
|
||||
attendee_name TEXT NOT NULL,
|
||||
attendee_email TEXT NOT NULL,
|
||||
attendee_phone TEXT NOT NULL,
|
||||
attendee_first_name TEXT NOT NULL,
|
||||
attendee_last_name TEXT,
|
||||
attendee_email TEXT,
|
||||
attendee_phone TEXT,
|
||||
attendee_ruc TEXT,
|
||||
preferred_language TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
checkin_at TEXT,
|
||||
qr_code TEXT,
|
||||
admin_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
@@ -197,6 +224,18 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
|
||||
// backfilled from is_guest and the payments table on first run
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`);
|
||||
await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
|
||||
await (db as any).run(sql`
|
||||
UPDATE tickets SET payment_status = 'paid'
|
||||
WHERE is_guest = 0
|
||||
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
|
||||
`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||
|
||||
@@ -235,6 +274,49 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
// Door check-in screen: split pre-sale vs door revenue and record the tender
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Idempotency records for door check-in actions (retries / double taps)
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
undo_state TEXT,
|
||||
undone_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: tickets.booking_source (online | walk_in | admin). On first run,
|
||||
// backfill walk-ins created on the door screen: each one left an idempotency
|
||||
// record whose undo_state is {kind: 'created', ticketId}. Nothing else can be
|
||||
// identified reliably, so all other existing tickets stay 'online'.
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_source TEXT NOT NULL DEFAULT 'online'`);
|
||||
await (db as any).run(sql`
|
||||
UPDATE tickets SET booking_source = 'walk_in'
|
||||
WHERE id IN (
|
||||
SELECT json_extract(undo_state, '$.ticketId') FROM idempotency_keys
|
||||
WHERE scope = 'door-checkin' AND json_extract(undo_state, '$.kind') = 'created'
|
||||
)
|
||||
`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).run(sql`
|
||||
@@ -331,6 +413,14 @@ async function migrate() {
|
||||
} catch (e) { /* column may already exist */ }
|
||||
}
|
||||
|
||||
// POS card terminal at the door: on globally by default, NULL override = inherit
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -429,6 +519,18 @@ async function migrate() {
|
||||
)
|
||||
`);
|
||||
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS email_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
params TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
processed_at TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Site settings table
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS site_settings (
|
||||
@@ -447,6 +549,8 @@ async function migrate() {
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message TEXT,
|
||||
maintenance_message_es TEXT,
|
||||
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
)
|
||||
@@ -457,6 +561,14 @@ async function migrate() {
|
||||
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Pre-sale closure site-wide defaults
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Legal pages table for admin-editable legal content
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS legal_pages (
|
||||
@@ -508,6 +620,81 @@ async function migrate() {
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// ==================== Better Auth ====================
|
||||
// Better Auth core + admin plugin columns on the existing users table
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Better Auth sessions (replaces the legacy user_sessions table).
|
||||
// Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode).
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
impersonated_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth accounts: credential (password hash) and OAuth provider links
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
id_token TEXT,
|
||||
access_token_expires_at INTEGER,
|
||||
refresh_token_expires_at INTEGER,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth verification values (magic links, password reset tokens)
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_verifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth rate limiting (used when Redis is not configured)
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
id TEXT PRIMARY KEY,
|
||||
key TEXT,
|
||||
count INTEGER,
|
||||
last_request INTEGER
|
||||
)
|
||||
`);
|
||||
} else {
|
||||
// PostgreSQL migrations
|
||||
await (db as any).execute(sql`
|
||||
@@ -541,6 +728,9 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Magic link tokens table
|
||||
await (db as any).execute(sql`
|
||||
@@ -589,6 +779,8 @@ async function migrate() {
|
||||
banner_url VARCHAR(500),
|
||||
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
external_booking_url VARCHAR(500),
|
||||
presale_closure_enabled INTEGER,
|
||||
presale_close_minutes_before INTEGER,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
@@ -602,6 +794,19 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN walk_in_price DECIMAL(10, 2)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Add short description columns to events
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
|
||||
@@ -675,6 +880,18 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
|
||||
// backfilled from is_guest and the payments table on first run
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`);
|
||||
await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
|
||||
await (db as any).execute(sql`
|
||||
UPDATE tickets SET payment_status = 'paid'
|
||||
WHERE is_guest = 0
|
||||
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
|
||||
`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -701,6 +918,48 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
// Door check-in screen: split pre-sale vs door revenue and record the tender
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Idempotency records for door check-in actions (retries / double taps)
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key VARCHAR(128) PRIMARY KEY,
|
||||
scope VARCHAR(64) NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
undo_state TEXT,
|
||||
undone_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: tickets.booking_source (online | walk_in | admin), with the
|
||||
// same one-time walk-in backfill as the sqlite branch.
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_source VARCHAR(20) NOT NULL DEFAULT 'online'`);
|
||||
await (db as any).execute(sql`
|
||||
UPDATE tickets SET booking_source = 'walk_in'
|
||||
WHERE id::text IN (
|
||||
SELECT undo_state::json->>'ticketId' FROM idempotency_keys
|
||||
WHERE scope = 'door-checkin' AND undo_state IS NOT NULL
|
||||
AND undo_state::json->>'kind' = 'created'
|
||||
)
|
||||
`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).execute(sql`
|
||||
@@ -795,6 +1054,14 @@ async function migrate() {
|
||||
} catch (e) { /* column may already exist */ }
|
||||
}
|
||||
|
||||
// POS card terminal at the door: on globally by default, NULL override = inherit
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -893,6 +1160,18 @@ async function migrate() {
|
||||
)
|
||||
`);
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS email_queue (
|
||||
id UUID PRIMARY KEY,
|
||||
params TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
processed_at TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Site settings table
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS site_settings (
|
||||
@@ -911,6 +1190,8 @@ async function migrate() {
|
||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||
maintenance_message TEXT,
|
||||
maintenance_message_es TEXT,
|
||||
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
updated_by UUID REFERENCES users(id)
|
||||
)
|
||||
@@ -921,6 +1202,14 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Pre-sale closure site-wide defaults
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Legal pages table for admin-editable legal content
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS legal_pages (
|
||||
@@ -972,6 +1261,415 @@ async function migrate() {
|
||||
updated_by UUID REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// ==================== Better Auth ====================
|
||||
// Better Auth core + admin plugin columns on the existing users table
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Better Auth sessions (replaces the legacy user_sessions table)
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
impersonated_by UUID,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth accounts: credential (password hash) and OAuth provider links
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_accounts (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id VARCHAR(255) NOT NULL,
|
||||
provider_id VARCHAR(100) NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
id_token TEXT,
|
||||
access_token_expires_at TIMESTAMP,
|
||||
refresh_token_expires_at TIMESTAMP,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth verification values (magic links, password reset tokens)
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_verifications (
|
||||
id UUID PRIMARY KEY,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth rate limiting (used when Redis is not configured)
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
key VARCHAR(255),
|
||||
count BIGINT,
|
||||
last_request BIGINT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// ==================== Event finance, partners & team access ====================
|
||||
// New tables only, so one engine-neutral block with a type map instead of a
|
||||
// copy per branch. Money is whole PYG (INTEGER), percentages are basis points.
|
||||
const T = dbType === 'sqlite'
|
||||
? { id: 'TEXT', ts: 'TEXT', str: 'TEXT', text: 'TEXT' }
|
||||
: { id: 'UUID', ts: 'TIMESTAMP', str: 'VARCHAR(300)', text: 'TEXT' };
|
||||
const run = (stmt: string) =>
|
||||
dbType === 'sqlite' ? (db as any).run(sql.raw(stmt)) : (db as any).execute(sql.raw(stmt));
|
||||
|
||||
try {
|
||||
await run(`ALTER TABLE events ADD COLUMN series ${dbType === 'sqlite' ? 'TEXT' : 'VARCHAR(100)'}`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
const financeTables = [
|
||||
`CREATE TABLE IF NOT EXISTS expense_categories (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
name_en ${T.str} NOT NULL,
|
||||
name_es ${T.str} NOT NULL,
|
||||
color ${T.str} NOT NULL DEFAULT '#6B7280',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS expense_templates (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
name ${T.str} NOT NULL,
|
||||
category_id ${T.id} REFERENCES expense_categories(id),
|
||||
description ${T.text},
|
||||
calc_type ${T.str} NOT NULL,
|
||||
amount INTEGER NOT NULL DEFAULT 0,
|
||||
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||
minimum_amount INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS expense_template_packs (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
name ${T.str} NOT NULL,
|
||||
description ${T.text},
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS expense_template_pack_items (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
pack_id ${T.id} NOT NULL REFERENCES expense_template_packs(id),
|
||||
template_id ${T.id} NOT NULL REFERENCES expense_templates(id),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_partners (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||
user_id ${T.id} REFERENCES users(id),
|
||||
external_name ${T.str},
|
||||
role_label ${T.str},
|
||||
share_type ${T.str} NOT NULL,
|
||||
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||
fixed_amount INTEGER NOT NULL DEFAULT 0,
|
||||
threshold_amount INTEGER NOT NULL DEFAULT 0,
|
||||
loss_rule ${T.str} NOT NULL DEFAULT 'none',
|
||||
loss_cap_amount INTEGER NOT NULL DEFAULT 0,
|
||||
payout_status ${T.str} NOT NULL DEFAULT 'pending',
|
||||
payout_date ${T.ts},
|
||||
payout_method ${T.str},
|
||||
payout_note ${T.text},
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_expenses (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||
category_id ${T.id} REFERENCES expense_categories(id),
|
||||
template_id ${T.id} REFERENCES expense_templates(id),
|
||||
description ${T.str} NOT NULL,
|
||||
calc_type ${T.str} NOT NULL DEFAULT 'fixed',
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
unit_amount INTEGER NOT NULL DEFAULT 0,
|
||||
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||
minimum_amount INTEGER NOT NULL DEFAULT 0,
|
||||
computed_amount INTEGER NOT NULL DEFAULT 0,
|
||||
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||
status ${T.str} NOT NULL DEFAULT 'planned',
|
||||
paid_by_partner_id ${T.id} REFERENCES event_partners(id),
|
||||
receipt_url ${T.str},
|
||||
expense_date ${T.ts},
|
||||
created_by ${T.id} REFERENCES users(id),
|
||||
updated_by ${T.id} REFERENCES users(id),
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_other_income (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||
description ${T.str} NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
created_by ${T.id} REFERENCES users(id),
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS payment_method_fees (
|
||||
method ${T.str} PRIMARY KEY,
|
||||
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||
fixed_amount INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at ${T.ts} NOT NULL,
|
||||
updated_by ${T.id} REFERENCES users(id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_finance_state (
|
||||
event_id ${T.id} PRIMARY KEY REFERENCES events(id),
|
||||
status ${T.str} NOT NULL DEFAULT 'open',
|
||||
finalized_at ${T.ts},
|
||||
finalized_by ${T.id} REFERENCES users(id),
|
||||
snapshot_json ${T.text},
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_members (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||
user_id ${T.id} NOT NULL REFERENCES users(id),
|
||||
role_preset ${T.str} NOT NULL,
|
||||
permissions ${T.text} NOT NULL DEFAULT '{}',
|
||||
created_by ${T.id} REFERENCES users(id),
|
||||
created_at ${T.ts} NOT NULL,
|
||||
updated_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS finance_audit_log (
|
||||
id ${T.id} PRIMARY KEY,
|
||||
event_id ${T.id},
|
||||
actor_user_id ${T.id} REFERENCES users(id),
|
||||
entity_type ${T.str} NOT NULL,
|
||||
entity_id ${T.str},
|
||||
action ${T.str} NOT NULL,
|
||||
before_json ${T.text},
|
||||
after_json ${T.text},
|
||||
created_at ${T.ts} NOT NULL
|
||||
)`,
|
||||
];
|
||||
for (const stmt of financeTables) {
|
||||
await run(stmt);
|
||||
}
|
||||
|
||||
// Defaults, only when the tables are still empty so archived/deleted rows stay gone.
|
||||
const now = getNow();
|
||||
const categoryCount = await dbGet<any>(
|
||||
(db as any).select({ count: sql<number>`count(*)` }).from(sql`expense_categories`)
|
||||
);
|
||||
if (Number(categoryCount?.count || 0) === 0) {
|
||||
const defaults: [string, string, string][] = [
|
||||
['Venue', 'Lugar', '#2563EB'],
|
||||
['Instructor / host', 'Instructor / anfitrión', '#7C3AED'],
|
||||
['Food & drinks', 'Comida y bebidas', '#EA580C'],
|
||||
['Staff', 'Personal', '#0D9488'],
|
||||
['Marketing', 'Marketing', '#DB2777'],
|
||||
['Supplies', 'Materiales', '#CA8A04'],
|
||||
['Other', 'Otros', '#6B7280'],
|
||||
];
|
||||
for (const [i, [en, es, color]] of defaults.entries()) {
|
||||
await (db as any).insert(expenseCategories).values({
|
||||
id: generateId(), nameEn: en, nameEs: es, color, sortOrder: i, archived: dbType === 'sqlite' ? false : 0,
|
||||
createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
console.log('Seeded default expense categories.');
|
||||
}
|
||||
// One fee row per payments.provider in use; 0% until an admin sets them.
|
||||
for (const method of ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos']) {
|
||||
await run(`INSERT INTO payment_method_fees (method, percent_bp, fixed_amount, updated_at)
|
||||
VALUES ('${method}', 0, 0, ${dbType === 'sqlite' ? `'${new Date().toISOString()}'` : 'NOW()'})
|
||||
ON CONFLICT (method) DO NOTHING`);
|
||||
}
|
||||
|
||||
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
||||
const indexStatements = [
|
||||
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS tickets_user_id_idx ON tickets(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS tickets_booking_id_idx ON tickets(booking_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS payments_source_idx ON payments(source)`,
|
||||
`CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx ON idempotency_keys(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
|
||||
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS auth_accounts_user_id_idx ON auth_accounts(user_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
|
||||
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_expenses_event_id_idx ON event_expenses(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_other_income_event_id_idx ON event_other_income(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_partners_event_id_idx ON event_partners(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_partners_user_id_idx ON event_partners(user_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS event_members_event_user_idx ON event_members(event_id, user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS event_members_user_id_idx ON event_members(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS expense_template_pack_items_pack_id_idx ON expense_template_pack_items(pack_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS finance_audit_log_event_id_idx ON finance_audit_log(event_id, created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS events_series_idx ON events(series)`,
|
||||
];
|
||||
for (const stmt of indexStatements) {
|
||||
try {
|
||||
if (dbType === 'sqlite') {
|
||||
await (db as any).run(sql.raw(stmt));
|
||||
} else {
|
||||
await (db as any).execute(sql.raw(stmt));
|
||||
}
|
||||
} catch (e) { /* index may already exist */ }
|
||||
}
|
||||
|
||||
// ==================== Better Auth data backfill ====================
|
||||
// Idempotent: every statement is guarded so re-running migrate is safe, and
|
||||
// legacy users are distinguished from Better-Auth-created users by having
|
||||
// users.password / users.google_id set (Better Auth never writes either).
|
||||
if (dbType === 'sqlite') {
|
||||
// Legacy password hashes -> credential accounts (argon2 and bcrypt hashes
|
||||
// both stay valid via the custom password verifier in lib/betterAuth.ts)
|
||||
await (db as any).run(sql`
|
||||
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
|
||||
SELECT lower(hex(randomblob(16))), u.id, u.id, 'credential', u.password,
|
||||
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
|
||||
FROM users u
|
||||
WHERE u.password IS NOT NULL AND u.password != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
|
||||
)
|
||||
`);
|
||||
|
||||
// Legacy Google links -> google provider accounts
|
||||
await (db as any).run(sql`
|
||||
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
|
||||
SELECT lower(hex(randomblob(16))), u.id, u.google_id, 'google',
|
||||
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
|
||||
FROM users u
|
||||
WHERE u.google_id IS NOT NULL AND u.google_id != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
|
||||
)
|
||||
`);
|
||||
|
||||
// Claimed legacy accounts proved their email (register/claim link/Google)
|
||||
await (db as any).run(sql`
|
||||
UPDATE users SET email_verified = 1
|
||||
WHERE email_verified = 0 AND is_claimed = 1
|
||||
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
|
||||
`);
|
||||
|
||||
// Suspended -> banned (admin plugin field); users.ts keeps them in sync
|
||||
await (db as any).run(sql`
|
||||
UPDATE users SET banned = 1, ban_reason = 'migrated: account suspended'
|
||||
WHERE account_status = 'suspended' AND banned = 0
|
||||
`);
|
||||
} else {
|
||||
await (db as any).execute(sql`
|
||||
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), u.id, u.id::text, 'credential', u.password, NOW(), NOW()
|
||||
FROM users u
|
||||
WHERE u.password IS NOT NULL AND u.password != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
|
||||
)
|
||||
`);
|
||||
|
||||
await (db as any).execute(sql`
|
||||
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), u.id, u.google_id, 'google', NOW(), NOW()
|
||||
FROM users u
|
||||
WHERE u.google_id IS NOT NULL AND u.google_id != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
|
||||
)
|
||||
`);
|
||||
|
||||
await (db as any).execute(sql`
|
||||
UPDATE users SET email_verified = TRUE
|
||||
WHERE email_verified = FALSE AND is_claimed = 1
|
||||
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
|
||||
`);
|
||||
|
||||
await (db as any).execute(sql`
|
||||
UPDATE users SET banned = TRUE, ban_reason = 'migrated: account suspended'
|
||||
WHERE account_status = 'suspended' AND banned = FALSE
|
||||
`);
|
||||
}
|
||||
|
||||
// ==================== users.email normalization ====================
|
||||
// Better Auth lowercases the address on every lookup and write it performs,
|
||||
// but the users.email unique index is case-sensitive on both dialects. Rows
|
||||
// written outside Better Auth (guest bookings, door sales, admin-added
|
||||
// tickets) used to keep the address exactly as typed, so a buyer who entered
|
||||
// "John@Gmail.com" was invisible to sign-in and to Google account linking:
|
||||
// signing in with Google minted a SECOND user row and left their tickets
|
||||
// stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this
|
||||
// fixes the rows already in the table.
|
||||
//
|
||||
// Idempotent, and deliberately conservative: a row is only lowercased when
|
||||
// nothing already occupies the lowercase address. A genuine collision means
|
||||
// two user rows for the same person, each with its own tickets, invoices and
|
||||
// payments — merging those is a judgement call, not a migration, so they are
|
||||
// reported for manual review instead.
|
||||
const lowercaseEmailsSql = `
|
||||
UPDATE users SET email = LOWER(email)
|
||||
WHERE email <> LOWER(email)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email)
|
||||
)
|
||||
`;
|
||||
if (dbType === 'sqlite') {
|
||||
await (db as any).run(sql.raw(lowercaseEmailsSql));
|
||||
} else {
|
||||
await (db as any).execute(sql.raw(lowercaseEmailsSql));
|
||||
}
|
||||
|
||||
// Whatever still differs from its own lowercase form is exactly the set the
|
||||
// UPDATE refused to touch, i.e. the collisions.
|
||||
const collisions = await dbAll<{ id: string; email: string }>(
|
||||
(db as any)
|
||||
.select({ id: (users as any).id, email: (users as any).email })
|
||||
.from(users)
|
||||
.where(ne((users as any).email, sql`LOWER(${(users as any).email})`))
|
||||
);
|
||||
if (collisions.length > 0) {
|
||||
console.warn(
|
||||
`WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` +
|
||||
`lowercase address is already taken. Sign-in and Google linking only ever reach ` +
|
||||
`the lowercase row, so these need a manual merge:`
|
||||
);
|
||||
for (const row of collisions) {
|
||||
const canonical = row.email.toLowerCase();
|
||||
const existing = await dbGet<{ id: string }>(
|
||||
(db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical))
|
||||
);
|
||||
console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
||||
|
||||
+429
-4
@@ -1,5 +1,5 @@
|
||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
|
||||
|
||||
// Type to determine which schema to use
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
@@ -18,6 +18,14 @@ export const sqliteUsers = sqliteTable('users', {
|
||||
googleId: text('google_id'),
|
||||
rucNumber: text('ruc_number'),
|
||||
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
||||
tokenVersion: integer('token_version').notNull().default(0),
|
||||
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
|
||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||
image: text('image'),
|
||||
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
@@ -74,12 +82,20 @@ export const sqliteEvents = sqliteTable('events', {
|
||||
location: text('location').notNull(),
|
||||
locationUrl: text('location_url'),
|
||||
price: real('price').notNull().default(0),
|
||||
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
|
||||
// Admin/staff only: never serialized to public event responses.
|
||||
walkInPrice: real('walk_in_price'),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
capacity: integer('capacity').notNull().default(50),
|
||||
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
||||
bannerUrl: text('banner_url'),
|
||||
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
externalBookingUrl: text('external_booking_url'),
|
||||
// Pre-sale closure: null = inherit the site_settings default
|
||||
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }),
|
||||
presaleCloseMinutesBefore: integer('presale_close_minutes_before'),
|
||||
// Groups recurring events (e.g. "Morning Club") for the cross-event finance overview
|
||||
series: text('series'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
@@ -102,33 +118,63 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
attendeePhone: text('attendee_phone'),
|
||||
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
|
||||
preferredLanguage: text('preferred_language'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'),
|
||||
checkinAt: text('checkin_at'),
|
||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||
qrCode: text('qr_code'),
|
||||
adminNote: text('admin_note'),
|
||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
|
||||
// How the booking was made: public checkout, a walk-in on the door screen, or
|
||||
// added by an admin. Distinct from payments.source, which is where money was taken.
|
||||
bookingSource: text('booking_source', { enum: ['online', 'walk_in', 'admin'] }).notNull().default('online'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqlitePayments = sqliteTable('payments', {
|
||||
id: text('id').primaryKey(),
|
||||
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
|
||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago', 'pos'] }).notNull(),
|
||||
amount: real('amount').notNull(),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
||||
reference: text('reference'),
|
||||
lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
|
||||
lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires
|
||||
lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
|
||||
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
||||
payerName: text('payer_name'), // Name of payer if different from attendee
|
||||
paidAt: text('paid_at'),
|
||||
paidByAdminId: text('paid_by_admin_id'),
|
||||
adminNote: text('admin_note'), // Internal admin notes
|
||||
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
|
||||
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
|
||||
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
|
||||
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
|
||||
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
|
||||
// 'guest' is a zero-amount comp entry and carries no revenue.
|
||||
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Idempotency records for door check-in actions.
|
||||
//
|
||||
// The door screen fires check-ins / walk-in creations optimistically and retries
|
||||
// on flaky venue wifi, so every action carries a client-generated key. The first
|
||||
// request stores its response here; replays return that stored response instead
|
||||
// of creating a second ticket, payment or check-in. `undoState` holds exactly
|
||||
// what the action changed so the 10-second Undo can reverse it precisely.
|
||||
export const sqliteIdempotencyKeys = sqliteTable('idempotency_keys', {
|
||||
key: text('key').primaryKey(),
|
||||
scope: text('scope').notNull(),
|
||||
result: text('result').notNull(), // JSON response body of the original request
|
||||
undoState: text('undo_state'), // JSON describing how to reverse the action
|
||||
undoneAt: text('undone_at'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
// Payment Options Configuration Table (global settings)
|
||||
export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -156,6 +202,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
||||
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
cashInstructions: text('cash_instructions'),
|
||||
cashInstructionsEs: text('cash_instructions_es'),
|
||||
// POS card terminal at the door (Scanner only, never offered at online checkout)
|
||||
posEnabled: integer('pos_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
|
||||
// Metadata
|
||||
@@ -188,6 +236,7 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
|
||||
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
|
||||
cashInstructions: text('cash_instructions'),
|
||||
cashInstructionsEs: text('cash_instructions_es'),
|
||||
posEnabled: integer('pos_enabled', { mode: 'boolean' }),
|
||||
// Metadata
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
@@ -271,6 +320,18 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Durable email queue. Jobs survive process restarts; a startup recovery step
|
||||
// resets any 'processing' rows back to 'pending'.
|
||||
export const sqliteEmailQueue = sqliteTable('email_queue', {
|
||||
id: text('id').primaryKey(),
|
||||
params: text('params').notNull(), // JSON-encoded TemplateEmailJobParams
|
||||
status: text('status', { enum: ['pending', 'processing', 'sent', 'failed'] }).notNull().default('pending'),
|
||||
attempts: integer('attempts').notNull().default(0),
|
||||
lastError: text('last_error'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
processedAt: text('processed_at'),
|
||||
});
|
||||
|
||||
// Legal Pages table for admin-editable legal content
|
||||
export const sqliteLegalPages = sqliteTable('legal_pages', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -340,11 +401,160 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
|
||||
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
|
||||
maintenanceMessage: text('maintenance_message'),
|
||||
maintenanceMessageEs: text('maintenance_message_es'),
|
||||
// Pre-sale closure defaults (events inherit these unless they override)
|
||||
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
presaleCloseMinutesBefore: integer('presale_close_minutes_before').notNull().default(120),
|
||||
// Metadata
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
});
|
||||
|
||||
// ==================== Event finance (SQLite) ====================
|
||||
// All money columns are whole PYG (integer). Percentages are stored in basis
|
||||
// points (hundredths of a percent): 290 = 2.90%. JSON columns are text.
|
||||
|
||||
export const sqliteExpenseCategories = sqliteTable('expense_categories', {
|
||||
id: text('id').primaryKey(),
|
||||
nameEn: text('name_en').notNull(),
|
||||
nameEs: text('name_es').notNull(),
|
||||
color: text('color').notNull().default('#6B7280'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteExpenseTemplates = sqliteTable('expense_templates', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
categoryId: text('category_id').references(() => sqliteExpenseCategories.id),
|
||||
description: text('description'),
|
||||
// fixed | per_ticket_sold | per_checked_in | percent_of_revenue | minimum_spend
|
||||
calcType: text('calc_type').notNull(),
|
||||
amount: integer('amount').notNull().default(0),
|
||||
percentBp: integer('percent_bp').notNull().default(0),
|
||||
minimumAmount: integer('minimum_amount').notNull().default(0),
|
||||
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteExpenseTemplatePacks = sqliteTable('expense_template_packs', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteExpenseTemplatePackItems = sqliteTable('expense_template_pack_items', {
|
||||
id: text('id').primaryKey(),
|
||||
packId: text('pack_id').notNull().references(() => sqliteExpenseTemplatePacks.id),
|
||||
templateId: text('template_id').notNull().references(() => sqliteExpenseTemplates.id),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
});
|
||||
|
||||
export const sqliteEventPartners = sqliteTable('event_partners', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
userId: text('user_id').references(() => sqliteUsers.id),
|
||||
externalName: text('external_name'),
|
||||
roleLabel: text('role_label'),
|
||||
// percent_profit | percent_revenue | fixed | fixed_plus_percent_above_threshold
|
||||
shareType: text('share_type').notNull(),
|
||||
percentBp: integer('percent_bp').notNull().default(0),
|
||||
fixedAmount: integer('fixed_amount').notNull().default(0),
|
||||
thresholdAmount: integer('threshold_amount').notNull().default(0),
|
||||
// proportional | none | capped
|
||||
lossRule: text('loss_rule').notNull().default('none'),
|
||||
lossCapAmount: integer('loss_cap_amount').notNull().default(0),
|
||||
payoutStatus: text('payout_status').notNull().default('pending'),
|
||||
payoutDate: text('payout_date'),
|
||||
payoutMethod: text('payout_method'),
|
||||
payoutNote: text('payout_note'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteEventExpenses = sqliteTable('event_expenses', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
categoryId: text('category_id').references(() => sqliteExpenseCategories.id),
|
||||
templateId: text('template_id').references(() => sqliteExpenseTemplates.id),
|
||||
description: text('description').notNull(),
|
||||
calcType: text('calc_type').notNull().default('fixed'),
|
||||
// fixed: units bought; auto types: the count used at the last calculation
|
||||
quantity: integer('quantity').notNull().default(1),
|
||||
unitAmount: integer('unit_amount').notNull().default(0),
|
||||
percentBp: integer('percent_bp').notNull().default(0),
|
||||
minimumAmount: integer('minimum_amount').notNull().default(0),
|
||||
computedAmount: integer('computed_amount').notNull().default(0),
|
||||
// Locked rows keep computed_amount instead of following ticket counts
|
||||
isLocked: integer('is_locked', { mode: 'boolean' }).notNull().default(false),
|
||||
status: text('status').notNull().default('planned'), // planned | paid
|
||||
// NULL = the organization paid; otherwise the partner who fronted it
|
||||
paidByPartnerId: text('paid_by_partner_id').references(() => sqliteEventPartners.id),
|
||||
receiptUrl: text('receipt_url'),
|
||||
expenseDate: text('expense_date'),
|
||||
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteEventOtherIncome = sqliteTable('event_other_income', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
description: text('description').notNull(),
|
||||
amount: integer('amount').notNull(),
|
||||
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Keyed by payments.provider (door payments carry the legacy provider too)
|
||||
export const sqlitePaymentMethodFees = sqliteTable('payment_method_fees', {
|
||||
method: text('method').primaryKey(),
|
||||
percentBp: integer('percent_bp').notNull().default(0),
|
||||
fixedAmount: integer('fixed_amount').notNull().default(0),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||
});
|
||||
|
||||
export const sqliteEventFinanceState = sqliteTable('event_finance_state', {
|
||||
eventId: text('event_id').primaryKey().references(() => sqliteEvents.id),
|
||||
status: text('status').notNull().default('open'), // open | finalized | paid_out
|
||||
finalizedAt: text('finalized_at'),
|
||||
finalizedBy: text('finalized_by').references(() => sqliteUsers.id),
|
||||
snapshotJson: text('snapshot_json'),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteEventMembers = sqliteTable('event_members', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||
rolePreset: text('role_preset').notNull(), // staff | collaborator | co_manager
|
||||
// JSON {permissionKey: boolean} applied on top of the preset
|
||||
permissions: text('permissions').notNull().default('{}'),
|
||||
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const sqliteFinanceAuditLog = sqliteTable('finance_audit_log', {
|
||||
id: text('id').primaryKey(),
|
||||
eventId: text('event_id'), // NULL for global finance settings
|
||||
actorUserId: text('actor_user_id').references(() => sqliteUsers.id),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id'),
|
||||
action: text('action').notNull(),
|
||||
beforeJson: text('before_json'),
|
||||
afterJson: text('after_json'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
// ==================== PostgreSQL Schema ====================
|
||||
export const pgUsers = pgTable('users', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -359,6 +569,14 @@ export const pgUsers = pgTable('users', {
|
||||
googleId: varchar('google_id', { length: 255 }),
|
||||
rucNumber: varchar('ruc_number', { length: 15 }),
|
||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
||||
tokenVersion: pgInteger('token_version').notNull().default(0),
|
||||
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
|
||||
emailVerified: pgBoolean('email_verified').notNull().default(false),
|
||||
image: pgText('image'),
|
||||
banned: pgBoolean('banned').notNull().default(false),
|
||||
banReason: pgText('ban_reason'),
|
||||
banExpires: timestamp('ban_expires'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
@@ -415,12 +633,19 @@ export const pgEvents = pgTable('events', {
|
||||
location: varchar('location', { length: 500 }).notNull(),
|
||||
locationUrl: varchar('location_url', { length: 500 }),
|
||||
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
|
||||
// Admin/staff only: never serialized to public event responses.
|
||||
walkInPrice: decimal('walk_in_price', { precision: 10, scale: 2 }),
|
||||
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||
capacity: pgInteger('capacity').notNull().default(50),
|
||||
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
||||
bannerUrl: varchar('banner_url', { length: 500 }),
|
||||
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
|
||||
externalBookingUrl: varchar('external_booking_url', { length: 500 }),
|
||||
// Pre-sale closure: null = inherit the site_settings default
|
||||
presaleClosureEnabled: pgInteger('presale_closure_enabled'),
|
||||
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before'),
|
||||
series: varchar('series', { length: 100 }),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
@@ -449,6 +674,10 @@ export const pgTickets = pgTable('tickets', {
|
||||
qrCode: varchar('qr_code', { length: 255 }),
|
||||
adminNote: pgText('admin_note'),
|
||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
|
||||
// online | walk_in | admin — see sqliteTickets.bookingSource
|
||||
bookingSource: varchar('booking_source', { length: 20 }).notNull().default('online'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -460,16 +689,35 @@ export const pgPayments = pgTable('payments', {
|
||||
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||
reference: varchar('reference', { length: 255 }),
|
||||
lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
|
||||
lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires
|
||||
lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
|
||||
userMarkedPaidAt: timestamp('user_marked_paid_at'),
|
||||
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
|
||||
paidAt: timestamp('paid_at'),
|
||||
paidByAdminId: uuid('paid_by_admin_id'),
|
||||
adminNote: pgText('admin_note'),
|
||||
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
|
||||
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
|
||||
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
|
||||
source: varchar('source', { length: 20 }).notNull().default('presale'),
|
||||
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
|
||||
// 'guest' is a zero-amount comp entry and carries no revenue.
|
||||
method: varchar('method', { length: 20 }),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Idempotency records for door check-in actions (see sqliteIdempotencyKeys).
|
||||
export const pgIdempotencyKeys = pgTable('idempotency_keys', {
|
||||
key: varchar('key', { length: 128 }).primaryKey(),
|
||||
scope: varchar('scope', { length: 64 }).notNull(),
|
||||
result: pgText('result').notNull(),
|
||||
undoState: pgText('undo_state'),
|
||||
undoneAt: timestamp('undone_at'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
// Payment Options Configuration Table (global settings)
|
||||
export const pgPaymentOptions = pgTable('payment_options', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -493,6 +741,7 @@ export const pgPaymentOptions = pgTable('payment_options', {
|
||||
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
|
||||
cashInstructions: pgText('cash_instructions'),
|
||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||
posEnabled: pgInteger('pos_enabled').notNull().default(1),
|
||||
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
@@ -522,6 +771,7 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
|
||||
cashEnabled: pgInteger('cash_enabled'),
|
||||
cashInstructions: pgText('cash_instructions'),
|
||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||
posEnabled: pgInteger('pos_enabled'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
@@ -604,6 +854,18 @@ export const pgEmailSettings = pgTable('email_settings', {
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// Durable email queue. Jobs survive process restarts; a startup recovery step
|
||||
// resets any 'processing' rows back to 'pending'.
|
||||
export const pgEmailQueue = pgTable('email_queue', {
|
||||
id: uuid('id').primaryKey(),
|
||||
params: pgText('params').notNull(), // JSON-encoded TemplateEmailJobParams
|
||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||
attempts: pgInteger('attempts').notNull().default(0),
|
||||
lastError: pgText('last_error'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
processedAt: timestamp('processed_at'),
|
||||
});
|
||||
|
||||
// Legal Pages table for admin-editable legal content
|
||||
export const pgLegalPages = pgTable('legal_pages', {
|
||||
id: uuid('id').primaryKey(),
|
||||
@@ -673,17 +935,159 @@ export const pgSiteSettings = pgTable('site_settings', {
|
||||
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
|
||||
maintenanceMessage: pgText('maintenance_message'),
|
||||
maintenanceMessageEs: pgText('maintenance_message_es'),
|
||||
// Pre-sale closure defaults (events inherit these unless they override)
|
||||
presaleClosureEnabled: pgInteger('presale_closure_enabled').notNull().default(1),
|
||||
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before').notNull().default(120),
|
||||
// Metadata
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
});
|
||||
|
||||
// ==================== Event finance (PostgreSQL) ====================
|
||||
// See the SQLite block: integer PYG, basis-point percentages, JSON as text.
|
||||
|
||||
export const pgExpenseCategories = pgTable('expense_categories', {
|
||||
id: uuid('id').primaryKey(),
|
||||
nameEn: varchar('name_en', { length: 100 }).notNull(),
|
||||
nameEs: varchar('name_es', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 20 }).notNull().default('#6B7280'),
|
||||
sortOrder: pgInteger('sort_order').notNull().default(0),
|
||||
archived: pgInteger('archived').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgExpenseTemplates = pgTable('expense_templates', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
categoryId: uuid('category_id').references(() => pgExpenseCategories.id),
|
||||
description: pgText('description'),
|
||||
calcType: varchar('calc_type', { length: 40 }).notNull(),
|
||||
amount: pgInteger('amount').notNull().default(0),
|
||||
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||
minimumAmount: pgInteger('minimum_amount').notNull().default(0),
|
||||
archived: pgInteger('archived').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgExpenseTemplatePacks = pgTable('expense_template_packs', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
description: pgText('description'),
|
||||
archived: pgInteger('archived').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgExpenseTemplatePackItems = pgTable('expense_template_pack_items', {
|
||||
id: uuid('id').primaryKey(),
|
||||
packId: uuid('pack_id').notNull().references(() => pgExpenseTemplatePacks.id),
|
||||
templateId: uuid('template_id').notNull().references(() => pgExpenseTemplates.id),
|
||||
sortOrder: pgInteger('sort_order').notNull().default(0),
|
||||
});
|
||||
|
||||
export const pgEventPartners = pgTable('event_partners', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
userId: uuid('user_id').references(() => pgUsers.id),
|
||||
externalName: varchar('external_name', { length: 200 }),
|
||||
roleLabel: varchar('role_label', { length: 100 }),
|
||||
shareType: varchar('share_type', { length: 40 }).notNull(),
|
||||
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||
fixedAmount: pgInteger('fixed_amount').notNull().default(0),
|
||||
thresholdAmount: pgInteger('threshold_amount').notNull().default(0),
|
||||
lossRule: varchar('loss_rule', { length: 20 }).notNull().default('none'),
|
||||
lossCapAmount: pgInteger('loss_cap_amount').notNull().default(0),
|
||||
payoutStatus: varchar('payout_status', { length: 20 }).notNull().default('pending'),
|
||||
payoutDate: timestamp('payout_date'),
|
||||
payoutMethod: varchar('payout_method', { length: 50 }),
|
||||
payoutNote: pgText('payout_note'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgEventExpenses = pgTable('event_expenses', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
categoryId: uuid('category_id').references(() => pgExpenseCategories.id),
|
||||
templateId: uuid('template_id').references(() => pgExpenseTemplates.id),
|
||||
description: varchar('description', { length: 300 }).notNull(),
|
||||
calcType: varchar('calc_type', { length: 40 }).notNull().default('fixed'),
|
||||
quantity: pgInteger('quantity').notNull().default(1),
|
||||
unitAmount: pgInteger('unit_amount').notNull().default(0),
|
||||
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||
minimumAmount: pgInteger('minimum_amount').notNull().default(0),
|
||||
computedAmount: pgInteger('computed_amount').notNull().default(0),
|
||||
isLocked: pgInteger('is_locked').notNull().default(0),
|
||||
status: varchar('status', { length: 20 }).notNull().default('planned'),
|
||||
paidByPartnerId: uuid('paid_by_partner_id').references(() => pgEventPartners.id),
|
||||
receiptUrl: varchar('receipt_url', { length: 500 }),
|
||||
expenseDate: timestamp('expense_date'),
|
||||
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgEventOtherIncome = pgTable('event_other_income', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
description: varchar('description', { length: 300 }).notNull(),
|
||||
amount: pgInteger('amount').notNull(),
|
||||
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgPaymentMethodFees = pgTable('payment_method_fees', {
|
||||
method: varchar('method', { length: 50 }).primaryKey(),
|
||||
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||
fixedAmount: pgInteger('fixed_amount').notNull().default(0),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||
});
|
||||
|
||||
export const pgEventFinanceState = pgTable('event_finance_state', {
|
||||
eventId: uuid('event_id').primaryKey().references(() => pgEvents.id),
|
||||
status: varchar('status', { length: 20 }).notNull().default('open'),
|
||||
finalizedAt: timestamp('finalized_at'),
|
||||
finalizedBy: uuid('finalized_by').references(() => pgUsers.id),
|
||||
snapshotJson: pgText('snapshot_json'),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgEventMembers = pgTable('event_members', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||
rolePreset: varchar('role_preset', { length: 20 }).notNull(),
|
||||
permissions: pgText('permissions').notNull().default('{}'),
|
||||
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgFinanceAuditLog = pgTable('finance_audit_log', {
|
||||
id: uuid('id').primaryKey(),
|
||||
eventId: uuid('event_id'),
|
||||
actorUserId: uuid('actor_user_id').references(() => pgUsers.id),
|
||||
entityType: varchar('entity_type', { length: 50 }).notNull(),
|
||||
// Text, not uuid: settings rows are keyed by method name
|
||||
entityId: varchar('entity_id', { length: 100 }),
|
||||
action: varchar('action', { length: 50 }).notNull(),
|
||||
beforeJson: pgText('before_json'),
|
||||
afterJson: pgText('after_json'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
// Export the appropriate schema based on DB_TYPE
|
||||
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
||||
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
||||
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
|
||||
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
|
||||
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
|
||||
export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys;
|
||||
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
|
||||
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
|
||||
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
|
||||
@@ -691,6 +1095,7 @@ export const auditLogs = dbType === 'postgres' ? pgAuditLogs : sqliteAuditLogs;
|
||||
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
|
||||
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
|
||||
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
|
||||
export const emailQueue = dbType === 'postgres' ? pgEmailQueue : sqliteEmailQueue;
|
||||
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
|
||||
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
|
||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||
@@ -700,6 +1105,17 @@ export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLeg
|
||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
||||
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
|
||||
export const expenseCategories = dbType === 'postgres' ? pgExpenseCategories : sqliteExpenseCategories;
|
||||
export const expenseTemplates = dbType === 'postgres' ? pgExpenseTemplates : sqliteExpenseTemplates;
|
||||
export const expenseTemplatePacks = dbType === 'postgres' ? pgExpenseTemplatePacks : sqliteExpenseTemplatePacks;
|
||||
export const expenseTemplatePackItems = dbType === 'postgres' ? pgExpenseTemplatePackItems : sqliteExpenseTemplatePackItems;
|
||||
export const eventPartners = dbType === 'postgres' ? pgEventPartners : sqliteEventPartners;
|
||||
export const eventExpenses = dbType === 'postgres' ? pgEventExpenses : sqliteEventExpenses;
|
||||
export const eventOtherIncome = dbType === 'postgres' ? pgEventOtherIncome : sqliteEventOtherIncome;
|
||||
export const paymentMethodFees = dbType === 'postgres' ? pgPaymentMethodFees : sqlitePaymentMethodFees;
|
||||
export const eventFinanceState = dbType === 'postgres' ? pgEventFinanceState : sqliteEventFinanceState;
|
||||
export const eventMembers = dbType === 'postgres' ? pgEventMembers : sqliteEventMembers;
|
||||
export const financeAuditLog = dbType === 'postgres' ? pgFinanceAuditLog : sqliteFinanceAuditLog;
|
||||
|
||||
// Type exports
|
||||
export type User = typeof sqliteUsers.$inferSelect;
|
||||
@@ -734,3 +1150,12 @@ export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
|
||||
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
||||
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
|
||||
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
|
||||
export type ExpenseCategory = typeof sqliteExpenseCategories.$inferSelect;
|
||||
export type ExpenseTemplate = typeof sqliteExpenseTemplates.$inferSelect;
|
||||
export type ExpenseTemplatePack = typeof sqliteExpenseTemplatePacks.$inferSelect;
|
||||
export type EventPartner = typeof sqliteEventPartners.$inferSelect;
|
||||
export type EventExpense = typeof sqliteEventExpenses.$inferSelect;
|
||||
export type EventOtherIncome = typeof sqliteEventOtherIncome.$inferSelect;
|
||||
export type PaymentMethodFee = typeof sqlitePaymentMethodFees.$inferSelect;
|
||||
export type EventFinanceState = typeof sqliteEventFinanceState.$inferSelect;
|
||||
export type EventMember = typeof sqliteEventMembers.$inferSelect;
|
||||
|
||||
+377
-132
@@ -7,9 +7,14 @@ import { logger } from 'hono/logger';
|
||||
import { swaggerUI } from '@hono/swagger-ui';
|
||||
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import { auth } from './lib/betterAuth.js';
|
||||
import authExtRoutes from './routes/authExt.js';
|
||||
import { getClientIp } from './lib/rateLimit.js';
|
||||
import eventsRoutes from './routes/events.js';
|
||||
import ticketsRoutes from './routes/tickets.js';
|
||||
import doorRoutes from './routes/door.js';
|
||||
import eventFinanceRoutes from './routes/eventFinance.js';
|
||||
import financeRoutes from './routes/finance.js';
|
||||
import usersRoutes from './routes/users.js';
|
||||
import contactsRoutes from './routes/contacts.js';
|
||||
import paymentsRoutes from './routes/payments.js';
|
||||
@@ -24,7 +29,13 @@ import legalPagesRoutes from './routes/legal-pages.js';
|
||||
import legalSettingsRoutes from './routes/legal-settings.js';
|
||||
import faqRoutes from './routes/faq.js';
|
||||
import emailService from './lib/email.js';
|
||||
import { initEmailQueue } from './lib/emailQueue.js';
|
||||
import { initEmailQueue, stopQueue } from './lib/emailQueue.js';
|
||||
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
|
||||
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
|
||||
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
|
||||
import { closeRedis } from './lib/redis.js';
|
||||
import { getLock } from './lib/stores/lock.js';
|
||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -51,11 +62,24 @@ app.use(
|
||||
if (!origin) return frontendUrl;
|
||||
return allowedOrigins.has(origin) ? origin : null;
|
||||
},
|
||||
// We use bearer tokens, but keeping credentials=true matches nginx config.
|
||||
// Session cookies must be allowed on cross-origin API calls (api.* vhost).
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Baseline security headers on every response.
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
app.use('*', async (c, next) => {
|
||||
await next();
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
c.header('X-Frame-Options', 'DENY');
|
||||
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
c.header('X-XSS-Protection', '0');
|
||||
if (isProduction) {
|
||||
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
});
|
||||
|
||||
// OpenAPI specification
|
||||
const openApiSpec = {
|
||||
openapi: '3.0.0',
|
||||
@@ -91,11 +115,15 @@ const openApiSpec = {
|
||||
],
|
||||
paths: {
|
||||
// ==================== Auth Endpoints ====================
|
||||
'/api/auth/register': {
|
||||
// Authentication is handled by Better Auth, mounted at /api/auth/*.
|
||||
// Sessions are httpOnly cookies (spanglish.session_token); the endpoints
|
||||
// below are the subset the frontend uses. See https://better-auth.com/docs
|
||||
// for the full endpoint reference.
|
||||
'/api/auth/sign-up/email': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Register a new user',
|
||||
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
|
||||
summary: 'Register a new user (Better Auth)',
|
||||
description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -105,8 +133,8 @@ const openApiSpec = {
|
||||
required: ['email', 'password', 'name'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
|
||||
name: { type: 'string', minLength: 2 },
|
||||
password: { type: 'string', minLength: 10 },
|
||||
name: { type: 'string' },
|
||||
phone: { type: 'string' },
|
||||
languagePreference: { type: 'string', enum: ['en', 'es'] },
|
||||
},
|
||||
@@ -115,16 +143,17 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: 'User created successfully' },
|
||||
400: { description: 'Email already registered or validation error' },
|
||||
200: { description: 'User created; session cookie set' },
|
||||
422: { description: 'Email already registered' },
|
||||
400: { description: 'Validation error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/login': {
|
||||
'/api/auth/sign-in/email': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login with email and password',
|
||||
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
|
||||
summary: 'Login with email and password (Better Auth)',
|
||||
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -141,42 +170,46 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Login successful, returns JWT token' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
401: { description: 'Invalid credentials' },
|
||||
429: { description: 'Too many login attempts' },
|
||||
429: { description: 'Too many attempts' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/google': {
|
||||
'/api/auth/sign-in/social': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login or register with Google',
|
||||
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
|
||||
summary: 'Login or register with Google (Better Auth)',
|
||||
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['credential'],
|
||||
required: ['provider'],
|
||||
properties: {
|
||||
credential: { type: 'string', description: 'Google ID token' },
|
||||
provider: { type: 'string', enum: ['google'] },
|
||||
idToken: {
|
||||
type: 'object',
|
||||
properties: { token: { type: 'string', description: 'Google ID token' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Login successful' },
|
||||
400: { description: 'Invalid Google token' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
401: { description: 'Invalid Google token' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/magic-link/request': {
|
||||
'/api/auth/sign-in/magic-link': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request magic link login',
|
||||
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
|
||||
summary: 'Request magic link login (Better Auth)',
|
||||
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -186,46 +219,33 @@ const openApiSpec = {
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
callbackURL: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Magic link sent (if account exists)' },
|
||||
},
|
||||
responses: { 200: { description: 'Magic link sent (if account exists)' } },
|
||||
},
|
||||
},
|
||||
'/api/auth/magic-link/verify': {
|
||||
post: {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Verify magic link token',
|
||||
description: 'Verify the magic link token and login user.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
summary: 'Verify magic link token (Better Auth)',
|
||||
parameters: [
|
||||
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: 'Login successful' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
400: { description: 'Invalid or expired token' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/password-reset/request': {
|
||||
'/api/auth/request-password-reset': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request password reset',
|
||||
description: 'Send a password reset link to email. Link expires in 30 minutes.',
|
||||
summary: 'Request password reset (Better Auth)',
|
||||
description: 'Emails a reset link. Token expires in 30 minutes.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -235,31 +255,30 @@ const openApiSpec = {
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
redirectTo: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Reset link sent (if account exists)' },
|
||||
responses: { 200: { description: 'Reset link sent (if account exists)' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/password-reset/confirm': {
|
||||
'/api/auth/reset-password': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Confirm password reset',
|
||||
description: 'Reset password using the token from email.',
|
||||
summary: 'Reset password with token (Better Auth)',
|
||||
description: 'Sets a new password and revokes all existing sessions.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token', 'password'],
|
||||
required: ['newPassword', 'token'],
|
||||
properties: {
|
||||
newPassword: { type: 'string', minLength: 10 },
|
||||
token: { type: 'string' },
|
||||
password: { type: 'string', minLength: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -271,62 +290,10 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/claim-account/request': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request account claim link',
|
||||
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Claim link sent (if unclaimed account exists)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/claim-account/confirm': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Confirm account claim',
|
||||
description: 'Claim an unclaimed account by setting password or linking Google.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
|
||||
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Account claimed successfully' },
|
||||
400: { description: 'Invalid token or missing credentials' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/change-password': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Change password',
|
||||
description: 'Change password for authenticated user.',
|
||||
summary: 'Change password (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -338,6 +305,7 @@ const openApiSpec = {
|
||||
properties: {
|
||||
currentPassword: { type: 'string' },
|
||||
newPassword: { type: 'string', minLength: 10 },
|
||||
revokeOtherSessions: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -350,27 +318,67 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/me': {
|
||||
'/api/auth/get-session': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Get current user',
|
||||
description: 'Get the currently authenticated user profile.',
|
||||
summary: 'Get current session and user (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
200: { description: 'Current user data' },
|
||||
401: { description: 'Unauthorized' },
|
||||
200: { description: '{ session, user } or null when not authenticated' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/logout': {
|
||||
'/api/auth/sign-out': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Logout',
|
||||
description: 'Logout current user (client-side token removal).',
|
||||
responses: {
|
||||
200: { description: 'Logged out' },
|
||||
summary: 'Logout (Better Auth)',
|
||||
description: 'Revokes the current session and clears the session cookie.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'Logged out' } },
|
||||
},
|
||||
},
|
||||
'/api/auth/list-sessions': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'List active sessions (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'Active sessions for the current user' } },
|
||||
},
|
||||
},
|
||||
'/api/auth-ext/claim-account': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Claim a guest-created account',
|
||||
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['password'],
|
||||
properties: { password: { type: 'string', minLength: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Account claimed successfully' },
|
||||
400: { description: 'Already claimed or validation error' },
|
||||
401: { description: 'No session (claim link required)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth-ext/claim-eligibility': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Check whether an email has an unclaimed account',
|
||||
parameters: [
|
||||
{ name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } },
|
||||
],
|
||||
responses: { 200: { description: '{ canClaim: boolean }' } },
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== User Dashboard Endpoints ====================
|
||||
@@ -657,11 +665,21 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/events/next': {
|
||||
get: {
|
||||
tags: ['Events'],
|
||||
summary: 'Get next upcoming event (chronological)',
|
||||
description: 'Get the single earliest upcoming published event, ignoring featured promotion.',
|
||||
responses: {
|
||||
200: { description: 'Next event or null' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/events/next/upcoming': {
|
||||
get: {
|
||||
tags: ['Events'],
|
||||
summary: 'Get next upcoming event',
|
||||
description: 'Get the single next upcoming published event.',
|
||||
description: 'Get the featured event if valid, otherwise the single next upcoming published event.',
|
||||
responses: {
|
||||
200: { description: 'Next event or null' },
|
||||
},
|
||||
@@ -752,6 +770,119 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// ==================== Door Check-in Screen ====================
|
||||
'/api/events/{eventId}/door-attendees': {
|
||||
get: {
|
||||
tags: ['Tickets'],
|
||||
summary: 'Full attendee list for the door check-in screen',
|
||||
description: 'One payload the door screen searches entirely client-side. Includes cancelled tickets so staff can see and reactivate them.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: 'Event, attendees and check-in stats' },
|
||||
404: { description: 'Event not found' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/events/{eventId}/door-checkin': {
|
||||
post: {
|
||||
tags: ['Tickets'],
|
||||
summary: 'Check in, settle payment, or create a walk-in (atomic)',
|
||||
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. The server prices the charge from the event: walk-ins pay the walk-in price (or the ticket price when none is set) x quantity, existing tickets the ticket price x quantity. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['idempotencyKey'],
|
||||
properties: {
|
||||
ticketId: { type: 'string' },
|
||||
attendee: {
|
||||
type: 'object',
|
||||
required: ['firstName'],
|
||||
properties: {
|
||||
firstName: { type: 'string' },
|
||||
lastName: { type: 'string' },
|
||||
phone: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
ruc: { type: 'string' },
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
type: 'object',
|
||||
required: ['method'],
|
||||
properties: {
|
||||
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] },
|
||||
quantity: { type: 'integer', minimum: 1, maximum: 50, description: 'Tickets paid for in one go (defaults to 1). The server multiplies it by the resolved unit price.' },
|
||||
amount: { type: 'number', description: 'Ignored unless amountOverride is true.' },
|
||||
amountOverride: { type: 'boolean', description: 'Charge `amount` instead of the computed price. Admin/organizer only; written to audit_logs.' },
|
||||
},
|
||||
},
|
||||
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
|
||||
idempotencyKey: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
|
||||
200: { description: 'Replay of an already-processed idempotencyKey' },
|
||||
400: { description: 'Ticket belongs to a different event, or the payment method is not enabled for this event' },
|
||||
403: { description: 'amountOverride sent by a role that may not override the door amount' },
|
||||
404: { description: 'Event or ticket not found' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/events/{eventId}/door-checkin/undo': {
|
||||
post: {
|
||||
tags: ['Tickets'],
|
||||
summary: 'Reverse one door check-in action',
|
||||
description: 'Reverts exactly what the keyed action did: restores the previous check-in and payment state, or cancels a ticket that was created at the door.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['idempotencyKey'],
|
||||
properties: { idempotencyKey: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Action reversed (or already undone)' },
|
||||
404: { description: 'No action recorded for this key' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/events/{eventId}/door-summary': {
|
||||
get: {
|
||||
tags: ['Payments'],
|
||||
summary: 'Door cash-up and pre-sale/door revenue split',
|
||||
description: 'Totals per door tender (cash, bitcoin, transfer, guest) for end-of-night reconciliation, plus the pre-sale versus door revenue split shown on the event dashboard.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: 'Door totals by method, door lines, and pre-sale totals' },
|
||||
404: { description: 'Event not found' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/tickets/{id}/checkin': {
|
||||
post: {
|
||||
tags: ['Tickets'],
|
||||
@@ -1733,10 +1864,10 @@ const openApiSpec = {
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT token obtained from login endpoint',
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: 'spanglish.session_token',
|
||||
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
@@ -1827,6 +1958,9 @@ const openApiSpec = {
|
||||
},
|
||||
};
|
||||
|
||||
// API documentation is disabled in production to avoid exposing the full API
|
||||
// surface (and schema) to anonymous users. Enable locally / in non-prod only.
|
||||
if (!isProduction) {
|
||||
// OpenAPI JSON endpoint
|
||||
app.get('/openapi.json', (c) => {
|
||||
return c.json(openApiSpec);
|
||||
@@ -1834,17 +1968,67 @@ app.get('/openapi.json', (c) => {
|
||||
|
||||
// Swagger UI
|
||||
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
|
||||
} else {
|
||||
app.get('/openapi.json', (c) => c.json({ error: 'Not Found' }, 404));
|
||||
app.get('/api-docs', (c) => c.json({ error: 'Not Found' }, 404));
|
||||
}
|
||||
|
||||
// Static file serving for uploads
|
||||
// Static file serving for uploads.
|
||||
// Uploads are validated as images at write time, but as defense-in-depth we force
|
||||
// any non-image path to download as an opaque attachment so a stray/legacy
|
||||
// .html/.svg can never be rendered (and therefore never execute script) in-origin.
|
||||
app.use('/uploads/*', async (c, next) => {
|
||||
await next();
|
||||
const path = c.req.path.toLowerCase();
|
||||
const isInlineImage = /\.(jpg|jpeg|png|gif|webp|avif)$/.test(path);
|
||||
if (!isInlineImage) {
|
||||
c.header('Content-Disposition', 'attachment');
|
||||
c.header('Content-Type', 'application/octet-stream');
|
||||
}
|
||||
});
|
||||
app.use('/uploads/*', serveStatic({ root: './' }));
|
||||
|
||||
// Health check
|
||||
// Health check.
|
||||
// Always returns 200 so a transient Redis blip does not cause the load balancer
|
||||
// to pull a node; Redis/subsystem status is reported in the body for monitoring.
|
||||
app.get('/health', (c) => {
|
||||
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
redis: describeRedis(),
|
||||
backends: describeBackends(),
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.route('/api/auth', authRoutes);
|
||||
// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link,
|
||||
// password reset, Google, session management). CORS above runs first.
|
||||
//
|
||||
// Better Auth only sees the Request (no TCP peer address), so its per-IP rate
|
||||
// limiting is fed the socket-anchored client IP resolved by getClientIp via a
|
||||
// private header. The inbound value is always discarded — a client cannot
|
||||
// choose its own rate-limit bucket.
|
||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.delete('x-client-ip');
|
||||
const clientIp = getClientIp(c);
|
||||
if (clientIp && clientIp !== 'unknown') {
|
||||
headers.set('x-client-ip', clientIp);
|
||||
}
|
||||
return auth.handler(
|
||||
new Request(c.req.raw, {
|
||||
headers,
|
||||
// Node's fetch requires duplex for requests carrying a body stream
|
||||
...(c.req.raw.body ? { duplex: 'half' as const } : {}),
|
||||
} as RequestInit)
|
||||
);
|
||||
});
|
||||
app.route('/api/auth-ext', authExtRoutes);
|
||||
// Door check-in screen endpoints live under /api/events/:eventId/door-*.
|
||||
// Mounted first so the generic /:id routes below can never shadow them.
|
||||
app.route('/api/events', doorRoutes);
|
||||
// Per-event finance, partners and team access (/api/events/:id/finance, /expenses, /members, ...)
|
||||
app.route('/api/events', eventFinanceRoutes);
|
||||
app.route('/api/events', eventsRoutes);
|
||||
app.route('/api/tickets', ticketsRoutes);
|
||||
app.route('/api/users', usersRoutes);
|
||||
@@ -1860,6 +2044,7 @@ app.route('/api/site-settings', siteSettingsRoutes);
|
||||
app.route('/api/legal-pages', legalPagesRoutes);
|
||||
app.route('/api/legal-settings', legalSettingsRoutes);
|
||||
app.route('/api/faq', faqRoutes);
|
||||
app.route('/api/finance', financeRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.notFound((c) => {
|
||||
@@ -1877,8 +2062,29 @@ const port = parseInt(process.env.PORT || '3001');
|
||||
// Initialize email queue with the email service reference
|
||||
initEmailQueue(emailService);
|
||||
|
||||
// Initialize email templates on startup
|
||||
emailService.seedDefaultTemplates().catch(err => {
|
||||
// Periodically expire abandoned pending bookings so they stop holding seats.
|
||||
startBookingCleanup();
|
||||
|
||||
// Periodically put stale pending-approval payments on hold, releasing their seats.
|
||||
startHoldSweep();
|
||||
|
||||
// Periodically auto-reject unconfirmed payments once their event is over (no email).
|
||||
startEventEndSweep();
|
||||
|
||||
// Initialize email templates on startup.
|
||||
// Guarded by a distributed lock so that, when running multiple replicas, only
|
||||
// one instance seeds/updates templates per boot instead of all of them racing.
|
||||
// onUnavailable 'run': at boot the Redis connection may not be ready yet, and
|
||||
// seeding is upsert-idempotent, so racing replicas are safe — never skipping
|
||||
// beats never seeding on a first boot during a Redis blip.
|
||||
getLock()
|
||||
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' })
|
||||
.then((result) => {
|
||||
if (result === null) {
|
||||
console.log('[Email] Template seeding skipped (another instance holds the lock)');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[Email] Failed to seed templates:', err);
|
||||
});
|
||||
|
||||
@@ -1886,7 +2092,46 @@ console.log(`🚀 Spanglish API server starting on port ${port}`);
|
||||
console.log(`📚 API docs available at http://localhost:${port}/api-docs`);
|
||||
console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
|
||||
|
||||
serve({
|
||||
// Log which backend (memory/redis, local/s3) each subsystem selected.
|
||||
logSelectedBackends();
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
});
|
||||
|
||||
// Graceful shutdown: stop the periodic jobs, stop accepting connections, then
|
||||
// close Redis and exit. Open SSE payment streams hold sockets forever, so
|
||||
// server.close() alone never completes — force-close remaining connections
|
||||
// after a short grace period, with a hard exit as the final backstop.
|
||||
let shuttingDown = false;
|
||||
function shutdown(signal: string): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`[shutdown] ${signal} received, draining...`);
|
||||
|
||||
stopBookingCleanup();
|
||||
stopHoldSweep();
|
||||
stopEventEndSweep();
|
||||
stopQueue();
|
||||
|
||||
server.close(() => {
|
||||
console.log('[shutdown] server closed, closing redis');
|
||||
closeRedis().finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
const forceClose = setTimeout(() => {
|
||||
console.warn('[shutdown] force-closing remaining connections (SSE streams)');
|
||||
(server as any).closeAllConnections?.();
|
||||
}, 5_000);
|
||||
forceClose.unref();
|
||||
|
||||
const forceExit = setTimeout(() => {
|
||||
console.warn('[shutdown] drain timed out, forcing exit');
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
forceExit.unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
+95
-222
@@ -1,234 +1,78 @@
|
||||
import * as jose from 'jose';
|
||||
import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { Context } from 'hono';
|
||||
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||
import { eq, and, gt } from 'drizzle-orm';
|
||||
import { generateId, getNow, toDbDate } from './utils.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { auth } from './betterAuth.js';
|
||||
import { db, dbGet } from '../db/index.js';
|
||||
import { authAccounts } from '../db/auth-schema.js';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
|
||||
const JWT_ISSUER = 'spanglish';
|
||||
const JWT_AUDIENCE = 'spanglish-app';
|
||||
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
|
||||
// sessions validated against the auth_sessions table on every request, so
|
||||
// revocation (ban/suspend/password reset) applies instantly. This module keeps
|
||||
// the request-side helpers that the route files use.
|
||||
|
||||
export interface JWTPayload {
|
||||
sub: string;
|
||||
// Re-exported for routes that hash/validate passwords outside Better Auth
|
||||
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
role: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
languagePreference: string | null;
|
||||
isClaimed: boolean;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
/** ID of the Better Auth session backing this request. */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Password hashing with Argon2 (spec requirement)
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||
if (hash.startsWith('$argon2')) {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
// Legacy bcrypt support
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Generate secure random token for magic links
|
||||
export function generateSecureToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Create magic link token
|
||||
export async function createMagicLinkToken(
|
||||
userId: string,
|
||||
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
|
||||
expiresInMinutes: number = 10
|
||||
): Promise<string> {
|
||||
const token = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
|
||||
|
||||
await (db as any).insert(magicLinkTokens).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
token,
|
||||
type,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Verify and consume magic link token
|
||||
export async function verifyMagicLinkToken(
|
||||
token: string,
|
||||
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
|
||||
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const now = getNow();
|
||||
|
||||
const tokenRecord = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(magicLinkTokens)
|
||||
.where(
|
||||
and(
|
||||
eq((magicLinkTokens as any).token, token),
|
||||
eq((magicLinkTokens as any).type, type)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (!tokenRecord) {
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
}
|
||||
|
||||
if (tokenRecord.usedAt) {
|
||||
return { valid: false, error: 'Token already used' };
|
||||
}
|
||||
|
||||
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
||||
return { valid: false, error: 'Token expired' };
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
await (db as any)
|
||||
.update(magicLinkTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
|
||||
|
||||
return { valid: true, userId: tokenRecord.userId };
|
||||
}
|
||||
|
||||
// Create user session
|
||||
export async function createUserSession(
|
||||
userId: string,
|
||||
userAgent?: string,
|
||||
ipAddress?: string
|
||||
): Promise<string> {
|
||||
const sessionToken = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
|
||||
|
||||
await (db as any).insert(userSessions).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
token: sessionToken,
|
||||
userAgent: userAgent || null,
|
||||
ipAddress: ipAddress || null,
|
||||
lastActiveAt: now,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
// Get user's active sessions
|
||||
export async function getUserSessions(userId: string) {
|
||||
const now = getNow();
|
||||
|
||||
return dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).userId, userId),
|
||||
gt((userSessions as any).expiresAt, now)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate a specific session
|
||||
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
|
||||
const result = await (db as any)
|
||||
.delete(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).id, sessionId),
|
||||
eq((userSessions as any).userId, userId)
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Invalidate all user sessions (logout everywhere)
|
||||
export async function invalidateAllUserSessions(userId: string): Promise<void> {
|
||||
await (db as any)
|
||||
.delete(userSessions)
|
||||
.where(eq((userSessions as any).userId, userId));
|
||||
}
|
||||
|
||||
// Password validation (min 10 characters per spec)
|
||||
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||
if (password.length < 10) {
|
||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export async function createToken(userId: string, email: string, role: string): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, email, role })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setAudience(JWT_AUDIENCE)
|
||||
.setExpirationTime('7d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function createRefreshToken(userId: string): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setExpirationTime('30d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
/**
|
||||
* Resolve the authenticated user for a request from its Better Auth session
|
||||
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
|
||||
* accounts never get API access even with a live session cookie.
|
||||
*/
|
||||
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
});
|
||||
return payload as unknown as JWTPayload;
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = session.user as any;
|
||||
|
||||
// Suspended (banned) or unclaimed accounts must not retain API access
|
||||
if (user.banned) {
|
||||
return null;
|
||||
}
|
||||
if (user.accountStatus && user.accountStatus !== 'active') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
phone: user.phone ?? null,
|
||||
role: user.role ?? 'user',
|
||||
languagePreference: user.languagePreference ?? null,
|
||||
isClaimed: Boolean(user.isClaimed),
|
||||
rucNumber: user.rucNumber ?? null,
|
||||
accountStatus: user.accountStatus ?? 'active',
|
||||
emailVerified: Boolean(user.emailVerified),
|
||||
image: user.image ?? null,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
sessionId: session.session.id,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthUser(c: Context): Promise<any | null> {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
|
||||
);
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export function requireAuth(roles?: string[]) {
|
||||
return async (c: Context, next: () => Promise<void>) => {
|
||||
const user = await getAuthUser(c);
|
||||
@@ -246,9 +90,38 @@ export function requireAuth(roles?: string[]) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function isFirstUser(): Promise<boolean> {
|
||||
const result = await dbAll(
|
||||
(db as any).select().from(users).limit(1)
|
||||
/**
|
||||
* Fetch only the credential password hash (never exposed via getAuthUser).
|
||||
* Returns null when the user has no password set (Google-only or unclaimed).
|
||||
*/
|
||||
export async function getUserPasswordHash(userId: string): Promise<string | null> {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ password: (authAccounts as any).password })
|
||||
.from(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, userId),
|
||||
eq((authAccounts as any).providerId, 'credential')
|
||||
)
|
||||
)
|
||||
);
|
||||
return !result || result.length === 0;
|
||||
const hash = row?.password;
|
||||
return hash && String(hash).length > 0 ? String(hash) : null;
|
||||
}
|
||||
|
||||
/** Whether the user has a linked Google account. */
|
||||
export async function hasGoogleAccount(userId: string): Promise<boolean> {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ id: (authAccounts as any).id })
|
||||
.from(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, userId),
|
||||
eq((authAccounts as any).providerId, 'google')
|
||||
)
|
||||
)
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Reports which backend each scalable subsystem is using, for the health
|
||||
// endpoint and startup logging.
|
||||
|
||||
import { isRedisEnabled, isRedisHealthy, getRedisHealthDetail } from './redis.js';
|
||||
import { getRateLimiter } from './stores/rateLimiter.js';
|
||||
import { getPubSub } from './stores/pubsub.js';
|
||||
import { getCache } from './stores/cache.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { getLoginLockout } from './stores/loginLockout.js';
|
||||
import { getStorage } from './storage.js';
|
||||
|
||||
export function describeBackends() {
|
||||
return {
|
||||
cache: getCache().backend,
|
||||
rateLimiter: getRateLimiter().backend,
|
||||
pubsub: getPubSub().backend,
|
||||
lock: getLock().backend,
|
||||
loginLockout: getLoginLockout().backend,
|
||||
storage: getStorage().backend,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeRedis() {
|
||||
return { enabled: isRedisEnabled(), healthy: isRedisHealthy(), ...getRedisHealthDetail() };
|
||||
}
|
||||
|
||||
/** Log one line per subsystem at startup so the active backend is obvious. */
|
||||
export function logSelectedBackends(): void {
|
||||
const b = describeBackends();
|
||||
const r = describeRedis();
|
||||
console.log('[startup] Subsystem backends:');
|
||||
console.log(` redis: ${r.enabled ? 'enabled' : 'disabled (in-memory fallback)'}`);
|
||||
console.log(` cache: ${b.cache}`);
|
||||
console.log(` rate limiter: ${b.rateLimiter}`);
|
||||
console.log(` pub/sub: ${b.pubsub}`);
|
||||
console.log(` lock: ${b.lock}`);
|
||||
console.log(` login lockout:${b.loginLockout}`);
|
||||
console.log(` storage: ${b.storage}`);
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
|
||||
import { normalizeEmail } from './utils.js';
|
||||
|
||||
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
|
||||
// (dotenv never overrides pre-set values).
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ba-test-'));
|
||||
const dbPath = join(dir, 'test.db');
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = dbPath;
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
|
||||
|
||||
// Google IS configured here: account linking is the whole point of the tests at
|
||||
// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely
|
||||
// when this is unset. No real credentials are involved — the id tokens are
|
||||
// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below.
|
||||
const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com';
|
||||
process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID;
|
||||
|
||||
// Capture outgoing auth emails (magic links, password resets)
|
||||
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
|
||||
vi.mock('./email.js', () => ({
|
||||
sendEmail: vi.fn(async (opts: any) => {
|
||||
sentEmails.push(opts);
|
||||
}),
|
||||
emailService: {},
|
||||
default: {},
|
||||
}));
|
||||
|
||||
let auth: (typeof import('./betterAuth.js'))['auth'];
|
||||
let db: any;
|
||||
let sqlite: any;
|
||||
|
||||
function lastEmailTo(email: string) {
|
||||
const found = [...sentEmails].reverse().find((e) => e.to === email);
|
||||
expect(found, `expected an email sent to ${email}`).toBeTruthy();
|
||||
return found!;
|
||||
}
|
||||
|
||||
function extractToken(html: string, param = 'token'): string {
|
||||
const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`));
|
||||
expect(match, `expected a ${param} in the email link`).toBeTruthy();
|
||||
return decodeURIComponent(match![1]);
|
||||
}
|
||||
|
||||
// ---- Google Identity Services stub -------------------------------------
|
||||
// verifyGoogleIdToken() checks signature, issuer, audience and max age against
|
||||
// Google's published JWKS; its only network call is that JWKS fetch. Signing
|
||||
// with our own key and serving our own JWKS exercises the real verification
|
||||
// path without touching the network or needing OAuth credentials.
|
||||
const GOOGLE_KID = 'spanglish-test-key';
|
||||
let googlePrivateKey: CryptoKey;
|
||||
|
||||
async function installGoogleStub() {
|
||||
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
|
||||
googlePrivateKey = privateKey as CryptoKey;
|
||||
const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' };
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: any, init?: any) => {
|
||||
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
|
||||
if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) {
|
||||
return new Response(JSON.stringify({ keys: [jwk] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return realFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) {
|
||||
return new SignJWT({
|
||||
email: opts.email,
|
||||
email_verified: opts.emailVerified ?? true,
|
||||
name: opts.name ?? 'Google User',
|
||||
picture: 'https://example.test/avatar.png',
|
||||
})
|
||||
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
|
||||
.setIssuer('https://accounts.google.com')
|
||||
.setAudience(GOOGLE_CLIENT_ID)
|
||||
.setSubject(opts.sub)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('10m')
|
||||
.sign(googlePrivateKey);
|
||||
}
|
||||
|
||||
async function signInWithGoogle(
|
||||
opts: Parameters<typeof googleIdToken>[0],
|
||||
returnHeaders = false
|
||||
): Promise<any> {
|
||||
const token = await googleIdToken(opts);
|
||||
return auth.api.signInSocial({
|
||||
body: { provider: 'google', idToken: { token } },
|
||||
headers: new Headers(),
|
||||
...(returnHeaders ? { returnHeaders: true } : {}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts):
|
||||
* unclaimed, unverified, and with no auth_accounts row at all. */
|
||||
function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') {
|
||||
const now = new Date().toISOString();
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)`
|
||||
)
|
||||
.run(id, email, name, now, now);
|
||||
return id;
|
||||
}
|
||||
|
||||
function userRow(email: string) {
|
||||
return sqlite
|
||||
.prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?')
|
||||
.get(email);
|
||||
}
|
||||
|
||||
function googleAccountsFor(userId: string) {
|
||||
return sqlite
|
||||
.prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'")
|
||||
.all(userId);
|
||||
}
|
||||
|
||||
function cookieHeaders(setCookie: string | null): Headers {
|
||||
const sessionPart = (setCookie || '')
|
||||
.split(/,(?=[^ ;]+=)/)
|
||||
.map((c) => c.split(';')[0].trim())
|
||||
.filter((c) => c.includes('session_token'))
|
||||
.join('; ');
|
||||
return new Headers({ cookie: sessionPart });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
|
||||
env: { ...process.env },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return (async () => {
|
||||
({ auth } = await import('./betterAuth.js'));
|
||||
({ db } = await import('../db/index.js'));
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
sqlite = new Database(dbPath);
|
||||
await installGoogleStub();
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
describe('Better Auth integration', () => {
|
||||
it('makes the first registered user an admin, later users regular', async () => {
|
||||
const first = await auth.api.signUpEmail({
|
||||
body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' },
|
||||
});
|
||||
expect((first.user as any).id).toBeTruthy();
|
||||
|
||||
const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py');
|
||||
expect(row.role).toBe('admin');
|
||||
expect(row.account_status).toBe('active');
|
||||
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' },
|
||||
});
|
||||
const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py');
|
||||
expect(row2.role).toBe('user');
|
||||
});
|
||||
|
||||
it('stores credential passwords as argon2id in auth_accounts, not users', async () => {
|
||||
const acct = sqlite
|
||||
.prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'")
|
||||
.get('admin@test.py');
|
||||
expect(acct.password.startsWith('$argon2id$')).toBe(true);
|
||||
const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py');
|
||||
expect(user.password).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects passwords that violate the policy', async () => {
|
||||
// Too short
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } })
|
||||
).rejects.toThrow(/at least 10 characters/);
|
||||
|
||||
// Long enough but no character mix (app policy hook)
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } })
|
||||
).rejects.toThrow(/uppercase and lowercase/);
|
||||
|
||||
// Common password normalized (policy blocklist)
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } })
|
||||
).rejects.toThrow(/too common/);
|
||||
|
||||
expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0);
|
||||
});
|
||||
|
||||
it('locks an email after 5 failed sign-ins', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' },
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } })
|
||||
).rejects.toThrow();
|
||||
}
|
||||
// Correct password now also refused: locked
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } })
|
||||
).rejects.toThrow(/Too many login attempts/);
|
||||
});
|
||||
|
||||
it('refuses sign-in for banned (suspended) users and kills nothing else', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' },
|
||||
});
|
||||
sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py');
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } })
|
||||
).rejects.toThrow(/suspended|banned/i);
|
||||
});
|
||||
|
||||
it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => {
|
||||
const bcrypt = (await import('bcryptjs')).default;
|
||||
const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10);
|
||||
const su = await auth.api.signUpEmail({
|
||||
body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' },
|
||||
});
|
||||
sqlite
|
||||
.prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.run(legacyHash, (su.user as any).id);
|
||||
|
||||
const si = await auth.api.signInEmail({
|
||||
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
|
||||
});
|
||||
expect(si.user.email).toBe('legacy@test.py');
|
||||
|
||||
// Upgrade happens in the after-hook; poll briefly for it
|
||||
let upgraded = '';
|
||||
for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
upgraded = sqlite
|
||||
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.get((su.user as any).id).password;
|
||||
}
|
||||
expect(upgraded.startsWith('$argon2id$')).toBe(true);
|
||||
|
||||
// And the upgraded hash still verifies
|
||||
const again = await auth.api.signInEmail({
|
||||
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
|
||||
});
|
||||
expect(again.user.email).toBe('legacy@test.py');
|
||||
});
|
||||
|
||||
it('magic link signs in existing users but never creates accounts', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' },
|
||||
});
|
||||
await auth.api.signInMagicLink({
|
||||
body: { email: 'magic@test.py', callbackURL: '/dashboard' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
const email = lastEmailTo('magic@test.py');
|
||||
// Emails link to the frontend page, not the raw API endpoint
|
||||
expect(email.html).toContain('http://localhost:3002/auth/magic-link?token=');
|
||||
const token = extractToken(email.html);
|
||||
|
||||
const verified = await auth.api.magicLinkVerify({
|
||||
query: { token },
|
||||
headers: new Headers(),
|
||||
});
|
||||
expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy();
|
||||
|
||||
// Unknown email: enumeration-safe success (an email may still go out),
|
||||
// but verification can never create an account (disableSignUp)
|
||||
const ghost = await auth.api.signInMagicLink({
|
||||
body: { email: 'ghost@test.py' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
expect((ghost as any).status).toBe(true);
|
||||
const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop();
|
||||
if (ghostEmail) {
|
||||
const ghostToken = extractToken(ghostEmail.html);
|
||||
await expect(
|
||||
auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() })
|
||||
).rejects.toThrow();
|
||||
}
|
||||
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0);
|
||||
});
|
||||
|
||||
it('completes the guest claim path: magic link session + setPassword', async () => {
|
||||
// Simulate tickets.ts guest creation: user row, no credential account
|
||||
const guestId = 'guest-claim-user-000001';
|
||||
const now = new Date().toISOString();
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)`
|
||||
)
|
||||
.run(guestId, 'guest@test.py', now, now);
|
||||
|
||||
await auth.api.signInMagicLink({
|
||||
body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
const token = extractToken(lastEmailTo('guest@test.py').html);
|
||||
const verified = await auth.api.magicLinkVerify({
|
||||
query: { token },
|
||||
returnHeaders: true,
|
||||
headers: new Headers(),
|
||||
});
|
||||
const headers = cookieHeaders(verified.headers.get('set-cookie'));
|
||||
|
||||
// The session works even while unclaimed (the claim endpoint depends on this)
|
||||
const session = await auth.api.getSession({ headers });
|
||||
expect((session?.user as any)?.accountStatus).toBe('unclaimed');
|
||||
|
||||
// Set the password (what /api/auth-ext/claim-account does)
|
||||
await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers });
|
||||
const acct = sqlite
|
||||
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.get(guestId);
|
||||
expect(acct.password.startsWith('$argon2id$')).toBe(true);
|
||||
|
||||
// The claim email uses the claim template with the frontend link
|
||||
const claimEmail = lastEmailTo('guest@test.py');
|
||||
expect(claimEmail.subject).toContain('Claim');
|
||||
expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account');
|
||||
});
|
||||
|
||||
it('password reset revokes existing sessions and applies the new password', async () => {
|
||||
const su = await auth.api.signUpEmail({
|
||||
body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' },
|
||||
});
|
||||
const userId = (su.user as any).id;
|
||||
// A live session from sign-in
|
||||
await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } });
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await auth.api.requestPasswordReset({
|
||||
body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' },
|
||||
});
|
||||
const email = lastEmailTo('reset@test.py');
|
||||
// Reset URLs are either .../reset-password/{token}?... or ...?token={token}
|
||||
const html = email.html;
|
||||
const pathMatch = html.match(/reset-password\/([^?"&\s]+)/);
|
||||
const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html);
|
||||
|
||||
await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } });
|
||||
|
||||
// revokeSessionsOnPasswordReset: true
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
|
||||
).toBe(0);
|
||||
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } })
|
||||
).rejects.toThrow();
|
||||
const after = await auth.api.signInEmail({
|
||||
body: { email: 'reset@test.py', password: 'AfterReset1!x' },
|
||||
});
|
||||
expect(after.user.email).toBe('reset@test.py');
|
||||
});
|
||||
|
||||
it('sessions are stored in auth_sessions with 7-day expiry', async () => {
|
||||
const si = await auth.api.signInEmail({
|
||||
body: { email: 'admin@test.py', password: 'FirstAdmin1!x' },
|
||||
});
|
||||
const row = sqlite
|
||||
.prepare('SELECT expires_at FROM auth_sessions WHERE token = ?')
|
||||
.get((si as any).token);
|
||||
expect(row).toBeTruthy();
|
||||
const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24);
|
||||
expect(days).toBeGreaterThan(6.5);
|
||||
expect(days).toBeLessThan(7.5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Google sign-in and account linking', () => {
|
||||
it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => {
|
||||
const id = insertBookingUser('booking-user-1', 'buyer@test.py');
|
||||
expect(userRow('buyer@test.py').email_verified).toBe(0);
|
||||
|
||||
const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' });
|
||||
|
||||
expect(res.user.id).toBe(id);
|
||||
expect(googleAccountsFor(id)).toHaveLength(1);
|
||||
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1);
|
||||
});
|
||||
|
||||
it('claims the booking account so the resulting session is actually accepted', async () => {
|
||||
// getAuthUser() (lib/auth.ts) rejects any session whose user is not
|
||||
// 'active', so linking alone would leave the user looking logged out.
|
||||
const id = insertBookingUser('booking-user-2', 'buyer2@test.py');
|
||||
|
||||
const { headers, response } = await signInWithGoogle(
|
||||
{ email: 'buyer2@test.py', sub: 'google-sub-buyer2' },
|
||||
true
|
||||
);
|
||||
expect(response.user.id).toBe(id);
|
||||
|
||||
const row = userRow('buyer2@test.py');
|
||||
expect(row.account_status).toBe('active');
|
||||
expect(row.is_claimed).toBe(1);
|
||||
expect(row.email_verified).toBe(1);
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: cookieHeaders(headers.get('set-cookie')),
|
||||
});
|
||||
expect(session?.user.id).toBe(id);
|
||||
expect((session?.user as any).accountStatus).toBe('active');
|
||||
});
|
||||
|
||||
it('never reactivates a suspended account through a Google link', async () => {
|
||||
const now = new Date().toISOString();
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at)
|
||||
VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)`
|
||||
)
|
||||
.run('suspended-google', 'suspended-google@test.py', now, now);
|
||||
|
||||
await expect(
|
||||
signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' })
|
||||
).rejects.toThrow();
|
||||
expect(userRow('suspended-google@test.py').account_status).toBe('suspended');
|
||||
});
|
||||
|
||||
it('links Google onto an email/password account created after the Better Auth migration', async () => {
|
||||
// Better Auth writes email_verified = 0 on sign-up (requireEmailVerification
|
||||
// is off), which used to be enough to block linking on its own.
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' },
|
||||
});
|
||||
expect(userRow('pwuser@test.py').email_verified).toBe(0);
|
||||
const id = userRow('pwuser@test.py').id;
|
||||
|
||||
const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' });
|
||||
|
||||
expect(res.user.id).toBe(id);
|
||||
expect(googleAccountsFor(id)).toHaveLength(1);
|
||||
// The credential account survives: they can still sign in with a password.
|
||||
const after = await auth.api.signInEmail({
|
||||
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' },
|
||||
});
|
||||
expect(after.user.id).toBe(id);
|
||||
});
|
||||
|
||||
it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => {
|
||||
const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
|
||||
const row = userRow('fresh@test.py');
|
||||
expect(row.id).toBe(first.user.id);
|
||||
expect(row.email_verified).toBe(1);
|
||||
expect(row.account_status).toBe('active');
|
||||
|
||||
const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
|
||||
expect(second.user.id).toBe(first.user.id);
|
||||
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1);
|
||||
expect(googleAccountsFor(first.user.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects an id token minted for a different client id', async () => {
|
||||
const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' })
|
||||
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
|
||||
.setIssuer('https://accounts.google.com')
|
||||
.setAudience('some-other-app.apps.googleusercontent.com')
|
||||
.setSubject('google-sub-forged')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('10m')
|
||||
.sign(googlePrivateKey);
|
||||
|
||||
await expect(
|
||||
auth.api.signInSocial({
|
||||
body: { provider: 'google', idToken: { token } },
|
||||
headers: new Headers(),
|
||||
} as any)
|
||||
).rejects.toThrow();
|
||||
expect(userRow('forged@test.py')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('users.email normalization', () => {
|
||||
it('normalizes an address to its canonical stored form', () => {
|
||||
expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com');
|
||||
});
|
||||
|
||||
it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => {
|
||||
const now = new Date().toISOString();
|
||||
const insert = (id: string, email: string, status = 'unclaimed') =>
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
|
||||
VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)`
|
||||
)
|
||||
.run(id, email, status, now, now);
|
||||
|
||||
insert('legacy-mixed', 'John@Example.com');
|
||||
// A pair that genuinely collides: the migration must leave both alone.
|
||||
insert('legacy-dup-lower', 'dup@example.com');
|
||||
insert('legacy-dup-mixed', 'Dup@Example.com');
|
||||
|
||||
// The backfill lives in migrate.ts and is idempotent, so just re-run it.
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||
|
||||
expect(userRow('john@example.com').id).toBe('legacy-mixed');
|
||||
expect(userRow('John@Example.com')).toBeUndefined();
|
||||
expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed');
|
||||
expect(userRow('dup@example.com').id).toBe('legacy-dup-lower');
|
||||
|
||||
// ...and the lowercased row is now reachable by Google sign-in.
|
||||
const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' });
|
||||
expect(res.user.id).toBe('legacy-mixed');
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { magicLink, admin } from 'better-auth/plugins';
|
||||
import { APIError, createAuthMiddleware } from 'better-auth/api';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db, dbGet, dbAll, isPostgres } from '../db/index.js';
|
||||
import {
|
||||
authUsers,
|
||||
authSessions,
|
||||
authAccounts,
|
||||
authVerifications,
|
||||
authRateLimits,
|
||||
} from '../db/auth-schema.js';
|
||||
import { generateId } from './utils.js';
|
||||
import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
||||
import { sendEmail } from './email.js';
|
||||
import { getLoginLockout } from './stores/loginLockout.js';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
||||
|
||||
// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
|
||||
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
|
||||
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
|
||||
// and needs a host-only cookie.
|
||||
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();
|
||||
|
||||
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
|
||||
const rawSecret = process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
// Never allow a weak/default secret in production: the secret signs session
|
||||
// cookies, so a guessable value means forgeable sessions = account takeover.
|
||||
if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) {
|
||||
throw new Error(
|
||||
'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.'
|
||||
);
|
||||
}
|
||||
if (!rawSecret) {
|
||||
console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.');
|
||||
}
|
||||
|
||||
// The site origin plus its www/non-www alias, mirroring the CORS allowlist in
|
||||
// index.ts. Requests whose Origin is not listed here are rejected by Better
|
||||
// Auth's CSRF origin check.
|
||||
function computeTrustedOrigins(): string[] {
|
||||
const origins = new Set<string>([frontendUrl]);
|
||||
try {
|
||||
const url = new URL(frontendUrl);
|
||||
const alias = url.hostname.startsWith('www.')
|
||||
? url.hostname.slice(4)
|
||||
: `www.${url.hostname}`;
|
||||
origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`);
|
||||
} catch {
|
||||
/* keep frontendUrl as-is */
|
||||
}
|
||||
if (process.env.API_URL) origins.add(process.env.API_URL);
|
||||
if (!isProduction) {
|
||||
// Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
|
||||
// port forwarding), so the browser's Origin is http://localhost:<random> and every
|
||||
// POST would fail the CSRF origin check. Trust any loopback port rather than pinning
|
||||
// FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
|
||||
// per better-auth's trusted-origins helper; production stays on the exact allowlist.
|
||||
origins.add('http://localhost:*');
|
||||
origins.add('http://127.0.0.1:*');
|
||||
}
|
||||
return [...origins];
|
||||
}
|
||||
|
||||
// Paths whose request body carries a new password that must satisfy the policy
|
||||
// (Better Auth's minPasswordLength alone is weaker than the app's policy).
|
||||
const PASSWORD_SETTING_PATHS = new Set([
|
||||
'/sign-up/email',
|
||||
'/reset-password',
|
||||
'/change-password',
|
||||
'/set-password',
|
||||
]);
|
||||
|
||||
async function getCredentialAccount(userId: string): Promise<any | null> {
|
||||
return dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, userId),
|
||||
eq((authAccounts as any).providerId, 'credential')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: 'Spanglish',
|
||||
baseURL: process.env.BETTER_AUTH_URL || frontendUrl,
|
||||
basePath: '/api/auth',
|
||||
secret: rawSecret || DEFAULT_DEV_SECRET,
|
||||
trustedOrigins: computeTrustedOrigins(),
|
||||
telemetry: { enabled: false },
|
||||
|
||||
database: drizzleAdapter(db as any, {
|
||||
provider: isPostgres() ? 'pg' : 'sqlite',
|
||||
schema: {
|
||||
user: authUsers,
|
||||
session: authSessions,
|
||||
account: authAccounts,
|
||||
verification: authVerifications,
|
||||
rateLimit: authRateLimits,
|
||||
},
|
||||
// better-sqlite3 cannot run Drizzle's async transactions; operations run
|
||||
// sequentially instead (also the adapter default).
|
||||
transaction: false,
|
||||
}),
|
||||
|
||||
advanced: {
|
||||
cookiePrefix: 'spanglish',
|
||||
useSecureCookies: isProduction,
|
||||
// Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
|
||||
// so the cookie names hardcoded in the frontend middleware and the Go photo-api
|
||||
// stay valid.
|
||||
...(cookieDomain
|
||||
? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
|
||||
: {}),
|
||||
ipAddress: {
|
||||
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
|
||||
// anchors trust in the TCP peer address (only our own proxies may speak
|
||||
// for the client via X-Real-IP / X-Forwarded-For). Never read the raw
|
||||
// forwarded headers here: without the socket they are spoofable, and
|
||||
// Better Auth would fall back to one shared rate-limit bucket.
|
||||
ipAddressHeaders: ['x-client-ip'],
|
||||
},
|
||||
database: {
|
||||
// Match the app's existing ID convention (uuid on pg, nanoid on sqlite)
|
||||
// so Better Auth rows are indistinguishable from legacy rows.
|
||||
generateId: () => generateId(),
|
||||
},
|
||||
},
|
||||
|
||||
user: {
|
||||
additionalFields: {
|
||||
// `role`, `banned`, `banReason`, `banExpires` come from the admin plugin.
|
||||
phone: { type: 'string', required: false, input: true },
|
||||
languagePreference: { type: 'string', required: false, input: true },
|
||||
rucNumber: { type: 'string', required: false, input: false },
|
||||
isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true },
|
||||
accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' },
|
||||
},
|
||||
},
|
||||
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling
|
||||
updateAge: 60 * 60 * 24, // refresh expiry at most once a day
|
||||
freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this
|
||||
// Disabled deliberately: every request validates against the session table,
|
||||
// so ban/suspend/password-reset revocations apply instantly — and the Go
|
||||
// photo-api (which reads the same table) can never disagree with us.
|
||||
cookieCache: { enabled: false },
|
||||
storeSessionInDatabase: true,
|
||||
},
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
minPasswordLength: 10,
|
||||
maxPasswordLength: 128,
|
||||
autoSignIn: true,
|
||||
requireEmailVerification: false,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
try {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Reset Your Spanglish Password',
|
||||
html: `
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
||||
<p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
||||
<p>Or copy this link: ${url}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send password reset email:', error);
|
||||
}
|
||||
},
|
||||
password: {
|
||||
// Keep the existing argon2id parameters; legacy bcrypt hashes (migrated
|
||||
// into auth_accounts) still verify and are upgraded on login below.
|
||||
hash: (password) => hashPassword(password),
|
||||
verify: ({ hash, password }) => verifyPassword(password, hash),
|
||||
},
|
||||
},
|
||||
|
||||
...(process.env.GOOGLE_CLIENT_ID
|
||||
? {
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
// Not required for ID-token (Google Identity Services) sign-in,
|
||||
// only for the redirect OAuth flow.
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
// Google verifies email ownership, so linking by email is safe — this
|
||||
// matches the legacy /api/auth/google auto-link behavior.
|
||||
trustedProviders: ['google'],
|
||||
// `trustedProviders` alone is NOT enough: better-auth ORs a second,
|
||||
// independent gate — `requireLocalEmailVerified` (default true) — which
|
||||
// refuses the link whenever the LOCAL users.email_verified is false.
|
||||
// That is the state of every guest-booking user (routes/tickets.ts,
|
||||
// routes/door.ts insert email_verified = false) and of every
|
||||
// email/password signup made after the Better Auth migration, so Google
|
||||
// sign-in failed for them with "account not linked".
|
||||
//
|
||||
// The local flag adds nothing here: the Google ID token is signature-
|
||||
// verified against Google's JWKS with issuer/audience/max-age checks and
|
||||
// carries its own `email_verified`, so Google — not our column — is what
|
||||
// proves ownership of the address.
|
||||
//
|
||||
// NOTE: upstream marks this option deprecated ("the gate will become
|
||||
// unconditional"). better-auth is pinned exactly at 1.6.25 in both
|
||||
// workspaces, and betterAuth.integration.test.ts covers this path, so an
|
||||
// upgrade that drops the option fails CI rather than silently locking
|
||||
// ticket buyers out again.
|
||||
requireLocalEmailVerified: false,
|
||||
},
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
// Explicitly enabled so dev behaves like production (off in dev by default).
|
||||
enabled: true,
|
||||
window: 60,
|
||||
max: 100,
|
||||
// DB-backed rather than Redis: our Redis layer is fail-open by design,
|
||||
// which is the wrong default for auth rate limiting. The per-email login
|
||||
// lockout below is already Redis-shared across replicas.
|
||||
storage: 'database',
|
||||
modelName: 'rateLimit',
|
||||
customRules: {
|
||||
'/sign-in/email': { window: 900, max: 10 },
|
||||
'/sign-up/email': { window: 900, max: 10 },
|
||||
'/sign-in/magic-link': { window: 900, max: 5 },
|
||||
'/magic-link/verify': { window: 900, max: 30 },
|
||||
'/request-password-reset': { window: 900, max: 5 },
|
||||
'/reset-password': { window: 900, max: 10 },
|
||||
'/sign-in/social': { window: 900, max: 20 },
|
||||
'/change-password': { window: 900, max: 10 },
|
||||
},
|
||||
},
|
||||
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
// First user to register becomes admin (replaces isFirstUser())
|
||||
const existing = await dbAll<any>((db as any).select().from(authUsers).limit(1));
|
||||
if (!existing || existing.length === 0) {
|
||||
return { data: { ...user, role: 'admin' } };
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
account: {
|
||||
create: {
|
||||
// Fires for both branches of the OAuth path: createOAuthUser (new user)
|
||||
// and linkAccount (existing user), since both go through the adapter's
|
||||
// createWithHooks(..., 'account').
|
||||
after: async (account) => {
|
||||
if (account.providerId !== 'google') return;
|
||||
// Attaching a Google account proves ownership of the address, so a
|
||||
// row created during guest booking is now a real, claimed account.
|
||||
// Without this, getAuthUser() (lib/auth.ts) rejects the brand-new
|
||||
// session because accountStatus is still 'unclaimed' — the user gets
|
||||
// a cookie and still looks logged out. Mirrors the tail of the
|
||||
// magic-link claim flow in routes/authExt.ts.
|
||||
//
|
||||
// Scoped to 'unclaimed' in the WHERE clause so a suspended account is
|
||||
// never silently reactivated by linking Google to it.
|
||||
try {
|
||||
await (db as any)
|
||||
.update(authUsers)
|
||||
// `emailVerified` is deliberately left alone: better-auth's link
|
||||
// branch sets it right after this hook, but only when Google's
|
||||
// id_token actually asserted email_verified.
|
||||
.set({
|
||||
isClaimed: true,
|
||||
accountStatus: 'active',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq((authUsers as any).id, account.userId),
|
||||
eq((authUsers as any).accountStatus, 'unclaimed')
|
||||
)
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('[auth] Failed to claim account on Google link:', err?.message || err);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
// Enforce the full password policy (character classes + blocklist) on
|
||||
// every password-setting path, for both client and server-side calls.
|
||||
if (PASSWORD_SETTING_PATHS.has(ctx.path)) {
|
||||
const password = ctx.body?.password ?? ctx.body?.newPassword;
|
||||
if (typeof password === 'string') {
|
||||
const result = validatePassword(password);
|
||||
if (!result.valid) {
|
||||
throw new APIError('BAD_REQUEST', { message: result.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-email lockout: 5 failures / 15 min, Redis-shared when configured.
|
||||
// Kept as defense-in-depth on top of Better Auth's per-IP rate limits.
|
||||
if (ctx.path === '/sign-in/email' && typeof ctx.body?.email === 'string') {
|
||||
const lockout = await getLoginLockout().isLocked(ctx.body.email);
|
||||
if (lockout.locked) {
|
||||
throw new APIError('TOO_MANY_REQUESTS', {
|
||||
message: 'Too many login attempts. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path !== '/sign-in/email' || typeof ctx.body?.email !== 'string') return;
|
||||
const email = ctx.body.email as string;
|
||||
|
||||
if (ctx.context.returned instanceof APIError) {
|
||||
// Failed sign-in attempt counts toward the per-email lockout
|
||||
await getLoginLockout().recordFailure(email);
|
||||
return;
|
||||
}
|
||||
|
||||
await getLoginLockout().clear(email);
|
||||
|
||||
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have
|
||||
// the verified plaintext. Best-effort: never block the login.
|
||||
try {
|
||||
const password = ctx.body?.password;
|
||||
const userId = (ctx.context.newSession?.user as any)?.id;
|
||||
if (typeof password === 'string' && userId) {
|
||||
const account = await getCredentialAccount(userId);
|
||||
if (account?.password && !String(account.password).startsWith('$argon2')) {
|
||||
const upgraded = await hashPassword(password);
|
||||
await (db as any)
|
||||
.update(authAccounts)
|
||||
.set({ password: upgraded, updatedAt: new Date() })
|
||||
.where(eq((authAccounts as any).id, account.id));
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
plugins: [
|
||||
magicLink({
|
||||
expiresIn: 60 * 10, // 10 minutes, matches the legacy flow
|
||||
// Magic links never create accounts (parity with the legacy behavior;
|
||||
// account creation is register / Google / guest booking only).
|
||||
disableSignUp: true,
|
||||
// Hashed at rest: a leaked verification table cannot be replayed.
|
||||
storeToken: 'hashed',
|
||||
sendMagicLink: async ({ email, url, token }) => {
|
||||
// Email a frontend URL (not the raw API verify URL) so the login
|
||||
// completes on the site, preserving the legacy UX. The page calls
|
||||
// authClient.magicLink.verify with the token.
|
||||
let callbackURL = '/';
|
||||
try {
|
||||
callbackURL = new URL(url).searchParams.get('callbackURL') || '/';
|
||||
} catch {
|
||||
/* default */
|
||||
}
|
||||
const link = `${frontendUrl}/auth/magic-link?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`;
|
||||
const isClaim = callbackURL.startsWith('/auth/claim-account');
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: isClaim ? 'Claim Your Spanglish Account' : 'Your Spanglish Login Link',
|
||||
html: isClaim
|
||||
? `
|
||||
<h2>Claim Your Account</h2>
|
||||
<p>An account was created for you during booking. Click below to set up your login credentials. This link expires in 10 minutes.</p>
|
||||
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
||||
<p>Or copy this link: ${link}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`
|
||||
: `
|
||||
<h2>Login to Spanglish</h2>
|
||||
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
||||
<p><a href="${link}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
||||
<p>Or copy this link: ${link}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send magic link email:', error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
admin({
|
||||
defaultRole: 'user',
|
||||
adminRoles: ['admin'],
|
||||
bannedUserMessage: 'Account is suspended. Please contact support.',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
@@ -0,0 +1,115 @@
|
||||
// Expire stale pending bookings.
|
||||
//
|
||||
// When a booking is started, its tickets are created with status 'pending' and
|
||||
// a 'pending' payment. Pending tickets count toward an event's capacity, so an
|
||||
// abandoned checkout would otherwise hold those seats forever. This job cancels
|
||||
// pending tickets whose payment is still 'pending' (i.e. never paid and not
|
||||
// awaiting admin approval) after a configurable TTL, freeing the seats.
|
||||
//
|
||||
// Two exclusions:
|
||||
// - Manual-verification providers (bank transfer / TPago / cash) are never
|
||||
// auto-failed; they are settled by an admin and instead follow the 72h on-hold
|
||||
// sweep. See holdSweep.ts and MANUAL_PAYMENT_PROVIDERS.
|
||||
// - Staleness is measured from `updatedAt`, not `createdAt`, so an admin action
|
||||
// (e.g. reopening a payment to 'pending' via /reopen) restarts the TTL rather
|
||||
// than being immediately re-failed on the next run.
|
||||
|
||||
import { and, eq, lt, inArray, notInArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { getNow, toDbDate } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||
|
||||
function getTtlMs(): number {
|
||||
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
|
||||
return (Number.isFinite(minutes) && minutes > 0 ? minutes : 30) * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel stale pending bookings. Returns the number of tickets cancelled.
|
||||
*
|
||||
* A booking is considered stale when its payment is still 'pending' (not
|
||||
* 'pending_approval', which means an admin is reviewing a manual transfer),
|
||||
* uses a non-manual provider, and has not been touched (updatedAt) for
|
||||
* PENDING_BOOKING_TTL_MINUTES.
|
||||
*/
|
||||
export async function cleanupStalePendingBookings(): Promise<number> {
|
||||
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
|
||||
|
||||
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
|
||||
(db as any)
|
||||
.select({
|
||||
ticketId: (payments as any).ticketId,
|
||||
paymentId: (payments as any).id,
|
||||
})
|
||||
.from(payments)
|
||||
.where(and(
|
||||
eq((payments as any).status, 'pending'),
|
||||
notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||
lt((payments as any).updatedAt, cutoff)
|
||||
))
|
||||
);
|
||||
|
||||
if (stale.length === 0) return 0;
|
||||
|
||||
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
|
||||
const paymentIds = stale.map((s) => s.paymentId);
|
||||
const now = getNow();
|
||||
|
||||
let cancelledTickets = 0;
|
||||
if (ticketIds.length > 0) {
|
||||
const result: any = await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
eq((tickets as any).status, 'pending')
|
||||
));
|
||||
cancelledTickets = result?.changes ?? result?.rowCount ?? ticketIds.length;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'failed', updatedAt: now })
|
||||
.where(inArray((payments as any).id, paymentIds));
|
||||
|
||||
console.log(
|
||||
`[BookingCleanup] Expired ${stale.length} stale pending payment(s); ` +
|
||||
`cancelled ${cancelledTickets} ticket(s).`
|
||||
);
|
||||
return cancelledTickets;
|
||||
}
|
||||
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start a periodic cleanup of stale pending bookings. Each run is guarded by a
|
||||
* distributed lock so that, across multiple replicas, only one instance does
|
||||
* the work per interval.
|
||||
*/
|
||||
export function startBookingCleanup(): void {
|
||||
const intervalMs = parseInt(process.env.PENDING_BOOKING_CLEANUP_INTERVAL_MS || '300000', 10); // 5 min
|
||||
|
||||
const run = () => {
|
||||
getLock()
|
||||
.withLock('cleanup-pending-bookings', Math.min(intervalMs, 60_000), () =>
|
||||
cleanupStalePendingBookings()
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error('[BookingCleanup] Run failed:', err?.message || err)
|
||||
);
|
||||
};
|
||||
|
||||
// Run shortly after startup, then on the interval.
|
||||
setTimeout(run, 30_000).unref?.();
|
||||
cleanupTimer = setInterval(run, intervalMs);
|
||||
cleanupTimer.unref?.();
|
||||
console.log(`[BookingCleanup] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
||||
}
|
||||
|
||||
export function stopBookingCleanup(): void {
|
||||
if (cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Single source of truth for event seat accounting.
|
||||
//
|
||||
// A seat is held by a booking the moment the money is real or claimed to be:
|
||||
// - ticket 'confirmed' or 'checked_in' (paid), or
|
||||
// - ticket 'pending' whose payment is 'pending_approval' (customer clicked
|
||||
// "I've paid" and is waiting for admin verification).
|
||||
//
|
||||
// A bare 'pending' payment — an opened checkout that was never paid nor claimed,
|
||||
// on any provider — holds NO seat, so abandoned bookings can never block sales.
|
||||
// The accepted trade-off is a small oversell window when several people book the
|
||||
// last seats and all later pay/claim; admin approval is the backstop and may
|
||||
// knowingly approve over capacity (see routes/payments.ts).
|
||||
//
|
||||
// 'pending_approval' is a payment status (the ticket row stays 'pending'), so
|
||||
// every capacity count joins tickets to payments. There is exactly one payment
|
||||
// row per ticket (created together in routes/tickets.ts).
|
||||
//
|
||||
// Every place that counts seats — booking creation, public availability,
|
||||
// hold recovery, admin dashboards — must go through these builders so the
|
||||
// formula cannot diverge between surfaces.
|
||||
|
||||
import { sql, and, eq, inArray } from 'drizzle-orm';
|
||||
import { tickets, payments } from '../db/index.js';
|
||||
|
||||
// COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no
|
||||
// payment row must count as "not holding" — NULL would poison NOT ...).
|
||||
export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`;
|
||||
|
||||
/**
|
||||
* Query: number of seats currently held for an event.
|
||||
* `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`;
|
||||
* async pg tx / plain db: await via dbGet).
|
||||
*/
|
||||
export function seatHolderCountQuery(executor: any, eventId: string) {
|
||||
return executor
|
||||
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
|
||||
.from(tickets)
|
||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(and(eq((tickets as any).eventId, eventId), seatHoldingSql));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query: how many of the given tickets do NOT currently hold a seat (and so
|
||||
* would need fresh capacity if promoted to a seat-holding state).
|
||||
*/
|
||||
export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) {
|
||||
return executor
|
||||
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
|
||||
.from(tickets)
|
||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
|
||||
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
|
||||
* Pass `eventId` to restrict to one event (still returns a grouped row), or an
|
||||
* array of ids to restrict to those events (e.g. one page of a listing). Callers
|
||||
* must skip the query for an empty array.
|
||||
*/
|
||||
export function eventSeatBreakdownQuery(executor: any, eventId?: string | string[]) {
|
||||
const query = executor
|
||||
.select({
|
||||
eventId: (tickets as any).eventId,
|
||||
paidCount: sql<number>`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`,
|
||||
claimedCount: sql<number>`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`,
|
||||
})
|
||||
.from(tickets)
|
||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
|
||||
const scoped = Array.isArray(eventId)
|
||||
? query.where(inArray((tickets as any).eventId, eventId))
|
||||
: eventId ? query.where(eq((tickets as any).eventId, eventId)) : query;
|
||||
return scoped.groupBy((tickets as any).eventId);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Door payment tenders.
|
||||
//
|
||||
// The door check-in screen offers five tenders. Each maps onto an
|
||||
// existing payments.provider so the rest of the app (capacity, sweeps, admin
|
||||
// payment lists, receipts) keeps working unchanged, while payments.method
|
||||
// records which tender was actually used for the end-of-night cash-up.
|
||||
//
|
||||
// Bitcoin currently maps to the 'lightning' provider but records the payment as
|
||||
// already made — the same trust model as cash, no invoice generated. When a real
|
||||
// Lightning flow lands it slots in here: the tender keeps its name and provider,
|
||||
// only the settlement path in routes/door.ts changes.
|
||||
//
|
||||
// POS is the physical card terminal. Staff open the POS step, which shows the
|
||||
// amount to key into the terminal, charge the card, then confirm "Mark as paid";
|
||||
// only that confirmation reaches this API, recorded like any other tender. A
|
||||
// future automatic amount push to the terminal belongs in the POS step itself
|
||||
// (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to
|
||||
// the terminal today.
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, dbGet, paymentOptions, eventPaymentOverrides } from '../db/index.js';
|
||||
|
||||
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
|
||||
|
||||
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
|
||||
|
||||
interface DoorTender {
|
||||
/** Existing payments.provider this tender is stored as. */
|
||||
provider: 'cash' | 'lightning' | 'bank_transfer' | 'pos';
|
||||
/** Human label used in payment references and toasts. */
|
||||
label: string;
|
||||
/** Comp tenders carry no revenue and always record a zero amount. */
|
||||
isComp: boolean;
|
||||
/**
|
||||
* How the money is confirmed before the door screen records it:
|
||||
* 'on_tap' staff already hold the money when they tap (cash, …)
|
||||
* 'staff_confirm' staff charge an external device first, then confirm
|
||||
*/
|
||||
confirmation: 'on_tap' | 'staff_confirm';
|
||||
}
|
||||
|
||||
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
|
||||
cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' },
|
||||
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' },
|
||||
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' },
|
||||
pos: { provider: 'pos', label: 'POS', isComp: false, confirmation: 'staff_confirm' },
|
||||
guest: { provider: 'cash', label: 'guest', isComp: true, confirmation: 'on_tap' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tenders that can be switched off per event through payment options. Only POS
|
||||
* is configurable: the other door tenders are always available to staff.
|
||||
*/
|
||||
export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMethod[] {
|
||||
return DOOR_PAYMENT_METHODS.filter((m) => m !== 'pos' || opts.posEnabled);
|
||||
}
|
||||
|
||||
/** Door tenders available for this event (POS can be switched off per event). */
|
||||
export async function loadDoorMethods(eventId: string): Promise<DoorPaymentMethod[]> {
|
||||
const [globalOptions, overrides] = await Promise.all([
|
||||
dbGet<any>((db as any).select().from(paymentOptions)),
|
||||
dbGet<any>(
|
||||
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
),
|
||||
]);
|
||||
// Override wins when set; POS defaults to on when nothing is configured.
|
||||
const posEnabled = overrides?.posEnabled ?? globalOptions?.posEnabled ?? true;
|
||||
return enabledDoorMethods({ posEnabled: posEnabled === true || posEnabled === 1 });
|
||||
}
|
||||
|
||||
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
|
||||
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Ticket paymentStatus a tender settles to: comps are 'comp', everything else 'paid'. */
|
||||
export function paymentStatusForMethod(method: DoorPaymentMethod): 'paid' | 'comp' {
|
||||
return DOOR_TENDERS[method].isComp ? 'comp' : 'paid';
|
||||
}
|
||||
|
||||
/** Amount actually recorded: comps are always zero regardless of what was requested. */
|
||||
export function amountForMethod(method: DoorPaymentMethod, requested: number): number {
|
||||
return DOOR_TENDERS[method].isComp ? 0 : Math.max(0, requested);
|
||||
}
|
||||
|
||||
export function doorReference(method: DoorPaymentMethod): string {
|
||||
return DOOR_TENDERS[method].isComp
|
||||
? 'Door — guest (comp)'
|
||||
: `Door — paid by ${DOOR_TENDERS[method].label}`;
|
||||
}
|
||||
+55
-1403
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
// High-level booking confirmation email sender.
|
||||
|
||||
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sendTemplateEmail } from './templateService.js';
|
||||
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
|
||||
|
||||
/**
|
||||
* Send booking confirmation email
|
||||
* Supports multi-ticket bookings - includes all tickets in the booking
|
||||
*/
|
||||
export async function sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket with event info
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Get all tickets in this booking (if multi-ticket)
|
||||
let allTickets: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
allTickets = await dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
const ticketCount = allTickets.length;
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
// Generate ticket PDF URL (primary ticket, or use combined endpoint for multi)
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const ticketPdfUrl = ticketCount > 1 && ticket.bookingId
|
||||
? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf`
|
||||
: `${apiUrl}/api/tickets/${ticket.id}/pdf`;
|
||||
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Build attendee list for multi-ticket emails
|
||||
const attendeeNames = allTickets.map(t =>
|
||||
`${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim()
|
||||
).join(', ');
|
||||
|
||||
// Calculate total price for multi-ticket bookings
|
||||
const totalPrice = event.price * ticketCount;
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug: 'booking-confirmation',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
bookingId: ticket.bookingId || ticket.id,
|
||||
qrCode: ticket.qrCode || '',
|
||||
ticketPdfUrl,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
eventPrice: formatCurrency(event.price, event.currency),
|
||||
// Multi-ticket specific variables
|
||||
ticketCount: ticketCount.toString(),
|
||||
totalPrice: formatCurrency(totalPrice, event.currency),
|
||||
attendeeNames,
|
||||
isMultiTicket: ticketCount > 1 ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Event-wide bulk email sending via the background queue.
|
||||
|
||||
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { enqueueBulkEmails, type TemplateEmailJobParams } from '../emailQueue.js';
|
||||
import { getTemplate } from './templateService.js';
|
||||
import { formatDate, formatTime, getSiteTimezone } from './formatting.js';
|
||||
|
||||
/**
|
||||
* Queue emails for event attendees (non-blocking).
|
||||
* Adds all matching recipients to the background email queue and returns immediately.
|
||||
* Rate limiting and actual sending is handled by the email queue.
|
||||
*/
|
||||
export async function queueEventEmails(params: {
|
||||
eventId: string;
|
||||
templateSlug: string;
|
||||
customVariables?: Record<string, any>;
|
||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
sentBy: string;
|
||||
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
|
||||
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||
|
||||
// Validate event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, queuedCount: 0, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Validate template exists
|
||||
const template = await getTemplate(templateSlug);
|
||||
if (!template) {
|
||||
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
|
||||
}
|
||||
|
||||
// Get tickets based on filter
|
||||
let ticketQuery = (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, eventId));
|
||||
|
||||
if (recipientFilter !== 'all') {
|
||||
ticketQuery = ticketQuery.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).status, recipientFilter)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const eventTickets = await dbAll<any>(ticketQuery);
|
||||
|
||||
if (eventTickets.length === 0) {
|
||||
return { success: true, queuedCount: 0, error: 'No recipients found' };
|
||||
}
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
// Build individual email jobs for the queue
|
||||
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
return {
|
||||
templateSlug,
|
||||
to: ticket.attendeeEmail,
|
||||
toName: fullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
sentBy,
|
||||
variables: {
|
||||
attendeeName: fullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
...customVariables,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Enqueue all emails for background processing
|
||||
enqueueBulkEmails(jobs);
|
||||
|
||||
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
queuedCount: jobs.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Shared formatting helpers and common template variables for emails.
|
||||
|
||||
import { db, dbGet, siteSettings } from '../../db/index.js';
|
||||
import { getCache } from '../stores/cache.js';
|
||||
|
||||
/**
|
||||
* Get common variables for all emails
|
||||
*/
|
||||
export function getCommonVariables(): Record<string, string> {
|
||||
return {
|
||||
siteName: 'Spanglish',
|
||||
siteUrl: process.env.FRONTEND_URL || 'https://spanglish.com',
|
||||
currentYear: new Date().getFullYear().toString(),
|
||||
supportEmail: process.env.EMAIL_FROM || 'hello@spanglish.com',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site timezone from settings (cached for performance).
|
||||
* Cached for a short TTL via the cache abstraction (in-memory or Redis).
|
||||
*/
|
||||
export async function getSiteTimezone(): Promise<string> {
|
||||
const cached = await getCache().get<string>('site:timezone');
|
||||
if (cached) return cached;
|
||||
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
const timezone = settings?.timezone || 'America/Asuncion';
|
||||
await getCache().set('site:timezone', timezone, 60);
|
||||
return timezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for emails using site timezone
|
||||
*/
|
||||
export function formatDate(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: timezone,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for emails using site timezone
|
||||
*/
|
||||
export function formatTime(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timezone,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency for emails. Kept distinct from lib/utils.ts formatCurrency
|
||||
* because the email output format ("12.345 PYG" / "$10.00 USD") must not change.
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||
if (currency === 'PYG') {
|
||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||
}
|
||||
return `$${amount.toFixed(2)} ${currency}`;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// High-level payment-related email senders and payment config resolution.
|
||||
|
||||
import { db, dbGet, dbAll, events, tickets, payments, paymentOptions, eventPaymentOverrides } from '../../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sendTemplateEmail } from './templateService.js';
|
||||
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
|
||||
|
||||
/**
|
||||
* Send payment receipt email
|
||||
*/
|
||||
export async function sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment with ticket and event info
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Calculate total amount for multi-ticket bookings
|
||||
let totalAmount = payment.amount;
|
||||
let ticketCount = 1;
|
||||
|
||||
if (ticket.bookingId) {
|
||||
// Get all payments for this booking
|
||||
const bookingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
|
||||
ticketCount = bookingTickets.length;
|
||||
|
||||
// Sum up all payment amounts for the booking
|
||||
const bookingPayments = await Promise.all(
|
||||
bookingTickets.map((t: any) =>
|
||||
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
|
||||
)
|
||||
);
|
||||
|
||||
totalAmount = bookingPayments
|
||||
.filter((p: any) => p)
|
||||
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
|
||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago', pos: 'POS' },
|
||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' },
|
||||
};
|
||||
|
||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Format amount with ticket count info for multi-ticket bookings
|
||||
const amountDisplay = ticketCount > 1
|
||||
? `${formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
|
||||
: formatCurrency(totalAmount, payment.currency);
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug: 'payment-receipt',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: receiptFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: receiptFullName,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
paymentAmount: amountDisplay,
|
||||
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
||||
paymentReference: payment.reference || payment.id,
|
||||
paymentDate: formatDate(payment.paidAt || payment.createdAt, locale, timezone),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get merged payment configuration for an event (global + overrides)
|
||||
*/
|
||||
export async function getPaymentConfig(eventId: string): Promise<Record<string, any>> {
|
||||
// Get global options
|
||||
const globalOptions = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
);
|
||||
|
||||
// Get event overrides
|
||||
const overrides = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventPaymentOverrides)
|
||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||
);
|
||||
|
||||
// Defaults
|
||||
const defaults = {
|
||||
tpagoEnabled: false,
|
||||
tpagoLink: null,
|
||||
tpagoLink2: null,
|
||||
tpagoLink3: null,
|
||||
tpagoLink4: null,
|
||||
tpagoLink5: null,
|
||||
tpagoInstructions: null,
|
||||
tpagoInstructionsEs: null,
|
||||
bankTransferEnabled: false,
|
||||
bankName: null,
|
||||
bankAccountHolder: null,
|
||||
bankAccountNumber: null,
|
||||
bankAlias: null,
|
||||
bankPhone: null,
|
||||
bankNotes: null,
|
||||
bankNotesEs: null,
|
||||
};
|
||||
|
||||
const global = globalOptions || defaults;
|
||||
|
||||
// Merge: override values take precedence if they're not null/undefined
|
||||
return {
|
||||
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
||||
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
||||
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
|
||||
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
|
||||
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
|
||||
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
|
||||
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
||||
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
||||
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
||||
bankName: overrides?.bankName ?? global.bankName,
|
||||
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
|
||||
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
|
||||
bankAlias: overrides?.bankAlias ?? global.bankAlias,
|
||||
bankPhone: overrides?.bankPhone ?? global.bankPhone,
|
||||
bankNotes: overrides?.bankNotes ?? global.bankNotes,
|
||||
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payment instructions email (for TPago or Bank Transfer)
|
||||
* This email is sent immediately after user clicks "Continue to Payment"
|
||||
*/
|
||||
export async function sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get ticket
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Get payment
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Only send for manual payment methods
|
||||
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
|
||||
}
|
||||
|
||||
// Get merged payment config for this event
|
||||
const paymentConfig = await getPaymentConfig(event.id);
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Calculate total price for multi-ticket bookings
|
||||
let totalPrice = event.price;
|
||||
let ticketCount = 1;
|
||||
|
||||
if (ticket.bookingId) {
|
||||
// Count all tickets in this booking
|
||||
const bookingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
ticketCount = bookingTickets.length;
|
||||
totalPrice = event.price * ticketCount;
|
||||
}
|
||||
|
||||
// Generate a payment reference using booking ID or ticket ID
|
||||
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
|
||||
|
||||
// Generate the booking URL for returning to payment page
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
||||
|
||||
// Determine which template to use
|
||||
const templateSlug = payment.provider === 'tpago'
|
||||
? 'payment-instructions-tpago'
|
||||
: 'payment-instructions-bank-transfer';
|
||||
|
||||
// Format amount with ticket count info for multi-ticket bookings
|
||||
const amountDisplay = ticketCount > 1
|
||||
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
||||
: formatCurrency(totalPrice, event.currency);
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
// Build variables based on payment method
|
||||
const variables: Record<string, any> = {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
paymentAmount: amountDisplay,
|
||||
paymentReference,
|
||||
bookingUrl,
|
||||
};
|
||||
|
||||
// Add payment-method specific variables
|
||||
if (payment.provider === 'tpago') {
|
||||
// Select the TPago link matching the number of tickets (1-5), falling back to the base link
|
||||
const tpagoLinkKey = ticketCount <= 1 ? 'tpagoLink' : `tpagoLink${Math.min(ticketCount, 5)}`;
|
||||
variables.tpagoLink = paymentConfig[tpagoLinkKey] || paymentConfig.tpagoLink || '';
|
||||
} else {
|
||||
// Bank transfer
|
||||
variables.bankName = paymentConfig.bankName || '';
|
||||
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
|
||||
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
|
||||
variables.bankAlias = paymentConfig.bankAlias || '';
|
||||
variables.bankPhone = paymentConfig.bankPhone || '';
|
||||
}
|
||||
|
||||
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug,
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payment rejection email
|
||||
* This email is sent when admin rejects a TPago or Bank Transfer payment
|
||||
*/
|
||||
export async function sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Get ticket
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Generate a new booking URL for the event
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
const newBookingUrl = `${frontendUrl}/book/${event.id}`;
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug: 'payment-rejected',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
newBookingUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payment reminder email
|
||||
* This email is sent when admin wants to remind attendee about pending payment
|
||||
*/
|
||||
export async function sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Get payment
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, paymentId))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return { success: false, error: 'Payment not found' };
|
||||
}
|
||||
|
||||
// Only send for pending/pending_approval payments
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
return { success: false, error: 'Payment reminder can only be sent for pending payments' };
|
||||
}
|
||||
|
||||
// Get ticket
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, error: 'Ticket not found' };
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: 'Event not found' };
|
||||
}
|
||||
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
// Calculate total price for multi-ticket bookings
|
||||
let totalPrice = event.price;
|
||||
let ticketCount = 1;
|
||||
|
||||
if (ticket.bookingId) {
|
||||
const bookingTickets = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
ticketCount = bookingTickets.length;
|
||||
totalPrice = event.price * ticketCount;
|
||||
}
|
||||
|
||||
// Generate the booking URL for returning to payment page
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||||
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
||||
|
||||
// Format amount with ticket count info for multi-ticket bookings
|
||||
const amountDisplay = ticketCount > 1
|
||||
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
||||
: formatCurrency(totalPrice, event.currency);
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await getSiteTimezone();
|
||||
|
||||
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
|
||||
|
||||
return sendTemplateEmail({
|
||||
templateSlug: 'payment-reminder',
|
||||
to: ticket.attendeeEmail,
|
||||
toName: attendeeFullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
variables: {
|
||||
attendeeName: attendeeFullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.bookingId || ticket.id,
|
||||
eventTitle,
|
||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
paymentAmount: amountDisplay,
|
||||
bookingUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// Template DB access, seeding, and the core template/custom send + logging logic.
|
||||
|
||||
import { db, dbGet, emailTemplates, emailLogs } from '../../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getNow, generateId } from '../utils.js';
|
||||
import { replaceTemplateVariables, wrapInBaseTemplate, defaultTemplates } from '../emailTemplates.js';
|
||||
import { sendEmail } from './transport.js';
|
||||
import { getCommonVariables } from './formatting.js';
|
||||
|
||||
/**
|
||||
* Get a template by slug
|
||||
*/
|
||||
export async function getTemplate(slug: string): Promise<any | null> {
|
||||
const template = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(emailTemplates)
|
||||
.where(eq((emailTemplates as any).slug, slug))
|
||||
);
|
||||
|
||||
return template || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed default templates if they don't exist, and update system templates with latest content
|
||||
*/
|
||||
export async function seedDefaultTemplates(): Promise<void> {
|
||||
console.log('[Email] Checking for default templates...');
|
||||
|
||||
for (const template of defaultTemplates) {
|
||||
const existing = await getTemplate(template.slug);
|
||||
const now = getNow();
|
||||
|
||||
if (!existing) {
|
||||
console.log(`[Email] Creating template: ${template.name}`);
|
||||
|
||||
await (db as any).insert(emailTemplates).values({
|
||||
id: generateId(),
|
||||
name: template.name,
|
||||
slug: template.slug,
|
||||
subject: template.subject,
|
||||
subjectEs: template.subjectEs,
|
||||
bodyHtml: template.bodyHtml,
|
||||
bodyHtmlEs: template.bodyHtmlEs,
|
||||
bodyText: template.bodyText,
|
||||
bodyTextEs: template.bodyTextEs,
|
||||
description: template.description,
|
||||
variables: JSON.stringify(template.variables),
|
||||
isSystem: template.isSystem ? 1 : 0,
|
||||
isActive: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (existing.isSystem) {
|
||||
// Update system templates with latest content from defaults
|
||||
console.log(`[Email] Updating system template: ${template.name}`);
|
||||
|
||||
await (db as any)
|
||||
.update(emailTemplates)
|
||||
.set({
|
||||
subject: template.subject,
|
||||
subjectEs: template.subjectEs,
|
||||
bodyHtml: template.bodyHtml,
|
||||
bodyHtmlEs: template.bodyHtmlEs,
|
||||
bodyText: template.bodyText,
|
||||
bodyTextEs: template.bodyTextEs,
|
||||
description: template.description,
|
||||
variables: JSON.stringify(template.variables),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((emailTemplates as any).slug, template.slug));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Email] Default templates check complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using a template
|
||||
*/
|
||||
export async function sendTemplateEmail(params: {
|
||||
templateSlug: string;
|
||||
to: string;
|
||||
toName?: string;
|
||||
variables: Record<string, any>;
|
||||
locale?: string;
|
||||
eventId?: string;
|
||||
sentBy?: string;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
|
||||
|
||||
// Get template
|
||||
const template = await getTemplate(templateSlug);
|
||||
if (!template) {
|
||||
return { success: false, error: `Template "${templateSlug}" not found` };
|
||||
}
|
||||
|
||||
// Build variables
|
||||
const allVariables = {
|
||||
...getCommonVariables(),
|
||||
lang: locale,
|
||||
...variables,
|
||||
};
|
||||
|
||||
// Get localized content
|
||||
const subject = locale === 'es' && template.subjectEs
|
||||
? template.subjectEs
|
||||
: template.subject;
|
||||
const bodyHtml = locale === 'es' && template.bodyHtmlEs
|
||||
? template.bodyHtmlEs
|
||||
: template.bodyHtml;
|
||||
const bodyText = locale === 'es' && template.bodyTextEs
|
||||
? template.bodyTextEs
|
||||
: template.bodyText;
|
||||
|
||||
// Replace variables
|
||||
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
|
||||
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
||||
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
|
||||
|
||||
// Create log entry
|
||||
const logId = generateId();
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailLogs).values({
|
||||
id: logId,
|
||||
templateId: template.id,
|
||||
eventId: eventId || null,
|
||||
recipientEmail: to,
|
||||
recipientName: toName || null,
|
||||
subject: finalSubject,
|
||||
bodyHtml: finalBodyHtml,
|
||||
status: 'pending',
|
||||
sentBy: sentBy || null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
to,
|
||||
subject: finalSubject,
|
||||
html: finalBodyHtml,
|
||||
text: finalBodyText,
|
||||
});
|
||||
|
||||
// Update log with result
|
||||
if (result.success) {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'sent',
|
||||
sentAt: getNow(),
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
} else {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: result.error,
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
logId,
|
||||
error: result.error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a custom email (not from template)
|
||||
*/
|
||||
export async function sendCustomEmail(params: {
|
||||
to: string;
|
||||
toName?: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
replyTo?: string;
|
||||
eventId?: string;
|
||||
sentBy?: string | null;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { to: rawTo, toName, subject: rawSubject, bodyHtml, bodyText, replyTo: rawReplyTo, eventId, sentBy = null } = params;
|
||||
|
||||
// Strip CR/LF from header-bound values to prevent email header injection
|
||||
// (e.g. an attacker-supplied subject/replyTo smuggling extra headers/recipients).
|
||||
const stripHeader = (v?: string) => (v ? v.replace(/[\r\n]+/g, ' ').trim() : v);
|
||||
const to = stripHeader(rawTo) as string;
|
||||
const subject = stripHeader(rawSubject) as string;
|
||||
const replyTo = stripHeader(rawReplyTo);
|
||||
|
||||
const allVariables = {
|
||||
...getCommonVariables(),
|
||||
subject,
|
||||
};
|
||||
|
||||
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
|
||||
|
||||
// Create log entry
|
||||
const logId = generateId();
|
||||
const now = getNow();
|
||||
|
||||
await (db as any).insert(emailLogs).values({
|
||||
id: logId,
|
||||
templateId: null,
|
||||
eventId: eventId || null,
|
||||
recipientEmail: to,
|
||||
recipientName: toName || null,
|
||||
subject,
|
||||
bodyHtml: finalBodyHtml,
|
||||
status: 'pending',
|
||||
sentBy: sentBy || null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html: finalBodyHtml,
|
||||
text: bodyText,
|
||||
replyTo,
|
||||
});
|
||||
|
||||
// Update log
|
||||
if (result.success) {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'sent',
|
||||
sentAt: getNow(),
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
} else {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: result.error,
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
logId,
|
||||
error: result.error
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend an email from an existing log entry
|
||||
*/
|
||||
export async function resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
|
||||
const log = await dbGet<any>(
|
||||
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
|
||||
);
|
||||
|
||||
if (!log) {
|
||||
return { success: false, error: 'Email log not found' };
|
||||
}
|
||||
|
||||
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
|
||||
return { success: false, error: 'Email log missing required data to resend' };
|
||||
}
|
||||
|
||||
const result = await sendEmail({
|
||||
to: log.recipientEmail,
|
||||
subject: log.subject,
|
||||
html: log.bodyHtml,
|
||||
text: undefined,
|
||||
});
|
||||
|
||||
const now = getNow();
|
||||
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
|
||||
|
||||
if (result.success) {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'sent',
|
||||
sentAt: now,
|
||||
errorMessage: null,
|
||||
resendAttempts: currentResendAttempts,
|
||||
lastResentAt: now,
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
} else {
|
||||
await (db as any)
|
||||
.update(emailLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: result.error,
|
||||
resendAttempts: currentResendAttempts,
|
||||
lastResentAt: now,
|
||||
})
|
||||
.where(eq((emailLogs as any).id, logId));
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
// Email transport layer: provider configuration, SMTP setup, and the low-level
|
||||
// sendEmail router. No template rendering or DB logging happens here.
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface SendEmailOptions {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type EmailProvider = 'resend' | 'smtp' | 'console';
|
||||
|
||||
// ==================== Provider Configuration ====================
|
||||
|
||||
function getEmailProvider(): EmailProvider {
|
||||
const provider = (process.env.EMAIL_PROVIDER || 'console').toLowerCase();
|
||||
if (provider === 'resend' || provider === 'smtp' || provider === 'console') {
|
||||
return provider;
|
||||
}
|
||||
console.warn(`[Email] Unknown provider "${provider}", falling back to console`);
|
||||
return 'console';
|
||||
}
|
||||
|
||||
function getFromEmail(): string {
|
||||
return process.env.EMAIL_FROM || 'noreply@spanglish.com';
|
||||
}
|
||||
|
||||
function getFromName(): string {
|
||||
return process.env.EMAIL_FROM_NAME || 'Spanglish';
|
||||
}
|
||||
|
||||
/** Provider info for diagnostics endpoints. */
|
||||
export function getProviderInfo(): { provider: EmailProvider; configured: boolean } {
|
||||
const provider = getEmailProvider();
|
||||
let configured = false;
|
||||
|
||||
switch (provider) {
|
||||
case 'resend':
|
||||
configured = !!(process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY);
|
||||
break;
|
||||
case 'smtp':
|
||||
configured = !!process.env.SMTP_HOST;
|
||||
break;
|
||||
case 'console':
|
||||
configured = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return { provider, configured };
|
||||
}
|
||||
|
||||
// ==================== SMTP Configuration ====================
|
||||
|
||||
interface SMTPConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth?: {
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
}
|
||||
|
||||
function getSMTPConfig(): SMTPConfig | null {
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = parseInt(process.env.SMTP_PORT || '587');
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
|
||||
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config: SMTPConfig = {
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
};
|
||||
|
||||
if (user && pass) {
|
||||
config.auth = { user, pass };
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Cached SMTP transporter
|
||||
let smtpTransporter: Transporter | null = null;
|
||||
|
||||
function getSMTPTransporter(): Transporter | null {
|
||||
if (smtpTransporter) {
|
||||
return smtpTransporter;
|
||||
}
|
||||
|
||||
const config = getSMTPConfig();
|
||||
if (!config) {
|
||||
console.error('[Email] SMTP configuration missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
smtpTransporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.auth,
|
||||
// Additional options for better deliverability
|
||||
pool: true,
|
||||
maxConnections: 5,
|
||||
maxMessages: 100,
|
||||
// TLS options
|
||||
tls: {
|
||||
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify connection configuration
|
||||
smtpTransporter.verify((error, success) => {
|
||||
if (error) {
|
||||
console.error('[Email] SMTP connection verification failed:', error.message);
|
||||
} else {
|
||||
console.log('[Email] SMTP server is ready to send emails');
|
||||
}
|
||||
});
|
||||
|
||||
return smtpTransporter;
|
||||
}
|
||||
|
||||
// ==================== Email Providers ====================
|
||||
|
||||
/**
|
||||
* Send email using Resend API
|
||||
*/
|
||||
async function sendWithResend(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const apiKey = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY;
|
||||
const fromEmail = getFromEmail();
|
||||
const fromName = getFromName();
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('[Email] Resend API key not configured');
|
||||
return { success: false, error: 'Resend API key not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: `${fromName} <${fromEmail}>`,
|
||||
to: Array.isArray(options.to) ? options.to : [options.to],
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text,
|
||||
reply_to: options.replyTo,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Email] Resend API error:', data);
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || data.error || 'Failed to send email'
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[Email] Email sent via Resend:', data.id);
|
||||
return {
|
||||
success: true,
|
||||
messageId: data.id
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[Email] Resend error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to send email via Resend'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email using SMTP (Nodemailer)
|
||||
*/
|
||||
async function sendWithSMTP(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const transporter = getSMTPTransporter();
|
||||
|
||||
if (!transporter) {
|
||||
return { success: false, error: 'SMTP not configured' };
|
||||
}
|
||||
|
||||
const fromEmail = getFromEmail();
|
||||
const fromName = getFromName();
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromEmail}>`,
|
||||
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
||||
replyTo: options.replyTo,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text,
|
||||
});
|
||||
|
||||
console.log('[Email] Email sent via SMTP:', info.messageId);
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[Email] SMTP error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Failed to send email via SMTP'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console logger for development/testing (no actual email sent)
|
||||
*/
|
||||
async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const to = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('[Email] Console Mode - Email Preview');
|
||||
console.log('========================================');
|
||||
console.log(`To: ${to}`);
|
||||
console.log(`Subject: ${options.subject}`);
|
||||
console.log(`Reply-To: ${options.replyTo || 'N/A'}`);
|
||||
console.log('----------------------------------------');
|
||||
console.log('HTML Body (truncated):');
|
||||
console.log(options.html?.substring(0, 500) + '...');
|
||||
console.log('========================================\n');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: `console-${Date.now()}`
|
||||
};
|
||||
}
|
||||
|
||||
// Mask an email address for logs: keep first char + domain (e.g. j***@example.com).
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = String(email).split('@');
|
||||
if (!domain) return '***';
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}***@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main send function that routes to the appropriate provider
|
||||
*/
|
||||
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const provider = getEmailProvider();
|
||||
|
||||
const recipientCount = Array.isArray(options.to) ? options.to.length : 1;
|
||||
const sample = Array.isArray(options.to) ? options.to[0] : options.to;
|
||||
console.log(`[Email] Sending email via ${provider} to ${maskEmail(sample)}${recipientCount > 1 ? ` (+${recipientCount - 1} more)` : ''}`);
|
||||
|
||||
switch (provider) {
|
||||
case 'resend':
|
||||
return sendWithResend(options);
|
||||
case 'smtp':
|
||||
return sendWithSMTP(options);
|
||||
case 'console':
|
||||
default:
|
||||
return sendWithConsole(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email configuration by sending a test email
|
||||
*/
|
||||
export async function testConnection(to: string): Promise<SendEmailResult> {
|
||||
const { provider, configured } = getProviderInfo();
|
||||
|
||||
if (!configured) {
|
||||
return { success: false, error: `Email provider "${provider}" is not configured` };
|
||||
}
|
||||
|
||||
return sendEmail({
|
||||
to,
|
||||
subject: 'Spanglish - Email Test',
|
||||
html: `
|
||||
<h2>Email Configuration Test</h2>
|
||||
<p>This is a test email from your Spanglish platform.</p>
|
||||
<p><strong>Provider:</strong> ${provider}</p>
|
||||
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
|
||||
<p>If you received this email, your email configuration is working correctly!</p>
|
||||
`,
|
||||
text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`,
|
||||
});
|
||||
}
|
||||
+159
-48
@@ -1,17 +1,16 @@
|
||||
// In-memory email queue with rate limiting
|
||||
// Processes emails asynchronously in the background without blocking the request thread
|
||||
// Durable email queue with rate limiting.
|
||||
// Jobs are persisted in the `email_queue` DB table so they survive process
|
||||
// restarts. Emails are processed asynchronously in the background without
|
||||
// blocking the request thread.
|
||||
|
||||
import { generateId } from './utils.js';
|
||||
import { eq, and, asc, sql } from 'drizzle-orm';
|
||||
import { db, dbGet, emailQueue } from '../db/index.js';
|
||||
import { generateId, getNow } from './utils.js';
|
||||
import { isRedisEnabled } from './redis.js';
|
||||
import { getRateLimiter } from './stores/rateLimiter.js';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface EmailJob {
|
||||
id: string;
|
||||
type: 'template';
|
||||
params: TemplateEmailJobParams;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface TemplateEmailJobParams {
|
||||
templateSlug: string;
|
||||
to: string;
|
||||
@@ -22,6 +21,11 @@ export interface TemplateEmailJobParams {
|
||||
sentBy?: string;
|
||||
}
|
||||
|
||||
interface ClaimedJob {
|
||||
id: string;
|
||||
params: TemplateEmailJobParams;
|
||||
}
|
||||
|
||||
export interface QueueStatus {
|
||||
queued: number;
|
||||
processing: boolean;
|
||||
@@ -31,7 +35,8 @@ export interface QueueStatus {
|
||||
|
||||
// ==================== Queue State ====================
|
||||
|
||||
const queue: EmailJob[] = [];
|
||||
// Tracks send timestamps for the per-process (non-Redis) sliding-window rate
|
||||
// limit. The job backlog itself lives in the database, not in memory.
|
||||
const sentTimestamps: number[] = [];
|
||||
let processing = false;
|
||||
let processTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -41,7 +46,6 @@ let _emailService: any = null;
|
||||
|
||||
function getEmailService() {
|
||||
if (!_emailService) {
|
||||
// Dynamic import to avoid circular dependency
|
||||
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
|
||||
}
|
||||
return _emailService;
|
||||
@@ -50,12 +54,31 @@ function getEmailService() {
|
||||
/**
|
||||
* Initialize the email queue with a reference to the email service.
|
||||
* Must be called once at startup.
|
||||
*
|
||||
* Also performs crash recovery: any jobs left in the 'processing' state by a
|
||||
* previous (crashed) process are reset to 'pending' so they get retried.
|
||||
*/
|
||||
export function initEmailQueue(emailService: any): void {
|
||||
_emailService = emailService;
|
||||
recoverProcessingJobs()
|
||||
.then((recovered) => {
|
||||
if (recovered > 0) {
|
||||
console.log(`[EmailQueue] Recovered ${recovered} in-flight job(s) after restart`);
|
||||
}
|
||||
scheduleProcessing();
|
||||
})
|
||||
.catch((err) => console.error('[EmailQueue] Recovery failed:', err?.message || err));
|
||||
console.log('[EmailQueue] Initialized');
|
||||
}
|
||||
|
||||
async function recoverProcessingJobs(): Promise<number> {
|
||||
const result: any = await (db as any)
|
||||
.update(emailQueue)
|
||||
.set({ status: 'pending' })
|
||||
.where(eq((emailQueue as any).status, 'processing'));
|
||||
return result?.changes ?? result?.rowCount ?? 0;
|
||||
}
|
||||
|
||||
// ==================== Rate Limiting ====================
|
||||
|
||||
function getMaxPerHour(): number {
|
||||
@@ -74,19 +97,26 @@ function cleanOldTimestamps(): void {
|
||||
|
||||
// ==================== Queue Operations ====================
|
||||
|
||||
async function insertJob(id: string, params: TemplateEmailJobParams): Promise<void> {
|
||||
await (db as any).insert(emailQueue).values({
|
||||
id,
|
||||
params: JSON.stringify(params),
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
createdAt: getNow(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single email job to the queue.
|
||||
* Returns the job ID.
|
||||
* Returns the job ID. Persistence happens asynchronously so the caller is not
|
||||
* blocked; processing is scheduled once the row is written.
|
||||
*/
|
||||
export function enqueueEmail(params: TemplateEmailJobParams): string {
|
||||
const id = generateId();
|
||||
queue.push({
|
||||
id,
|
||||
type: 'template',
|
||||
params,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
scheduleProcessing();
|
||||
insertJob(id, params)
|
||||
.then(() => scheduleProcessing())
|
||||
.catch((err) => console.error('[EmailQueue] Failed to enqueue email:', err?.message || err));
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -95,31 +125,32 @@ export function enqueueEmail(params: TemplateEmailJobParams): string {
|
||||
* Returns array of job IDs.
|
||||
*/
|
||||
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const params of paramsList) {
|
||||
const id = generateId();
|
||||
queue.push({
|
||||
id,
|
||||
type: 'template',
|
||||
params,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
ids.push(id);
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
const ids = paramsList.map(() => generateId());
|
||||
if (ids.length === 0) return ids;
|
||||
|
||||
Promise.all(paramsList.map((params, i) => insertJob(ids[i], params)))
|
||||
.then(() => {
|
||||
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
|
||||
scheduleProcessing();
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[EmailQueue] Failed to enqueue bulk emails:', err?.message || err));
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current queue status
|
||||
*/
|
||||
export function getQueueStatus(): QueueStatus {
|
||||
export async function getQueueStatus(): Promise<QueueStatus> {
|
||||
cleanOldTimestamps();
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emailQueue)
|
||||
.where(eq((emailQueue as any).status, 'pending'))
|
||||
);
|
||||
return {
|
||||
queued: queue.length,
|
||||
queued: Number(row?.count || 0),
|
||||
processing,
|
||||
sentInLastHour: sentTimestamps.length,
|
||||
maxPerHour: getMaxPerHour(),
|
||||
@@ -135,46 +166,125 @@ function scheduleProcessing(): void {
|
||||
setImmediate(() => processNext());
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim the oldest pending job by flipping its status to
|
||||
* 'processing'. Returns null if there is nothing to do. The conditional update
|
||||
* (WHERE status='pending') guards against two workers claiming the same row.
|
||||
*/
|
||||
async function claimNextJob(): Promise<ClaimedJob | null> {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ id: (emailQueue as any).id, params: (emailQueue as any).params })
|
||||
.from(emailQueue)
|
||||
.where(eq((emailQueue as any).status, 'pending'))
|
||||
.orderBy(asc((emailQueue as any).createdAt))
|
||||
.limit(1)
|
||||
);
|
||||
if (!row) return null;
|
||||
|
||||
const result: any = await (db as any)
|
||||
.update(emailQueue)
|
||||
.set({ status: 'processing' })
|
||||
.where(and(
|
||||
eq((emailQueue as any).id, row.id),
|
||||
eq((emailQueue as any).status, 'pending')
|
||||
));
|
||||
|
||||
const affected = result?.changes ?? result?.rowCount ?? 0;
|
||||
if (affected > 0) {
|
||||
try {
|
||||
return { id: row.id, params: JSON.parse(row.params) };
|
||||
} catch {
|
||||
// Corrupt params: mark failed and move on rather than crash-looping.
|
||||
await markJob(row.id, 'failed', 'Invalid job params (JSON parse failed)');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Lost the race for this row; try the next pending one.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function markJob(id: string, status: 'sent' | 'failed', error?: string | null): Promise<void> {
|
||||
const update: any = { status, processedAt: getNow() };
|
||||
if (status === 'failed') {
|
||||
update.attempts = sql`${(emailQueue as any).attempts} + 1`;
|
||||
if (error) update.lastError = error.slice(0, 1000);
|
||||
}
|
||||
await (db as any).update(emailQueue).set(update).where(eq((emailQueue as any).id, id));
|
||||
}
|
||||
|
||||
async function releaseJob(id: string): Promise<void> {
|
||||
await (db as any)
|
||||
.update(emailQueue)
|
||||
.set({ status: 'pending' })
|
||||
.where(eq((emailQueue as any).id, id));
|
||||
}
|
||||
|
||||
async function processNext(): Promise<void> {
|
||||
if (queue.length === 0) {
|
||||
let job: ClaimedJob | null;
|
||||
try {
|
||||
job = await claimNextJob();
|
||||
} catch (error: any) {
|
||||
// Database error while claiming: back off and retry rather than stop.
|
||||
console.error('[EmailQueue] Failed to claim next job:', error?.message || error);
|
||||
processTimer = setTimeout(() => processNext(), 5_000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!job) {
|
||||
processing = false;
|
||||
console.log('[EmailQueue] Queue empty. Processing stopped.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
// Rate limit check.
|
||||
// - Without Redis: per-process sliding window.
|
||||
// - With Redis: a shared hourly counter so the cap applies across all
|
||||
// instances rather than once per replica.
|
||||
cleanOldTimestamps();
|
||||
const maxPerHour = getMaxPerHour();
|
||||
let waitMs = 0;
|
||||
|
||||
if (sentTimestamps.length >= maxPerHour) {
|
||||
if (isRedisEnabled()) {
|
||||
const result = await getRateLimiter().consume('email:hourly', maxPerHour, 3_600_000);
|
||||
if (!result.allowed) {
|
||||
waitMs = (result.retryAfter ?? 60) * 1000 + 500; // 500ms buffer
|
||||
}
|
||||
} else if (sentTimestamps.length >= maxPerHour) {
|
||||
// Calculate when the oldest timestamp in the window expires
|
||||
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
|
||||
waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
|
||||
}
|
||||
|
||||
if (waitMs > 0) {
|
||||
// Put the claimed job back so it is retried after the cooldown.
|
||||
await releaseJob(job.id);
|
||||
console.log(
|
||||
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
|
||||
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
|
||||
`Pausing for ${Math.ceil(waitMs / 1000)}s.`
|
||||
);
|
||||
processTimer = setTimeout(() => processNext(), waitMs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dequeue and process
|
||||
const job = queue.shift()!;
|
||||
|
||||
try {
|
||||
const emailService = getEmailService();
|
||||
await emailService.sendTemplateEmail(job.params);
|
||||
sentTimestamps.push(Date.now());
|
||||
await markJob(job.id, 'sent');
|
||||
console.log(
|
||||
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
|
||||
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
||||
`Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
await markJob(job.id, 'failed', error?.message || String(error));
|
||||
console.error(
|
||||
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
|
||||
error?.message || error
|
||||
);
|
||||
// The sendTemplateEmail method already logs the failure in the email_logs table,
|
||||
// so we don't need to retry here. The error is logged and we move on.
|
||||
// The sendTemplateEmail method already logs the failure in the email_logs
|
||||
// table, so we just record it on the queue row and move on.
|
||||
}
|
||||
|
||||
// Small delay between sends to be gentle on the email server
|
||||
@@ -182,7 +292,8 @@ async function processNext(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop processing (for graceful shutdown)
|
||||
* Stop processing (for graceful shutdown). In-flight and pending jobs remain
|
||||
* persisted in the database and resume on the next startup.
|
||||
*/
|
||||
export function stopQueue(): void {
|
||||
if (processTimer) {
|
||||
@@ -190,5 +301,5 @@ export function stopQueue(): void {
|
||||
processTimer = null;
|
||||
}
|
||||
processing = false;
|
||||
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
|
||||
console.log('[EmailQueue] Stopped. Pending jobs remain persisted in the database.');
|
||||
}
|
||||
|
||||
@@ -1216,8 +1216,26 @@ Spanglish`,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to replace template variables
|
||||
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
|
||||
// Escape HTML-significant characters so substituted variable values can't inject markup.
|
||||
function escapeHtmlValue(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Helper function to replace template variables.
|
||||
// When `escapeHtml` is true (HTML rendering contexts), substituted *values* are
|
||||
// HTML-escaped to prevent stored-XSS via attacker-influenced variables (e.g. event
|
||||
// location, attendee name). The template markup itself is never escaped. Subjects and
|
||||
// plain-text bodies pass `escapeHtml=false` so they don't show literal entities.
|
||||
export function replaceTemplateVariables(
|
||||
template: string,
|
||||
variables: Record<string, any>,
|
||||
escapeHtml: boolean = false
|
||||
): string {
|
||||
let result = template;
|
||||
|
||||
// Handle conditional blocks {{#if variable}}...{{/if}}
|
||||
@@ -1229,16 +1247,20 @@ export function replaceTemplateVariables(template: string, variables: Record<str
|
||||
// Replace simple variables {{variable}}
|
||||
const variableRegex = /\{\{(\w+)\}\}/g;
|
||||
result = result.replace(variableRegex, (match, varName) => {
|
||||
return variables[varName] !== undefined ? String(variables[varName]) : match;
|
||||
if (variables[varName] === undefined) return match;
|
||||
const raw = String(variables[varName]);
|
||||
return escapeHtml ? escapeHtmlValue(raw) : raw;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to wrap content in the base template
|
||||
// Helper to wrap content in the base template.
|
||||
// `content` is treated as trusted HTML (it is the already-rendered body). The wrapper's
|
||||
// own variables (subject, year, etc.) are HTML-escaped on substitution.
|
||||
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
|
||||
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
|
||||
return replaceTemplateVariables(wrappedContent, variables);
|
||||
const wrappedContent = baseEmailWrapper.replace('{{content}}', () => content);
|
||||
return replaceTemplateVariables(wrappedContent, variables, true);
|
||||
}
|
||||
|
||||
// Get all available variables for a template by slug
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Auto-reject unconfirmed payments once their event is over.
|
||||
//
|
||||
// After an event ends, any booking whose payment was never confirmed
|
||||
// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored.
|
||||
// This job silently fails those payments and cancels their tickets so they stop
|
||||
// lingering as "pending" forever. It deliberately sends NO email — unlike the
|
||||
// admin reject route, this is a housekeeping sweep and users are not notified.
|
||||
//
|
||||
// An event is considered over when COALESCE(end_datetime, start_datetime) is in
|
||||
// the past. Updates are guarded by the current status so re-running is a no-op.
|
||||
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments, events } from '../db/index.js';
|
||||
import { getNow } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
|
||||
// Payment statuses that represent an unconfirmed booking.
|
||||
const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold'];
|
||||
// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled).
|
||||
const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold'];
|
||||
|
||||
/**
|
||||
* Fail unconfirmed payments (and cancel their tickets) for events that have
|
||||
* already ended. Returns the number of payments rejected.
|
||||
*/
|
||||
export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise<number> {
|
||||
// Pull candidate rows first, then decide "ended" in JS so the comparison works
|
||||
// identically for SQLite (ISO text) and Postgres (timestamp) datetime columns.
|
||||
const rows = await dbAll<{
|
||||
paymentId: string;
|
||||
ticketId: string;
|
||||
endDatetime: string | Date | null;
|
||||
startDatetime: string | Date | null;
|
||||
}>(
|
||||
(db as any)
|
||||
.select({
|
||||
paymentId: (payments as any).id,
|
||||
ticketId: (tickets as any).id,
|
||||
endDatetime: (events as any).endDatetime,
|
||||
startDatetime: (events as any).startDatetime,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.innerJoin(events, eq((tickets as any).eventId, (events as any).id))
|
||||
.where(and(
|
||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
||||
))
|
||||
);
|
||||
|
||||
const nowMs = Date.now();
|
||||
const ended = rows.filter((r) => {
|
||||
const ref = r.endDatetime || r.startDatetime;
|
||||
if (!ref) return false;
|
||||
return new Date(ref as any).getTime() < nowMs;
|
||||
});
|
||||
|
||||
if (ended.length === 0) return 0;
|
||||
|
||||
const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId)));
|
||||
const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id)));
|
||||
const now = getNow();
|
||||
|
||||
// Fail the payments. The status guard keeps this idempotent and avoids
|
||||
// clobbering anything that changed since we read the candidates.
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now })
|
||||
.where(and(
|
||||
inArray((payments as any).id, paymentIds),
|
||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
||||
));
|
||||
|
||||
// Cancel the associated tickets, freeing any seats they still hold.
|
||||
if (ticketIds.length > 0) {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
||||
));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` +
|
||||
`cancelled ${ticketIds.length} ticket(s).`
|
||||
);
|
||||
return paymentIds.length;
|
||||
}
|
||||
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start a periodic sweep that auto-rejects unconfirmed payments for ended
|
||||
* events. Each run is guarded by a distributed lock so that, across multiple
|
||||
* replicas, only one instance does the work per interval.
|
||||
*/
|
||||
export function startEventEndSweep(): void {
|
||||
const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
|
||||
|
||||
const run = () => {
|
||||
getLock()
|
||||
.withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () =>
|
||||
rejectUnconfirmedPaymentsForEndedEvents()
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error('[EventEndSweep] Run failed:', err?.message || err)
|
||||
);
|
||||
};
|
||||
|
||||
// Run shortly after startup, then on the interval.
|
||||
setTimeout(run, 60_000).unref?.();
|
||||
sweepTimer = setInterval(run, intervalMs);
|
||||
sweepTimer.unref?.();
|
||||
console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
||||
}
|
||||
|
||||
export function stopEventEndSweep(): void {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Per-event access control.
|
||||
//
|
||||
// Global roles still work exactly as before: every route passes the roles it
|
||||
// always allowed (`globalRoles`). On top of that, a user linked to one event
|
||||
// through event_members gets the permissions of their role preset, adjusted by
|
||||
// per-member overrides — for that event only. A collaborator with role 'user'
|
||||
// therefore reaches the routes of their own events and gets 403 everywhere
|
||||
// else.
|
||||
//
|
||||
// Finance and team management are deliberately NOT part of any global role
|
||||
// except admin: organizers only see them on events where an admin granted it.
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, dbGet, eventMembers, tickets, payments } from '../db/index.js';
|
||||
import { getAuthUser, type AuthUser } from './auth.js';
|
||||
|
||||
export const EVENT_PERMISSIONS = [
|
||||
'view_overview',
|
||||
'check_in',
|
||||
'view_attendees_names',
|
||||
'view_attendees_pii',
|
||||
'email_attendees',
|
||||
'view_payments',
|
||||
'view_finance',
|
||||
'edit_expenses',
|
||||
'edit_own_expenses_only',
|
||||
'view_full_split',
|
||||
'edit_event',
|
||||
'manage_team',
|
||||
] as const;
|
||||
export type EventPermission = (typeof EVENT_PERMISSIONS)[number];
|
||||
|
||||
export const ROLE_PRESETS = ['staff', 'collaborator', 'co_manager'] as const;
|
||||
export type RolePreset = (typeof ROLE_PRESETS)[number];
|
||||
|
||||
export const PRESET_PERMISSIONS: Record<RolePreset, readonly EventPermission[]> = {
|
||||
staff: ['view_overview', 'check_in', 'view_attendees_names'],
|
||||
// Sees the event's P&L and only their own share, not the full split.
|
||||
collaborator: ['view_overview', 'view_finance'],
|
||||
co_manager: EVENT_PERMISSIONS.filter((p) => p !== 'manage_team'),
|
||||
};
|
||||
|
||||
/**
|
||||
* What a global role can already do on every event, mirroring the existing
|
||||
* route allowlists. Used for the UI (which tabs to show) and for new routes.
|
||||
*/
|
||||
export const GLOBAL_ROLE_PERMISSIONS: Record<string, readonly EventPermission[]> = {
|
||||
admin: EVENT_PERMISSIONS,
|
||||
organizer: ['view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii', 'email_attendees', 'view_payments', 'edit_event'],
|
||||
staff: ['view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii'],
|
||||
};
|
||||
|
||||
export function isEventPermission(key: string): key is EventPermission {
|
||||
return (EVENT_PERMISSIONS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
export function parseOverrides(raw: unknown): Partial<Record<EventPermission, boolean>> {
|
||||
let obj: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { obj = JSON.parse(raw); } catch { obj = {}; }
|
||||
}
|
||||
const out: Partial<Record<EventPermission, boolean>> = {};
|
||||
if (obj && typeof obj === 'object') {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (isEventPermission(k) && typeof v === 'boolean') out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Preset permissions with the member's overrides applied in either direction. */
|
||||
export function resolveMemberPermissions(preset: string, overrides: unknown): Set<EventPermission> {
|
||||
const base = PRESET_PERMISSIONS[preset as RolePreset] || [];
|
||||
const set = new Set<EventPermission>(base);
|
||||
for (const [k, v] of Object.entries(parseOverrides(overrides))) {
|
||||
if (v) set.add(k as EventPermission); else set.delete(k as EventPermission);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
export interface EventAccess {
|
||||
eventId: string | null;
|
||||
/** True when the user's global role passed the route's allowlist. */
|
||||
global: boolean;
|
||||
role: string;
|
||||
permissions: Set<EventPermission>;
|
||||
membership: { id: string; rolePreset: RolePreset } | null;
|
||||
}
|
||||
|
||||
export async function getMembership(userId: string, eventId: string) {
|
||||
return dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(eventMembers)
|
||||
.where(and(eq((eventMembers as any).eventId, eventId), eq((eventMembers as any).userId, userId)))
|
||||
);
|
||||
}
|
||||
|
||||
/** Union of what the user's global role and their membership (if any) grant on this event. */
|
||||
export async function getEffectivePermissions(user: Pick<AuthUser, 'id' | 'role'>, eventId: string): Promise<EventAccess> {
|
||||
const permissions = new Set<EventPermission>(GLOBAL_ROLE_PERMISSIONS[user.role] || []);
|
||||
const member = await getMembership(user.id, eventId);
|
||||
if (member) {
|
||||
for (const p of resolveMemberPermissions(member.rolePreset, member.permissions)) permissions.add(p);
|
||||
}
|
||||
return {
|
||||
eventId,
|
||||
global: user.role === 'admin',
|
||||
role: user.role,
|
||||
permissions,
|
||||
membership: member ? { id: member.id, rolePreset: member.rolePreset } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function canUnfinalize(access: EventAccess): boolean {
|
||||
return access.role === 'admin' || access.membership?.rolePreset === 'co_manager';
|
||||
}
|
||||
|
||||
// ==================== Event id resolvers ====================
|
||||
|
||||
type EventIdResolver = (c: Context) => Promise<string | null> | string | null;
|
||||
|
||||
export const eventFromParam = (name = 'id'): EventIdResolver => (c) => c.req.param(name) || null;
|
||||
|
||||
export const eventFromQuery = (name = 'eventId'): EventIdResolver => (c) => c.req.query(name) || null;
|
||||
|
||||
/** Reads eventId from a JSON body. Hono caches the parsed body, so validators can read it again. */
|
||||
export const eventFromBody = (name = 'eventId'): EventIdResolver => async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
return typeof body?.[name] === 'string' ? body[name] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Query string first, then the JSON body (routes that accept both). */
|
||||
export const eventFromQueryOrBody = (name = 'eventId'): EventIdResolver => async (c) =>
|
||||
eventFromQuery(name)(c) || (c.req.method === 'GET' ? null : await eventFromBody(name)(c));
|
||||
|
||||
export const eventFromTicketParam = (name = 'id'): EventIdResolver => async (c) => {
|
||||
const id = c.req.param(name);
|
||||
if (!id) return null;
|
||||
const row = await dbGet<any>(
|
||||
(db as any).select({ eventId: (tickets as any).eventId }).from(tickets).where(eq((tickets as any).id, id))
|
||||
);
|
||||
return row?.eventId ?? null;
|
||||
};
|
||||
|
||||
export const eventFromPaymentParam = (name = 'id'): EventIdResolver => async (c) => {
|
||||
const id = c.req.param(name);
|
||||
if (!id) return null;
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ eventId: (tickets as any).eventId })
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(eq((payments as any).id, id))
|
||||
);
|
||||
return row?.eventId ?? null;
|
||||
};
|
||||
|
||||
// ==================== Middleware ====================
|
||||
|
||||
export interface RequireEventPermissionOptions {
|
||||
/** Global roles that pass on every event (the route's existing allowlist). Default: admin only. */
|
||||
globalRoles?: readonly string[];
|
||||
/** Where the event id comes from. Default: the :id path param. */
|
||||
eventId?: EventIdResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow the request when the user's global role is in `globalRoles`, or when
|
||||
* their membership on the resolved event grants any of `keys`. Sets
|
||||
* c.get('user') like requireAuth, and c.get('eventAccess') for handlers that
|
||||
* shape their response (e.g. hide attendee contact details).
|
||||
*/
|
||||
export function requireEventPermission(
|
||||
keys: EventPermission | readonly EventPermission[],
|
||||
opts: RequireEventPermissionOptions = {},
|
||||
) {
|
||||
const wanted = (Array.isArray(keys) ? keys : [keys]) as readonly EventPermission[];
|
||||
const globalRoles = opts.globalRoles || ['admin'];
|
||||
const resolve = opts.eventId || eventFromParam('id');
|
||||
|
||||
return async (c: Context, next: () => Promise<void>) => {
|
||||
const user = await getAuthUser(c);
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
c.set('user', user);
|
||||
|
||||
if (globalRoles.includes(user.role)) {
|
||||
const eventId = await resolve(c);
|
||||
c.set('eventAccess', {
|
||||
eventId,
|
||||
global: true,
|
||||
role: user.role,
|
||||
permissions: new Set(GLOBAL_ROLE_PERMISSIONS[user.role] || EVENT_PERMISSIONS),
|
||||
membership: null,
|
||||
} satisfies EventAccess);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = await resolve(c);
|
||||
if (!eventId) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
const access = await getEffectivePermissions(user, eventId);
|
||||
if (!wanted.some((k) => access.permissions.has(k))) {
|
||||
return c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: wanted[0] }, 403);
|
||||
}
|
||||
c.set('eventAccess', access);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export function getEventAccess(c: Context): EventAccess | null {
|
||||
return ((c as any).get('eventAccess') as EventAccess | undefined) || null;
|
||||
}
|
||||
|
||||
/** True when the request may see attendee contact details (email, phone, RUC). */
|
||||
export function canSeeAttendeePii(c: Context): boolean {
|
||||
const access = getEventAccess(c);
|
||||
return !access || access.global || access.permissions.has('view_attendees_pii');
|
||||
}
|
||||
|
||||
/** Drop attendee contact details for members without view_attendees_pii. */
|
||||
export function redactAttendee<T extends Record<string, any>>(t: T): T {
|
||||
const { attendeeEmail, attendeePhone, attendeeRuc, ...rest } = t as any;
|
||||
return { ...rest, attendeeEmail: null, attendeePhone: null, attendeeRuc: null } as T;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// finance_audit_log rows. Returned as TxOps so every change and its audit row
|
||||
// commit (or roll back) together.
|
||||
|
||||
import { financeAuditLog } from '../../db/index.js';
|
||||
import { generateId, getNow } from '../utils.js';
|
||||
import { insertOp, type TxOp } from '../txOps.js';
|
||||
|
||||
export type FinanceEntity =
|
||||
| 'expense' | 'other_income' | 'partner' | 'finance_state' | 'member'
|
||||
| 'expense_category' | 'expense_template' | 'expense_template_pack' | 'payment_fee';
|
||||
|
||||
export function financeAuditOp(entry: {
|
||||
eventId: string | null;
|
||||
actorUserId: string;
|
||||
entityType: FinanceEntity;
|
||||
entityId: string | null;
|
||||
action: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
}): TxOp {
|
||||
return insertOp(financeAuditLog, {
|
||||
id: generateId(),
|
||||
eventId: entry.eventId,
|
||||
actorUserId: entry.actorUserId,
|
||||
entityType: entry.entityType,
|
||||
entityId: entry.entityId,
|
||||
action: entry.action,
|
||||
beforeJson: entry.before === undefined || entry.before === null ? null : JSON.stringify(entry.before),
|
||||
afterJson: entry.after === undefined || entry.after === null ? null : JSON.stringify(entry.after),
|
||||
createdAt: getNow(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculateEventFinance,
|
||||
expenseAmount,
|
||||
paymentFee,
|
||||
roundPyg,
|
||||
type FinanceExpense,
|
||||
type FinanceInput,
|
||||
type FinancePartner,
|
||||
type FinancePayment,
|
||||
} from './calculate.js';
|
||||
|
||||
let seq = 0;
|
||||
const pay = (amount: number, over: Partial<FinancePayment> = {}): FinancePayment => ({
|
||||
id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over,
|
||||
});
|
||||
const expense = (over: Partial<FinanceExpense> = {}): FinanceExpense => ({
|
||||
id: `e${++seq}`, description: 'x', categoryId: null, calcType: 'fixed', quantity: 1, unitAmount: 0, percentBp: 0,
|
||||
minimumAmount: 0, computedAmount: 0, isLocked: false, status: 'planned', paidByPartnerId: null, ...over,
|
||||
});
|
||||
const partner = (over: Partial<FinancePartner> = {}): FinancePartner => ({
|
||||
id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0,
|
||||
lossRule: 'none', lossCapAmount: 0, ...over,
|
||||
});
|
||||
const input = (over: Partial<FinanceInput> = {}): FinanceInput => ({
|
||||
ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over,
|
||||
});
|
||||
const sumShares = (r: ReturnType<typeof calculateEventFinance>) =>
|
||||
r.split.partners.reduce((s, p) => s + p.share, 0) + r.split.organization;
|
||||
|
||||
describe('calculateEventFinance: profit case', () => {
|
||||
const studio = partner({ name: 'Studio', percentBp: 3000 });
|
||||
const r = calculateEventFinance(input({
|
||||
ticketsSold: 20,
|
||||
checkedIn: 18,
|
||||
payments: [
|
||||
...Array.from({ length: 15 }, () => pay(100000)),
|
||||
...Array.from({ length: 3 }, () => pay(100000, { provider: 'lightning' })),
|
||||
pay(120000, { provider: 'cash', source: 'door', paidAt: '2026-09-05T20:00:00Z' }),
|
||||
pay(120000, { provider: 'pos', source: 'door', paidAt: '2026-09-05T20:10:00Z' }),
|
||||
],
|
||||
fees: [
|
||||
{ method: 'tpago', percentBp: 350, fixedAmount: 0 }, // 3.5%
|
||||
{ method: 'pos', percentBp: 290, fixedAmount: 500 },
|
||||
],
|
||||
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 150000 }],
|
||||
expenses: [expense({ unitAmount: 800000, status: 'paid' }), expense({ calcType: 'per_checked_in', unitAmount: 15000 })],
|
||||
partners: [studio],
|
||||
}));
|
||||
|
||||
it('splits gross into pre-sale and door and by method', () => {
|
||||
expect(r.revenue.gross).toBe(2040000);
|
||||
expect(r.revenue.presale).toBe(1800000);
|
||||
expect(r.revenue.door).toBe(240000);
|
||||
expect(r.revenue.byMethod.map((m) => [m.method, m.gross])).toEqual([
|
||||
['tpago', 1500000], ['lightning', 300000], ['cash', 120000], ['pos', 120000],
|
||||
]);
|
||||
});
|
||||
|
||||
it('charges fees per payment from the method rules', () => {
|
||||
// tpago: 15 x 3500; pos: 3480 + 500; lightning and cash have no rule
|
||||
expect(r.revenue.fees).toBe(15 * 3500 + 3980);
|
||||
expect(r.revenue.byMethod.find((m) => m.method === 'pos')!.fees).toBe(3980);
|
||||
});
|
||||
|
||||
it('adds other income into net revenue and subtracts expenses', () => {
|
||||
expect(r.revenue.net).toBe(2040000 - 56480 + 150000);
|
||||
expect(r.expenses.total).toBe(800000 + 18 * 15000);
|
||||
expect(r.expenses.paid).toBe(800000);
|
||||
expect(r.expenses.planned).toBe(270000);
|
||||
expect(r.profit).toBe(2133520 - 1070000);
|
||||
});
|
||||
|
||||
it('gives the partner its percentage of profit and the organization the rest', () => {
|
||||
expect(r.split.partners[0].share).toBe(roundPyg(1063520 * 0.3));
|
||||
expect(r.split.organization).toBe(1063520 - roundPyg(1063520 * 0.3));
|
||||
expect(sumShares(r)).toBe(r.profit);
|
||||
});
|
||||
|
||||
it('builds a cumulative sales timeline and a waterfall ending in the split', () => {
|
||||
expect(r.salesTimeline).toEqual([
|
||||
{ date: '2026-09-01', tickets: 18, revenue: 1800000 },
|
||||
{ date: '2026-09-05', tickets: 20, revenue: 2040000 },
|
||||
]);
|
||||
expect(r.waterfall.map((w) => w.key)).toEqual([
|
||||
'gross', 'refunds', 'fees', 'otherIncome', 'net', 'expenses', 'profit', `partner:${studio.id}`, 'organization',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: refunds', () => {
|
||||
it('subtracts refunded payments and charges no fee on them', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(100000), pay(100000), pay(100000, { status: 'refunded' })],
|
||||
fees: [{ method: 'tpago', percentBp: 1000, fixedAmount: 0 }],
|
||||
}));
|
||||
expect(r.revenue.gross).toBe(300000);
|
||||
expect(r.revenue.refunds).toBe(100000);
|
||||
expect(r.revenue.sales).toBe(200000);
|
||||
expect(r.revenue.fees).toBe(20000);
|
||||
expect(r.revenue.net).toBe(180000);
|
||||
expect(r.salesTimeline.at(-1)).toEqual({ date: '2026-09-01', tickets: 2, revenue: 200000 });
|
||||
});
|
||||
|
||||
it('uses sales after refunds as the base for revenue percentages', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(100000), pay(100000, { status: 'refunded' })],
|
||||
expenses: [expense({ calcType: 'percent_of_revenue', percentBp: 1000 })],
|
||||
}));
|
||||
expect(r.expenses.total).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expense calc types', () => {
|
||||
const ctx = { ticketsSold: 30, checkedIn: 25, sales: 3000000 };
|
||||
|
||||
it('fixed multiplies units by the unit amount', () => {
|
||||
expect(expenseAmount(expense({ quantity: 3, unitAmount: 50000 }), ctx)).toEqual({ quantity: 3, amount: 150000 });
|
||||
});
|
||||
|
||||
it('per_ticket_sold follows tickets sold', () => {
|
||||
expect(expenseAmount(expense({ calcType: 'per_ticket_sold', unitAmount: 10000 }), ctx)).toEqual({ quantity: 30, amount: 300000 });
|
||||
});
|
||||
|
||||
it('per_checked_in follows check-ins', () => {
|
||||
expect(expenseAmount(expense({ calcType: 'per_checked_in', unitAmount: 10000 }), ctx)).toEqual({ quantity: 25, amount: 250000 });
|
||||
});
|
||||
|
||||
it('percent_of_revenue takes basis points of sales, rounded to the guaraní', () => {
|
||||
expect(expenseAmount(expense({ calcType: 'percent_of_revenue', percentBp: 1250 }), ctx).amount).toBe(375000);
|
||||
expect(expenseAmount(expense({ calcType: 'percent_of_revenue', percentBp: 333 }), { ...ctx, sales: 1001 }).amount).toBe(33);
|
||||
});
|
||||
|
||||
it('minimum_spend charges the minimum until per-head spend passes it', () => {
|
||||
const e = expense({ calcType: 'minimum_spend', unitAmount: 40000, minimumAmount: 1500000 });
|
||||
expect(expenseAmount(e, { ...ctx, checkedIn: 20 }).amount).toBe(1500000); // 800k < minimum
|
||||
expect(expenseAmount(e, { ...ctx, checkedIn: 50 }).amount).toBe(2000000); // 2.0M > minimum
|
||||
expect(expenseAmount(e, { ...ctx, checkedIn: 0 }).amount).toBe(1500000);
|
||||
});
|
||||
|
||||
it('locked rows keep their stored amount regardless of counts', () => {
|
||||
const e = expense({ calcType: 'per_checked_in', unitAmount: 10000, isLocked: true, computedAmount: 123000, quantity: 12 });
|
||||
expect(expenseAmount(e, ctx)).toEqual({ quantity: 12, amount: 123000 });
|
||||
const r = calculateEventFinance(input({ checkedIn: 99, expenses: [e] }));
|
||||
expect(r.expenses.lines[0]).toMatchObject({ amount: 123000, auto: false });
|
||||
});
|
||||
|
||||
it('marks unlocked non-fixed rows as auto-calculated', () => {
|
||||
const r = calculateEventFinance(input({ expenses: [expense(), expense({ calcType: 'per_ticket_sold' })] }));
|
||||
expect(r.expenses.lines.map((l) => l.auto)).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: loss rules', () => {
|
||||
// Net revenue 500k, expenses 1.5M => profit -1,000,000
|
||||
const lossInput = (p: FinancePartner) => input({
|
||||
payments: [pay(500000)],
|
||||
expenses: [expense({ unitAmount: 1500000 })],
|
||||
partners: [p],
|
||||
});
|
||||
|
||||
it('proportional: the partner carries its percentage of the loss', () => {
|
||||
const r = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'proportional' })));
|
||||
expect(r.profit).toBe(-1000000);
|
||||
expect(r.split.partners[0].share).toBe(-400000);
|
||||
expect(r.split.organization).toBe(-600000);
|
||||
expect(sumShares(r)).toBe(r.profit);
|
||||
});
|
||||
|
||||
it('none: the organization carries the whole loss', () => {
|
||||
const r = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'none' })));
|
||||
expect(r.split.partners[0].share).toBe(0);
|
||||
expect(r.split.organization).toBe(-1000000);
|
||||
});
|
||||
|
||||
it('capped: the partner carries its percentage up to the cap', () => {
|
||||
const capped = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 250000 })));
|
||||
expect(capped.split.partners[0].share).toBe(-250000);
|
||||
expect(capped.split.organization).toBe(-750000);
|
||||
// A cap larger than the proportional share does not increase it
|
||||
const loose = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 900000 })));
|
||||
expect(loose.split.partners[0].share).toBe(-400000);
|
||||
});
|
||||
|
||||
it('loss rules do not change a profitable split', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(1000000)],
|
||||
partners: [partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 1 })],
|
||||
}));
|
||||
expect(r.split.partners[0].share).toBe(400000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: reimbursements', () => {
|
||||
it('adds paid costs a partner fronted to their payout without counting them twice', () => {
|
||||
const host = partner({ name: 'Host', percentBp: 5000 });
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(1000000)],
|
||||
expenses: [
|
||||
expense({ unitAmount: 200000, status: 'paid', paidByPartnerId: host.id }),
|
||||
expense({ unitAmount: 100000, status: 'planned', paidByPartnerId: host.id }), // not fronted yet
|
||||
expense({ unitAmount: 100000, status: 'paid' }),
|
||||
],
|
||||
partners: [host],
|
||||
}));
|
||||
expect(r.profit).toBe(600000);
|
||||
const h = r.split.partners[0];
|
||||
expect(h.reimbursement).toBe(200000);
|
||||
expect(h.share).toBe(300000);
|
||||
expect(h.payout).toBe(500000);
|
||||
expect(r.split.organization).toBe(300000);
|
||||
expect(sumShares(r)).toBe(r.profit);
|
||||
});
|
||||
|
||||
it('nets a loss share against a reimbursement', () => {
|
||||
const host = partner({ percentBp: 5000, lossRule: 'proportional' });
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(100000)],
|
||||
expenses: [expense({ unitAmount: 300000, status: 'paid', paidByPartnerId: host.id })],
|
||||
partners: [host],
|
||||
}));
|
||||
expect(r.profit).toBe(-200000);
|
||||
expect(r.split.partners[0]).toMatchObject({ share: -100000, reimbursement: 300000, payout: 200000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: partner share types', () => {
|
||||
it('percent_revenue is paid on sales after refunds, before profit shares', () => {
|
||||
const venue = partner({ name: 'Venue', shareType: 'percent_revenue', percentBp: 2000 });
|
||||
const cohost = partner({ name: 'Co-host', shareType: 'percent_profit', percentBp: 5000 });
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(600000), pay(400000), pay(100000, { status: 'refunded' })],
|
||||
expenses: [expense({ unitAmount: 300000 })],
|
||||
partners: [venue, cohost],
|
||||
}));
|
||||
expect(r.profit).toBe(700000);
|
||||
expect(r.split.partners[0].share).toBe(200000); // 20% of 1,000,000
|
||||
expect(r.split.distributable).toBe(500000);
|
||||
expect(r.split.partners[1].share).toBe(250000);
|
||||
expect(r.split.organization).toBe(250000);
|
||||
expect(sumShares(r)).toBe(r.profit);
|
||||
});
|
||||
|
||||
it('percent_revenue is still owed when the event loses money', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
payments: [pay(500000)],
|
||||
expenses: [expense({ unitAmount: 800000 })],
|
||||
partners: [partner({ shareType: 'percent_revenue', percentBp: 1000 })],
|
||||
}));
|
||||
expect(r.split.partners[0].share).toBe(50000);
|
||||
expect(r.split.organization).toBe(-350000);
|
||||
});
|
||||
|
||||
it('fixed is owed regardless of profit', () => {
|
||||
const r = calculateEventFinance(input({ partners: [partner({ shareType: 'fixed', fixedAmount: 300000 })] }));
|
||||
expect(r.split.partners[0].share).toBe(300000);
|
||||
expect(r.split.organization).toBe(-300000);
|
||||
});
|
||||
|
||||
it('fixed_plus_percent_above_threshold pays the fixed part plus a percentage above the threshold', () => {
|
||||
const p = partner({ shareType: 'fixed_plus_percent_above_threshold', fixedAmount: 100000, percentBp: 1000, thresholdAmount: 500000 });
|
||||
const high = calculateEventFinance(input({ payments: [pay(1600000)], partners: [p] }));
|
||||
// distributable = 1.6M - 100k fixed = 1.5M; 10% of (1.5M - 500k) = 100k
|
||||
expect(high.split.partners[0].share).toBe(200000);
|
||||
expect(sumShares(high)).toBe(high.profit);
|
||||
const low = calculateEventFinance(input({ payments: [pay(400000)], partners: [p] }));
|
||||
expect(low.split.partners[0].share).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: break-even', () => {
|
||||
it('finds the smallest ticket count that covers all expenses', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000,
|
||||
ticketsSold: 4,
|
||||
expenses: [expense({ unitAmount: 1000000 }), expense({ calcType: 'per_ticket_sold', unitAmount: 20000 })],
|
||||
}));
|
||||
// 80k contribution per ticket => ceil(1,000,000 / 80,000) = 13
|
||||
expect(r.breakEven).toEqual({ tickets: 13, ticketPrice: 100000, remaining: 9 });
|
||||
});
|
||||
|
||||
it('accounts for minimum spend, other income and fees', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000,
|
||||
fees: [{ method: 'tpago', percentBp: 1000, fixedAmount: 0 }],
|
||||
otherIncome: [{ id: 'i', description: 's', amount: 90000 }],
|
||||
expenses: [expense({ calcType: 'minimum_spend', unitAmount: 30000, minimumAmount: 900000 })],
|
||||
}));
|
||||
// 90k net per ticket, 90k income: 9 tickets => 810k + 90k = 900k = minimum
|
||||
expect(r.breakEven.tickets).toBe(9);
|
||||
});
|
||||
|
||||
it('is null when a ticket cannot cover its own variable cost, or the price is 0', () => {
|
||||
expect(calculateEventFinance(input({ expenses: [expense({ calcType: 'per_ticket_sold', unitAmount: 150000 }), expense({ unitAmount: 1 })] })).breakEven.tickets).toBeNull();
|
||||
expect(calculateEventFinance(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1 })] })).breakEven.tickets).toBeNull();
|
||||
});
|
||||
|
||||
it('is 0 when there is nothing to cover', () => {
|
||||
expect(calculateEventFinance(input()).breakEven.tickets).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paymentFee', () => {
|
||||
it('adds the fixed part and skips free payments', () => {
|
||||
expect(paymentFee(100000, { method: 'pos', percentBp: 290, fixedAmount: 500 })).toBe(3400);
|
||||
expect(paymentFee(0, { method: 'pos', percentBp: 290, fixedAmount: 500 })).toBe(0);
|
||||
expect(paymentFee(100000, undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression scenarios pinned while reworking the Finance tab UI: the UI now
|
||||
// derives its waterfall, break-even and lifecycle from these results, so the
|
||||
// numbers themselves must not move.
|
||||
describe('calculateEventFinance: pinned scenarios', () => {
|
||||
const tpagoFee = { method: 'tpago', percentBp: 290, fixedAmount: 0 };
|
||||
|
||||
it('no expenses: profit is revenue after fees and break-even is 0', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||
payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
|
||||
fees: [tpagoFee],
|
||||
}));
|
||||
expect(r.revenue).toMatchObject({ gross: 200000, presale: 200000, door: 0, fees: 5800, sales: 200000, net: 194200 });
|
||||
expect(r.expenses.total).toBe(0);
|
||||
expect(r.profit).toBe(194200);
|
||||
expect(r.breakEven).toEqual({ tickets: 0, ticketPrice: 50000, remaining: 0 });
|
||||
expect(r.split).toEqual({ distributable: 194200, partners: [], organization: 194200 });
|
||||
});
|
||||
|
||||
it('a loss: planned and paid costs above revenue, break-even beyond sales', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||
payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
|
||||
fees: [tpagoFee],
|
||||
expenses: [
|
||||
expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000, status: 'paid' }),
|
||||
expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
|
||||
],
|
||||
}));
|
||||
expect(r.revenue).toMatchObject({ gross: 200000, presale: 150000, door: 50000, fees: 4350, net: 195650 });
|
||||
expect(r.expenses).toMatchObject({ total: 340000, paid: 300000, planned: 40000 });
|
||||
expect(r.profit).toBe(-144350);
|
||||
// 218 bp fee mix: each extra ticket adds 50000 - 1090 - 10000 = 38910 against 300000 fixed.
|
||||
expect(r.breakEven).toEqual({ tickets: 8, ticketPrice: 50000, remaining: 4 });
|
||||
expect(r.split.organization).toBe(-144350);
|
||||
});
|
||||
|
||||
it('partners: fixed deal first, then percent of what is left, reimbursement on top', () => {
|
||||
const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
|
||||
const venue = partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 });
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
|
||||
payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
|
||||
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
|
||||
expenses: [
|
||||
expense({ calcType: 'fixed', quantity: 1, unitAmount: 200000, status: 'paid' }),
|
||||
expense({ calcType: 'fixed', quantity: 1, unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
|
||||
],
|
||||
partners: [ana, venue],
|
||||
}));
|
||||
expect(r.revenue).toMatchObject({ gross: 1000000, fees: 0, otherIncome: 50000, net: 1050000 });
|
||||
expect(r.profit).toBe(700000);
|
||||
expect(r.split.distributable).toBe(600000);
|
||||
const byName = Object.fromEntries(r.split.partners.map((p) => [p.name, p]));
|
||||
expect(byName.Ana).toMatchObject({ share: 180000, reimbursement: 150000, payout: 330000 });
|
||||
expect(byName['Venue Co']).toMatchObject({ share: 100000, reimbursement: 0, payout: 100000 });
|
||||
expect(r.split.organization).toBe(420000);
|
||||
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateEventFinance: break-even edges', () => {
|
||||
it('lands exactly on the ticket where profit reaches 0', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000, ticketsSold: 1,
|
||||
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000 })],
|
||||
}));
|
||||
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 2 });
|
||||
});
|
||||
|
||||
it('reports 0 remaining once sales pass break-even', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000, ticketsSold: 5,
|
||||
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 250000 })],
|
||||
}));
|
||||
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 0 });
|
||||
});
|
||||
|
||||
it('other income can cover costs before any ticket', () => {
|
||||
const r = calculateEventFinance(input({
|
||||
ticketPrice: 100000,
|
||||
otherIncome: [{ id: 'i', description: 'Sponsor', amount: 500000 }],
|
||||
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000 })],
|
||||
}));
|
||||
expect(r.breakEven.tickets).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Event P&L: a pure function from an event's money facts to the numbers the
|
||||
* Finance tab, the partner statements and the finalize snapshot show.
|
||||
*
|
||||
* Everything is whole PYG. Percentages arrive in basis points (290 = 2.90%)
|
||||
* and every percentage product is rounded to the nearest guaraní once, at the
|
||||
* line it applies to, so totals always equal the sum of the lines shown.
|
||||
*
|
||||
* The waterfall:
|
||||
* gross (paid + later-refunded payments)
|
||||
* - refunds
|
||||
* - payment fees (per payment, from payment_method_fees by provider)
|
||||
* + other income
|
||||
* = net revenue
|
||||
* - expenses (planned and paid; auto rows follow the current counts unless locked)
|
||||
* = profit / loss
|
||||
* then the split:
|
||||
* 1. fixed amounts and revenue shares are owed regardless of profit, so they
|
||||
* come off first and leave the distributable profit;
|
||||
* 2. profit shares are taken from the distributable profit, applying each
|
||||
* partner's loss rule when it is negative;
|
||||
* 3. the organization keeps the remainder.
|
||||
* Reimbursements for costs a partner fronted are already inside expenses (so
|
||||
* they are not deducted twice); they are added to that partner's payout.
|
||||
*/
|
||||
|
||||
export const CALC_TYPES = ['fixed', 'per_ticket_sold', 'per_checked_in', 'percent_of_revenue', 'minimum_spend'] as const;
|
||||
export type CalcType = (typeof CALC_TYPES)[number];
|
||||
|
||||
export const SHARE_TYPES = ['percent_profit', 'percent_revenue', 'fixed', 'fixed_plus_percent_above_threshold'] as const;
|
||||
export type ShareType = (typeof SHARE_TYPES)[number];
|
||||
|
||||
export const LOSS_RULES = ['proportional', 'none', 'capped'] as const;
|
||||
export type LossRule = (typeof LOSS_RULES)[number];
|
||||
|
||||
export interface FinancePayment {
|
||||
id: string;
|
||||
/** PYG recorded at payment time (Lightning included). */
|
||||
amount: number;
|
||||
/** payments.provider: tpago | bank_transfer | lightning | cash | pos | bancard */
|
||||
provider: string;
|
||||
source: 'presale' | 'door';
|
||||
status: 'paid' | 'refunded';
|
||||
paidAt: string | null;
|
||||
}
|
||||
|
||||
export interface FinanceExpense {
|
||||
id: string;
|
||||
description: string;
|
||||
categoryId: string | null;
|
||||
calcType: CalcType;
|
||||
quantity: number;
|
||||
unitAmount: number;
|
||||
percentBp: number;
|
||||
minimumAmount: number;
|
||||
/** Stored amount; authoritative for locked rows. */
|
||||
computedAmount: number;
|
||||
isLocked: boolean;
|
||||
status: 'planned' | 'paid';
|
||||
/** null = paid by the organization. */
|
||||
paidByPartnerId: string | null;
|
||||
}
|
||||
|
||||
export interface FinanceOtherIncome {
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface FeeRule {
|
||||
method: string;
|
||||
percentBp: number;
|
||||
fixedAmount: number;
|
||||
}
|
||||
|
||||
export interface FinancePartner {
|
||||
id: string;
|
||||
name: string;
|
||||
shareType: ShareType;
|
||||
percentBp: number;
|
||||
fixedAmount: number;
|
||||
thresholdAmount: number;
|
||||
lossRule: LossRule;
|
||||
lossCapAmount: number;
|
||||
}
|
||||
|
||||
export interface FinanceInput {
|
||||
/** Current online ticket price, used for break-even. */
|
||||
ticketPrice: number;
|
||||
/** Paid, non-cancelled tickets (comps excluded). */
|
||||
ticketsSold: number;
|
||||
/** Checked-in tickets, comps included (they still eat and drink). */
|
||||
checkedIn: number;
|
||||
payments: FinancePayment[];
|
||||
expenses: FinanceExpense[];
|
||||
otherIncome: FinanceOtherIncome[];
|
||||
fees: FeeRule[];
|
||||
partners: FinancePartner[];
|
||||
}
|
||||
|
||||
export interface ExpenseLine {
|
||||
id: string;
|
||||
/** Count the amount was derived from (units, tickets or check-ins). */
|
||||
quantity: number;
|
||||
amount: number;
|
||||
/** True when the amount follows ticket counts / revenue. */
|
||||
auto: boolean;
|
||||
}
|
||||
|
||||
export interface MethodTotals {
|
||||
method: string;
|
||||
count: number;
|
||||
gross: number;
|
||||
refunds: number;
|
||||
fees: number;
|
||||
net: number;
|
||||
}
|
||||
|
||||
export interface PartnerResult {
|
||||
partnerId: string;
|
||||
name: string;
|
||||
shareType: ShareType;
|
||||
/** What the base amount was for the percentage part (profit or revenue). */
|
||||
basis: number;
|
||||
/** Positive = paid to the partner; negative = the partner carries part of a loss. */
|
||||
share: number;
|
||||
reimbursement: number;
|
||||
/** share + reimbursement. Negative means the partner owes the organization. */
|
||||
payout: number;
|
||||
}
|
||||
|
||||
export interface FinanceResult {
|
||||
counts: { ticketsSold: number; checkedIn: number; payments: number };
|
||||
revenue: {
|
||||
gross: number;
|
||||
presale: number;
|
||||
door: number;
|
||||
refunds: number;
|
||||
fees: number;
|
||||
otherIncome: number;
|
||||
/** gross - refunds: the base for revenue percentages. */
|
||||
sales: number;
|
||||
net: number;
|
||||
byMethod: MethodTotals[];
|
||||
};
|
||||
expenses: {
|
||||
lines: ExpenseLine[];
|
||||
total: number;
|
||||
planned: number;
|
||||
paid: number;
|
||||
byCategory: { categoryId: string | null; planned: number; paid: number; total: number }[];
|
||||
};
|
||||
profit: number;
|
||||
breakEven: {
|
||||
/** Tickets needed at ticketPrice for profit >= 0; null when unreachable. */
|
||||
tickets: number | null;
|
||||
ticketPrice: number;
|
||||
/** Tickets still needed beyond those already sold. */
|
||||
remaining: number | null;
|
||||
};
|
||||
split: {
|
||||
/** Profit left after fixed amounts and revenue shares. */
|
||||
distributable: number;
|
||||
partners: PartnerResult[];
|
||||
organization: number;
|
||||
};
|
||||
waterfall: { key: string; label?: string; amount: number }[];
|
||||
/** Cumulative paid sales per day (YYYY-MM-DD, UTC). */
|
||||
salesTimeline: { date: string; tickets: number; revenue: number }[];
|
||||
}
|
||||
|
||||
/** Round half away from zero so a loss and a profit of the same size split symmetrically. */
|
||||
export function roundPyg(value: number): number {
|
||||
return Math.sign(value) * Math.round(Math.abs(value));
|
||||
}
|
||||
|
||||
export function applyBp(base: number, bp: number): number {
|
||||
return roundPyg((base * bp) / 10000);
|
||||
}
|
||||
|
||||
export function paymentFee(amount: number, rule: FeeRule | undefined): number {
|
||||
if (!rule || amount <= 0) return 0;
|
||||
return applyBp(amount, rule.percentBp) + rule.fixedAmount;
|
||||
}
|
||||
|
||||
/** Amount for one expense row at the given counts. Locked rows keep their stored amount. */
|
||||
export function expenseAmount(
|
||||
e: Pick<FinanceExpense, 'calcType' | 'quantity' | 'unitAmount' | 'percentBp' | 'minimumAmount' | 'computedAmount' | 'isLocked'>,
|
||||
ctx: { ticketsSold: number; checkedIn: number; sales: number },
|
||||
): { quantity: number; amount: number } {
|
||||
if (e.isLocked) return { quantity: e.quantity, amount: e.computedAmount };
|
||||
switch (e.calcType) {
|
||||
case 'per_ticket_sold':
|
||||
return { quantity: ctx.ticketsSold, amount: e.unitAmount * ctx.ticketsSold };
|
||||
case 'per_checked_in':
|
||||
return { quantity: ctx.checkedIn, amount: e.unitAmount * ctx.checkedIn };
|
||||
case 'percent_of_revenue':
|
||||
return { quantity: 1, amount: applyBp(Math.max(0, ctx.sales), e.percentBp) };
|
||||
case 'minimum_spend':
|
||||
return { quantity: ctx.checkedIn, amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn) };
|
||||
case 'fixed':
|
||||
default:
|
||||
return { quantity: e.quantity, amount: e.unitAmount * e.quantity };
|
||||
}
|
||||
}
|
||||
|
||||
export function isAutoCalc(calcType: CalcType): boolean {
|
||||
return calcType !== 'fixed';
|
||||
}
|
||||
|
||||
function computePartners(profit: number, sales: number, partners: FinancePartner[], reimbursements: Map<string, number>) {
|
||||
// Pass 1: amounts owed regardless of profit.
|
||||
const upfront = new Map<string, number>();
|
||||
for (const p of partners) {
|
||||
let owed = 0;
|
||||
if (p.shareType === 'fixed' || p.shareType === 'fixed_plus_percent_above_threshold') owed = p.fixedAmount;
|
||||
else if (p.shareType === 'percent_revenue') owed = applyBp(Math.max(0, sales), p.percentBp);
|
||||
upfront.set(p.id, owed);
|
||||
}
|
||||
const distributable = profit - [...upfront.values()].reduce((a, b) => a + b, 0);
|
||||
|
||||
// Pass 2: profit-based parts, taken from the distributable profit.
|
||||
let profitParts = 0;
|
||||
const results: PartnerResult[] = partners.map((p) => {
|
||||
let basis = p.shareType === 'percent_revenue' ? sales : distributable;
|
||||
let profitPart = 0;
|
||||
if (p.shareType === 'percent_profit') {
|
||||
const raw = applyBp(distributable, p.percentBp);
|
||||
if (distributable >= 0 || p.lossRule === 'proportional') profitPart = raw;
|
||||
else if (p.lossRule === 'capped') profitPart = Math.max(raw, -Math.abs(p.lossCapAmount));
|
||||
} else if (p.shareType === 'fixed_plus_percent_above_threshold') {
|
||||
basis = Math.max(0, distributable - p.thresholdAmount);
|
||||
profitPart = applyBp(basis, p.percentBp);
|
||||
}
|
||||
profitParts += profitPart;
|
||||
const share = (upfront.get(p.id) || 0) + profitPart;
|
||||
const reimbursement = reimbursements.get(p.id) || 0;
|
||||
return { partnerId: p.id, name: p.name, shareType: p.shareType, basis, share, reimbursement, payout: share + reimbursement };
|
||||
});
|
||||
|
||||
return { distributable, partners: results, organization: distributable - profitParts };
|
||||
}
|
||||
|
||||
/** Profit at a hypothetical ticket count, for break-even. Assumes every sold ticket checks in. */
|
||||
function projectedProfit(n: number, input: FinanceInput, feeRate: { bp: number; fixed: number }, otherIncome: number): number {
|
||||
const sales = n * input.ticketPrice;
|
||||
const fees = n > 0 && input.ticketPrice > 0 ? n * (applyBp(input.ticketPrice, feeRate.bp) + feeRate.fixed) : 0;
|
||||
const ctx = { ticketsSold: n, checkedIn: n, sales };
|
||||
const expenses = input.expenses.reduce((sum, e) => sum + expenseAmount(e, ctx).amount, 0);
|
||||
return sales - fees + otherIncome - expenses;
|
||||
}
|
||||
|
||||
const BREAK_EVEN_SEARCH_LIMIT = 100000;
|
||||
|
||||
export function calculateEventFinance(input: FinanceInput): FinanceResult {
|
||||
const feeByMethod = new Map(input.fees.map((f) => [f.method, f]));
|
||||
|
||||
// ---- Revenue
|
||||
const methods = new Map<string, MethodTotals>();
|
||||
let gross = 0, presale = 0, door = 0, refunds = 0, fees = 0;
|
||||
for (const p of input.payments) {
|
||||
const m = methods.get(p.provider) || { method: p.provider, count: 0, gross: 0, refunds: 0, fees: 0, net: 0 };
|
||||
const fee = p.status === 'paid' ? paymentFee(p.amount, feeByMethod.get(p.provider)) : 0;
|
||||
m.count += 1;
|
||||
m.gross += p.amount;
|
||||
gross += p.amount;
|
||||
if (p.source === 'door') door += p.amount; else presale += p.amount;
|
||||
if (p.status === 'refunded') {
|
||||
// Refunds are whole-payment; the processor fee on a refunded payment is
|
||||
// not tracked, so it is treated as returned too.
|
||||
m.refunds += p.amount;
|
||||
refunds += p.amount;
|
||||
}
|
||||
m.fees += fee;
|
||||
fees += fee;
|
||||
m.net = m.gross - m.refunds - m.fees;
|
||||
methods.set(p.provider, m);
|
||||
}
|
||||
const otherIncome = input.otherIncome.reduce((sum, i) => sum + i.amount, 0);
|
||||
const sales = gross - refunds;
|
||||
const net = sales - fees + otherIncome;
|
||||
|
||||
// ---- Expenses
|
||||
const ctx = { ticketsSold: input.ticketsSold, checkedIn: input.checkedIn, sales };
|
||||
const lines: ExpenseLine[] = [];
|
||||
const byCategory = new Map<string | null, { categoryId: string | null; planned: number; paid: number; total: number }>();
|
||||
const reimbursements = new Map<string, number>();
|
||||
let planned = 0, paid = 0;
|
||||
for (const e of input.expenses) {
|
||||
const { quantity, amount } = expenseAmount(e, ctx);
|
||||
lines.push({ id: e.id, quantity, amount, auto: isAutoCalc(e.calcType) && !e.isLocked });
|
||||
const cat = byCategory.get(e.categoryId) || { categoryId: e.categoryId, planned: 0, paid: 0, total: 0 };
|
||||
if (e.status === 'paid') {
|
||||
paid += amount;
|
||||
cat.paid += amount;
|
||||
if (e.paidByPartnerId) reimbursements.set(e.paidByPartnerId, (reimbursements.get(e.paidByPartnerId) || 0) + amount);
|
||||
} else {
|
||||
planned += amount;
|
||||
cat.planned += amount;
|
||||
}
|
||||
cat.total += amount;
|
||||
byCategory.set(e.categoryId, cat);
|
||||
}
|
||||
const expenseTotal = planned + paid;
|
||||
const profit = net - expenseTotal;
|
||||
|
||||
// ---- Break-even at the current price, using the fee mix seen so far
|
||||
// (or the most expensive configured method before any sales).
|
||||
const paidPayments = input.payments.filter((p) => p.status === 'paid' && p.amount > 0);
|
||||
const feeRate = paidPayments.length > 0 && gross > 0
|
||||
? { bp: Math.round((fees / Math.max(1, sales)) * 10000), fixed: 0 }
|
||||
: input.fees.reduce((worst, f) => (f.percentBp > worst.bp ? { bp: f.percentBp, fixed: f.fixedAmount } : worst), { bp: 0, fixed: 0 });
|
||||
const profitAt = (n: number) => projectedProfit(n, input, feeRate, otherIncome);
|
||||
let breakEvenTickets: number | null = null;
|
||||
if (input.ticketPrice > 0) {
|
||||
// Profit is non-decreasing in n for every calc type, so a doubling search
|
||||
// followed by bisection finds the smallest n with profit >= 0.
|
||||
if (profitAt(0) >= 0) {
|
||||
breakEvenTickets = 0;
|
||||
} else {
|
||||
let lo = 0; // profitAt(lo) < 0
|
||||
let hi = 1;
|
||||
while (hi < BREAK_EVEN_SEARCH_LIMIT && profitAt(hi) < 0) {
|
||||
lo = hi;
|
||||
hi = Math.min(hi * 2, BREAK_EVEN_SEARCH_LIMIT);
|
||||
}
|
||||
if (profitAt(hi) >= 0) {
|
||||
while (lo + 1 < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (profitAt(mid) >= 0) hi = mid; else lo = mid;
|
||||
}
|
||||
breakEvenTickets = hi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Split
|
||||
const split = computePartners(profit, sales, input.partners, reimbursements);
|
||||
|
||||
// ---- Waterfall (signed amounts, in display order)
|
||||
const waterfall: FinanceResult['waterfall'] = [
|
||||
{ key: 'gross', amount: gross },
|
||||
{ key: 'refunds', amount: -refunds },
|
||||
{ key: 'fees', amount: -fees },
|
||||
{ key: 'otherIncome', amount: otherIncome },
|
||||
{ key: 'net', amount: net },
|
||||
{ key: 'expenses', amount: -expenseTotal },
|
||||
{ key: 'profit', amount: profit },
|
||||
...split.partners.map((p) => ({ key: `partner:${p.partnerId}`, label: p.name, amount: -p.share })),
|
||||
{ key: 'organization', amount: split.organization },
|
||||
];
|
||||
|
||||
// ---- Cumulative sales per day
|
||||
const byDay = new Map<string, { tickets: number; revenue: number }>();
|
||||
for (const p of input.payments) {
|
||||
if (p.status !== 'paid' || !p.paidAt) continue;
|
||||
const day = new Date(p.paidAt).toISOString().slice(0, 10);
|
||||
const d = byDay.get(day) || { tickets: 0, revenue: 0 };
|
||||
d.tickets += 1;
|
||||
d.revenue += p.amount;
|
||||
byDay.set(day, d);
|
||||
}
|
||||
let runTickets = 0, runRevenue = 0;
|
||||
const salesTimeline = [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, d]) => {
|
||||
runTickets += d.tickets;
|
||||
runRevenue += d.revenue;
|
||||
return { date, tickets: runTickets, revenue: runRevenue };
|
||||
});
|
||||
|
||||
return {
|
||||
counts: { ticketsSold: input.ticketsSold, checkedIn: input.checkedIn, payments: input.payments.length },
|
||||
revenue: {
|
||||
gross, presale, door, refunds, fees, otherIncome, sales, net,
|
||||
byMethod: [...methods.values()].sort((a, b) => b.gross - a.gross),
|
||||
},
|
||||
expenses: {
|
||||
lines,
|
||||
total: expenseTotal,
|
||||
planned,
|
||||
paid,
|
||||
byCategory: [...byCategory.values()].sort((a, b) => b.total - a.total),
|
||||
},
|
||||
profit,
|
||||
breakEven: {
|
||||
tickets: breakEvenTickets,
|
||||
ticketPrice: input.ticketPrice,
|
||||
remaining: breakEvenTickets === null ? null : Math.max(0, breakEvenTickets - input.ticketsSold),
|
||||
},
|
||||
split,
|
||||
waterfall,
|
||||
salesTimeline,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Reads an event's money facts from the database and runs calculateEventFinance.
|
||||
// Once an event is finalized, the frozen snapshot is returned instead so the
|
||||
// numbers partners were paid on never drift with later ticket changes.
|
||||
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbAll, dbGet, events, tickets, payments, eventExpenses, eventOtherIncome, eventPartners,
|
||||
paymentMethodFees, eventFinanceState, users,
|
||||
} from '../../db/index.js';
|
||||
import { calculateEventFinance, type FinanceInput, type FinanceResult, type CalcType, type ShareType, type LossRule } from './calculate.js';
|
||||
|
||||
export const num = (v: any): number => {
|
||||
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
export const pyg = (v: any): number => Math.round(num(v));
|
||||
export const iso = (v: any): string | null => {
|
||||
if (!v) return null;
|
||||
const d = v instanceof Date ? v : new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
};
|
||||
export const bool = (v: any): boolean => v === true || v === 1 || v === '1';
|
||||
|
||||
export type FinanceStatus = 'open' | 'finalized' | 'paid_out';
|
||||
|
||||
export interface FinanceSnapshot {
|
||||
version: 1;
|
||||
computedAt: string;
|
||||
result: FinanceResult;
|
||||
}
|
||||
|
||||
export function serializeExpense(e: any) {
|
||||
return {
|
||||
id: e.id,
|
||||
eventId: e.eventId,
|
||||
categoryId: e.categoryId ?? null,
|
||||
templateId: e.templateId ?? null,
|
||||
description: e.description,
|
||||
calcType: e.calcType as CalcType,
|
||||
quantity: pyg(e.quantity),
|
||||
unitAmount: pyg(e.unitAmount),
|
||||
percentBp: pyg(e.percentBp),
|
||||
minimumAmount: pyg(e.minimumAmount),
|
||||
computedAmount: pyg(e.computedAmount),
|
||||
isLocked: bool(e.isLocked),
|
||||
status: e.status as 'planned' | 'paid',
|
||||
paidByPartnerId: e.paidByPartnerId ?? null,
|
||||
receiptUrl: e.receiptUrl ?? null,
|
||||
expenseDate: iso(e.expenseDate),
|
||||
createdBy: e.createdBy ?? null,
|
||||
updatedBy: e.updatedBy ?? null,
|
||||
createdAt: iso(e.createdAt),
|
||||
updatedAt: iso(e.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializePartner(p: any, userName?: string | null) {
|
||||
return {
|
||||
id: p.id,
|
||||
eventId: p.eventId,
|
||||
userId: p.userId ?? null,
|
||||
externalName: p.externalName ?? null,
|
||||
name: p.externalName || userName || 'Partner',
|
||||
roleLabel: p.roleLabel ?? null,
|
||||
shareType: p.shareType as ShareType,
|
||||
percentBp: pyg(p.percentBp),
|
||||
fixedAmount: pyg(p.fixedAmount),
|
||||
thresholdAmount: pyg(p.thresholdAmount),
|
||||
lossRule: p.lossRule as LossRule,
|
||||
lossCapAmount: pyg(p.lossCapAmount),
|
||||
payoutStatus: p.payoutStatus as 'pending' | 'paid',
|
||||
payoutDate: iso(p.payoutDate),
|
||||
payoutMethod: p.payoutMethod ?? null,
|
||||
payoutNote: p.payoutNote ?? null,
|
||||
createdAt: iso(p.createdAt),
|
||||
updatedAt: iso(p.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeIncome(i: any) {
|
||||
return {
|
||||
id: i.id,
|
||||
eventId: i.eventId,
|
||||
description: i.description,
|
||||
amount: pyg(i.amount),
|
||||
createdBy: i.createdBy ?? null,
|
||||
createdAt: iso(i.createdAt),
|
||||
updatedAt: iso(i.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export type SerializedExpense = ReturnType<typeof serializeExpense>;
|
||||
export type SerializedPartner = ReturnType<typeof serializePartner>;
|
||||
export type SerializedIncome = ReturnType<typeof serializeIncome>;
|
||||
|
||||
export async function getFinanceState(eventId: string) {
|
||||
const row = await dbGet<any>((db as any).select().from(eventFinanceState).where(eq((eventFinanceState as any).eventId, eventId)));
|
||||
let snapshot: FinanceSnapshot | null = null;
|
||||
if (row?.snapshotJson) {
|
||||
try { snapshot = JSON.parse(row.snapshotJson); } catch { snapshot = null; }
|
||||
}
|
||||
return {
|
||||
exists: !!row,
|
||||
status: (row?.status || 'open') as FinanceStatus,
|
||||
finalizedAt: iso(row?.finalizedAt),
|
||||
finalizedBy: row?.finalizedBy ?? null,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPartners(eventId: string): Promise<SerializedPartner[]> {
|
||||
const rows = await dbAll<any>((db as any).select().from(eventPartners).where(eq((eventPartners as any).eventId, eventId)));
|
||||
const userIds = [...new Set(rows.map((r: any) => r.userId).filter(Boolean))] as string[];
|
||||
const names = new Map<string, string>();
|
||||
if (userIds.length > 0) {
|
||||
const us = await dbAll<any>(
|
||||
(db as any).select({ id: (users as any).id, name: (users as any).name }).from(users).where(inArray((users as any).id, userIds))
|
||||
);
|
||||
for (const u of us) names.set(u.id, u.name);
|
||||
}
|
||||
return rows
|
||||
.map((r: any) => serializePartner(r, r.userId ? names.get(r.userId) : null))
|
||||
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||
}
|
||||
|
||||
export async function loadExpenses(eventId: string): Promise<SerializedExpense[]> {
|
||||
const rows = await dbAll<any>((db as any).select().from(eventExpenses).where(eq((eventExpenses as any).eventId, eventId)));
|
||||
return rows
|
||||
.map(serializeExpense)
|
||||
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||
}
|
||||
|
||||
export async function loadOtherIncome(eventId: string): Promise<SerializedIncome[]> {
|
||||
const rows = await dbAll<any>((db as any).select().from(eventOtherIncome).where(eq((eventOtherIncome as any).eventId, eventId)));
|
||||
return rows
|
||||
.map(serializeIncome)
|
||||
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||
}
|
||||
|
||||
export async function loadFeeRules() {
|
||||
const rows = await dbAll<any>((db as any).select().from(paymentMethodFees));
|
||||
return rows.map((r: any) => ({
|
||||
method: r.method as string,
|
||||
percentBp: pyg(r.percentBp),
|
||||
fixedAmount: pyg(r.fixedAmount),
|
||||
updatedAt: iso(r.updatedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Ticket counts the auto-calculated expenses follow. */
|
||||
export async function loadTicketCounts(eventId: string) {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
sold: sql<number>`sum(case when ${(tickets as any).status} in ('confirmed', 'checked_in') and ${(tickets as any).paymentStatus} = 'paid' then 1 else 0 end)`,
|
||||
checkedIn: sql<number>`sum(case when ${(tickets as any).status} = 'checked_in' then 1 else 0 end)`,
|
||||
})
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, eventId))
|
||||
);
|
||||
return { ticketsSold: Number(row?.sold || 0), checkedIn: Number(row?.checkedIn || 0) };
|
||||
}
|
||||
|
||||
export async function buildFinanceInput(eventId: string, event: any) {
|
||||
const [counts, payRows, expenses, otherIncome, partners, fees] = await Promise.all([
|
||||
loadTicketCounts(eventId),
|
||||
dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (payments as any).id,
|
||||
amount: (payments as any).amount,
|
||||
provider: (payments as any).provider,
|
||||
source: (payments as any).source,
|
||||
status: (payments as any).status,
|
||||
paidAt: (payments as any).paidAt,
|
||||
createdAt: (payments as any).createdAt,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
inArray((payments as any).status, ['paid', 'refunded'])
|
||||
))
|
||||
),
|
||||
loadExpenses(eventId),
|
||||
loadOtherIncome(eventId),
|
||||
loadPartners(eventId),
|
||||
loadFeeRules(),
|
||||
]);
|
||||
|
||||
const input: FinanceInput = {
|
||||
ticketPrice: pyg(event.price),
|
||||
ticketsSold: counts.ticketsSold,
|
||||
checkedIn: counts.checkedIn,
|
||||
payments: payRows
|
||||
.map((p: any) => ({
|
||||
id: p.id,
|
||||
amount: pyg(p.amount),
|
||||
provider: p.provider,
|
||||
source: p.source === 'door' ? 'door' as const : 'presale' as const,
|
||||
status: p.status as 'paid' | 'refunded',
|
||||
paidAt: iso(p.paidAt) || iso(p.createdAt),
|
||||
}))
|
||||
// Comps are recorded as 0 PYG payments; they are not sales.
|
||||
.filter((p) => p.amount > 0),
|
||||
expenses: expenses.map((e) => ({
|
||||
id: e.id, description: e.description, categoryId: e.categoryId, calcType: e.calcType, quantity: e.quantity,
|
||||
unitAmount: e.unitAmount, percentBp: e.percentBp, minimumAmount: e.minimumAmount, computedAmount: e.computedAmount,
|
||||
isLocked: e.isLocked, status: e.status, paidByPartnerId: e.paidByPartnerId,
|
||||
})),
|
||||
otherIncome: otherIncome.map((i) => ({ id: i.id, description: i.description, amount: i.amount })),
|
||||
fees,
|
||||
partners: partners.map((p) => ({
|
||||
id: p.id, name: p.name, shareType: p.shareType, percentBp: p.percentBp, fixedAmount: p.fixedAmount,
|
||||
thresholdAmount: p.thresholdAmount, lossRule: p.lossRule, lossCapAmount: p.lossCapAmount,
|
||||
})),
|
||||
};
|
||||
return { input, expenses, otherIncome, partners, counts };
|
||||
}
|
||||
|
||||
export async function getEvent(eventId: string) {
|
||||
return dbGet<any>((db as any).select().from(events).where(eq((events as any).id, eventId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's finance numbers plus the rows behind them. Uses the finalize
|
||||
* snapshot when there is one; otherwise calculates live.
|
||||
*/
|
||||
export async function getEventFinance(eventId: string, event?: any) {
|
||||
const ev = event || await getEvent(eventId);
|
||||
if (!ev) return null;
|
||||
const [state, built] = await Promise.all([getFinanceState(eventId), buildFinanceInput(eventId, ev)]);
|
||||
const frozen = state.status !== 'open' && state.snapshot;
|
||||
const result = frozen ? state.snapshot!.result : calculateEventFinance(built.input);
|
||||
return {
|
||||
event: ev,
|
||||
state,
|
||||
live: !frozen,
|
||||
computedAt: frozen ? state.snapshot!.computedAt : new Date().toISOString(),
|
||||
result,
|
||||
expenses: built.expenses,
|
||||
otherIncome: built.otherIncome,
|
||||
partners: built.partners,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Partner statement PDF: the event P&L summary, the partner's deal, what they
|
||||
// fronted, and the resulting payout. Uses the ticket PDF's brand helpers.
|
||||
|
||||
import {
|
||||
COLORS, PAGE_W, PAGE_H, MARGIN, CONTENT_W, ACCENT_H, FOOTER_H, LOGO_RATIO,
|
||||
getLogo, drawLabel, drawDivider, createDoc, collect, siteUrl,
|
||||
} from '../pdf.js';
|
||||
import type { FinanceResult, PartnerResult } from './calculate.js';
|
||||
import type { SerializedExpense, SerializedPartner } from './load.js';
|
||||
|
||||
export function formatPygAmount(amount: number): string {
|
||||
const sign = amount < 0 ? '-' : '';
|
||||
return `${sign}${Math.abs(Math.round(amount)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.')} PYG`;
|
||||
}
|
||||
|
||||
const pct = (bp: number) => `${(bp / 100).toLocaleString('es-PY', { maximumFractionDigits: 2 })}%`;
|
||||
|
||||
const STRINGS = {
|
||||
en: {
|
||||
title: 'Partner statement',
|
||||
draft: 'DRAFT: the event is not finalized, numbers may still change.',
|
||||
event: 'Event',
|
||||
partner: 'Partner',
|
||||
summary: 'Event summary',
|
||||
gross: 'Gross ticket revenue',
|
||||
refunds: 'Refunds',
|
||||
fees: 'Payment fees',
|
||||
otherIncome: 'Other income',
|
||||
net: 'Net revenue',
|
||||
expenses: 'Expenses',
|
||||
profit: 'Profit / loss',
|
||||
deal: 'Agreement',
|
||||
share: 'Share',
|
||||
reimbursements: 'Reimbursements (costs you paid)',
|
||||
none: 'None',
|
||||
payout: 'Total payout',
|
||||
owes: 'Amount owed to the organization',
|
||||
status: 'Payout status',
|
||||
paid: 'Paid',
|
||||
pending: 'Pending',
|
||||
generated: 'Generated',
|
||||
lossNote: { proportional: 'shares losses proportionally', none: 'does not share losses', capped: 'shares losses up to' },
|
||||
shareTypes: {
|
||||
percent_profit: (p: SerializedPartner) => `${pct(p.percentBp)} of profit`,
|
||||
percent_revenue: (p: SerializedPartner) => `${pct(p.percentBp)} of ticket revenue`,
|
||||
fixed: (p: SerializedPartner) => `Fixed ${formatPygAmount(p.fixedAmount)}`,
|
||||
fixed_plus_percent_above_threshold: (p: SerializedPartner) =>
|
||||
`${formatPygAmount(p.fixedAmount)} + ${pct(p.percentBp)} of profit above ${formatPygAmount(p.thresholdAmount)}`,
|
||||
},
|
||||
},
|
||||
es: {
|
||||
title: 'Liquidación de socio',
|
||||
draft: 'BORRADOR: el evento no está cerrado, los números pueden cambiar.',
|
||||
event: 'Evento',
|
||||
partner: 'Socio',
|
||||
summary: 'Resumen del evento',
|
||||
gross: 'Ingresos brutos por entradas',
|
||||
refunds: 'Reembolsos',
|
||||
fees: 'Comisiones de pago',
|
||||
otherIncome: 'Otros ingresos',
|
||||
net: 'Ingresos netos',
|
||||
expenses: 'Gastos',
|
||||
profit: 'Ganancia / pérdida',
|
||||
deal: 'Acuerdo',
|
||||
share: 'Participación',
|
||||
reimbursements: 'Reembolsos (gastos que pagaste)',
|
||||
none: 'Ninguno',
|
||||
payout: 'Total a pagar',
|
||||
owes: 'Monto adeudado a la organización',
|
||||
status: 'Estado del pago',
|
||||
paid: 'Pagado',
|
||||
pending: 'Pendiente',
|
||||
generated: 'Generado',
|
||||
lossNote: { proportional: 'comparte pérdidas proporcionalmente', none: 'no comparte pérdidas', capped: 'comparte pérdidas hasta' },
|
||||
shareTypes: {
|
||||
percent_profit: (p: SerializedPartner) => `${pct(p.percentBp)} de la ganancia`,
|
||||
percent_revenue: (p: SerializedPartner) => `${pct(p.percentBp)} de los ingresos por entradas`,
|
||||
fixed: (p: SerializedPartner) => `Fijo ${formatPygAmount(p.fixedAmount)}`,
|
||||
fixed_plus_percent_above_threshold: (p: SerializedPartner) =>
|
||||
`${formatPygAmount(p.fixedAmount)} + ${pct(p.percentBp)} de la ganancia sobre ${formatPygAmount(p.thresholdAmount)}`,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface StatementData {
|
||||
locale: 'en' | 'es';
|
||||
event: { title: string; startDatetime: string | Date; location: string };
|
||||
finalized: boolean;
|
||||
timezone?: string;
|
||||
result: FinanceResult;
|
||||
partner: SerializedPartner;
|
||||
line: PartnerResult | undefined;
|
||||
frontedExpenses: SerializedExpense[];
|
||||
/** Amount per expense id, from the same result. */
|
||||
expenseAmounts: Map<string, number>;
|
||||
}
|
||||
|
||||
export async function generatePartnerStatementPDF(data: StatementData): Promise<Buffer> {
|
||||
const t = STRINGS[data.locale];
|
||||
const doc = createDoc();
|
||||
const done = collect(doc);
|
||||
const tz = data.timezone || 'America/Asuncion';
|
||||
const dateFmt = new Intl.DateTimeFormat(data.locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'long', timeZone: tz });
|
||||
|
||||
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
|
||||
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
|
||||
|
||||
let y = MARGIN + 6;
|
||||
const logo = getLogo();
|
||||
if (logo) {
|
||||
const w = 120;
|
||||
doc.image(logo, MARGIN, y, { width: w });
|
||||
y += w / LOGO_RATIO + 16;
|
||||
} else {
|
||||
doc.font('Helvetica-Bold').fontSize(18).fillColor(COLORS.navy).text('spanglish social', MARGIN, y);
|
||||
y += 32;
|
||||
}
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(22).fillColor(COLORS.navy).text(t.title, MARGIN, y, { width: CONTENT_W });
|
||||
y += 32;
|
||||
|
||||
if (!data.finalized) {
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(COLORS.orange).text(t.draft, MARGIN, y, { width: CONTENT_W });
|
||||
y += 20;
|
||||
}
|
||||
|
||||
const col = CONTENT_W / 2;
|
||||
drawLabel(doc, t.event, y, col - 12);
|
||||
drawLabel(doc, t.partner, y, col, MARGIN + col);
|
||||
y += 14;
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor(COLORS.navy);
|
||||
doc.text(data.event.title, MARGIN, y, { width: col - 12 });
|
||||
doc.text(data.partner.name, MARGIN + col, y, { width: col });
|
||||
y += 16;
|
||||
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||
doc.text(`${dateFmt.format(new Date(data.event.startDatetime))} · ${data.event.location}`, MARGIN, y, { width: col - 12 });
|
||||
if (data.partner.roleLabel) doc.text(data.partner.roleLabel, MARGIN + col, y, { width: col });
|
||||
y += 34;
|
||||
|
||||
const row = (label: string, amount: number, opts: { bold?: boolean } = {}) => {
|
||||
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.bold ? 12 : 10.5).fillColor(COLORS.navy);
|
||||
doc.text(label, MARGIN, y, { width: CONTENT_W - 160 });
|
||||
doc.text(formatPygAmount(amount), MARGIN + CONTENT_W - 160, y, { width: 160, align: 'right' });
|
||||
y += opts.bold ? 20 : 17;
|
||||
};
|
||||
|
||||
drawLabel(doc, t.summary, y);
|
||||
y += 16;
|
||||
const r = data.result;
|
||||
row(t.gross, r.revenue.gross);
|
||||
if (r.revenue.refunds) row(t.refunds, -r.revenue.refunds);
|
||||
row(t.fees, -r.revenue.fees);
|
||||
if (r.revenue.otherIncome) row(t.otherIncome, r.revenue.otherIncome);
|
||||
drawDivider(doc, y); y += 8;
|
||||
row(t.net, r.revenue.net, { bold: true });
|
||||
row(t.expenses, -r.expenses.total);
|
||||
drawDivider(doc, y); y += 8;
|
||||
row(t.profit, r.profit, { bold: true });
|
||||
y += 18;
|
||||
|
||||
drawLabel(doc, t.deal, y);
|
||||
y += 16;
|
||||
let deal = t.shareTypes[data.partner.shareType](data.partner);
|
||||
if (data.partner.shareType === 'percent_profit') {
|
||||
deal += data.partner.lossRule === 'capped'
|
||||
? ` · ${t.lossNote.capped} ${formatPygAmount(data.partner.lossCapAmount)}`
|
||||
: ` · ${t.lossNote[data.partner.lossRule]}`;
|
||||
}
|
||||
doc.font('Helvetica').fontSize(10.5).fillColor(COLORS.navy).text(deal, MARGIN, y, { width: CONTENT_W });
|
||||
y += 26;
|
||||
|
||||
const share = data.line?.share ?? 0;
|
||||
const reimbursement = data.line?.reimbursement ?? 0;
|
||||
const payout = data.line?.payout ?? 0;
|
||||
row(t.share, share);
|
||||
|
||||
drawLabel(doc, t.reimbursements, y + 4);
|
||||
y += 20;
|
||||
if (data.frontedExpenses.length === 0) {
|
||||
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted).text(t.none, MARGIN, y);
|
||||
y += 16;
|
||||
} else {
|
||||
for (const e of data.frontedExpenses) {
|
||||
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||
doc.text(e.description, MARGIN + 10, y, { width: CONTENT_W - 170 });
|
||||
doc.text(formatPygAmount(data.expenseAmounts.get(e.id) ?? e.computedAmount), MARGIN + CONTENT_W - 160, y, { width: 160, align: 'right' });
|
||||
y += 15;
|
||||
}
|
||||
row(t.reimbursements, reimbursement);
|
||||
}
|
||||
y += 6;
|
||||
drawDivider(doc, y); y += 10;
|
||||
row(payout < 0 ? t.owes : t.payout, Math.abs(payout), { bold: true });
|
||||
y += 6;
|
||||
|
||||
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||
const status = data.partner.payoutStatus === 'paid'
|
||||
? `${t.paid}${data.partner.payoutDate ? ` · ${dateFmt.format(new Date(data.partner.payoutDate))}` : ''}${data.partner.payoutMethod ? ` · ${data.partner.payoutMethod}` : ''}`
|
||||
: t.pending;
|
||||
doc.text(`${t.status}: ${status}`, MARGIN, y, { width: CONTENT_W });
|
||||
if (data.partner.payoutNote) {
|
||||
y += 15;
|
||||
doc.text(data.partner.payoutNote, MARGIN, y, { width: CONTENT_W });
|
||||
}
|
||||
|
||||
const footerY = PAGE_H - FOOTER_H;
|
||||
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
|
||||
doc.font('Helvetica').fontSize(9).fillColor(COLORS.footerMuted)
|
||||
.text(`${t.generated} ${dateFmt.format(new Date())}`, MARGIN, footerY + FOOTER_H / 2 - 5, { width: CONTENT_W / 2 });
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#FFFFFF')
|
||||
.text(siteUrl().domain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
|
||||
|
||||
doc.end();
|
||||
return done;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
|
||||
//
|
||||
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
|
||||
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
|
||||
// states — user clicking "I've paid", an admin approving/reactivating a payment —
|
||||
// must atomically re-check that the event still has room, exactly like the original
|
||||
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
|
||||
// (e.g. reopening a failed payment) claims no seat and skips the check.
|
||||
//
|
||||
// Callers choose which ticket statuses are eligible to be flipped via
|
||||
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
|
||||
// left untouched. Tickets that already hold a seat cost no new capacity.
|
||||
// `options.skipCapacityCheck` lets an admin knowingly approve over capacity —
|
||||
// the UI warns first (routes/payments.ts /approve with allowOverCapacity).
|
||||
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
||||
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
||||
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
|
||||
|
||||
export class HoldCapacityError extends Error {
|
||||
constructor(public available: number) {
|
||||
super('EVENT_FULL');
|
||||
}
|
||||
}
|
||||
|
||||
interface ReserveOptions {
|
||||
paidByAdminId?: string;
|
||||
extraPaymentFields?: Record<string, any>;
|
||||
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
||||
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
|
||||
/** Admin override: reserve even when it puts the event over capacity. */
|
||||
skipCapacityCheck?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
|
||||
* bookingId), atomically re-checking capacity before flipping their status.
|
||||
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
|
||||
* Capacity is asserted against the number of those tickets that don't already
|
||||
* hold a seat, so re-reserving tickets that are already seated is a no-op.
|
||||
* Throws HoldCapacityError if the event no longer has room (unless the target
|
||||
* state holds no seat, or skipCapacityCheck is set).
|
||||
*/
|
||||
export async function reserveOnHoldBooking(
|
||||
eventId: string,
|
||||
ticketIds: string[],
|
||||
targetTicketStatus: 'pending' | 'confirmed',
|
||||
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
|
||||
options: ReserveOptions = {}
|
||||
): Promise<void> {
|
||||
if (ticketIds.length === 0) return;
|
||||
|
||||
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
|
||||
// Bare 'pending' payments hold no seat, so moving a booking back to 'pending'
|
||||
// consumes no capacity and needs no check.
|
||||
const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed';
|
||||
const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck;
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const paymentUpdate: Record<string, any> = {
|
||||
status: targetPaymentStatus,
|
||||
updatedAt: now,
|
||||
...options.extraPaymentFields,
|
||||
};
|
||||
if (targetPaymentStatus === 'paid') {
|
||||
paymentUpdate.paidAt = now;
|
||||
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
|
||||
}
|
||||
|
||||
// Keep the ticket-level payment flag in sync when the payment settles
|
||||
const ticketUpdate: Record<string, any> = { status: targetTicketStatus };
|
||||
if (targetPaymentStatus === 'paid') {
|
||||
ticketUpdate.paymentStatus = 'paid';
|
||||
}
|
||||
|
||||
// `needed` is how many of these tickets don't currently hold a seat and so must
|
||||
// be found new capacity; tickets already in a seat-holding state cost nothing.
|
||||
const assertCapacity = (reserved: number, needed: number) => {
|
||||
if (needed <= 0) return;
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new HoldCapacityError(0);
|
||||
}
|
||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
||||
if (needed > seatsLeft) {
|
||||
throw new HoldCapacityError(seatsLeft);
|
||||
}
|
||||
};
|
||||
|
||||
if (isSqlite()) {
|
||||
(db as any).transaction((tx: any) => {
|
||||
if (checkCapacity) {
|
||||
const countRow = seatHolderCountQuery(tx, eventId).get();
|
||||
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
}
|
||||
|
||||
tx.update(tickets)
|
||||
.set(ticketUpdate)
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, fromTicketStatuses)
|
||||
))
|
||||
.run();
|
||||
|
||||
tx.update(payments)
|
||||
.set(paymentUpdate)
|
||||
.where(inArray((payments as any).ticketId, ticketIds))
|
||||
.run();
|
||||
});
|
||||
} else {
|
||||
await (db as any).transaction(async (tx: any) => {
|
||||
if (checkCapacity) {
|
||||
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
|
||||
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
}
|
||||
|
||||
await tx.update(tickets)
|
||||
.set(ticketUpdate)
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, fromTicketStatuses)
|
||||
));
|
||||
|
||||
await tx.update(payments)
|
||||
.set(paymentUpdate)
|
||||
.where(inArray((payments as any).ticketId, ticketIds));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Auto-hold stale unsettled manual-payment bookings.
|
||||
//
|
||||
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
|
||||
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
|
||||
// the customer never clicked "I've paid" and no admin settled them. These are exempt
|
||||
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
|
||||
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
|
||||
// dead checkouts out of the admin's pending queues.
|
||||
//
|
||||
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
|
||||
// claimed payment keeps its seat until an admin approves or rejects it — the admin
|
||||
// UI surfaces aging claims instead of silently releasing them.
|
||||
//
|
||||
// The user receives no notification — they can recover via "I've paid", and an
|
||||
// admin can approve/reactivate directly; every recovery path re-checks capacity.
|
||||
|
||||
import { and, eq, lt, inArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { getNow, toDbDate } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||
|
||||
function getThresholdMs(): number {
|
||||
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
||||
return (Number.isFinite(hours) && hours > 0 ? hours : 72) * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
|
||||
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
|
||||
* is never swept. Returns the number of payments put on hold.
|
||||
*/
|
||||
export async function sweepStaleApprovals(): Promise<number> {
|
||||
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
||||
|
||||
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
|
||||
(db as any)
|
||||
.select({
|
||||
ticketId: (payments as any).ticketId,
|
||||
paymentId: (payments as any).id,
|
||||
})
|
||||
.from(payments)
|
||||
.where(and(
|
||||
eq((payments as any).status, 'pending'),
|
||||
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||
lt((payments as any).createdAt, cutoff)
|
||||
))
|
||||
);
|
||||
|
||||
if (stale.length === 0) return 0;
|
||||
|
||||
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
|
||||
const paymentIds = stale.map((s) => s.paymentId);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'on_hold', updatedAt: now })
|
||||
.where(inArray((payments as any).id, paymentIds));
|
||||
|
||||
if (ticketIds.length > 0) {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'on_hold' })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
eq((tickets as any).status, 'pending')
|
||||
));
|
||||
}
|
||||
|
||||
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start a periodic sweep of stale pending-approval payments. Each run is guarded by
|
||||
* a distributed lock so that, across multiple replicas, only one instance does the
|
||||
* work per interval.
|
||||
*/
|
||||
export function startHoldSweep(): void {
|
||||
const intervalMs = parseInt(process.env.HOLD_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
|
||||
|
||||
const run = () => {
|
||||
getLock()
|
||||
.withLock('sweep-hold-stale-approvals', Math.min(intervalMs, 60_000), () =>
|
||||
sweepStaleApprovals()
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error('[HoldSweep] Run failed:', err?.message || err)
|
||||
);
|
||||
};
|
||||
|
||||
// Run shortly after startup, then on the interval.
|
||||
setTimeout(run, 45_000).unref?.();
|
||||
sweepTimer = setInterval(run, intervalMs);
|
||||
sweepTimer.unref?.();
|
||||
console.log(`[HoldSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
||||
}
|
||||
|
||||
export function stopHoldSweep(): void {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,11 @@ export interface CreateInvoiceParams {
|
||||
extra?: Record<string, any>; // Additional metadata
|
||||
}
|
||||
|
||||
// How long a booking's Lightning invoice is valid for, in seconds. Shared
|
||||
// between initial booking creation and invoice regeneration so both produce
|
||||
// invoices with the same lifetime.
|
||||
export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes
|
||||
|
||||
/**
|
||||
* Check if LNbits is configured
|
||||
*/
|
||||
@@ -80,11 +85,8 @@ export async function createInvoice(params: CreateInvoiceParams): Promise<LNbits
|
||||
}
|
||||
|
||||
console.log('Creating LNbits invoice:', {
|
||||
url: `${config.url}${apiEndpoint}`,
|
||||
amount: params.amount,
|
||||
unit: payload.unit,
|
||||
memo: params.memo,
|
||||
webhook: params.webhookUrl,
|
||||
});
|
||||
|
||||
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Password hashing with Argon2 (spec requirement)
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||
if (hash.startsWith('$argon2')) {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
// Legacy bcrypt support
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Small blocklist of common/weak passwords (and obvious app-specific ones).
|
||||
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
|
||||
// "P@ssw0rd!" still matches "password".
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
|
||||
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
|
||||
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
|
||||
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
|
||||
]);
|
||||
|
||||
// Password policy: 10-128 chars, requires a mix of character types, and rejects
|
||||
// common/weak passwords. Centralized so register/reset/change all share it.
|
||||
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||
if (password.length < 10) {
|
||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
||||
}
|
||||
if (password.length > 128) {
|
||||
return { valid: false, error: 'Password must be at most 128 characters long' };
|
||||
}
|
||||
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasDigit = /\d/.test(password);
|
||||
const hasSymbol = /[^A-Za-z0-9]/.test(password);
|
||||
|
||||
// Require lowercase, uppercase, and at least one digit or symbol.
|
||||
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (COMMON_PASSWORDS.has(normalized)) {
|
||||
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Payment provider registry.
|
||||
//
|
||||
// Every provider is either:
|
||||
// - 'automatic': the gateway itself confirms the payment (webhook/invoice
|
||||
// settlement) and the booking is auto-approved on success. No admin involved.
|
||||
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
||||
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
||||
// card handled offline, cash or the POS terminal at the door). These are
|
||||
// never auto-confirmed and never auto-failed; an admin settles them by hand. Bank transfer and
|
||||
// TPago additionally expose an online "I've paid" step that moves the
|
||||
// payment to 'pending_approval'.
|
||||
//
|
||||
// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and
|
||||
// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either
|
||||
// kind — holds no seat, so an abandoned checkout can never block sales.
|
||||
|
||||
export type PaymentProviderKind = 'automatic' | 'manual';
|
||||
|
||||
export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> = {
|
||||
lightning: { kind: 'automatic' },
|
||||
tpago: { kind: 'manual' },
|
||||
bank_transfer: { kind: 'manual' },
|
||||
card: { kind: 'manual' },
|
||||
cash: { kind: 'manual' },
|
||||
// Card on the physical POS terminal at the door; staff confirm it by hand
|
||||
pos: { kind: 'manual' },
|
||||
};
|
||||
|
||||
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
|
||||
(p) => PAYMENT_PROVIDERS[p].kind === 'manual'
|
||||
);
|
||||
|
||||
export function isManualProvider(provider: string): boolean {
|
||||
return PAYMENT_PROVIDERS[provider]?.kind === 'manual';
|
||||
}
|
||||
|
||||
export function isAutomaticProvider(provider: string): boolean {
|
||||
return PAYMENT_PROVIDERS[provider]?.kind === 'automatic';
|
||||
}
|
||||
+350
-193
@@ -1,6 +1,8 @@
|
||||
// PDF Ticket Generation Service
|
||||
import PDFDocument from 'pdfkit';
|
||||
import QRCode from 'qrcode';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
interface TicketData {
|
||||
id: string;
|
||||
@@ -15,235 +17,390 @@ interface TicketData {
|
||||
locationUrl?: string;
|
||||
};
|
||||
timezone?: string;
|
||||
/** 'en' | 'es' - drives the labels and the date/time format on the ticket */
|
||||
locale?: string;
|
||||
/** Optional perk line shown under the ticket holder (falls back to the terms line) */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// ==================== Brand ====================
|
||||
|
||||
export const COLORS = {
|
||||
navy: '#002F44',
|
||||
orange: '#F5821F',
|
||||
cream: '#FDF8F0',
|
||||
card: '#FFFFFF',
|
||||
cardBorder: '#EFE6D8',
|
||||
divider: '#E7DFD1',
|
||||
label: '#9AA3AC',
|
||||
muted: '#6B7580',
|
||||
footerMuted: '#7FA3B5',
|
||||
};
|
||||
|
||||
export const PAGE_W = 595.28;
|
||||
export const PAGE_H = 841.89;
|
||||
export const MARGIN = 48;
|
||||
export const CONTENT_W = PAGE_W - MARGIN * 2;
|
||||
export const ACCENT_H = 10;
|
||||
export const FOOTER_H = 48;
|
||||
|
||||
export const LOGO_RATIO = 1158 / 324;
|
||||
|
||||
const STRINGS = {
|
||||
en: {
|
||||
scan: 'SCAN AT THE ENTRANCE',
|
||||
venue: 'VENUE',
|
||||
holder: 'TICKET HOLDER',
|
||||
terms: 'This ticket is non-transferable. One scan per entry.',
|
||||
},
|
||||
es: {
|
||||
scan: 'ESCANEÁ AL INGRESAR',
|
||||
venue: 'LUGAR',
|
||||
holder: 'TITULAR',
|
||||
terms: 'Esta entrada es personal e intransferible. Un escaneo por ingreso.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function strings(locale?: string) {
|
||||
return locale === 'es' ? STRINGS.es : STRINGS.en;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as a data URL
|
||||
* Locate the logo. `../../assets` resolves to backend/assets from both
|
||||
* src/lib (tsx) and dist/lib (compiled), with the frontend copy as a fallback.
|
||||
*/
|
||||
function loadLogo(): Buffer | null {
|
||||
const candidates = [
|
||||
new URL('../../assets/logo-spanglish.png', import.meta.url),
|
||||
new URL('../../../frontend/public/images/logo-spanglish.png', import.meta.url),
|
||||
].map((u) => fileURLToPath(u));
|
||||
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return readFileSync(path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let logoCache: Buffer | null | undefined;
|
||||
export function getLogo(): Buffer | null {
|
||||
if (logoCache === undefined) logoCache = loadLogo();
|
||||
return logoCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as a PNG buffer
|
||||
*/
|
||||
async function generateQRCode(data: string): Promise<Buffer> {
|
||||
return QRCode.toBuffer(data, {
|
||||
type: 'png',
|
||||
width: 200,
|
||||
margin: 2,
|
||||
width: 600,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#000000', light: '#FFFFFF' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display using site timezone
|
||||
* Short date + time as shown in the ticket header:
|
||||
* en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30"
|
||||
*/
|
||||
function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: timezone,
|
||||
});
|
||||
}
|
||||
function formatWhen(
|
||||
startStr: string,
|
||||
endStr: string | undefined,
|
||||
timezone: string,
|
||||
locale: string
|
||||
): string {
|
||||
const isEs = locale === 'es';
|
||||
const start = new Date(startStr);
|
||||
const tag = isEs ? 'es-ES' : 'en-US';
|
||||
|
||||
/**
|
||||
* Format time for display using site timezone
|
||||
*/
|
||||
function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone });
|
||||
const month = start
|
||||
.toLocaleDateString(tag, { month: 'short', timeZone: timezone })
|
||||
.replace(/\.$/, '')
|
||||
.toUpperCase();
|
||||
|
||||
const time = (d: Date) =>
|
||||
d
|
||||
.toLocaleTimeString(tag, {
|
||||
hour: isEs ? '2-digit' : 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
hour12: !isEs,
|
||||
timeZone: timezone,
|
||||
})
|
||||
.toUpperCase();
|
||||
|
||||
const date = isEs ? `${day} ${month}` : `${month} ${day}`;
|
||||
const end = endStr ? new Date(endStr) : null;
|
||||
const when = end ? `${time(start)} – ${time(end)}` : time(start);
|
||||
|
||||
return `${date} · ${when}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events store the venue as a single string; the part before the first comma
|
||||
* reads as the venue name and the remainder as its address.
|
||||
*/
|
||||
function splitLocation(location: string): { name: string; address?: string } {
|
||||
const idx = location.indexOf(',');
|
||||
if (idx === -1) return { name: location.trim() };
|
||||
return {
|
||||
name: location.slice(0, idx).trim(),
|
||||
address: location.slice(idx + 1).trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Drawing helpers ====================
|
||||
|
||||
export function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(8)
|
||||
.fillColor(COLORS.label)
|
||||
.text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 });
|
||||
}
|
||||
|
||||
export function drawDivider(doc: PDFKit.PDFDocument, y: number) {
|
||||
doc
|
||||
.moveTo(MARGIN, y)
|
||||
.lineTo(PAGE_W - MARGIN, y)
|
||||
.lineWidth(1)
|
||||
.strokeColor(COLORS.divider)
|
||||
.stroke();
|
||||
}
|
||||
|
||||
/** Centered text with letter spacing: pdfkit also spaces the last glyph, so nudge it back. */
|
||||
function drawSpacedCentered(
|
||||
doc: PDFKit.PDFDocument,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
spacing: number
|
||||
) {
|
||||
doc.text(text, x - spacing / 2, y, { width, align: 'center', characterSpacing: spacing });
|
||||
}
|
||||
|
||||
interface DetailBlock {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw (or, with `measureOnly`, just measure) the venue / ticket holder / note
|
||||
* block. Returns its total height so the caller can anchor it above the footer.
|
||||
*/
|
||||
function renderDetails(
|
||||
doc: PDFKit.PDFDocument,
|
||||
blocks: DetailBlock[],
|
||||
note: string,
|
||||
yStart: number,
|
||||
measureOnly: boolean
|
||||
): number {
|
||||
let y = yStart;
|
||||
|
||||
blocks.forEach((block, i) => {
|
||||
if (i > 0) {
|
||||
y += 14;
|
||||
if (!measureOnly) drawDivider(doc, y);
|
||||
y += 18;
|
||||
}
|
||||
|
||||
if (!measureOnly) drawLabel(doc, block.label, y);
|
||||
y += 15;
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(13);
|
||||
if (!measureOnly) doc.fillColor(COLORS.navy).text(block.value, MARGIN, y, { width: CONTENT_W });
|
||||
y += doc.heightOfString(block.value, { width: CONTENT_W }) + 3;
|
||||
|
||||
if (block.sub) {
|
||||
doc.font('Helvetica').fontSize(10.5);
|
||||
if (!measureOnly) doc.fillColor(COLORS.muted).text(block.sub, MARGIN, y, { width: CONTENT_W });
|
||||
y += doc.heightOfString(block.sub, { width: CONTENT_W }) + 3;
|
||||
}
|
||||
});
|
||||
|
||||
y += 16;
|
||||
doc.font('Helvetica').fontSize(10.5);
|
||||
if (!measureOnly) doc.fillColor(COLORS.muted).text(note, MARGIN, y, { width: CONTENT_W });
|
||||
y += doc.heightOfString(note, { width: CONTENT_W });
|
||||
|
||||
return y - yStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one full-page ticket. Assumes the page is already added.
|
||||
*/
|
||||
function renderTicketPage(
|
||||
doc: PDFKit.PDFDocument,
|
||||
ticket: TicketData,
|
||||
qrBuffer: Buffer,
|
||||
siteDomain: string,
|
||||
index = 0,
|
||||
total = 1
|
||||
) {
|
||||
const locale = ticket.locale === 'es' ? 'es' : 'en';
|
||||
const t = strings(locale);
|
||||
const tz = ticket.timezone || 'America/Asuncion';
|
||||
const footerY = PAGE_H - FOOTER_H;
|
||||
|
||||
// ==================== Background ====================
|
||||
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
|
||||
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
|
||||
|
||||
// ==================== Logo ====================
|
||||
const logo = getLogo();
|
||||
let headerY = MARGIN + 6;
|
||||
|
||||
if (logo) {
|
||||
const logoW = 158;
|
||||
doc.image(logo, MARGIN, headerY, { width: logoW });
|
||||
headerY += logoW / LOGO_RATIO;
|
||||
} else {
|
||||
doc.font('Helvetica-Bold').fontSize(21).fillColor(COLORS.navy).text('spanglish social', MARGIN, headerY);
|
||||
headerY += 26;
|
||||
}
|
||||
|
||||
// ==================== Title + date ====================
|
||||
const titleY = headerY + 30;
|
||||
const when = formatWhen(ticket.event.startDatetime, ticket.event.endDatetime, tz, locale);
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(11.5);
|
||||
const whenW = Math.min(doc.widthOfString(when) + 2, CONTENT_W * 0.5);
|
||||
const titleW = CONTENT_W - whenW - 20;
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(26);
|
||||
if (doc.widthOfString(ticket.event.title) > titleW) doc.fontSize(20);
|
||||
doc.fillColor(COLORS.navy).text(ticket.event.title, MARGIN, titleY, { width: titleW });
|
||||
const titleBottom = doc.y;
|
||||
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(11.5)
|
||||
.fillColor(COLORS.orange)
|
||||
.text(when, PAGE_W - MARGIN - whenW, titleY + 9, { width: whenW, align: 'right' });
|
||||
|
||||
// ==================== Layout: card fills what the detail block leaves ====================
|
||||
const venue = splitLocation(ticket.event.location);
|
||||
const note = ticket.note || t.terms;
|
||||
const blocks: DetailBlock[] = [
|
||||
{ label: t.venue, value: venue.name, sub: venue.address },
|
||||
{ label: t.holder, value: ticket.attendeeName, sub: ticket.attendeeEmail },
|
||||
];
|
||||
|
||||
const detailsH = renderDetails(doc, blocks, note, 0, true);
|
||||
const detailsY = footerY - 46 - detailsH;
|
||||
|
||||
const cardY = Math.max(titleBottom, titleY + 36) + 24;
|
||||
const cardX = MARGIN;
|
||||
const cardW = CONTENT_W;
|
||||
const cardH = Math.max(300, Math.min(detailsY - 32 - cardY, 430));
|
||||
|
||||
doc
|
||||
.roundedRect(cardX, cardY, cardW, cardH, 14)
|
||||
.lineWidth(1)
|
||||
.fillAndStroke(COLORS.card, COLORS.cardBorder);
|
||||
|
||||
// ==================== QR card contents ====================
|
||||
const labelH = 12;
|
||||
const codeH = 24;
|
||||
const qrSize = Math.min(236, cardH - (labelH + 20 + 22 + codeH + 44));
|
||||
const stackH = labelH + 20 + qrSize + 22 + codeH;
|
||||
let inner = cardY + (cardH - stackH) / 2;
|
||||
|
||||
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(COLORS.label);
|
||||
drawSpacedCentered(doc, t.scan, cardX, inner, cardW, 2);
|
||||
|
||||
if (total > 1) {
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(8.5)
|
||||
.fillColor(COLORS.label)
|
||||
.text(`${index + 1} / ${total}`, cardX, inner, { width: cardW - 22, align: 'right', characterSpacing: 1 });
|
||||
}
|
||||
|
||||
inner += labelH + 20;
|
||||
doc.image(qrBuffer, (PAGE_W - qrSize) / 2, inner, { width: qrSize, height: qrSize });
|
||||
inner += qrSize + 22;
|
||||
|
||||
const code = ticket.qrCode || ticket.id.slice(0, 8).toUpperCase();
|
||||
doc.font('Courier-Bold').fontSize(19).fillColor(COLORS.navy);
|
||||
drawSpacedCentered(doc, code, cardX, inner, cardW, 3);
|
||||
|
||||
// ==================== Venue / ticket holder / note ====================
|
||||
renderDetails(doc, blocks, note, detailsY, false);
|
||||
|
||||
// ==================== Footer ====================
|
||||
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
|
||||
|
||||
doc
|
||||
.font('Courier')
|
||||
.fontSize(7.5)
|
||||
.fillColor(COLORS.footerMuted)
|
||||
.text(ticket.id, MARGIN, footerY + FOOTER_H / 2 - 4, { width: CONTENT_W * 0.6, lineBreak: false });
|
||||
|
||||
doc
|
||||
.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.fillColor('#FFFFFF')
|
||||
.text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
|
||||
}
|
||||
|
||||
export function createDoc(): PDFKit.PDFDocument {
|
||||
return new PDFDocument({ size: 'A4', margin: 0 });
|
||||
}
|
||||
|
||||
export function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function siteUrl(): { base: string; domain: string } {
|
||||
const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||
let domain = base;
|
||||
try {
|
||||
domain = new URL(base).host.replace(/^www\./, '');
|
||||
} catch {
|
||||
domain = base.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '');
|
||||
}
|
||||
return { base, domain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PDF ticket for a single ticket
|
||||
*/
|
||||
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
margin: 50,
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||
|
||||
// Generate QR code with ticket URL
|
||||
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
|
||||
const qrBuffer = await generateQRCode(qrUrl);
|
||||
|
||||
// ==================== Header ====================
|
||||
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
|
||||
|
||||
// Divider line
|
||||
doc.moveDown(1);
|
||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||
doc.moveDown(1);
|
||||
|
||||
// ==================== Event Info ====================
|
||||
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
// Date and time (using site timezone)
|
||||
const tz = ticket.timezone || 'America/Asuncion';
|
||||
doc.fontSize(14).fillColor('#333');
|
||||
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
|
||||
|
||||
const startTime = formatTime(ticket.event.startDatetime, tz);
|
||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
|
||||
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
||||
doc.text(timeRange, { align: 'center' });
|
||||
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
|
||||
|
||||
// ==================== QR Code ====================
|
||||
doc.moveDown(2);
|
||||
|
||||
// Center the QR code
|
||||
const qrSize = 180;
|
||||
const pageWidth = 595; // A4 width in points
|
||||
const qrX = (pageWidth - qrSize) / 2;
|
||||
|
||||
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
|
||||
doc.y += qrSize + 10;
|
||||
|
||||
// ==================== Attendee Info ====================
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
|
||||
|
||||
if (ticket.attendeeEmail) {
|
||||
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
|
||||
}
|
||||
|
||||
// ==================== Ticket ID ====================
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
|
||||
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
|
||||
|
||||
// ==================== Footer ====================
|
||||
doc.moveDown(2);
|
||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||
doc.moveDown(0.5);
|
||||
|
||||
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
|
||||
|
||||
doc.end();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
return generateCombinedTicketsPDF([ticket]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a combined PDF with multiple tickets
|
||||
* Generate a combined PDF with multiple tickets (one page each)
|
||||
*/
|
||||
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const doc = createDoc();
|
||||
const done = collect(doc);
|
||||
const { base, domain } = siteUrl();
|
||||
|
||||
try {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
margin: 50,
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||
|
||||
for (let i = 0; i < tickets.length; i++) {
|
||||
const ticket = tickets[i];
|
||||
if (i > 0) doc.addPage();
|
||||
|
||||
if (i > 0) {
|
||||
doc.addPage();
|
||||
const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`);
|
||||
renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length);
|
||||
}
|
||||
|
||||
// Generate QR code
|
||||
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
|
||||
const qrBuffer = await generateQRCode(qrUrl);
|
||||
|
||||
// ==================== Header ====================
|
||||
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
|
||||
|
||||
// Divider line
|
||||
doc.moveDown(1);
|
||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||
doc.moveDown(1);
|
||||
|
||||
// ==================== Event Info ====================
|
||||
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
// Date and time (using site timezone)
|
||||
const tz = ticket.timezone || 'America/Asuncion';
|
||||
doc.fontSize(14).fillColor('#333');
|
||||
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
|
||||
|
||||
const startTime = formatTime(ticket.event.startDatetime, tz);
|
||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
|
||||
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
||||
doc.text(timeRange, { align: 'center' });
|
||||
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
|
||||
|
||||
// ==================== QR Code ====================
|
||||
doc.moveDown(2);
|
||||
|
||||
const qrSize = 180;
|
||||
const pageWidth = 595;
|
||||
const qrX = (pageWidth - qrSize) / 2;
|
||||
|
||||
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
|
||||
doc.y += qrSize + 10;
|
||||
|
||||
// ==================== Attendee Info ====================
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
|
||||
|
||||
if (ticket.attendeeEmail) {
|
||||
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
|
||||
}
|
||||
|
||||
// ==================== Ticket ID ====================
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
|
||||
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
|
||||
|
||||
// Ticket number for multi-ticket bookings
|
||||
if (tickets.length > 1) {
|
||||
doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
|
||||
}
|
||||
|
||||
// ==================== Footer ====================
|
||||
doc.moveDown(2);
|
||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
||||
doc.moveDown(0.5);
|
||||
|
||||
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
doc.end();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return done;
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolvePresaleClosure,
|
||||
isPresaleClosed,
|
||||
DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||
} from './presale.js';
|
||||
|
||||
const START = '2030-01-01T20:00:00.000Z';
|
||||
const startMs = new Date(START).getTime();
|
||||
const minutes = (n: number) => n * 60_000;
|
||||
|
||||
describe('resolvePresaleClosure', () => {
|
||||
it('falls back to the built-in defaults when neither event nor settings specify anything', () => {
|
||||
const r = resolvePresaleClosure({ startDatetime: START }, null);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.minutesBefore).toBe(DEFAULT_PRESALE_CLOSE_MINUTES);
|
||||
expect(r.closesAt?.getTime()).toBe(startMs - minutes(DEFAULT_PRESALE_CLOSE_MINUTES));
|
||||
});
|
||||
|
||||
it('inherits from site settings when the event has null overrides', () => {
|
||||
const r = resolvePresaleClosure(
|
||||
{ startDatetime: START, presaleClosureEnabled: null, presaleCloseMinutesBefore: null },
|
||||
{ presaleClosureEnabled: true, presaleCloseMinutesBefore: 30 }
|
||||
);
|
||||
expect(r.minutesBefore).toBe(30);
|
||||
expect(r.closesAt?.getTime()).toBe(startMs - minutes(30));
|
||||
});
|
||||
|
||||
it('lets the event override the site settings', () => {
|
||||
const r = resolvePresaleClosure(
|
||||
{ startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 1440 },
|
||||
{ presaleClosureEnabled: false, presaleCloseMinutesBefore: 30 }
|
||||
);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.closesAt?.getTime()).toBe(startMs - minutes(1440));
|
||||
});
|
||||
|
||||
it('returns closesAt null when closure is disabled (site-wide or per event)', () => {
|
||||
expect(resolvePresaleClosure({ startDatetime: START }, { presaleClosureEnabled: false }).closesAt).toBeNull();
|
||||
expect(
|
||||
resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: false }, { presaleClosureEnabled: true }).closesAt
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts PostgreSQL 0/1 integers and string minutes', () => {
|
||||
const r = resolvePresaleClosure(
|
||||
{ startDatetime: new Date(START), presaleClosureEnabled: 1, presaleCloseMinutesBefore: '45' },
|
||||
{ presaleClosureEnabled: 0, presaleCloseMinutesBefore: 10 }
|
||||
);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.minutesBefore).toBe(45);
|
||||
const off = resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: 0 }, { presaleClosureEnabled: 1 });
|
||||
expect(off.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('closes at the event start when minutes is 0', () => {
|
||||
const r = resolvePresaleClosure({ startDatetime: START, presaleCloseMinutesBefore: 0 }, null);
|
||||
expect(r.closesAt?.getTime()).toBe(startMs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPresaleClosed', () => {
|
||||
const event = { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 60 };
|
||||
|
||||
it('is open before the cutoff and closed from the cutoff onwards', () => {
|
||||
const cutoff = startMs - minutes(60);
|
||||
expect(isPresaleClosed(event, null, cutoff - 1)).toBe(false);
|
||||
expect(isPresaleClosed(event, null, cutoff)).toBe(true);
|
||||
expect(isPresaleClosed(event, null, startMs + minutes(5))).toBe(true);
|
||||
});
|
||||
|
||||
it('never closes when closure is disabled', () => {
|
||||
expect(isPresaleClosed({ ...event, presaleClosureEnabled: false }, null, startMs + minutes(60))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Pre-sale closure: online registration for an event closes a configurable
|
||||
* number of minutes before the event starts.
|
||||
*
|
||||
* Each event may override the site-wide default; a `null`/`undefined` value on
|
||||
* the event means "inherit from site_settings". Values arrive as booleans on
|
||||
* SQLite and as 0/1 integers on PostgreSQL, so both are accepted here.
|
||||
*/
|
||||
|
||||
export const DEFAULT_PRESALE_CLOSURE_ENABLED = true;
|
||||
export const DEFAULT_PRESALE_CLOSE_MINUTES = 120;
|
||||
|
||||
export interface PresaleEventLike {
|
||||
startDatetime: string | Date;
|
||||
presaleClosureEnabled?: boolean | number | null;
|
||||
presaleCloseMinutesBefore?: number | string | null;
|
||||
}
|
||||
|
||||
export interface PresaleSettingsLike {
|
||||
presaleClosureEnabled?: boolean | number | null;
|
||||
presaleCloseMinutesBefore?: number | string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedPresaleClosure {
|
||||
enabled: boolean;
|
||||
minutesBefore: number;
|
||||
/** When online registration closes, or null when closure is disabled. */
|
||||
closesAt: Date | null;
|
||||
}
|
||||
|
||||
function toBool(value: boolean | number | null | undefined): boolean | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return typeof value === 'number' ? value !== 0 : Boolean(value);
|
||||
}
|
||||
|
||||
function toMinutes(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const n = typeof value === 'string' ? parseInt(value, 10) : value;
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||
}
|
||||
|
||||
export function resolvePresaleClosure(
|
||||
event: PresaleEventLike,
|
||||
settings?: PresaleSettingsLike | null
|
||||
): ResolvedPresaleClosure {
|
||||
const enabled =
|
||||
toBool(event.presaleClosureEnabled) ??
|
||||
toBool(settings?.presaleClosureEnabled) ??
|
||||
DEFAULT_PRESALE_CLOSURE_ENABLED;
|
||||
const minutesBefore =
|
||||
toMinutes(event.presaleCloseMinutesBefore) ??
|
||||
toMinutes(settings?.presaleCloseMinutesBefore) ??
|
||||
DEFAULT_PRESALE_CLOSE_MINUTES;
|
||||
|
||||
if (!enabled) return { enabled, minutesBefore, closesAt: null };
|
||||
|
||||
const startMs = new Date(event.startDatetime).getTime();
|
||||
if (!Number.isFinite(startMs)) return { enabled, minutesBefore, closesAt: null };
|
||||
|
||||
return { enabled, minutesBefore, closesAt: new Date(startMs - minutesBefore * 60_000) };
|
||||
}
|
||||
|
||||
export function isPresaleClosed(
|
||||
event: PresaleEventLike,
|
||||
settings?: PresaleSettingsLike | null,
|
||||
nowMs: number = Date.now()
|
||||
): boolean {
|
||||
const { closesAt } = resolvePresaleClosure(event, settings);
|
||||
return closesAt !== null && closesAt.getTime() <= nowMs;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
|
||||
|
||||
// Minimal Hono-Context stand-in: headers + the node-server env with the
|
||||
// socket peer address.
|
||||
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
|
||||
const headers = new Map(
|
||||
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||||
);
|
||||
return {
|
||||
req: { header: (name: string) => headers.get(name.toLowerCase()) },
|
||||
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('isTrustedProxyIp', () => {
|
||||
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
|
||||
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
|
||||
expect(isTrustedProxyIp('::1')).toBe(true);
|
||||
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
|
||||
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
|
||||
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
|
||||
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trust public addresses or near-miss ranges', () => {
|
||||
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
|
||||
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
|
||||
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
|
||||
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
|
||||
expect(isTrustedProxyIp('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientIp', () => {
|
||||
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
|
||||
// spoofed prefix, then the real client appended by nginx, then the Next
|
||||
// proxy hop — the rightmost untrusted entry wins
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('ignores forwarded headers entirely when the peer is untrusted', () => {
|
||||
// A client hitting the API directly cannot pick its own bucket
|
||||
const c = fakeContext({
|
||||
peer: '198.51.100.4',
|
||||
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('198.51.100.4');
|
||||
});
|
||||
|
||||
it('falls back to the socket address for local traffic with no headers', () => {
|
||||
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
|
||||
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('falls back to the socket address when every forwarded hop is internal', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': '127.0.0.1' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('rejects junk header values instead of using them as bucket keys', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('returns "unknown" without a socket address or trusted headers', () => {
|
||||
expect(getClientIp(fakeContext({}))).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Context } from 'hono';
|
||||
import { getRateLimiter } from './stores/rateLimiter.js';
|
||||
|
||||
/**
|
||||
* Rate limiting helpers.
|
||||
*
|
||||
* The actual counting is delegated to a pluggable rate limiter (in-memory by
|
||||
* default, Redis-backed when REDIS_URL is set) so limits are enforced either
|
||||
* per instance (single-instance deployments) or across all instances
|
||||
* (horizontal scaling). See lib/stores/rateLimiter.ts.
|
||||
*/
|
||||
|
||||
// Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For:
|
||||
// loopback (nginx on the same host, the Next.js proxy) and RFC1918 ranges
|
||||
// (the docker-compose scale deployment, where nginx is another container).
|
||||
// Extend with TRUSTED_PROXIES (comma-separated IP prefixes, e.g. "172.20.").
|
||||
const DEFAULT_TRUSTED_PROXY_PREFIXES = ['127.', '10.', '192.168.', '::1'];
|
||||
|
||||
function trustedProxyPrefixes(): string[] {
|
||||
const extra = (process.env.TRUSTED_PROXIES || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return [...DEFAULT_TRUSTED_PROXY_PREFIXES, ...extra];
|
||||
}
|
||||
|
||||
/** Strip the IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.". */
|
||||
function normalizeIp(ip: string | undefined | null): string {
|
||||
const trimmed = (ip || '').trim();
|
||||
return trimmed.toLowerCase().startsWith('::ffff:') ? trimmed.slice(7) : trimmed;
|
||||
}
|
||||
|
||||
export function isTrustedProxyIp(ip: string): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized) return false;
|
||||
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalized)) return true; // 172.16.0.0/12
|
||||
return trustedProxyPrefixes().some((prefix) => normalized === prefix || normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
// Rough shape check so a junk header value can't become a rate-limit key.
|
||||
function looksLikeIp(value: string): boolean {
|
||||
return value.length > 0 && value.length <= 45 && /^[0-9a-fA-F.:]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spoof-resistant client IP resolution.
|
||||
*
|
||||
* The TCP peer address (via @hono/node-server's env.incoming) anchors the
|
||||
* trust decision: forwarded headers are only honoured when the direct peer is
|
||||
* one of our own proxies. X-Real-IP is preferred because nginx overwrites it
|
||||
* at the edge (deploy/*.conf); X-Forwarded-For is append-only, so it is
|
||||
* walked from the right past our proxy hops — the leftmost entries are
|
||||
* client-controlled and never trusted on their own.
|
||||
*/
|
||||
export function getClientIp(c: Context): string {
|
||||
const socketAddr = normalizeIp((c.env as any)?.incoming?.socket?.remoteAddress);
|
||||
|
||||
if (socketAddr && isTrustedProxyIp(socketAddr)) {
|
||||
const realIp = normalizeIp(c.req.header('x-real-ip'));
|
||||
if (realIp && looksLikeIp(realIp)) return realIp;
|
||||
|
||||
const forwarded = c.req.header('x-forwarded-for');
|
||||
if (forwarded) {
|
||||
const chain = forwarded.split(',').map((s) => normalizeIp(s)).filter(Boolean);
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
if (!isTrustedProxyIp(chain[i])) {
|
||||
return looksLikeIp(chain[i]) ? chain[i] : socketAddr;
|
||||
}
|
||||
}
|
||||
// Every hop is one of ours: a genuinely local/internal client.
|
||||
}
|
||||
}
|
||||
|
||||
return socketAddr || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one unit against a key. Returns whether the request is allowed and,
|
||||
* when blocked, how many seconds until the window resets.
|
||||
*/
|
||||
export function consumeRateLimit(
|
||||
key: string,
|
||||
max: number,
|
||||
windowMs: number
|
||||
): Promise<{ allowed: boolean; retryAfter?: number }> {
|
||||
return getRateLimiter().consume(key, max, windowMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono middleware factory that rate-limits by client IP.
|
||||
* Use a distinct `prefix` per endpoint group so unrelated routes don't share a bucket.
|
||||
*/
|
||||
export function rateLimitMiddleware(opts: { max: number; windowMs: number; prefix: string }) {
|
||||
return async (c: Context, next: () => Promise<void>) => {
|
||||
const ip = getClientIp(c);
|
||||
const result = await consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs);
|
||||
if (!result.allowed) {
|
||||
return c.json(
|
||||
{ error: 'Too many requests. Please try again later.', retryAfter: result.retryAfter },
|
||||
429
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Optional Redis connection manager.
|
||||
//
|
||||
// Redis is entirely optional. When REDIS_URL is unset the app runs exactly as
|
||||
// before with in-memory backends. When set, this module owns a single shared
|
||||
// command connection plus a dedicated subscriber connection (a connection in
|
||||
// subscribe mode cannot run normal commands), with auto-reconnect, capped
|
||||
// backoff, an active PING probe, and a health flag that callers and the health
|
||||
// endpoint can read.
|
||||
//
|
||||
// TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path
|
||||
// selects a DB index (e.g. redis://host:6379/1) when sharing an instance.
|
||||
// We deliberately do not use ioredis's keyPrefix option: all keys are already
|
||||
// namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a
|
||||
// pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not)
|
||||
// that would silently break the payment SSE channel.
|
||||
|
||||
import Redis from 'ioredis';
|
||||
|
||||
let client: Redis | null = null;
|
||||
let subscriber: Redis | null = null;
|
||||
let healthy = false;
|
||||
let initialized = false;
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastPingOkAt: string | null = null;
|
||||
let lastPingMs: number | null = null;
|
||||
|
||||
// Debounce repeated error logs during a sustained outage: transitions are
|
||||
// always logged, repeated per-retry errors at most once per LOG_EVERY_MS.
|
||||
const ERROR_LOG_EVERY_MS = 30_000;
|
||||
let lastErrorLogAt = 0;
|
||||
|
||||
/** Whether Redis is configured via REDIS_URL. */
|
||||
export function isRedisEnabled(): boolean {
|
||||
return !!process.env.REDIS_URL;
|
||||
}
|
||||
|
||||
/** Whether the Redis connection is currently usable. */
|
||||
export function isRedisHealthy(): boolean {
|
||||
return isRedisEnabled() && healthy;
|
||||
}
|
||||
|
||||
/** Detail for the health endpoint: when the last successful PING happened. */
|
||||
export function getRedisHealthDetail(): { lastPingOkAt: string | null; lastPingMs: number | null } {
|
||||
return { lastPingOkAt, lastPingMs };
|
||||
}
|
||||
|
||||
function setHealthy(next: boolean, context: string): void {
|
||||
if (next !== healthy) {
|
||||
if (next) {
|
||||
console.log(`[redis] healthy (${context})`);
|
||||
} else {
|
||||
console.warn(`[redis] unhealthy (${context})`);
|
||||
}
|
||||
}
|
||||
healthy = next;
|
||||
}
|
||||
|
||||
function logErrorDebounced(label: string, err: unknown): void {
|
||||
const now = Date.now();
|
||||
if (now - lastErrorLogAt >= ERROR_LOG_EVERY_MS) {
|
||||
lastErrorLogAt = now;
|
||||
console.error(`[redis] (${label}) error:`, (err as any)?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
function buildClient(label: string): Redis {
|
||||
const url = process.env.REDIS_URL as string;
|
||||
const instance = new Redis(url, {
|
||||
// Keep the process responsive: fail fast on a per-command basis and let the
|
||||
// callers degrade to their in-memory fallback rather than hanging. Do not
|
||||
// queue commands while disconnected — with fail-open callers everywhere a
|
||||
// growing offline queue would only add latency and memory pressure.
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: false,
|
||||
connectTimeout: 5000,
|
||||
retryStrategy(times) {
|
||||
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
|
||||
const delay = Math.min(times * 200, 5000);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
instance.on('connect', () => {
|
||||
console.log(`[redis] (${label}) connecting`);
|
||||
});
|
||||
instance.on('ready', () => {
|
||||
setHealthy(true, `${label} ready`);
|
||||
});
|
||||
instance.on('error', (err) => {
|
||||
setHealthy(false, `${label} error`);
|
||||
logErrorDebounced(label, err);
|
||||
});
|
||||
instance.on('reconnecting', () => {
|
||||
setHealthy(false, `${label} reconnecting`);
|
||||
});
|
||||
instance.on('end', () => {
|
||||
setHealthy(false, `${label} connection closed`);
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Actively probe the command connection. The event-driven flag alone misses a
|
||||
// silently hung connection; a periodic PING with a hard timeout catches it.
|
||||
async function pingOnce(): Promise<void> {
|
||||
if (!client) return;
|
||||
const started = Date.now();
|
||||
try {
|
||||
await Promise.race([
|
||||
client.ping(),
|
||||
new Promise((_, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('ping timeout')), 2000);
|
||||
(t as any).unref?.();
|
||||
}),
|
||||
]);
|
||||
lastPingMs = Date.now() - started;
|
||||
lastPingOkAt = new Date().toISOString();
|
||||
setHealthy(true, 'ping ok');
|
||||
} catch (err) {
|
||||
setHealthy(false, 'ping failed');
|
||||
logErrorDebounced('ping', err);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInit(): void {
|
||||
if (initialized || !isRedisEnabled()) return;
|
||||
initialized = true;
|
||||
client = buildClient('commands');
|
||||
subscriber = buildClient('subscriber');
|
||||
pingTimer = setInterval(() => {
|
||||
void pingOnce();
|
||||
}, 10_000);
|
||||
(pingTimer as any).unref?.();
|
||||
}
|
||||
|
||||
/** Shared command connection, or null when Redis is not configured. */
|
||||
export function getRedis(): Redis | null {
|
||||
ensureInit();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Dedicated subscriber connection, or null when Redis is not configured. */
|
||||
export function getSubscriber(): Redis | null {
|
||||
ensureInit();
|
||||
return subscriber;
|
||||
}
|
||||
|
||||
/** Close connections (used for graceful shutdown). */
|
||||
export async function closeRedis(): Promise<void> {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (client) tasks.push(client.quit().catch(() => undefined));
|
||||
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
|
||||
await Promise.all(tasks);
|
||||
client = null;
|
||||
subscriber = null;
|
||||
initialized = false;
|
||||
healthy = false;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveSalesState, publicSalesFields, isOnlineSalesClosed } from './salesState.js';
|
||||
|
||||
const START = '2030-01-01T23:00:00.000Z'; // 20:00 in Asunción
|
||||
const END = '2030-01-02T02:00:00.000Z';
|
||||
const startMs = new Date(START).getTime();
|
||||
const minutes = (n: number) => n * 60_000;
|
||||
|
||||
// Pre-sale closes 120 minutes before the start (the site default).
|
||||
const event = {
|
||||
status: 'published',
|
||||
startDatetime: START,
|
||||
endDatetime: END,
|
||||
externalBookingEnabled: false,
|
||||
price: 21000,
|
||||
walkInPrice: 30000 as number | null,
|
||||
};
|
||||
const settings = { presaleClosureEnabled: true, presaleCloseMinutesBefore: 120 };
|
||||
const beforeClose = startMs - minutes(121);
|
||||
const afterClose = startMs - minutes(60);
|
||||
|
||||
describe('resolveSalesState', () => {
|
||||
it('is online while pre-sale is open and seats are left', () => {
|
||||
expect(resolveSalesState(event, settings, 10, beforeClose)).toBe('online');
|
||||
});
|
||||
|
||||
it('is door after pre-sale closes, until the event ends', () => {
|
||||
expect(resolveSalesState(event, settings, 10, afterClose)).toBe('door');
|
||||
expect(resolveSalesState(event, settings, 10, startMs + minutes(30))).toBe('door');
|
||||
expect(resolveSalesState(event, settings, 10, new Date(END).getTime() - 1)).toBe('door');
|
||||
});
|
||||
|
||||
it('is sold_out with no seats left, online or at the door', () => {
|
||||
expect(resolveSalesState(event, settings, 0, beforeClose)).toBe('sold_out');
|
||||
expect(resolveSalesState(event, settings, 0, afterClose)).toBe('sold_out');
|
||||
});
|
||||
|
||||
it('is ended once the end time passes, or the start time when there is no end', () => {
|
||||
expect(resolveSalesState(event, settings, 10, new Date(END).getTime())).toBe('ended');
|
||||
expect(resolveSalesState(event, settings, 0, new Date(END).getTime())).toBe('ended');
|
||||
expect(resolveSalesState({ ...event, endDatetime: null }, settings, 10, startMs)).toBe('ended');
|
||||
expect(resolveSalesState({ ...event, status: 'completed' }, settings, 10, beforeClose)).toBe('ended');
|
||||
});
|
||||
|
||||
it('is external for external booking events, with no door state', () => {
|
||||
const external = { ...event, externalBookingEnabled: true };
|
||||
expect(resolveSalesState(external, settings, 10, beforeClose)).toBe('external');
|
||||
expect(resolveSalesState(external, settings, 10, afterClose)).toBe('external');
|
||||
expect(resolveSalesState({ ...event, externalBookingEnabled: 1 }, settings, 10, afterClose)).toBe('external');
|
||||
});
|
||||
|
||||
it('keeps cancelled events cancelled', () => {
|
||||
expect(resolveSalesState({ ...event, status: 'cancelled' }, settings, 10, afterClose)).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('closes online sales at the start when pre-sale closure is off', () => {
|
||||
const noClosure = { ...event, presaleClosureEnabled: false };
|
||||
expect(resolveSalesState(noClosure, settings, 10, startMs - 1)).toBe('online');
|
||||
expect(resolveSalesState(noClosure, settings, 10, startMs)).toBe('door');
|
||||
expect(isOnlineSalesClosed(noClosure, settings, startMs - 1)).toBe(false);
|
||||
expect(isOnlineSalesClosed(noClosure, settings, startMs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicSalesFields', () => {
|
||||
it('door with walk_in_price set: doorPrice is the walk-in price', () => {
|
||||
expect(publicSalesFields(event, settings, 48, afterClose)).toEqual({ salesState: 'door', doorPrice: 30000 });
|
||||
});
|
||||
|
||||
it('door with walk_in_price null: doorPrice falls back to the ticket price', () => {
|
||||
expect(publicSalesFields({ ...event, walkInPrice: null }, settings, 48, afterClose))
|
||||
.toEqual({ salesState: 'door', doorPrice: 21000 });
|
||||
// Postgres decimals arrive as strings
|
||||
expect(publicSalesFields({ ...event, price: '21000.00', walkInPrice: null }, settings, 48, afterClose).doorPrice)
|
||||
.toBe(21000);
|
||||
});
|
||||
|
||||
it('door with a free walk-in keeps 0, not the ticket price', () => {
|
||||
expect(publicSalesFields({ ...event, walkInPrice: 0 }, settings, 48, afterClose).doorPrice).toBe(0);
|
||||
});
|
||||
|
||||
it('omits doorPrice in every other state', () => {
|
||||
const cases: Array<[number, number, any]> = [
|
||||
[10, beforeClose, event], // online
|
||||
[0, afterClose, event], // sold_out
|
||||
[10, new Date(END).getTime(), event], // ended
|
||||
[10, afterClose, { ...event, externalBookingEnabled: true }], // external
|
||||
[10, afterClose, { ...event, status: 'cancelled' }], // cancelled
|
||||
];
|
||||
for (const [spots, now, e] of cases) {
|
||||
const fields = publicSalesFields(e, settings, spots, now);
|
||||
expect(fields.salesState).not.toBe('door');
|
||||
expect(fields).not.toHaveProperty('doorPrice');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// Public sales state of an event: what the event page offers a visitor right now.
|
||||
//
|
||||
// online pre-sale open and seats left (book online)
|
||||
// door online sales closed, the event has not ended and seats are left:
|
||||
// people can still come and pay at the door until the event ends
|
||||
// sold_out no seats left, online or at the door
|
||||
// ended the event is over (end time passed, or status completed/archived)
|
||||
// external bookings happen on an external site; no door state
|
||||
// cancelled the event was cancelled
|
||||
//
|
||||
// This is the single source of truth for the public page, listings and JSON-LD.
|
||||
// Online sales close at the pre-sale cutoff (lib/presale.ts) or, when pre-sale
|
||||
// closure is off, when the event starts — the booking API enforces the same rule
|
||||
// through isOnlineSalesClosed. The state flips on the clock without any edit to
|
||||
// the event, so cached copies must be refreshed around presaleClosesAt.
|
||||
|
||||
import { isPresaleClosed, type PresaleEventLike, type PresaleSettingsLike } from './presale.js';
|
||||
import { resolveWalkInPrice } from './walkInPrice.js';
|
||||
|
||||
export type SalesState = 'online' | 'door' | 'sold_out' | 'ended' | 'external' | 'cancelled';
|
||||
|
||||
export interface SalesStateEventLike extends PresaleEventLike {
|
||||
status: string;
|
||||
endDatetime?: string | Date | null;
|
||||
externalBookingEnabled?: boolean | number | null;
|
||||
}
|
||||
|
||||
/** When the event ends: its end time, or its start time when no end is set (as in eventEndSweep). */
|
||||
export function eventEndMs(event: { startDatetime: string | Date; endDatetime?: string | Date | null }): number {
|
||||
return new Date(event.endDatetime || event.startDatetime).getTime();
|
||||
}
|
||||
|
||||
/** True once online booking is closed: pre-sale cutoff passed, or the event has started. */
|
||||
export function isOnlineSalesClosed(
|
||||
event: PresaleEventLike,
|
||||
settings?: PresaleSettingsLike | null,
|
||||
nowMs: number = Date.now()
|
||||
): boolean {
|
||||
return isPresaleClosed(event, settings, nowMs) || new Date(event.startDatetime).getTime() <= nowMs;
|
||||
}
|
||||
|
||||
export function resolveSalesState(
|
||||
event: SalesStateEventLike,
|
||||
settings: PresaleSettingsLike | null | undefined,
|
||||
spotsLeft: number,
|
||||
nowMs: number = Date.now()
|
||||
): SalesState {
|
||||
if (event.status === 'cancelled') return 'cancelled';
|
||||
if (event.status === 'completed' || event.status === 'archived') return 'ended';
|
||||
if (Boolean(event.externalBookingEnabled)) return 'external';
|
||||
if (eventEndMs(event) <= nowMs) return 'ended';
|
||||
if (spotsLeft <= 0) return 'sold_out';
|
||||
if (isOnlineSalesClosed(event, settings, nowMs)) return 'door';
|
||||
return 'online';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sales fields for a public event response. `doorPrice` (the resolved walk-in
|
||||
* price: walk_in_price, else the ticket price) is present only in the `door`
|
||||
* state, so the internal walk-in price never leaks while online sales are open.
|
||||
* `event` is the raw row (it still carries walkInPrice).
|
||||
*/
|
||||
export function publicSalesFields(
|
||||
event: SalesStateEventLike & { price: unknown; walkInPrice?: unknown },
|
||||
settings: PresaleSettingsLike | null | undefined,
|
||||
spotsLeft: number,
|
||||
nowMs: number = Date.now()
|
||||
): { salesState: SalesState; doorPrice?: number } {
|
||||
const salesState = resolveSalesState(event, settings, spotsLeft, nowMs);
|
||||
if (salesState !== 'door') return { salesState };
|
||||
return { salesState, doorPrice: resolveWalkInPrice(event).unitPrice };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Media storage abstraction with two implementations:
|
||||
// - local: writes to the ./uploads directory and serves via /uploads/* (the
|
||||
// original behavior, and the zero-config default)
|
||||
// - s3: stores objects in an S3-compatible bucket (e.g. Garage), so uploads are
|
||||
// shared across instances instead of living on one container's local disk
|
||||
//
|
||||
// S3 is enabled only when S3_ENDPOINT and S3_BUCKET are set. With it unset the
|
||||
// app behaves exactly as before. A "key" is the object name (e.g. "abc123.jpg").
|
||||
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const UPLOAD_DIR = './uploads';
|
||||
|
||||
export interface Storage {
|
||||
readonly backend: 'local' | 's3';
|
||||
put(key: string, buffer: Buffer, contentType: string): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
// Public URL to persist as the media record's fileUrl.
|
||||
publicUrl(key: string): string;
|
||||
}
|
||||
|
||||
/** Whether S3-compatible storage is configured. */
|
||||
export function isS3Enabled(): boolean {
|
||||
return !!(process.env.S3_ENDPOINT && process.env.S3_BUCKET);
|
||||
}
|
||||
|
||||
/** Extract the storage key (object name) from a stored fileUrl. */
|
||||
export function keyFromUrl(fileUrl: string): string {
|
||||
return fileUrl.split('/').pop() || fileUrl;
|
||||
}
|
||||
|
||||
// ==================== Local implementation ====================
|
||||
|
||||
class LocalStorage implements Storage {
|
||||
readonly backend = 'local' as const;
|
||||
|
||||
private async ensureDir(): Promise<void> {
|
||||
if (!existsSync(UPLOAD_DIR)) {
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async put(key: string, buffer: Buffer): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await writeFile(join(UPLOAD_DIR, key), buffer);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const filepath = join(UPLOAD_DIR, key);
|
||||
if (existsSync(filepath)) {
|
||||
await unlink(filepath);
|
||||
}
|
||||
}
|
||||
|
||||
publicUrl(key: string): string {
|
||||
return `/uploads/${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== S3 implementation ====================
|
||||
|
||||
// Imported lazily so the AWS SDK is only loaded when S3 is actually configured.
|
||||
type S3ClientType = import('@aws-sdk/client-s3').S3Client;
|
||||
|
||||
class S3Storage implements Storage {
|
||||
readonly backend = 's3' as const;
|
||||
private client: S3ClientType | null = null;
|
||||
private bucket = process.env.S3_BUCKET as string;
|
||||
|
||||
private async getClient(): Promise<S3ClientType> {
|
||||
if (this.client) return this.client;
|
||||
const { S3Client } = await import('@aws-sdk/client-s3');
|
||||
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE || 'true') !== 'false';
|
||||
this.client = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
forcePathStyle,
|
||||
credentials:
|
||||
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
|
||||
? {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async put(key: string, buffer: Buffer, contentType: string): Promise<void> {
|
||||
const client = await this.getClient();
|
||||
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const client = await this.getClient();
|
||||
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
publicUrl(key: string): string {
|
||||
// Prefer an explicit public base URL (e.g. a CDN or Garage web endpoint).
|
||||
const base = process.env.S3_PUBLIC_URL;
|
||||
if (base) {
|
||||
return `${base.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
// Fall back to a path-style URL against the configured endpoint.
|
||||
const endpoint = (process.env.S3_ENDPOINT || '').replace(/\/$/, '');
|
||||
return `${endpoint}/${this.bucket}/${key}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Selection ====================
|
||||
|
||||
let instance: Storage | null = null;
|
||||
|
||||
export function getStorage(): Storage {
|
||||
if (!instance) {
|
||||
instance = isS3Enabled() ? new S3Storage() : new LocalStorage();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Cache abstraction with two implementations:
|
||||
// - memory: per-process Map with TTL expiry (single instance)
|
||||
// - redis: shared GET / SETEX / DEL with JSON values (all instances)
|
||||
//
|
||||
// Values are JSON-serialized. Selection happens once based on REDIS_URL. On any
|
||||
// Redis error the cache behaves as a miss so callers fall back to their source.
|
||||
|
||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
||||
|
||||
export interface Cache {
|
||||
readonly backend: 'memory' | 'redis';
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
|
||||
del(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
// ==================== Memory implementation ====================
|
||||
|
||||
interface Entry {
|
||||
value: unknown;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class MemoryCache implements Cache {
|
||||
readonly backend = 'memory' as const;
|
||||
private store = new Map<string, Entry>();
|
||||
|
||||
constructor() {
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.store) {
|
||||
if (now > entry.expiresAt) this.store.delete(key);
|
||||
}
|
||||
}, 60_000);
|
||||
(cleanup as any).unref?.();
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
||||
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Redis implementation ====================
|
||||
|
||||
class RedisCache implements Cache {
|
||||
readonly backend = 'redis' as const;
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return null;
|
||||
try {
|
||||
const raw = await redis.get(`cache:${key}`);
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (err: any) {
|
||||
console.error('[cache] redis get error:', err?.message || err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.set(`cache:${key}`, JSON.stringify(value), 'EX', ttlSeconds);
|
||||
} catch (err: any) {
|
||||
console.error('[cache] redis set error:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.del(`cache:${key}`);
|
||||
} catch (err: any) {
|
||||
console.error('[cache] redis del error:', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Selection ====================
|
||||
|
||||
let instance: Cache | null = null;
|
||||
|
||||
export function getCache(): Cache {
|
||||
if (!instance) {
|
||||
instance = isRedisEnabled() ? new RedisCache() : new MemoryCache();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RedisMock from 'ioredis-mock';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
||||
|
||||
vi.mock('../redis.js', () => ({
|
||||
isRedisEnabled: () => true,
|
||||
getRedis: () => mocks.redis,
|
||||
}));
|
||||
|
||||
import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js';
|
||||
|
||||
describe('MemoryLock', () => {
|
||||
it('grants the lock, blocks contenders, and frees on release', async () => {
|
||||
const lock = new MemoryLock();
|
||||
const token = await lock.acquire('a', 60_000);
|
||||
expect(token).toBeTruthy();
|
||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
||||
await lock.release('a', token!);
|
||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores release with the wrong token', async () => {
|
||||
const lock = new MemoryLock();
|
||||
const token = await lock.acquire('a', 60_000);
|
||||
await lock.release('a', 'not-the-token');
|
||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
||||
await lock.release('a', token!);
|
||||
});
|
||||
|
||||
it('withLock runs fn while held and returns null under contention', async () => {
|
||||
const lock = new MemoryLock();
|
||||
const held = await lock.acquire('a', 60_000);
|
||||
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull();
|
||||
await lock.release('a', held!);
|
||||
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran');
|
||||
// Released after withLock completes.
|
||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisLock', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.redis = new RedisMock();
|
||||
// ioredis-mock shares data between instances by connection string.
|
||||
await mocks.redis.flushall();
|
||||
});
|
||||
|
||||
it('grants the lock and blocks contenders', async () => {
|
||||
const lock = new RedisLock();
|
||||
const token = await lock.acquire('a', 60_000);
|
||||
expect(token).toBeTruthy();
|
||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('release is compare-and-delete: wrong token does not free the lock', async () => {
|
||||
const lock = new RedisLock();
|
||||
const token = await lock.acquire('a', 60_000);
|
||||
await lock.release('a', 'not-the-token');
|
||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
||||
await lock.release('a', token!);
|
||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('throws LockUnavailableError when the client is not initialized', async () => {
|
||||
mocks.redis = null;
|
||||
const lock = new RedisLock();
|
||||
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
|
||||
});
|
||||
|
||||
it('throws LockUnavailableError when the backend errors', async () => {
|
||||
mocks.redis = { set: () => Promise.reject(new Error('connection refused')) };
|
||||
const lock = new RedisLock();
|
||||
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
|
||||
});
|
||||
|
||||
it('withLock skips (returns null, fn not called) when unavailable by default', async () => {
|
||||
mocks.redis = null;
|
||||
const lock = new RedisLock();
|
||||
const fn = vi.fn(async () => 'ran');
|
||||
expect(await lock.withLock('a', 60_000, fn)).toBeNull();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('withLock runs fn without the lock when onUnavailable is "run"', async () => {
|
||||
mocks.redis = null;
|
||||
const lock = new RedisLock();
|
||||
const fn = vi.fn(async () => 'ran');
|
||||
expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran');
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('withLock returns fn result and releases the lock afterwards', async () => {
|
||||
const lock = new RedisLock();
|
||||
expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42);
|
||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// Distributed lock abstraction with two implementations:
|
||||
// - memory: per-process key set with TTL (only meaningful within one instance)
|
||||
// - redis: SET key token NX PX ttl, released with a compare-and-delete Lua
|
||||
// script so only the holder can release it
|
||||
//
|
||||
// Use acquire/release for long-lived ownership (e.g. a background poller) and
|
||||
// withLock for a one-shot critical section. Selection is based on REDIS_URL.
|
||||
//
|
||||
// There is deliberately no auto-renewal/watchdog: every guarded section (sweep
|
||||
// jobs, template seeding) is a handful of status-conditional bulk UPDATEs that
|
||||
// finish far below the lock TTL, and a rare TTL overrun only risks one
|
||||
// idempotent overlapping run. Keep TTLs generous instead of adding renewal.
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
||||
|
||||
// Thrown when Redis is configured but the lock backend cannot be reached.
|
||||
// This is distinct from contention (acquire resolves null): the caller must
|
||||
// decide whether its critical section is safe to run without mutual exclusion.
|
||||
export class LockUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LockUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface WithLockOptions {
|
||||
// What withLock should do when the lock backend is unavailable (not mere
|
||||
// contention): 'skip' (default) returns null as if the lock were held
|
||||
// elsewhere; 'run' executes fn without mutual exclusion.
|
||||
onUnavailable?: 'skip' | 'run';
|
||||
}
|
||||
|
||||
export interface Lock {
|
||||
readonly backend: 'memory' | 'redis';
|
||||
// Returns a token when the lock was acquired, or null when already held.
|
||||
// Throws LockUnavailableError when the backend is configured but erroring.
|
||||
acquire(key: string, ttlMs: number): Promise<string | null>;
|
||||
release(key: string, token: string): Promise<void>;
|
||||
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
|
||||
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null>;
|
||||
}
|
||||
|
||||
// ==================== Memory implementation ====================
|
||||
|
||||
export class MemoryLock implements Lock {
|
||||
readonly backend = 'memory' as const;
|
||||
private held = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
async acquire(key: string, ttlMs: number): Promise<string | null> {
|
||||
const existing = this.held.get(key);
|
||||
const now = Date.now();
|
||||
if (existing && existing.expiresAt > now) {
|
||||
return null;
|
||||
}
|
||||
const token = randomUUID();
|
||||
this.held.set(key, { token, expiresAt: now + ttlMs });
|
||||
return token;
|
||||
}
|
||||
|
||||
async release(key: string, token: string): Promise<void> {
|
||||
const existing = this.held.get(key);
|
||||
if (existing && existing.token === token) {
|
||||
this.held.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
|
||||
const token = await this.acquire(key, ttlMs);
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await this.release(key, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Redis implementation ====================
|
||||
|
||||
const RELEASE_SCRIPT =
|
||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
||||
|
||||
export class RedisLock implements Lock {
|
||||
readonly backend = 'redis' as const;
|
||||
|
||||
async acquire(key: string, ttlMs: number): Promise<string | null> {
|
||||
const redis = getRedis();
|
||||
if (!redis) {
|
||||
throw new LockUnavailableError('redis client not initialized');
|
||||
}
|
||||
const token = randomUUID();
|
||||
try {
|
||||
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
|
||||
return result === 'OK' ? token : null;
|
||||
} catch (err: any) {
|
||||
// Do NOT fabricate a token here: during an outage every replica would
|
||||
// "acquire" every lock and run the guarded sections concurrently. Let the
|
||||
// caller choose between skipping the run and running unlocked.
|
||||
throw new LockUnavailableError(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async release(key: string, token: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.eval(RELEASE_SCRIPT, 1, `lock:${key}`, token);
|
||||
} catch (err: any) {
|
||||
console.error('[lock] redis release error:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null> {
|
||||
let token: string | null;
|
||||
try {
|
||||
token = await this.acquire(key, ttlMs);
|
||||
} catch (err) {
|
||||
if (!(err instanceof LockUnavailableError)) throw err;
|
||||
if (opts?.onUnavailable === 'run') {
|
||||
console.warn(`[lock] backend unavailable, running "${key}" WITHOUT mutual exclusion:`, err.message);
|
||||
return fn();
|
||||
}
|
||||
console.warn(`[lock] backend unavailable, skipping "${key}" this run:`, err.message);
|
||||
return null;
|
||||
}
|
||||
if (!token) return null;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await this.release(key, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Selection ====================
|
||||
|
||||
let instance: Lock | null = null;
|
||||
|
||||
export function getLock(): Lock {
|
||||
if (!instance) {
|
||||
instance = isRedisEnabled() ? new RedisLock() : new MemoryLock();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RedisMock from 'ioredis-mock';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
||||
|
||||
vi.mock('../redis.js', () => ({
|
||||
isRedisEnabled: () => true,
|
||||
getRedis: () => mocks.redis,
|
||||
}));
|
||||
|
||||
import {
|
||||
MemoryLoginLockout,
|
||||
RedisLoginLockout,
|
||||
MAX_LOGIN_ATTEMPTS,
|
||||
} from './loginLockout.js';
|
||||
|
||||
describe('MemoryLoginLockout', () => {
|
||||
it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => {
|
||||
const lockout = new MemoryLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
}
|
||||
await lockout.recordFailure('user@example.com');
|
||||
const status = await lockout.isLocked('user@example.com');
|
||||
expect(status.locked).toBe(true);
|
||||
expect(status.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('clear removes the lockout', async () => {
|
||||
const lockout = new MemoryLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
}
|
||||
await lockout.clear('user@example.com');
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
});
|
||||
|
||||
it('treats emails case-insensitively', async () => {
|
||||
const lockout = new MemoryLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('User@Example.com');
|
||||
}
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
||||
});
|
||||
|
||||
it('expires after the lockout window', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const lockout = new MemoryLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
}
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
||||
vi.advanceTimersByTime(16 * 60 * 1000);
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisLoginLockout', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.redis = new RedisMock();
|
||||
// ioredis-mock shares data between instances by connection string.
|
||||
await mocks.redis.flushall();
|
||||
});
|
||||
|
||||
it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => {
|
||||
const lockout = new RedisLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
}
|
||||
await lockout.recordFailure('user@example.com');
|
||||
const status = await lockout.isLocked('user@example.com');
|
||||
expect(status.locked).toBe(true);
|
||||
expect(status.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('sets the lockout window TTL on the first failure', async () => {
|
||||
const lockout = new RedisLoginLockout();
|
||||
await lockout.recordFailure('user@example.com');
|
||||
const ttl = await mocks.redis.pttl('lockout:user@example.com');
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('clear removes the lockout', async () => {
|
||||
const lockout = new RedisLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
}
|
||||
await lockout.clear('user@example.com');
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
});
|
||||
|
||||
it('treats emails case-insensitively', async () => {
|
||||
const lockout = new RedisLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('User@Example.com');
|
||||
}
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open when the client is not initialized', async () => {
|
||||
mocks.redis = null;
|
||||
const lockout = new RedisLoginLockout();
|
||||
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when the backend errors', async () => {
|
||||
mocks.redis = {
|
||||
get: () => Promise.reject(new Error('connection refused')),
|
||||
pttl: () => Promise.reject(new Error('connection refused')),
|
||||
del: () => Promise.reject(new Error('connection refused')),
|
||||
lockoutRecordFailure: () => Promise.reject(new Error('connection refused')),
|
||||
};
|
||||
const lockout = new RedisLoginLockout();
|
||||
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
await expect(lockout.clear('user@example.com')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// Per-email login lockout abstraction with two implementations:
|
||||
// - memory: per-process Map (the original routes/auth.ts behavior)
|
||||
// - redis: shared counter across all instances so the lockout cannot be
|
||||
// bypassed by round-robining replicas
|
||||
//
|
||||
// Semantics: recordFailure starts a window on the first failure; once the
|
||||
// failure count reaches the max the email is locked until the window expires;
|
||||
// clear removes the counter on successful login. Selection is based on
|
||||
// REDIS_URL. The redis implementation fails open (never locked, failures not
|
||||
// recorded) — the per-IP auth rate limit remains as a backstop during a
|
||||
// Redis outage.
|
||||
|
||||
import type Redis from 'ioredis';
|
||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
||||
|
||||
export const MAX_LOGIN_ATTEMPTS = 5;
|
||||
export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export interface LockoutStatus {
|
||||
locked: boolean;
|
||||
// Seconds until the lockout lifts; only set when locked.
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
export interface LoginLockout {
|
||||
readonly backend: 'memory' | 'redis';
|
||||
isLocked(email: string): Promise<LockoutStatus>;
|
||||
recordFailure(email: string): Promise<void>;
|
||||
clear(email: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Emails are compared case-insensitively so "User@x.com" and "user@x.com"
|
||||
// share one counter.
|
||||
function normalize(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ==================== Memory implementation ====================
|
||||
|
||||
export class MemoryLoginLockout implements LoginLockout {
|
||||
readonly backend = 'memory' as const;
|
||||
private attempts = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
constructor() {
|
||||
// Periodically drop expired entries so the Map does not grow unbounded.
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.attempts) {
|
||||
if (now > entry.resetAt) this.attempts.delete(key);
|
||||
}
|
||||
}, 60_000);
|
||||
(cleanup as any).unref?.();
|
||||
}
|
||||
|
||||
async isLocked(email: string): Promise<LockoutStatus> {
|
||||
const entry = this.attempts.get(normalize(email));
|
||||
const now = Date.now();
|
||||
if (!entry || now > entry.resetAt) return { locked: false };
|
||||
if (entry.count >= MAX_LOGIN_ATTEMPTS) {
|
||||
return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
||||
}
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
async recordFailure(email: string): Promise<void> {
|
||||
const key = normalize(email);
|
||||
const now = Date.now();
|
||||
const entry = this.attempts.get(key);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
}
|
||||
|
||||
async clear(email: string): Promise<void> {
|
||||
this.attempts.delete(normalize(email));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Redis implementation ====================
|
||||
|
||||
// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the
|
||||
// window starts at the first failure and any TTL-less counter is repaired.
|
||||
const RECORD_FAILURE_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
if redis.call('PTTL', KEYS[1]) < 0 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return count
|
||||
`;
|
||||
|
||||
type RedisWithLockout = Redis & {
|
||||
lockoutRecordFailure(key: string, windowMs: number): Promise<number>;
|
||||
};
|
||||
|
||||
function withLockoutCommand(redis: Redis): RedisWithLockout {
|
||||
if (typeof (redis as any).lockoutRecordFailure !== 'function') {
|
||||
redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT });
|
||||
}
|
||||
return redis as RedisWithLockout;
|
||||
}
|
||||
|
||||
export class RedisLoginLockout implements LoginLockout {
|
||||
readonly backend = 'redis' as const;
|
||||
|
||||
private key(email: string): string {
|
||||
return `lockout:${normalize(email)}`;
|
||||
}
|
||||
|
||||
async isLocked(email: string): Promise<LockoutStatus> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return { locked: false };
|
||||
try {
|
||||
const [count, ttl] = await Promise.all([
|
||||
redis.get(this.key(email)),
|
||||
redis.pttl(this.key(email)),
|
||||
]);
|
||||
if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) {
|
||||
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000);
|
||||
return { locked: true, retryAfter };
|
||||
}
|
||||
return { locked: false };
|
||||
} catch (err: any) {
|
||||
// Fail open: the per-IP auth rate limit still applies.
|
||||
console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err);
|
||||
return { locked: false };
|
||||
}
|
||||
}
|
||||
|
||||
async recordFailure(email: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS);
|
||||
} catch (err: any) {
|
||||
console.error('[loginLockout] redis error recording failure:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async clear(email: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.del(this.key(email));
|
||||
} catch (err: any) {
|
||||
console.error('[loginLockout] redis error clearing failures:', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Selection ====================
|
||||
|
||||
let instance: LoginLockout | null = null;
|
||||
|
||||
export function getLoginLockout(): LoginLockout {
|
||||
if (!instance) {
|
||||
instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Pub/Sub abstraction with two implementations:
|
||||
// - memory: in-process EventEmitter (single instance only)
|
||||
// - redis: PUBLISH / SUBSCRIBE so a message published on one instance reaches
|
||||
// subscribers on every instance
|
||||
//
|
||||
// Messages are JSON-serialized. Selection happens once based on REDIS_URL.
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { getRedis, getSubscriber, isRedisEnabled } from '../redis.js';
|
||||
|
||||
export type PubSubHandler = (message: any) => void;
|
||||
|
||||
export interface PubSub {
|
||||
readonly backend: 'memory' | 'redis';
|
||||
publish(channel: string, message: any): Promise<void>;
|
||||
// Returns an unsubscribe function for this specific handler.
|
||||
subscribe(channel: string, handler: PubSubHandler): Promise<() => void>;
|
||||
}
|
||||
|
||||
// ==================== Memory implementation ====================
|
||||
|
||||
class MemoryPubSub implements PubSub {
|
||||
readonly backend = 'memory' as const;
|
||||
private emitter = new EventEmitter();
|
||||
|
||||
constructor() {
|
||||
// SSE fan-out can attach many listeners to the same channel; lift the cap.
|
||||
this.emitter.setMaxListeners(0);
|
||||
}
|
||||
|
||||
async publish(channel: string, message: any): Promise<void> {
|
||||
this.emitter.emit(channel, message);
|
||||
}
|
||||
|
||||
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
|
||||
this.emitter.on(channel, handler);
|
||||
return () => this.emitter.off(channel, handler);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Redis implementation ====================
|
||||
|
||||
class RedisPubSub implements PubSub {
|
||||
readonly backend = 'redis' as const;
|
||||
// Per-channel handler sets so a single Redis subscription fans out locally.
|
||||
private handlers = new Map<string, Set<PubSubHandler>>();
|
||||
private wired = false;
|
||||
|
||||
private ensureWired(): void {
|
||||
if (this.wired) return;
|
||||
const sub = getSubscriber();
|
||||
if (!sub) return;
|
||||
this.wired = true;
|
||||
sub.on('message', (channel: string, payload: string) => {
|
||||
const set = this.handlers.get(channel);
|
||||
if (!set || set.size === 0) return;
|
||||
let parsed: any = payload;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
// Leave as raw string if it was not JSON.
|
||||
}
|
||||
for (const handler of set) {
|
||||
try {
|
||||
handler(parsed);
|
||||
} catch (err: any) {
|
||||
console.error('[pubsub] handler error:', err?.message || err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async publish(channel: string, message: any): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.publish(channel, JSON.stringify(message));
|
||||
} catch (err: any) {
|
||||
console.error('[pubsub] publish error:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
|
||||
this.ensureWired();
|
||||
const sub = getSubscriber();
|
||||
if (!sub) return () => undefined;
|
||||
|
||||
let set = this.handlers.get(channel);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.handlers.set(channel, set);
|
||||
try {
|
||||
await sub.subscribe(channel);
|
||||
} catch (err: any) {
|
||||
console.error('[pubsub] subscribe error:', err?.message || err);
|
||||
}
|
||||
}
|
||||
set.add(handler);
|
||||
|
||||
return () => {
|
||||
const current = this.handlers.get(channel);
|
||||
if (!current) return;
|
||||
current.delete(handler);
|
||||
if (current.size === 0) {
|
||||
this.handlers.delete(channel);
|
||||
sub.unsubscribe(channel).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Selection ====================
|
||||
|
||||
let instance: PubSub | null = null;
|
||||
|
||||
export function getPubSub(): PubSub {
|
||||
if (!instance) {
|
||||
instance = isRedisEnabled() ? new RedisPubSub() : new MemoryPubSub();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RedisMock from 'ioredis-mock';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
||||
|
||||
vi.mock('../redis.js', () => ({
|
||||
isRedisEnabled: () => true,
|
||||
getRedis: () => mocks.redis,
|
||||
}));
|
||||
|
||||
import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js';
|
||||
|
||||
describe('MemoryRateLimiter', () => {
|
||||
it('allows up to max within the window, then blocks with retryAfter', async () => {
|
||||
const limiter = new MemoryRateLimiter();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true);
|
||||
}
|
||||
const blocked = await limiter.consume('k', 3, 60_000);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfter).toBeGreaterThan(0);
|
||||
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('resets after the window elapses', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const limiter = new MemoryRateLimiter();
|
||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
|
||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false);
|
||||
vi.advanceTimersByTime(1_500);
|
||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisRateLimiter', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.redis = new RedisMock();
|
||||
// ioredis-mock shares data between instances by connection string.
|
||||
await mocks.redis.flushall();
|
||||
});
|
||||
|
||||
it('sets the window TTL atomically on the first hit', async () => {
|
||||
const limiter = new RedisRateLimiter();
|
||||
expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true);
|
||||
const ttl = await mocks.redis.pttl('rl:k');
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
it('blocks over the limit with a sane retryAfter', async () => {
|
||||
const limiter = new RedisRateLimiter();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true);
|
||||
}
|
||||
const blocked = await limiter.consume('k', 2, 60_000);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfter).toBeGreaterThan(0);
|
||||
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('self-heals a counter stranded without a TTL', async () => {
|
||||
await mocks.redis.set('rl:k', '3');
|
||||
expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0);
|
||||
const limiter = new RedisRateLimiter();
|
||||
await limiter.consume('k', 10, 60_000);
|
||||
expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('fails open when the backend errors', async () => {
|
||||
mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) };
|
||||
const limiter = new RedisRateLimiter();
|
||||
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open when the client is not initialized', async () => {
|
||||
mocks.redis = null;
|
||||
const limiter = new RedisRateLimiter();
|
||||
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Rate limiter abstraction with two implementations:
|
||||
// - memory: per-process fixed window (the original behavior)
|
||||
// - redis: shared fixed window across all instances, implemented as a single
|
||||
// Lua script so INCR and PEXPIRE are atomic (a crash between separate calls
|
||||
// would otherwise strand a counter with no expiry)
|
||||
//
|
||||
// Selection happens once based on REDIS_URL. On any Redis error the limiter
|
||||
// fails open (allows the request) so a Redis blip never takes the API down.
|
||||
|
||||
import type Redis from 'ioredis';
|
||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
export interface RateLimiter {
|
||||
readonly backend: 'memory' | 'redis';
|
||||
consume(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
|
||||
}
|
||||
|
||||
// ==================== Memory implementation ====================
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
export class MemoryRateLimiter implements RateLimiter {
|
||||
readonly backend = 'memory' as const;
|
||||
private buckets = new Map<string, Bucket>();
|
||||
|
||||
constructor() {
|
||||
// Periodically drop expired buckets so the Map does not grow unbounded.
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, bucket] of this.buckets) {
|
||||
if (now > bucket.resetAt) this.buckets.delete(key);
|
||||
}
|
||||
}, 60_000);
|
||||
(cleanup as any).unref?.();
|
||||
}
|
||||
|
||||
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
||||
const now = Date.now();
|
||||
const bucket = this.buckets.get(key);
|
||||
|
||||
if (!bucket || now > bucket.resetAt) {
|
||||
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
bucket.count++;
|
||||
if (bucket.count > max) {
|
||||
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Redis implementation ====================
|
||||
|
||||
// Atomically increments the window counter, sets the expiry on the first hit,
|
||||
// and repairs any counter left without a TTL (self-heals keys stranded by the
|
||||
// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}.
|
||||
export const CONSUME_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local ttl = redis.call('PTTL', KEYS[1])
|
||||
if ttl < 0 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
ttl = tonumber(ARGV[1])
|
||||
end
|
||||
return {count, ttl}
|
||||
`;
|
||||
|
||||
type RedisWithConsume = Redis & {
|
||||
rlConsume(key: string, windowMs: number): Promise<[number, number]>;
|
||||
};
|
||||
|
||||
function withConsumeCommand(redis: Redis): RedisWithConsume {
|
||||
if (typeof (redis as any).rlConsume !== 'function') {
|
||||
// ioredis caches the script SHA and transparently handles NOSCRIPT.
|
||||
redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT });
|
||||
}
|
||||
return redis as RedisWithConsume;
|
||||
}
|
||||
|
||||
export class RedisRateLimiter implements RateLimiter {
|
||||
readonly backend = 'redis' as const;
|
||||
|
||||
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return { allowed: true };
|
||||
|
||||
try {
|
||||
const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
|
||||
if (count > max) {
|
||||
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
|
||||
return { allowed: false, retryAfter };
|
||||
}
|
||||
return { allowed: true };
|
||||
} catch (err: any) {
|
||||
// Fail open: never block traffic because Redis is unavailable.
|
||||
console.error('[rateLimiter] redis error, allowing request:', err?.message || err);
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Selection ====================
|
||||
|
||||
let instance: RateLimiter | null = null;
|
||||
|
||||
export function getRateLimiter(): RateLimiter {
|
||||
if (!instance) {
|
||||
instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Engine-neutral transactional writes.
|
||||
//
|
||||
// better-sqlite3 transactions take a *synchronous* callback (awaiting inside one
|
||||
// silently breaks atomicity), while node-postgres takes an async one. Rather than
|
||||
// fork every multi-write route into two near-identical branches, callers build a
|
||||
// plain list of operations and hand it here: the business logic stays in one
|
||||
// place and only the six lines below know which driver is underneath.
|
||||
|
||||
import { db, isSqlite } from '../db/index.js';
|
||||
|
||||
export type TxOp =
|
||||
| { kind: 'insert'; table: any; values: any }
|
||||
| { kind: 'update'; table: any; values: any; where: any }
|
||||
| { kind: 'delete'; table: any; where: any };
|
||||
|
||||
export const insertOp = (table: any, values: any): TxOp => ({ kind: 'insert', table, values });
|
||||
export const updateOp = (table: any, values: any, where: any): TxOp => ({ kind: 'update', table, values, where });
|
||||
export const deleteOp = (table: any, where: any): TxOp => ({ kind: 'delete', table, where });
|
||||
|
||||
/** Apply every op inside a single transaction; any throw rolls back all of them. */
|
||||
export async function runOps(ops: TxOp[]): Promise<void> {
|
||||
if (ops.length === 0) return;
|
||||
|
||||
if (isSqlite()) {
|
||||
(db as any).transaction((tx: any) => {
|
||||
for (const op of ops) {
|
||||
if (op.kind === 'insert') tx.insert(op.table).values(op.values).run();
|
||||
else if (op.kind === 'update') tx.update(op.table).set(op.values).where(op.where).run();
|
||||
else tx.delete(op.table).where(op.where).run();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await (db as any).transaction(async (tx: any) => {
|
||||
for (const op of ops) {
|
||||
if (op.kind === 'insert') await tx.insert(op.table).values(op.values);
|
||||
else if (op.kind === 'update') await tx.update(op.table).set(op.values).where(op.where);
|
||||
else await tx.delete(op.table).where(op.where);
|
||||
}
|
||||
});
|
||||
}
|
||||
+18
-11
@@ -1,5 +1,6 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { randomUUID } from 'crypto';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
/**
|
||||
* Get database type (reads env var each time to handle module loading order)
|
||||
@@ -21,6 +22,19 @@ export function generateTicketCode(): string {
|
||||
return `TKT-${nanoid(8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form for `users.email`.
|
||||
*
|
||||
* Better Auth lowercases the address on every lookup and write it performs,
|
||||
* but the `users.email` unique index is case-sensitive on both dialects. Any
|
||||
* row written outside Better Auth (guest bookings, door sales, admin-added
|
||||
* tickets) must therefore be normalized the same way, or the row becomes
|
||||
* invisible to sign-in / Google linking and a duplicate person gets created.
|
||||
*/
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current timestamp in the format appropriate for the database type.
|
||||
* - SQLite: returns ISO string
|
||||
@@ -59,17 +73,10 @@ export function parseEventDatetime(
|
||||
return new Date(datetime);
|
||||
}
|
||||
|
||||
// Treat the digits as UTC so we have a stable reference instant.
|
||||
const fakeUTC = new Date(datetime + 'Z');
|
||||
|
||||
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
|
||||
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
|
||||
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
|
||||
|
||||
// The gap between the two tells us the tz offset at this point in time.
|
||||
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
|
||||
|
||||
return new Date(fakeUTC.getTime() + offsetMs);
|
||||
// Interpret the wall-clock digits as local time in `timezone` using
|
||||
// moment-timezone's bundled IANA data. This keeps the conversion correct
|
||||
// regardless of the host Node runtime's (possibly stale) tz database.
|
||||
return moment.tz(datetime, timezone).toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveWalkInPrice, omitWalkInPrice, parseWalkInPrice, canSeeWalkInPrice } from './walkInPrice.js';
|
||||
|
||||
describe('resolveWalkInPrice', () => {
|
||||
it('falls back to the ticket price when no walk-in price is set', () => {
|
||||
expect(resolveWalkInPrice({ price: 21000, walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||
expect(resolveWalkInPrice({ price: 21000 })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||
});
|
||||
|
||||
it('treats 0 as a free walk-in, not as unset', () => {
|
||||
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 0 })).toEqual({ unitPrice: 0, source: 'walk_in' });
|
||||
});
|
||||
|
||||
it('uses a set walk-in price', () => {
|
||||
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 25000 })).toEqual({ unitPrice: 25000, source: 'walk_in' });
|
||||
});
|
||||
|
||||
it('reads Postgres decimal strings', () => {
|
||||
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: '25000.00' })).toEqual({ unitPrice: 25000, source: 'walk_in' });
|
||||
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||
expect(parseWalkInPrice('0.00')).toBe(0);
|
||||
expect(parseWalkInPrice('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('omitWalkInPrice', () => {
|
||||
it('drops the walk-in price and keeps everything else', () => {
|
||||
const event = { id: 'e1', price: 21000, walkInPrice: 25000, currency: 'PYG' };
|
||||
const result = omitWalkInPrice(event);
|
||||
expect(result).not.toHaveProperty('walkInPrice');
|
||||
expect(result).toEqual({ id: 'e1', price: 21000, currency: 'PYG' });
|
||||
expect(event.walkInPrice).toBe(25000);
|
||||
});
|
||||
|
||||
it('passes null through', () => {
|
||||
expect(omitWalkInPrice(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSeeWalkInPrice', () => {
|
||||
it('is limited to event managers and door staff', () => {
|
||||
expect(canSeeWalkInPrice('admin')).toBe(true);
|
||||
expect(canSeeWalkInPrice('organizer')).toBe(true);
|
||||
expect(canSeeWalkInPrice('staff')).toBe(true);
|
||||
expect(canSeeWalkInPrice('marketing')).toBe(false);
|
||||
expect(canSeeWalkInPrice('user')).toBe(false);
|
||||
expect(canSeeWalkInPrice(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Walk-in (door) pricing.
|
||||
//
|
||||
// An event may set a separate price for people who buy at the door. It is an
|
||||
// internal number: staff charge it on the door screen, but it never appears on
|
||||
// the public event page, listings or JSON-LD, so every public serializer must
|
||||
// drop it (see omitWalkInPrice).
|
||||
//
|
||||
// walkInPrice null -> not set, walk-ins pay the regular ticket price
|
||||
// walkInPrice 0 -> free walk-in (a real value, distinct from null)
|
||||
// walkInPrice n -> walk-ins pay n, in the event's currency
|
||||
|
||||
export type WalkInPriceSource = 'walk_in' | 'ticket';
|
||||
|
||||
/** Roles that may see an event's walk-in price: event managers and door staff. */
|
||||
export const WALK_IN_PRICE_ROLES = ['admin', 'organizer', 'staff'] as const;
|
||||
|
||||
export function canSeeWalkInPrice(role: string | null | undefined): boolean {
|
||||
return !!role && (WALK_IN_PRICE_ROLES as readonly string[]).includes(role);
|
||||
}
|
||||
|
||||
/** Postgres decimals arrive as strings; null/undefined/garbage stay null. */
|
||||
export function parseWalkInPrice(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const n = typeof value === 'string' ? parseFloat(value) : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** The per-ticket price a walk-in is charged, and where it came from. */
|
||||
export function resolveWalkInPrice(event: { price: unknown; walkInPrice?: unknown }): {
|
||||
unitPrice: number;
|
||||
source: WalkInPriceSource;
|
||||
} {
|
||||
const walkIn = parseWalkInPrice(event.walkInPrice);
|
||||
if (walkIn !== null) return { unitPrice: walkIn, source: 'walk_in' };
|
||||
const price = typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price);
|
||||
return { unitPrice: Number.isFinite(price) ? price : 0, source: 'ticket' };
|
||||
}
|
||||
|
||||
/** Copy of an event row without its walk-in price, for anything a non-staff caller can read. */
|
||||
export function omitWalkInPrice<T extends Record<string, any> | null | undefined>(event: T): T {
|
||||
if (!event) return event;
|
||||
const { walkInPrice: _omitted, ...rest } = event as Record<string, any>;
|
||||
return rest as T;
|
||||
}
|
||||
+123
-86
@@ -2,16 +2,29 @@ import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromParam } from '../lib/eventPermissions.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
|
||||
const adminRouter = new Hono();
|
||||
|
||||
// Escape a value for inclusion in a CSV cell (RFC 4180 quoting).
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
// Dashboard overview stats (admin)
|
||||
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const now = getNow();
|
||||
|
||||
// Get upcoming events
|
||||
const upcomingEvents = await dbAll(
|
||||
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
|
||||
// so the dashboard's capacity alerts reflect real availability.
|
||||
const upcomingEventsRaw = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
@@ -25,6 +38,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.limit(5)
|
||||
);
|
||||
|
||||
const seatRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||
const seatsByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||
for (const row of seatRows) {
|
||||
seatsByEvent.set(row.eventId, {
|
||||
paid: Number(row.paidCount) || 0,
|
||||
claimed: Number(row.claimedCount) || 0,
|
||||
});
|
||||
}
|
||||
const upcomingEvents = upcomingEventsRaw.map((event: any) => {
|
||||
const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...event,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed),
|
||||
};
|
||||
});
|
||||
|
||||
// Get recent tickets
|
||||
const recentTickets = await dbAll(
|
||||
(db as any)
|
||||
@@ -60,6 +91,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.where(eq((tickets as any).status, 'confirmed'))
|
||||
);
|
||||
|
||||
// 'pending' = checkout opened, nothing paid or claimed (informational);
|
||||
// 'pending_approval' = customer says they paid, needs admin verification (actionable).
|
||||
const pendingPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -67,14 +100,28 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.where(eq((payments as any).status, 'pending'))
|
||||
);
|
||||
|
||||
const paidPayments = await dbAll<any>(
|
||||
const awaitingApprovalPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending_approval'))
|
||||
);
|
||||
|
||||
const onHoldPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'on_hold'))
|
||||
);
|
||||
|
||||
const revenueRow = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'paid'))
|
||||
);
|
||||
|
||||
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||
const totalRevenue = Number(revenueRow?.total || 0);
|
||||
|
||||
const newContacts = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -98,6 +145,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
totalTickets: totalTickets?.count || 0,
|
||||
confirmedTickets: confirmedTickets?.count || 0,
|
||||
pendingPayments: pendingPayments?.count || 0,
|
||||
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
|
||||
onHoldPayments: onHoldPayments?.count || 0,
|
||||
totalRevenue,
|
||||
newContacts: newContacts?.count || 0,
|
||||
totalSubscribers: totalSubscribers?.count || 0,
|
||||
@@ -110,56 +159,64 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
|
||||
// Get analytics data (admin)
|
||||
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||
// Get events with ticket counts
|
||||
// Get events with ticket counts using grouped aggregates (avoids 3 queries per event).
|
||||
const allEvents = await dbAll<any>((db as any).select().from(events));
|
||||
|
||||
const eventStats = await Promise.all(
|
||||
allEvents.map(async (event: any) => {
|
||||
const ticketCount = await dbGet<any>(
|
||||
const totalRows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, event.id))
|
||||
.groupBy((tickets as any).eventId)
|
||||
);
|
||||
const confirmedRows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(and(eq((tickets as any).status, 'confirmed'), ne((tickets as any).isGuest, 1)))
|
||||
.groupBy((tickets as any).eventId)
|
||||
);
|
||||
const checkedInRows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(and(eq((tickets as any).status, 'checked_in'), ne((tickets as any).isGuest, 1)))
|
||||
.groupBy((tickets as any).eventId)
|
||||
);
|
||||
|
||||
const confirmedCount = await dbGet<any>(
|
||||
// Revenue is what was actually paid, not tickets × the current event price
|
||||
// (which an admin can change after tickets have been sold).
|
||||
const revenueRows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'confirmed'),
|
||||
ne((tickets as any).isGuest, 1)
|
||||
)
|
||||
)
|
||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`coalesce(sum(${(payments as any).amount}), 0)` })
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(eq((payments as any).status, 'paid'))
|
||||
.groupBy((tickets as any).eventId)
|
||||
);
|
||||
|
||||
const checkedInCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
eq((tickets as any).status, 'checked_in'),
|
||||
ne((tickets as any).isGuest, 1)
|
||||
)
|
||||
)
|
||||
);
|
||||
const toMap = (rows: any[]) => {
|
||||
const m = new Map<string, number>();
|
||||
for (const r of rows) m.set(r.eventId, Number(r.count) || 0);
|
||||
return m;
|
||||
};
|
||||
const totalMap = toMap(totalRows);
|
||||
const confirmedMap = toMap(confirmedRows);
|
||||
const checkedInMap = toMap(checkedInRows);
|
||||
const revenueMap = toMap(revenueRows);
|
||||
|
||||
const eventStats = allEvents.map((event: any) => {
|
||||
const confirmedBookings = confirmedMap.get(event.id) || 0;
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
date: event.startDatetime,
|
||||
capacity: event.capacity,
|
||||
totalBookings: ticketCount?.count || 0,
|
||||
confirmedBookings: confirmedCount?.count || 0,
|
||||
checkedIn: checkedInCount?.count || 0,
|
||||
revenue: (confirmedCount?.count || 0) * event.price,
|
||||
totalBookings: totalMap.get(event.id) || 0,
|
||||
confirmedBookings,
|
||||
checkedIn: checkedInMap.get(event.id) || 0,
|
||||
revenue: revenueMap.get(event.id) || 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return c.json({
|
||||
analytics: {
|
||||
@@ -180,29 +237,24 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
|
||||
const ticketList = await dbAll<any>(query);
|
||||
|
||||
// Get user and event details for each ticket
|
||||
const enrichedTickets = await Promise.all(
|
||||
ticketList.map(async (ticket: any) => {
|
||||
const user = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq((users as any).id, ticket.userId))
|
||||
);
|
||||
const userIds = [...new Set(ticketList.map((t: any) => t.userId).filter(Boolean))];
|
||||
const eventIds = [...new Set(ticketList.map((t: any) => t.eventId).filter(Boolean))];
|
||||
const ticketIds = ticketList.map((t: any) => t.id);
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
const [userRows, eventRows, paymentRows] = await Promise.all([
|
||||
userIds.length ? dbAll<any>((db as any).select().from(users).where(inArray((users as any).id, userIds))) : Promise.resolve([]),
|
||||
eventIds.length ? dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds))) : Promise.resolve([]),
|
||||
ticketIds.length ? dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
const usersById = new Map(userRows.map((u: any) => [u.id, u]));
|
||||
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
|
||||
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
|
||||
|
||||
const enrichedTickets = ticketList.map((ticket: any) => {
|
||||
const user = usersById.get(ticket.userId);
|
||||
const event = eventsById.get(ticket.eventId);
|
||||
const payment = paymentsByTicketId.get(ticket.id);
|
||||
|
||||
return {
|
||||
ticketId: ticket.id,
|
||||
@@ -212,20 +264,20 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
userName: user?.name,
|
||||
userEmail: user?.email,
|
||||
userPhone: user?.phone,
|
||||
attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null,
|
||||
eventTitle: event?.title,
|
||||
eventDate: event?.startDatetime,
|
||||
paymentStatus: payment?.status,
|
||||
paymentAmount: payment?.amount,
|
||||
createdAt: ticket.createdAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return c.json({ tickets: enrichedTickets });
|
||||
});
|
||||
|
||||
// Export attendees for a specific event (admin) — CSV download
|
||||
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
|
||||
adminRouter.get('/events/:eventId/attendees/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
|
||||
const q = c.req.query('q') || '';
|
||||
@@ -291,6 +343,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
'Full Name': fullName,
|
||||
'Email': ticket.attendeeEmail || '',
|
||||
'Phone': ticket.attendeePhone || '',
|
||||
'RUC': ticket.attendeeRuc || '',
|
||||
'Status': ticket.status,
|
||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
@@ -301,18 +354,8 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
})
|
||||
);
|
||||
|
||||
// Generate CSV
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC',
|
||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||
'Booked At', 'Notes',
|
||||
];
|
||||
@@ -338,14 +381,14 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
});
|
||||
|
||||
// Legacy alias — keep old path working
|
||||
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
|
||||
adminRouter.get('/events/:eventId/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const newUrl = new URL(c.req.url);
|
||||
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
|
||||
return c.redirect(newUrl.toString(), 301);
|
||||
});
|
||||
|
||||
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
|
||||
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
|
||||
adminRouter.get('/events/:eventId/tickets/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
|
||||
const q = c.req.query('q') || '';
|
||||
@@ -390,21 +433,13 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
|
||||
});
|
||||
}
|
||||
|
||||
const csvEscape = (value: string) => {
|
||||
if (value == null) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'RUC', 'Status', 'Check-in Time', 'Booked At'];
|
||||
|
||||
const rows = ticketList.map((ticket: any) => ({
|
||||
'Ticket ID': ticket.id,
|
||||
'Booking ID': ticket.bookingId || '',
|
||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||
'RUC': ticket.attendeeRuc || '',
|
||||
'Status': ticket.status,
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
@@ -477,6 +512,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
attendeeFirstName: ticket.attendeeFirstName,
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeeRuc: ticket.attendeeRuc || null,
|
||||
eventId: event?.id,
|
||||
eventTitle: event?.title,
|
||||
eventDate: event?.startDatetime,
|
||||
@@ -496,6 +532,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||
pos: filteredPayments.filter((p: any) => p.provider === 'pos' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||
},
|
||||
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
|
||||
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
|
||||
|
||||
@@ -1,672 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
createToken,
|
||||
createRefreshToken,
|
||||
isFirstUser,
|
||||
getAuthUser,
|
||||
validatePassword,
|
||||
createMagicLinkToken,
|
||||
verifyMagicLinkToken,
|
||||
invalidateAllUserSessions,
|
||||
requireAuth,
|
||||
} from '../lib/auth.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
import { sendEmail } from '../lib/email.js';
|
||||
|
||||
// User type that includes all fields (some added in schema updates)
|
||||
type AuthUser = User & {
|
||||
isClaimed: boolean;
|
||||
googleId: string | null;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
};
|
||||
|
||||
const auth = new Hono();
|
||||
|
||||
// Rate limiting store (in production, use Redis)
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
|
||||
const now = Date.now();
|
||||
const attempts = loginAttempts.get(email);
|
||||
|
||||
if (!attempts) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (now > attempts.resetAt) {
|
||||
loginAttempts.delete(email);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
|
||||
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function recordFailedAttempt(email: string): void {
|
||||
const now = Date.now();
|
||||
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
|
||||
attempts.count++;
|
||||
loginAttempts.set(email, attempts);
|
||||
}
|
||||
|
||||
function clearFailedAttempts(email: string): void {
|
||||
loginAttempts.delete(email);
|
||||
}
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
name: z.string().min(2),
|
||||
phone: z.string().optional(),
|
||||
languagePreference: z.enum(['en', 'es']).optional(),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
const magicLinkRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
const magicLinkVerifySchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
const passwordResetRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
const passwordResetSchema = z.object({
|
||||
token: z.string(),
|
||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
});
|
||||
|
||||
const claimAccountSchema = z.object({
|
||||
token: z.string(),
|
||||
password: z.string().min(10, 'Password must be at least 10 characters').optional(),
|
||||
googleId: z.string().optional(),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
});
|
||||
|
||||
const googleAuthSchema = z.object({
|
||||
credential: z.string(), // Google ID token
|
||||
});
|
||||
|
||||
// Register
|
||||
auth.post('/register', zValidator('json', registerSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePassword(data.password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
||||
);
|
||||
if (existing) {
|
||||
// If user exists but is unclaimed, allow claiming
|
||||
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
|
||||
return c.json({
|
||||
error: 'Email already registered',
|
||||
canClaim: true,
|
||||
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
|
||||
}, 400);
|
||||
}
|
||||
return c.json({ error: 'Email already registered' }, 400);
|
||||
}
|
||||
|
||||
// Check if first user (becomes admin)
|
||||
const firstUser = await isFirstUser();
|
||||
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
|
||||
const newUser = {
|
||||
id,
|
||||
email: data.email,
|
||||
password: hashedPassword,
|
||||
name: data.name,
|
||||
phone: data.phone || null,
|
||||
role: firstUser ? 'admin' : 'user',
|
||||
languagePreference: data.languagePreference || null,
|
||||
isClaimed: toDbBool(true),
|
||||
googleId: null,
|
||||
rucNumber: null,
|
||||
accountStatus: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(users).values(newUser);
|
||||
|
||||
const token = await createToken(id, data.email, newUser.role);
|
||||
const refreshToken = await createRefreshToken(id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role: newUser.role,
|
||||
isClaimed: true,
|
||||
},
|
||||
token,
|
||||
refreshToken,
|
||||
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Login with email/password
|
||||
auth.post('/login', zValidator('json', loginSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Check rate limit
|
||||
const rateLimit = checkRateLimit(data.email);
|
||||
if (!rateLimit.allowed) {
|
||||
return c.json({
|
||||
error: 'Too many login attempts. Please try again later.',
|
||||
retryAfter: rateLimit.retryAfter
|
||||
}, 429);
|
||||
}
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
||||
);
|
||||
if (!user) {
|
||||
recordFailedAttempt(data.email);
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
// Check if account is suspended
|
||||
if (user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||
}
|
||||
|
||||
// Check if user has a password set
|
||||
if (!user.password) {
|
||||
return c.json({
|
||||
error: 'No password set for this account',
|
||||
needsClaim: !user.isClaimed,
|
||||
message: user.isClaimed
|
||||
? 'Please use Google login or request a password reset.'
|
||||
: 'Please claim your account first.'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(data.password, user.password);
|
||||
if (!validPassword) {
|
||||
recordFailedAttempt(data.email);
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
// Clear failed attempts on successful login
|
||||
clearFailedAttempts(data.email);
|
||||
|
||||
const token = await createToken(user.id, user.email, user.role);
|
||||
const refreshToken = await createRefreshToken(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: user.isClaimed,
|
||||
phone: user.phone,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
},
|
||||
token,
|
||||
refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
// Request magic link login
|
||||
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists
|
||||
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||
}
|
||||
|
||||
if (user.accountStatus === 'suspended') {
|
||||
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||
}
|
||||
|
||||
// Create magic link token (expires in 10 minutes)
|
||||
const token = await createMagicLinkToken(user.id, 'login', 10);
|
||||
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
|
||||
|
||||
// Send email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Your Spanglish Login Link',
|
||||
html: `
|
||||
<h2>Login to Spanglish</h2>
|
||||
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
||||
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
||||
<p>Or copy this link: ${magicLink}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send magic link email:', error);
|
||||
}
|
||||
|
||||
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||
});
|
||||
|
||||
// Verify magic link and login
|
||||
auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => {
|
||||
const { token } = c.req.valid('json');
|
||||
|
||||
const verification = await verifyMagicLinkToken(token, 'login');
|
||||
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 400);
|
||||
}
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
||||
);
|
||||
|
||||
if (!user || user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Invalid token' }, 400);
|
||||
}
|
||||
|
||||
const authToken = await createToken(user.id, user.email, user.role);
|
||||
const refreshToken = await createRefreshToken(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: user.isClaimed,
|
||||
phone: user.phone,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
},
|
||||
token: authToken,
|
||||
refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
// Request password reset
|
||||
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists
|
||||
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
}
|
||||
|
||||
if (user.accountStatus === 'suspended') {
|
||||
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
}
|
||||
|
||||
// Create reset token (expires in 30 minutes)
|
||||
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
|
||||
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
|
||||
|
||||
// Send email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Reset Your Spanglish Password',
|
||||
html: `
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
||||
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
||||
<p>Or copy this link: ${resetLink}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send password reset email:', error);
|
||||
}
|
||||
|
||||
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
});
|
||||
|
||||
// Reset password
|
||||
auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => {
|
||||
const { token, password } = c.req.valid('json');
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
const verification = await verifyMagicLinkToken(token, 'reset_password');
|
||||
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 400);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, verification.userId));
|
||||
|
||||
// Invalidate all existing sessions for security
|
||||
await invalidateAllUserSessions(verification.userId!);
|
||||
|
||||
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
|
||||
});
|
||||
|
||||
// Claim unclaimed account
|
||||
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
||||
}
|
||||
|
||||
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
|
||||
return c.json({ error: 'Account is already claimed' }, 400);
|
||||
}
|
||||
|
||||
// Create claim token (expires in 24 hours)
|
||||
const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60);
|
||||
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
|
||||
|
||||
// Send email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Claim Your Spanglish Account',
|
||||
html: `
|
||||
<h2>Claim Your Account</h2>
|
||||
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
|
||||
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
||||
<p>Or copy this link: ${claimLink}</p>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send claim account email:', error);
|
||||
}
|
||||
|
||||
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
||||
});
|
||||
|
||||
// Complete account claim
|
||||
auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => {
|
||||
const { token, password, googleId } = c.req.valid('json');
|
||||
|
||||
if (!password && !googleId) {
|
||||
return c.json({ error: 'Please provide either a password or link a Google account' }, 400);
|
||||
}
|
||||
|
||||
const verification = await verifyMagicLinkToken(token, 'claim_account');
|
||||
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const updates: Record<string, any> = {
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (password) {
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
updates.password = await hashPassword(password);
|
||||
}
|
||||
|
||||
if (googleId) {
|
||||
updates.googleId = googleId;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set(updates)
|
||||
.where(eq((users as any).id, verification.userId));
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
||||
);
|
||||
|
||||
const authToken = await createToken(user.id, user.email, user.role);
|
||||
const refreshToken = await createRefreshToken(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: user.isClaimed,
|
||||
phone: user.phone,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
},
|
||||
token: authToken,
|
||||
refreshToken,
|
||||
message: 'Account claimed successfully!',
|
||||
});
|
||||
});
|
||||
|
||||
// Google OAuth login/register
|
||||
auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
|
||||
const { credential } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// Verify Google token
|
||||
// In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
|
||||
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
|
||||
|
||||
if (!response.ok) {
|
||||
return c.json({ error: 'Invalid Google token' }, 400);
|
||||
}
|
||||
|
||||
const googleData = await response.json() as {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
email_verified: string;
|
||||
};
|
||||
|
||||
if (googleData.email_verified !== 'true') {
|
||||
return c.json({ error: 'Google email not verified' }, 400);
|
||||
}
|
||||
|
||||
const { sub: googleId, email, name } = googleData;
|
||||
|
||||
// Check if user exists by email or google_id
|
||||
let user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// Check by google_id
|
||||
user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
|
||||
);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
if (user) {
|
||||
// User exists - link Google account if not already linked
|
||||
if (user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||
}
|
||||
|
||||
if (!user.googleId) {
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
googleId,
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
}
|
||||
|
||||
// Refresh user data
|
||||
user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
||||
);
|
||||
} else {
|
||||
// Create new user
|
||||
const firstUser = await isFirstUser();
|
||||
const id = generateId();
|
||||
|
||||
const newUser = {
|
||||
id,
|
||||
email,
|
||||
password: null,
|
||||
name,
|
||||
phone: null,
|
||||
role: firstUser ? 'admin' : 'user',
|
||||
languagePreference: null,
|
||||
isClaimed: toDbBool(true),
|
||||
googleId,
|
||||
rucNumber: null,
|
||||
accountStatus: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(users).values(newUser);
|
||||
user = newUser;
|
||||
}
|
||||
|
||||
const authToken = await createToken(user.id, user.email, user.role);
|
||||
const refreshToken = await createRefreshToken(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: user.isClaimed,
|
||||
phone: user.phone,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
},
|
||||
token: authToken,
|
||||
refreshToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Google auth error:', error);
|
||||
return c.json({ error: 'Failed to authenticate with Google' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
auth.get('/me', async (c) => {
|
||||
const user = await getAuthUser(c);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
phone: user.phone,
|
||||
isClaimed: user.isClaimed,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
accountStatus: user.accountStatus,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Change password (authenticated users)
|
||||
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const { currentPassword, newPassword } = c.req.valid('json');
|
||||
|
||||
// Validate new password
|
||||
const passwordValidation = validatePassword(newPassword);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
// Verify current password if user has one
|
||||
if (user.password) {
|
||||
const validPassword = await verifyPassword(currentPassword, user.password);
|
||||
if (!validPassword) {
|
||||
return c.json({ error: 'Current password is incorrect' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
return c.json({ message: 'Password changed successfully' });
|
||||
});
|
||||
|
||||
// Logout (client-side token removal, but we can log the action)
|
||||
auth.post('/logout', async (c) => {
|
||||
return c.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
export default auth;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { validatePassword } from '../lib/passwordPolicy.js';
|
||||
import { db, dbGet, users } from '../db/index.js';
|
||||
import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
|
||||
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
|
||||
// at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler.
|
||||
const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' });
|
||||
|
||||
const authExt = new Hono();
|
||||
|
||||
const claimAccountSchema = z.object({
|
||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
});
|
||||
|
||||
// Complete a progressive-account claim. The user arrives here already holding
|
||||
// a session established by the claim magic link; this endpoint deliberately
|
||||
// accepts accountStatus 'unclaimed' sessions (requireAuth would reject them)
|
||||
// and is the ONLY endpoint that does.
|
||||
authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session?.user) {
|
||||
return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401);
|
||||
}
|
||||
|
||||
const user = session.user as any;
|
||||
if (user.banned || user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||
}
|
||||
if (user.isClaimed && user.accountStatus === 'active') {
|
||||
return c.json({ error: 'Account is already claimed' }, 400);
|
||||
}
|
||||
|
||||
const { password } = c.req.valid('json');
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
// Creates the credential account with the argon2id hash from lib/betterAuth.ts
|
||||
await auth.api.setPassword({
|
||||
body: { newPassword: password },
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
// The magic link click proved email ownership
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
emailVerified: true,
|
||||
updatedAt: getNow(),
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
return c.json({
|
||||
message: 'Account claimed successfully!',
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: true,
|
||||
phone: user.phone ?? null,
|
||||
rucNumber: user.rucNumber ?? null,
|
||||
languagePreference: user.languagePreference ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Whether an email belongs to an unclaimed account. Deliberate, rate-limited
|
||||
// exception to enumeration-safety, matching the legacy register/login UX that
|
||||
// surfaced "this account can be claimed".
|
||||
authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
|
||||
const email = c.req.query('email');
|
||||
if (!email || !z.string().email().safeParse(email).success) {
|
||||
return c.json({ canClaim: false });
|
||||
}
|
||||
|
||||
// Normalized to match how the row is stored (see lib/utils.ts normalizeEmail)
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email)))
|
||||
);
|
||||
|
||||
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
|
||||
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
|
||||
|
||||
return c.json({ canClaim });
|
||||
});
|
||||
|
||||
export default authExt;
|
||||
@@ -4,25 +4,17 @@ import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { generateId, getNow, sanitizeHtml } from '../lib/utils.js';
|
||||
import { emailService } from '../lib/email.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
|
||||
const contactsRouter = new Hono();
|
||||
|
||||
// ==================== Sanitization Helpers ====================
|
||||
// Per-IP rate limit for public, unauthenticated write endpoints (contact form,
|
||||
// newsletter subscribe/unsubscribe) to prevent spam and email flooding.
|
||||
const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000, prefix: 'contacts' });
|
||||
|
||||
/**
|
||||
* Sanitize a string to prevent HTML injection
|
||||
* Escapes HTML special characters
|
||||
*/
|
||||
function sanitizeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
// ==================== Sanitization Helpers ====================
|
||||
|
||||
/**
|
||||
* Sanitize email header values to prevent email header injection
|
||||
@@ -33,14 +25,14 @@ function sanitizeHeaderValue(str: string): string {
|
||||
}
|
||||
|
||||
const createContactSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(10),
|
||||
name: z.string().min(2).max(200),
|
||||
email: z.string().email().max(254),
|
||||
message: z.string().min(10).max(5000),
|
||||
});
|
||||
|
||||
const subscribeSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().max(254),
|
||||
name: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
const updateContactSchema = z.object({
|
||||
@@ -48,7 +40,7 @@ const updateContactSchema = z.object({
|
||||
});
|
||||
|
||||
// Submit contact form (public)
|
||||
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||
contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
@@ -125,7 +117,7 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||
});
|
||||
|
||||
// Subscribe to newsletter (public)
|
||||
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
|
||||
contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Check if already subscribed
|
||||
@@ -166,28 +158,30 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
|
||||
});
|
||||
|
||||
// Unsubscribe from newsletter (public)
|
||||
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
|
||||
contactsRouter.post('/unsubscribe', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: 'Email not found' }, 404);
|
||||
}
|
||||
|
||||
// Always return the same response whether or not the address exists, to avoid
|
||||
// leaking which emails are subscribed (enumeration).
|
||||
if (existing && existing.status !== 'unsubscribed') {
|
||||
await (db as any)
|
||||
.update(emailSubscribers)
|
||||
.set({ status: 'unsubscribed' })
|
||||
.where(eq((emailSubscribers as any).id, existing.id));
|
||||
}
|
||||
|
||||
return c.json({ message: 'Successfully unsubscribed' });
|
||||
return c.json({ message: 'If this email was subscribed, it has been unsubscribed.' });
|
||||
});
|
||||
|
||||
// Get all contacts (admin)
|
||||
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const status = c.req.query('status');
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 500);
|
||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
||||
|
||||
let query = (db as any).select().from(contacts);
|
||||
|
||||
@@ -195,7 +189,9 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
query = query.where(eq((contacts as any).status, status));
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
|
||||
const result = await dbAll(
|
||||
query.orderBy(desc((contacts as any).createdAt)).limit(limit).offset(offset)
|
||||
);
|
||||
|
||||
return c.json({ contacts: result });
|
||||
});
|
||||
@@ -255,6 +251,8 @@ contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
// Get all subscribers (admin)
|
||||
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
|
||||
const status = c.req.query('status');
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 1000);
|
||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
||||
|
||||
let query = (db as any).select().from(emailSubscribers);
|
||||
|
||||
@@ -262,7 +260,9 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy
|
||||
query = query.where(eq((emailSubscribers as any).status, status));
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
|
||||
const result = await dbAll(
|
||||
query.orderBy(desc((emailSubscribers as any).createdAt)).limit(limit).offset(offset)
|
||||
);
|
||||
|
||||
return c.json({ subscribers: result });
|
||||
});
|
||||
|
||||
+138
-69
@@ -1,24 +1,54 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
||||
import { eq, desc, and, gt, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
|
||||
// User type that includes all fields (some added in schema updates)
|
||||
type AuthUser = User & {
|
||||
isClaimed: boolean;
|
||||
googleId: string | null;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
};
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, eventMembers } from '../db/index.js';
|
||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||
import { resolveMemberPermissions, EVENT_PERMISSIONS } from '../lib/eventPermissions.js';
|
||||
|
||||
const dashboard = new Hono();
|
||||
|
||||
// Apply authentication to all routes
|
||||
dashboard.use('*', requireAuth());
|
||||
|
||||
// ==================== My Events (team memberships) ====================
|
||||
|
||||
// Events the user was added to as staff / collaborator / co-manager, with what
|
||||
// they may do on each. Opens the scoped event page at /dashboard/events/:id.
|
||||
dashboard.get('/my-events', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ m: eventMembers, e: events })
|
||||
.from(eventMembers)
|
||||
.innerJoin(events, eq((eventMembers as any).eventId, (events as any).id))
|
||||
.where(eq((eventMembers as any).userId, user.id))
|
||||
);
|
||||
const toIso = (v: any) => (v instanceof Date ? v.toISOString() : v);
|
||||
return c.json({
|
||||
events: rows
|
||||
.map((r: any) => ({
|
||||
event: {
|
||||
id: r.e.id,
|
||||
slug: r.e.slug,
|
||||
title: r.e.title,
|
||||
titleEs: r.e.titleEs,
|
||||
startDatetime: toIso(r.e.startDatetime),
|
||||
location: r.e.location,
|
||||
status: r.e.status,
|
||||
bannerUrl: r.e.bannerUrl,
|
||||
},
|
||||
rolePreset: r.m.rolePreset,
|
||||
permissions: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(r.m.rolePreset, r.m.permissions).has(p)),
|
||||
}))
|
||||
.sort((a: any, b: any) => String(b.event.startDatetime).localeCompare(String(a.event.startDatetime))),
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Profile Routes ====================
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
@@ -37,6 +67,8 @@ dashboard.get('/profile', async (c) => {
|
||||
const now = new Date();
|
||||
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const hasPassword = !!(await getUserPasswordHash(user.id));
|
||||
|
||||
return c.json({
|
||||
profile: {
|
||||
id: user.id,
|
||||
@@ -47,8 +79,8 @@ dashboard.get('/profile', async (c) => {
|
||||
rucNumber: user.rucNumber,
|
||||
isClaimed: user.isClaimed,
|
||||
accountStatus: user.accountStatus,
|
||||
hasPassword: !!user.password,
|
||||
hasGoogleLinked: !!user.googleId,
|
||||
hasPassword,
|
||||
hasGoogleLinked: await hasGoogleAccount(user.id),
|
||||
memberSince: user.createdAt,
|
||||
membershipDays,
|
||||
createdAt: user.createdAt,
|
||||
@@ -104,33 +136,30 @@ dashboard.get('/tickets', async (c) => {
|
||||
.orderBy(desc((tickets as any).createdAt))
|
||||
);
|
||||
|
||||
// Get event details for each ticket
|
||||
const ticketsWithEvents = await Promise.all(
|
||||
userTickets.map(async (ticket: any) => {
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
// Batch-fetch related events, payments, and invoices (avoids N+1 per ticket).
|
||||
const eventIds = [...new Set(userTickets.map((t: any) => t.eventId).filter(Boolean))];
|
||||
const ticketIds = userTickets.map((t: any) => t.id);
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
const eventRows = eventIds.length
|
||||
? await dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds)))
|
||||
: [];
|
||||
const paymentRows = ticketIds.length
|
||||
? await dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds)))
|
||||
: [];
|
||||
|
||||
// Check for invoice
|
||||
let invoice: any = null;
|
||||
if (payment && payment.status === 'paid') {
|
||||
invoice = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq((invoices as any).paymentId, payment.id))
|
||||
);
|
||||
}
|
||||
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
|
||||
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
|
||||
|
||||
const paidPaymentIds = paymentRows.filter((p: any) => p.status === 'paid').map((p: any) => p.id);
|
||||
const invoiceRows = paidPaymentIds.length
|
||||
? await dbAll<any>((db as any).select().from(invoices).where(inArray((invoices as any).paymentId, paidPaymentIds)))
|
||||
: [];
|
||||
const invoicesByPaymentId = new Map(invoiceRows.map((inv: any) => [inv.paymentId, inv]));
|
||||
|
||||
const ticketsWithEvents = userTickets.map((ticket: any) => {
|
||||
const event = eventsById.get(ticket.eventId);
|
||||
const payment = paymentsByTicketId.get(ticket.id);
|
||||
const invoice = payment && payment.status === 'paid' ? invoicesByPaymentId.get(payment.id) : null;
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
@@ -162,8 +191,7 @@ dashboard.get('/tickets', async (c) => {
|
||||
createdAt: invoice.createdAt,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return c.json({ tickets: ticketsWithEvents });
|
||||
});
|
||||
@@ -216,7 +244,7 @@ dashboard.get('/tickets/:id', async (c) => {
|
||||
return c.json({
|
||||
ticket: {
|
||||
...ticket,
|
||||
event,
|
||||
event: omitWalkInPrice(event as any),
|
||||
payment,
|
||||
invoice,
|
||||
},
|
||||
@@ -280,7 +308,7 @@ dashboard.get('/next-event', async (c) => {
|
||||
|
||||
return c.json({
|
||||
nextEvent: {
|
||||
event: nextEvent,
|
||||
event: omitWalkInPrice(nextEvent),
|
||||
ticket: nextTicket,
|
||||
payment: nextPayment,
|
||||
},
|
||||
@@ -425,40 +453,77 @@ dashboard.get('/invoices', async (c) => {
|
||||
|
||||
// ==================== Security Routes ====================
|
||||
|
||||
// Get active sessions
|
||||
// Get active sessions (Better Auth session table; validated per-request so
|
||||
// this list is always live). Session tokens are never exposed to the client.
|
||||
dashboard.get('/sessions', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
const sessions = await getUserSessions(user.id);
|
||||
const sessions = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (authSessions as any).id,
|
||||
userAgent: (authSessions as any).userAgent,
|
||||
ipAddress: (authSessions as any).ipAddress,
|
||||
createdAt: (authSessions as any).createdAt,
|
||||
updatedAt: (authSessions as any).updatedAt,
|
||||
expiresAt: (authSessions as any).expiresAt,
|
||||
})
|
||||
.from(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).userId, user.id),
|
||||
gt((authSessions as any).expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.orderBy(desc((authSessions as any).updatedAt))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
sessions: sessions.map((s: any) => ({
|
||||
id: s.id,
|
||||
userAgent: s.userAgent,
|
||||
ipAddress: s.ipAddress,
|
||||
lastActiveAt: s.lastActiveAt,
|
||||
lastActiveAt: s.updatedAt,
|
||||
createdAt: s.createdAt,
|
||||
expiresAt: s.expiresAt,
|
||||
current: s.id === user.sessionId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Revoke a specific session
|
||||
// Revoke a specific session. Deleting the row is immediately effective:
|
||||
// sessions are validated against the table on every request (no cookie cache).
|
||||
dashboard.delete('/sessions/:id', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const sessionId = c.req.param('id');
|
||||
|
||||
await invalidateSession(sessionId, user.id);
|
||||
await (db as any)
|
||||
.delete(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).id, sessionId),
|
||||
eq((authSessions as any).userId, user.id)
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({ message: 'Session revoked' });
|
||||
});
|
||||
|
||||
// Revoke all sessions (logout everywhere)
|
||||
// Revoke all other sessions (logout everywhere else); the current session
|
||||
// stays valid so this device remains signed in.
|
||||
dashboard.post('/sessions/revoke-all', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
await invalidateAllUserSessions(user.id);
|
||||
await (db as any)
|
||||
.delete(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).userId, user.id),
|
||||
sql`${(authSessions as any).id} != ${user.sessionId}`
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({ message: 'All sessions revoked. Please log in again.' });
|
||||
return c.json({ message: 'All other sessions revoked.' });
|
||||
});
|
||||
|
||||
// Set password (for users without one)
|
||||
@@ -471,25 +536,25 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
|
||||
const { password } = c.req.valid('json');
|
||||
|
||||
// Check if user already has a password
|
||||
if (user.password) {
|
||||
if (await getUserPasswordHash(user.id)) {
|
||||
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
||||
}
|
||||
|
||||
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
|
||||
// policy hook does not cover it — validate explicitly.
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
try {
|
||||
await auth.api.setPassword({
|
||||
body: { newPassword: password },
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
|
||||
}
|
||||
|
||||
return c.json({ message: 'Password set successfully' });
|
||||
});
|
||||
@@ -498,22 +563,26 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
|
||||
dashboard.post('/unlink-google', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
if (!user.googleId) {
|
||||
if (!(await hasGoogleAccount(user.id))) {
|
||||
return c.json({ error: 'Google account not linked' }, 400);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
if (!(await getUserPasswordHash(user.id))) {
|
||||
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
await (db as any)
|
||||
.delete(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, user.id),
|
||||
eq((authAccounts as any).providerId, 'google')
|
||||
)
|
||||
);
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
googleId: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.set({ updatedAt: getNow() })
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
return c.json({ message: 'Google account unlinked' });
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// Env must be pinned before the db singleton is imported (dotenv never overrides).
|
||||
const dir = mkdtempSync(join(tmpdir(), 'door-test-'));
|
||||
const dbPath = join(dir, 'test.db');
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = dbPath;
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
|
||||
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
|
||||
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
|
||||
|
||||
// Who the next request is from. Session auth itself is Better Auth's concern and
|
||||
// has its own integration suite; this mock keeps the *role* check real so the
|
||||
// tests can prove which endpoints door staff may reach.
|
||||
let currentUser: { id: string; name: string; role: string } = STAFF;
|
||||
|
||||
vi.mock('../lib/auth.js', () => ({
|
||||
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||
if (roles && !roles.includes(currentUser.role)) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
c.set('user', currentUser);
|
||||
await next();
|
||||
},
|
||||
getAuthUser: async () => currentUser,
|
||||
}));
|
||||
|
||||
/** Run one request as a given role, always restoring the default afterwards. */
|
||||
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = currentUser;
|
||||
currentUser = user;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
currentUser = previous;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
|
||||
vi.mock('../lib/email.js', () => ({
|
||||
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
|
||||
}));
|
||||
|
||||
let app: any;
|
||||
let sqlite: any;
|
||||
|
||||
const EVENT_ID = 'evt-door-1';
|
||||
const PRICE = 60000;
|
||||
|
||||
/** POST helper that mirrors how the door screen calls the API. */
|
||||
async function post(path: string, body: unknown) {
|
||||
const res = await app.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function get(path: string) {
|
||||
const res = await app.request(path);
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
function seedTicket(row: {
|
||||
id: string;
|
||||
first: string;
|
||||
last?: string | null;
|
||||
status: string;
|
||||
paymentStatus: string;
|
||||
phone?: string | null;
|
||||
bookingId?: string | null;
|
||||
qr?: string;
|
||||
/** Seed the pre-sale payment a paid ticket was bought with, at this amount. */
|
||||
paidAmount?: number;
|
||||
}) {
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO tickets (id, booking_id, user_id, event_id, attendee_first_name, attendee_last_name,
|
||||
attendee_email, attendee_phone, status, payment_status, is_guest, qr_code, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
row.id,
|
||||
row.bookingId ?? null,
|
||||
'seed-user',
|
||||
EVENT_ID,
|
||||
row.first,
|
||||
row.last ?? null,
|
||||
`${row.id}@test.py`,
|
||||
row.phone ?? null,
|
||||
row.status,
|
||||
row.paymentStatus,
|
||||
row.qr ?? `QR-${row.id}`,
|
||||
new Date().toISOString()
|
||||
);
|
||||
if (row.paidAmount !== undefined) seedPayment(row.id, row.paidAmount, 'paid');
|
||||
}
|
||||
|
||||
function seedPayment(ticketId: string, amount: number, status: string, provider = 'bancard') {
|
||||
const now = new Date().toISOString();
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO payments (id, ticket_id, provider, amount, currency, status, paid_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'PYG', ?, ?, ?, ?)`
|
||||
)
|
||||
.run(`pay-${ticketId}`, ticketId, provider, amount, status, status === 'paid' ? now : null, now, now);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||
|
||||
return (async () => {
|
||||
const { Hono } = await import('hono');
|
||||
const doorRoutes = (await import('./door.js')).default;
|
||||
app = new Hono();
|
||||
app.route('/api/events', doorRoutes);
|
||||
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
sqlite = new Database(dbPath);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'user', 0, 'unclaimed', ?, ?)`
|
||||
)
|
||||
.run('seed-user', 'seed@test.py', 'Seed User', now, now);
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'staff', 1, 'active', ?, ?)`
|
||||
)
|
||||
.run(STAFF.id, 'staff@test.py', STAFF.name, now, now);
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO events (id, title, description, start_datetime, location, price, currency, capacity, status, created_at, updated_at)
|
||||
VALUES (?, 'Door Night', 'desc', ?, 'Asuncion', ?, 'PYG', 2, 'published', ?, ?)`
|
||||
)
|
||||
.run(EVENT_ID, now, PRICE, now, now);
|
||||
|
||||
seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567', paidAmount: PRICE });
|
||||
seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
|
||||
seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
|
||||
seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' });
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
describe('door-attendees', () => {
|
||||
it('returns everyone including cancelled, with group bookings flagged', async () => {
|
||||
const { status, body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
|
||||
expect(status).toBe(200);
|
||||
expect(body.event.price).toBe(PRICE);
|
||||
expect(body.attendees).toHaveLength(4);
|
||||
|
||||
const cancelled = body.attendees.find((a: any) => a.ticketId === 'tkt-cancelled');
|
||||
expect(cancelled.status).toBe('cancelled');
|
||||
|
||||
const grouped = body.attendees.find((a: any) => a.ticketId === 'tkt-unpaid');
|
||||
expect(grouped.isGroupBooking).toBe(true);
|
||||
expect(grouped.amountDue).toBe(PRICE);
|
||||
|
||||
const solo = body.attendees.find((a: any) => a.ticketId === 'tkt-paid');
|
||||
expect(solo.isGroupBooking).toBe(false);
|
||||
expect(solo.amountDue).toBe(0);
|
||||
});
|
||||
|
||||
it('is sorted alphabetically so an empty search is scrollable', async () => {
|
||||
const { body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
|
||||
const names = body.attendees.map((a: any) => a.fullName);
|
||||
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })));
|
||||
});
|
||||
});
|
||||
|
||||
describe('door-checkin: existing ticket', () => {
|
||||
it('checks in a paid attendee with no payment record touched', async () => {
|
||||
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-paid',
|
||||
entryMethod: 'search',
|
||||
idempotencyKey: 'key-paid-checkin',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.attendee.checkedIn).toBe(true);
|
||||
expect(body.attendee.checkinAt).toBeTruthy();
|
||||
expect(body.attendee.checkedInBy).toBe(STAFF.name);
|
||||
expect(body.payment).toBeNull();
|
||||
|
||||
const row = sqlite.prepare('SELECT status, checked_in_by_admin_id FROM tickets WHERE id = ?').get('tkt-paid');
|
||||
expect(row.status).toBe('checked_in');
|
||||
expect(row.checked_in_by_admin_id).toBe(STAFF.id);
|
||||
});
|
||||
|
||||
it('replays an already-processed key instead of checking in twice', async () => {
|
||||
const before = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
|
||||
|
||||
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-paid',
|
||||
idempotencyKey: 'key-paid-checkin',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body.replayed).toBe(true);
|
||||
|
||||
const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
|
||||
expect(after).toBe(before);
|
||||
expect(sqlite.prepare("SELECT COUNT(*) n FROM payments WHERE ticket_id = ? AND source = 'door'").get('tkt-paid').n).toBe(0);
|
||||
});
|
||||
|
||||
it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => {
|
||||
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-unpaid',
|
||||
payment: { method: 'cash', amount: PRICE },
|
||||
entryMethod: 'search',
|
||||
idempotencyKey: 'key-unpaid-cash',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.attendee.paymentStatus).toBe('paid');
|
||||
expect(body.attendee.checkedIn).toBe(true);
|
||||
expect(body.payment).toMatchObject({ method: 'cash', amount: PRICE });
|
||||
|
||||
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid');
|
||||
expect(payment.source).toBe('door');
|
||||
expect(payment.method).toBe('cash');
|
||||
expect(payment.provider).toBe('cash');
|
||||
expect(payment.status).toBe('paid');
|
||||
expect(payment.paid_by_admin_id).toBe(STAFF.id);
|
||||
});
|
||||
|
||||
it('takes a group payment at a multiple of the ticket price', async () => {
|
||||
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-unpaid-2',
|
||||
payment: { method: 'transfer', quantity: 2 },
|
||||
idempotencyKey: 'key-unpaid-2-transfer',
|
||||
});
|
||||
expect(body.payment.amount).toBe(PRICE * 2);
|
||||
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid-2');
|
||||
expect(payment.provider).toBe('bank_transfer');
|
||||
expect(payment.method).toBe('transfer');
|
||||
expect(payment.amount).toBe(PRICE * 2);
|
||||
});
|
||||
|
||||
it('reactivates a cancelled ticket through the same payment flow', async () => {
|
||||
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-cancelled',
|
||||
payment: { method: 'bitcoin' },
|
||||
idempotencyKey: 'key-cancelled-reactivate',
|
||||
});
|
||||
expect(body.attendee.status).toBe('checked_in');
|
||||
expect(body.attendee.paymentStatus).toBe('paid');
|
||||
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-cancelled');
|
||||
// Bitcoin is recorded as already-paid Lightning: same trust model as cash,
|
||||
// no invoice generated (see lib/doorPayments.ts).
|
||||
expect(payment.provider).toBe('lightning');
|
||||
expect(payment.method).toBe('bitcoin');
|
||||
expect(payment.amount).toBe(PRICE);
|
||||
});
|
||||
|
||||
it('rejects a ticket from another event', async () => {
|
||||
const { status, body } = await post('/api/events/other-event/door-checkin', {
|
||||
ticketId: 'tkt-paid',
|
||||
idempotencyKey: 'key-wrong-event',
|
||||
});
|
||||
expect(status).toBe(404);
|
||||
expect(body.error).toMatch(/Event not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('door-checkin: walk-ins', () => {
|
||||
it('creates a cash walk-in confirmed, paid and checked in with no email', async () => {
|
||||
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Walk' },
|
||||
payment: { method: 'cash' },
|
||||
entryMethod: 'walkin',
|
||||
idempotencyKey: 'key-walkin-cash',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.action).toBe('walkin');
|
||||
expect(body.attendee.fullName).toBe('Walk');
|
||||
expect(body.attendee.checkedIn).toBe(true);
|
||||
expect(body.attendee.paymentStatus).toBe('paid');
|
||||
expect(body.attendee.email).toBeNull();
|
||||
|
||||
const ticket = sqlite.prepare('SELECT * FROM tickets WHERE id = ?').get(body.attendee.ticketId);
|
||||
expect(ticket.status).toBe('checked_in');
|
||||
expect(ticket.qr_code).toBeTruthy();
|
||||
// A placeholder account keeps users.email unique without mailing anyone.
|
||||
const account = sqlite.prepare('SELECT email FROM users WHERE id = ?').get(ticket.user_id);
|
||||
expect(account.email).toMatch(/@doorentry\.local$/);
|
||||
});
|
||||
|
||||
it('records a guest walk-in as a zero-amount comp', async () => {
|
||||
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Free', lastName: 'Guest' },
|
||||
payment: { method: 'guest', amount: PRICE },
|
||||
entryMethod: 'walkin',
|
||||
idempotencyKey: 'key-walkin-guest',
|
||||
});
|
||||
expect(body.attendee.paymentStatus).toBe('comp');
|
||||
expect(body.attendee.isGuest).toBe(true);
|
||||
expect(body.payment.amount).toBe(0);
|
||||
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment.amount).toBe(0);
|
||||
expect(payment.method).toBe('guest');
|
||||
});
|
||||
|
||||
it('does not create a second ticket when the same walk-in key is retried', async () => {
|
||||
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
|
||||
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Walk' },
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-walkin-cash',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body.replayed).toBe(true);
|
||||
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
|
||||
});
|
||||
|
||||
it('warns rather than blocks once the event is over capacity', async () => {
|
||||
// Capacity is 2 and several tickets already hold seats.
|
||||
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Overflow' },
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-walkin-overflow',
|
||||
});
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.warnings).toContain('at_capacity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
it('reverts a plain check-in to its previous state', async () => {
|
||||
seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid', paidAmount: PRICE });
|
||||
await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-undo',
|
||||
idempotencyKey: 'key-undo-checkin',
|
||||
});
|
||||
expect(sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get('tkt-undo').status).toBe('checked_in');
|
||||
|
||||
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
|
||||
idempotencyKey: 'key-undo-checkin',
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body.reverted).toBe('existing');
|
||||
|
||||
const row = sqlite.prepare('SELECT status, checkin_at FROM tickets WHERE id = ?').get('tkt-undo');
|
||||
expect(row.status).toBe('confirmed');
|
||||
expect(row.checkin_at).toBeNull();
|
||||
});
|
||||
|
||||
it('removes the payment it created and restores the unpaid balance', async () => {
|
||||
seedTicket({ id: 'tkt-undo-pay', first: 'Undo', last: 'Pay', status: 'confirmed', paymentStatus: 'unpaid' });
|
||||
await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-undo-pay',
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-undo-pay',
|
||||
});
|
||||
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(1);
|
||||
|
||||
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-pay' });
|
||||
|
||||
const row = sqlite.prepare('SELECT status, payment_status FROM tickets WHERE id = ?').get('tkt-undo-pay');
|
||||
expect(row.status).toBe('confirmed');
|
||||
expect(row.payment_status).toBe('unpaid');
|
||||
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(0);
|
||||
});
|
||||
|
||||
it('cancels a walk-in it created', async () => {
|
||||
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Mistake' },
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-undo-walkin',
|
||||
});
|
||||
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
|
||||
|
||||
const ticket = sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get(body.attendee.ticketId);
|
||||
expect(ticket.status).toBe('cancelled');
|
||||
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('is safe to call twice and rejects an unknown key', async () => {
|
||||
const repeat = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
|
||||
expect(repeat.body.alreadyUndone).toBe(true);
|
||||
|
||||
const unknown = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'never-happened' });
|
||||
expect(unknown.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('door-summary access', () => {
|
||||
it('is hidden from door staff — whole-event takings are not door information', async () => {
|
||||
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
|
||||
expect(status).toBe(403);
|
||||
// The numbers must not leak in the body either: hiding the section in the UI
|
||||
// alone would still expose them to anyone reading the network response.
|
||||
expect(body).not.toHaveProperty('door');
|
||||
expect(body).not.toHaveProperty('presale');
|
||||
});
|
||||
|
||||
it('is available to admin and organizer', async () => {
|
||||
for (const role of [ADMIN, ORGANIZER]) {
|
||||
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
|
||||
expect(status, `${role.role} should see door takings`).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('still lets door staff do their job — list, check in and undo', async () => {
|
||||
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
|
||||
|
||||
// Comp, so this ticket stays out of the revenue totals asserted below and
|
||||
// the two tests cannot drift into each other through the shared database.
|
||||
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
|
||||
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-role',
|
||||
idempotencyKey: 'key-role-check',
|
||||
});
|
||||
expect(checkin.status).toBe(201);
|
||||
|
||||
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
|
||||
idempotencyKey: 'key-role-check',
|
||||
});
|
||||
expect(undo.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('door-summary', () => {
|
||||
it('totals door takings by tender and splits them from pre-sale', async () => {
|
||||
const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
|
||||
expect(status).toBe(200);
|
||||
|
||||
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
|
||||
// cancelled and no longer count).
|
||||
expect(body.door.byMethod.cash.count).toBe(3);
|
||||
expect(body.door.byMethod.cash.total).toBe(PRICE * 3);
|
||||
expect(body.door.byMethod.transfer).toEqual({ count: 1, total: PRICE * 2 });
|
||||
expect(body.door.byMethod.bitcoin).toEqual({ count: 1, total: PRICE });
|
||||
expect(body.door.byMethod.guest).toEqual({ count: 1, total: 0 });
|
||||
expect(body.door.total).toBe(PRICE * 6);
|
||||
|
||||
// Settled tickets with no door payment against them: tkt-paid, plus tkt-undo,
|
||||
// whose door check-in was undone and which is a pre-paid ticket again.
|
||||
expect(body.presale.count).toBe(2);
|
||||
expect(body.presale.total).toBe(PRICE * 2);
|
||||
expect(body.total).toBe(PRICE * 8);
|
||||
|
||||
expect(body.door.lines.length).toBe(body.door.count);
|
||||
expect(body.door.lines[0]).toHaveProperty('name');
|
||||
});
|
||||
|
||||
it('keeps pre-sale revenue at what was paid when the ticket price changes', async () => {
|
||||
sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(99000, EVENT_ID);
|
||||
try {
|
||||
const { body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
|
||||
expect(body.presale).toEqual({ count: 2, total: PRICE * 2 });
|
||||
expect(body.door.total).toBe(PRICE * 6);
|
||||
} finally {
|
||||
sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(PRICE, EVENT_ID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Walk-in price & POS ====================
|
||||
// Separate events so these cannot drift into the door-summary totals above.
|
||||
|
||||
const WALKIN_EVENT_ID = 'evt-door-walkin';
|
||||
const WALKIN_PRICE = 25000;
|
||||
const WALKIN_TICKET_PRICE = 21000;
|
||||
const FREE_WALKIN_EVENT_ID = 'evt-door-free-walkin';
|
||||
const NO_POS_EVENT_ID = 'evt-door-no-pos';
|
||||
|
||||
describe('walk-in pricing', () => {
|
||||
beforeAll(() => {
|
||||
const now = new Date().toISOString();
|
||||
const insertEvent = sqlite.prepare(
|
||||
`INSERT INTO events (id, title, description, start_datetime, location, price, walk_in_price, currency, capacity, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'desc', ?, 'Asuncion', ?, ?, 'PYG', 100, 'published', ?, ?)`
|
||||
);
|
||||
insertEvent.run(WALKIN_EVENT_ID, 'Walk-in Night', now, WALKIN_TICKET_PRICE, WALKIN_PRICE, now, now);
|
||||
insertEvent.run(FREE_WALKIN_EVENT_ID, 'Free Door Night', now, WALKIN_TICKET_PRICE, 0, now, now);
|
||||
insertEvent.run(NO_POS_EVENT_ID, 'No POS Night', now, WALKIN_TICKET_PRICE, null, now, now);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'admin', 1, 'active', ?, ?)`
|
||||
)
|
||||
.run(ADMIN.id, 'admin@test.py', ADMIN.name, now, now);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO event_payment_overrides (id, event_id, pos_enabled, created_at, updated_at)
|
||||
VALUES ('ovr-no-pos', ?, 0, ?, ?)`
|
||||
)
|
||||
.run(NO_POS_EVENT_ID, now, now);
|
||||
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO tickets (id, user_id, event_id, attendee_first_name, status, payment_status, is_guest, qr_code, created_at)
|
||||
VALUES ('tkt-walkin-event-unpaid', 'seed-user', ?, 'Pre', 'confirmed', 'unpaid', 0, 'QR-walkin-unpaid', ?)`
|
||||
)
|
||||
.run(WALKIN_EVENT_ID, now);
|
||||
});
|
||||
|
||||
it('resolves the walk-in unit price on the server for the door screen', async () => {
|
||||
const withPrice = await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`);
|
||||
expect(withPrice.body.event).toMatchObject({
|
||||
price: WALKIN_TICKET_PRICE,
|
||||
walkInPrice: WALKIN_PRICE,
|
||||
walkInUnitPrice: WALKIN_PRICE,
|
||||
walkInPriceSource: 'walk_in',
|
||||
});
|
||||
|
||||
const fallback = await get(`/api/events/${EVENT_ID}/door-attendees`);
|
||||
expect(fallback.body.event).toMatchObject({
|
||||
walkInPrice: null,
|
||||
walkInUnitPrice: PRICE,
|
||||
walkInPriceSource: 'ticket',
|
||||
});
|
||||
|
||||
const free = await get(`/api/events/${FREE_WALKIN_EVENT_ID}/door-attendees`);
|
||||
expect(free.body.event).toMatchObject({ walkInPrice: 0, walkInUnitPrice: 0, walkInPriceSource: 'walk_in' });
|
||||
});
|
||||
|
||||
it('charges walk-ins the walk-in price and marks them as walk-in bookings', async () => {
|
||||
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Door', lastName: 'Buyer' },
|
||||
payment: { method: 'cash' },
|
||||
entryMethod: 'walkin',
|
||||
idempotencyKey: 'key-walkin-price-cash',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.payment).toMatchObject({ method: 'cash', amount: WALKIN_PRICE, currency: 'PYG', amountOverridden: false });
|
||||
|
||||
const ticket = sqlite.prepare('SELECT booking_source FROM tickets WHERE id = ?').get(body.attendee.ticketId);
|
||||
expect(ticket.booking_source).toBe('walk_in');
|
||||
const payment = sqlite.prepare('SELECT amount, currency, source FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment).toMatchObject({ amount: WALKIN_PRICE, currency: 'PYG', source: 'door' });
|
||||
});
|
||||
|
||||
it('treats a walk-in price of 0 as a free walk-in, not as "unset"', async () => {
|
||||
const { body } = await post(`/api/events/${FREE_WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Free' },
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-walkin-free',
|
||||
});
|
||||
expect(body.payment.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('multiplies the resolved price by quantity', async () => {
|
||||
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Group' },
|
||||
payment: { method: 'cash', quantity: 3 },
|
||||
idempotencyKey: 'key-walkin-price-group',
|
||||
});
|
||||
expect(body.payment.amount).toBe(WALKIN_PRICE * 3);
|
||||
});
|
||||
|
||||
it('ignores a client-sent amount without the override flag', async () => {
|
||||
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Cheap' },
|
||||
payment: { method: 'cash', amount: 1 },
|
||||
idempotencyKey: 'key-walkin-client-amount',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.payment.amount).toBe(WALKIN_PRICE);
|
||||
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment.amount).toBe(WALKIN_PRICE);
|
||||
});
|
||||
|
||||
it('refuses an amount override from door staff and writes nothing', async () => {
|
||||
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
|
||||
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Discount' },
|
||||
payment: { method: 'cash', amount: 1000, amountOverride: true },
|
||||
idempotencyKey: 'key-walkin-staff-override',
|
||||
});
|
||||
expect(status).toBe(403);
|
||||
expect(body.code).toBe('OVERRIDE_FORBIDDEN');
|
||||
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
|
||||
});
|
||||
|
||||
it('lets an admin override the amount and records it in the audit log', async () => {
|
||||
const { status, body } = await as(ADMIN, () => post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Friend' },
|
||||
payment: { method: 'cash', amount: 10000, amountOverride: true },
|
||||
idempotencyKey: 'key-walkin-admin-override',
|
||||
}));
|
||||
expect(status).toBe(201);
|
||||
expect(body.payment).toMatchObject({ amount: 10000, amountOverridden: true });
|
||||
|
||||
const log = sqlite
|
||||
.prepare(`SELECT * FROM audit_logs WHERE action = 'door_amount_override' AND target_id = ?`)
|
||||
.get(body.payment.id);
|
||||
expect(log.user_id).toBe(ADMIN.id);
|
||||
expect(JSON.parse(log.details)).toMatchObject({
|
||||
eventId: WALKIN_EVENT_ID,
|
||||
computedAmount: WALKIN_PRICE,
|
||||
chargedAmount: 10000,
|
||||
currency: 'PYG',
|
||||
});
|
||||
});
|
||||
|
||||
it('settles an existing unpaid ticket at the ticket price, not the walk-in price', async () => {
|
||||
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
ticketId: 'tkt-walkin-event-unpaid',
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-walkin-event-existing',
|
||||
});
|
||||
expect(body.payment.amount).toBe(WALKIN_TICKET_PRICE);
|
||||
});
|
||||
|
||||
it('keeps the charged amount when the walk-in price is edited afterwards', async () => {
|
||||
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Snapshot' },
|
||||
payment: { method: 'cash' },
|
||||
idempotencyKey: 'key-walkin-snapshot',
|
||||
});
|
||||
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(99000, WALKIN_EVENT_ID);
|
||||
try {
|
||||
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment.amount).toBe(WALKIN_PRICE);
|
||||
} finally {
|
||||
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(WALKIN_PRICE, WALKIN_EVENT_ID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POS tender', () => {
|
||||
it('is offered on the door screen unless switched off for the event', async () => {
|
||||
expect((await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`)).body.doorMethods)
|
||||
.toEqual(['cash', 'bitcoin', 'transfer', 'pos', 'guest']);
|
||||
expect((await get(`/api/events/${NO_POS_EVENT_ID}/door-attendees`)).body.doorMethods)
|
||||
.toEqual(['cash', 'bitcoin', 'transfer', 'guest']);
|
||||
});
|
||||
|
||||
it('records a confirmed POS walk-in as a paid door payment at the walk-in price', async () => {
|
||||
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Card', lastName: 'Payer' },
|
||||
payment: { method: 'pos' },
|
||||
entryMethod: 'walkin',
|
||||
idempotencyKey: 'key-walkin-pos',
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
expect(body.attendee).toMatchObject({ checkedIn: true, paymentStatus: 'paid', doorMethod: 'pos' });
|
||||
expect(body.payment).toMatchObject({ method: 'pos', amount: WALKIN_PRICE });
|
||||
|
||||
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment).toMatchObject({
|
||||
provider: 'pos',
|
||||
method: 'pos',
|
||||
source: 'door',
|
||||
status: 'paid',
|
||||
amount: WALKIN_PRICE,
|
||||
paid_by_admin_id: STAFF.id,
|
||||
reference: 'Door — paid by POS',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows POS takings in the door summary', async () => {
|
||||
const { body } = await as(ADMIN, () => get(`/api/events/${WALKIN_EVENT_ID}/door-summary`));
|
||||
expect(body.door.byMethod.pos).toEqual({ count: 1, total: WALKIN_PRICE });
|
||||
});
|
||||
|
||||
it('can be undone like any other walk-in', async () => {
|
||||
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Declined' },
|
||||
payment: { method: 'pos' },
|
||||
idempotencyKey: 'key-walkin-pos-undo',
|
||||
});
|
||||
await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-walkin-pos-undo' });
|
||||
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||
expect(payment.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('is rejected when POS is disabled for the event', async () => {
|
||||
const { status, body } = await post(`/api/events/${NO_POS_EVENT_ID}/door-checkin`, {
|
||||
attendee: { firstName: 'Nope' },
|
||||
payment: { method: 'pos' },
|
||||
idempotencyKey: 'key-walkin-pos-disabled',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(body.code).toBe('METHOD_DISABLED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
// Door check-in screen (admin/scanner) API.
|
||||
//
|
||||
// At the door, check-in and ticket creation are the same action, so everything
|
||||
// here is written for one-tap speed on a phone with unreliable venue wifi:
|
||||
//
|
||||
// GET /:eventId/door-attendees full attendee list, fetched once and searched
|
||||
// client-side so typing never hits the network
|
||||
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
|
||||
// payment, or creates a walk-in, atomically
|
||||
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
|
||||
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
|
||||
// split (admin/organizer only)
|
||||
//
|
||||
// Every write carries a client-generated idempotencyKey. The key is inserted in
|
||||
// the same transaction as the writes, so a double tap or a retry after a timeout
|
||||
// can never produce a second ticket, a second payment or a double check-in — the
|
||||
// replay returns the original response instead.
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq, and, inArray, sql } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs,
|
||||
} from '../db/index.js';
|
||||
import { requireEventPermission, eventFromParam, canSeeAttendeePii } from '../lib/eventPermissions.js';
|
||||
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
|
||||
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||
import {
|
||||
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
|
||||
paymentStatusForMethod, loadDoorMethods, type DoorPaymentMethod,
|
||||
} from '../lib/doorPayments.js';
|
||||
import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js';
|
||||
import emailService from '../lib/email.js';
|
||||
|
||||
const doorRouter = new Hono();
|
||||
|
||||
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
|
||||
// Whole-event money is management information, not door information: door staff
|
||||
// reconcile their own shift from the session feed the client keeps locally, and
|
||||
// never see what the event took overall. Matches the existing convention for
|
||||
// revenue aggregates (admin/export/financial, admin/analytics).
|
||||
const REVENUE_ROLES = ['admin', 'organizer'] as const;
|
||||
// The server prices every door charge from the event record. Only the roles the
|
||||
// app treats as administrators may override that with a typed amount, and every
|
||||
// override is written to audit_logs.
|
||||
const AMOUNT_OVERRIDE_ROLES = ['admin', 'organizer'] as const;
|
||||
const MAX_DOOR_QUANTITY = 50;
|
||||
const IDEMPOTENCY_SCOPE = 'door-checkin';
|
||||
|
||||
// ==================== Shared helpers ====================
|
||||
|
||||
const num = (v: any): number => {
|
||||
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
const iso = (v: any): string | null => {
|
||||
if (!v) return null;
|
||||
return v instanceof Date ? v.toISOString() : String(v);
|
||||
};
|
||||
|
||||
function fullName(ticket: any): string {
|
||||
return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The row shape the door screen renders. Returned both by the preload list and
|
||||
* by every write, so the client can splice an updated attendee straight back
|
||||
* into its in-memory list without a refetch.
|
||||
*/
|
||||
function toDoorAttendee(
|
||||
ticket: any,
|
||||
opts: { price: number; groupBookingIds: Set<string>; adminNames: Map<string, string>; doorMethod?: string | null } ,
|
||||
) {
|
||||
return {
|
||||
ticketId: ticket.id,
|
||||
firstName: ticket.attendeeFirstName,
|
||||
lastName: ticket.attendeeLastName || null,
|
||||
fullName: fullName(ticket),
|
||||
email: ticket.attendeeEmail || null,
|
||||
phone: ticket.attendeePhone || null,
|
||||
status: ticket.status,
|
||||
paymentStatus: ticket.paymentStatus,
|
||||
isGuest: !!ticket.isGuest,
|
||||
checkedIn: ticket.status === 'checked_in',
|
||||
checkinAt: iso(ticket.checkinAt),
|
||||
checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null,
|
||||
bookingId: ticket.bookingId || null,
|
||||
isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)),
|
||||
amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0,
|
||||
doorMethod: opts.doorMethod ?? null,
|
||||
qrCode: ticket.qrCode || null,
|
||||
createdAt: iso(ticket.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadEvent(eventId: string | undefined) {
|
||||
if (!eventId) return null;
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) return null;
|
||||
return {
|
||||
...event,
|
||||
price: num(event.price),
|
||||
walkInPrice: parseWalkInPrice(event.walkInPrice),
|
||||
capacity: Number(event.capacity),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
|
||||
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
|
||||
const unique = [...new Set(adminIds.filter(Boolean))];
|
||||
if (unique.length === 0) return new Map();
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ id: (users as any).id, name: (users as any).name })
|
||||
.from(users)
|
||||
.where(inArray((users as any).id, unique))
|
||||
);
|
||||
return new Map(rows.map((r: any) => [r.id, r.name]));
|
||||
}
|
||||
|
||||
/** Seats currently held for an event, used only to warn (never to block) at the door. */
|
||||
async function seatsHeld(eventId: string): Promise<number> {
|
||||
const row = await dbGet<any>(seatHolderCountQuery(db, eventId));
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
// ==================== GET /:eventId/door-attendees ====================
|
||||
// One payload, fetched on load and refreshed every ~30s by the client. Cancelled
|
||||
// tickets are included on purpose: staff must be able to see and reactivate them.
|
||||
|
||||
doorRouter.get('/:eventId/door-attendees', requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
const event = await loadEvent(eventId);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
|
||||
const rows = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId))
|
||||
);
|
||||
|
||||
// A booking id shared by more than one ticket marks a group booking, which is
|
||||
// the usual reason an otherwise-confirmed attendee still shows as unpaid.
|
||||
const bookingCounts = new Map<string, number>();
|
||||
for (const t of rows) {
|
||||
if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1);
|
||||
}
|
||||
const groupBookingIds = new Set(
|
||||
[...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id)
|
||||
);
|
||||
|
||||
const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId));
|
||||
|
||||
// Door tender per ticket, so a row already settled at the door shows how.
|
||||
// Joined on the event rather than on a list of ticket ids: the id list would
|
||||
// grow with the guest list and eventually blow the statement parameter limit.
|
||||
const doorMethods = new Map<string, string>();
|
||||
const doorPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ ticketId: (payments as any).ticketId, method: (payments as any).method })
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((payments as any).source, 'door')
|
||||
))
|
||||
);
|
||||
for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);
|
||||
|
||||
const enabledMethods = await loadDoorMethods(event.id);
|
||||
const walkIn = resolveWalkInPrice(event);
|
||||
|
||||
const attendees = rows
|
||||
.map((t: any) => toDoorAttendee(t, {
|
||||
price: event.price,
|
||||
groupBookingIds,
|
||||
adminNames,
|
||||
doorMethod: doorMethods.get(t.id) || null,
|
||||
}))
|
||||
.map((a) => (canSeeAttendeePii(c) ? a : { ...a, email: null, phone: null }))
|
||||
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
|
||||
|
||||
const checkedIn = attendees.filter((a) => a.checkedIn).length;
|
||||
const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length;
|
||||
|
||||
return c.json({
|
||||
event: {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
price: event.price,
|
||||
// What one walk-in ticket costs, resolved server-side (walk-in price, or
|
||||
// the ticket price when none is set) so the screen shows what will be charged.
|
||||
walkInPrice: event.walkInPrice,
|
||||
walkInUnitPrice: walkIn.unitPrice,
|
||||
walkInPriceSource: walkIn.source,
|
||||
currency: event.currency,
|
||||
capacity: event.capacity,
|
||||
},
|
||||
doorMethods: enabledMethods,
|
||||
attendees,
|
||||
stats: { checkedIn, totalActive, capacity: event.capacity },
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== POST /:eventId/door-checkin ====================
|
||||
|
||||
const doorCheckinSchema = z.object({
|
||||
// Existing ticket to check in (and optionally settle), or…
|
||||
ticketId: z.string().optional(),
|
||||
// …a walk-in to create. Only a first name is ever required.
|
||||
attendee: z.object({
|
||||
firstName: z.string().trim().min(1).max(255),
|
||||
lastName: z.string().trim().max(255).optional().or(z.literal('')),
|
||||
phone: z.string().trim().max(50).optional().or(z.literal('')),
|
||||
email: z.string().trim().email().optional().or(z.literal('')),
|
||||
ruc: z.string().trim().max(15).optional().or(z.literal('')),
|
||||
}).optional(),
|
||||
payment: z.object({
|
||||
method: z.enum(DOOR_PAYMENT_METHODS),
|
||||
// How many tickets' worth is being paid (someone paying for their group).
|
||||
// The server prices it: walk-in price for walk-ins, ticket price otherwise.
|
||||
quantity: z.number().int().min(1).max(MAX_DOOR_QUANTITY).optional(),
|
||||
// A typed amount is ignored unless amountOverride is set, which only
|
||||
// admin/organizer may do.
|
||||
amount: z.number().min(0).optional(),
|
||||
amountOverride: z.boolean().optional(),
|
||||
}).refine((p) => !p.amountOverride || typeof p.amount === 'number', {
|
||||
message: 'amount is required when amountOverride is set',
|
||||
path: ['amount'],
|
||||
}).optional(),
|
||||
// How the attendee reached this action, for the session feed.
|
||||
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
|
||||
idempotencyKey: z.string().min(8).max(128),
|
||||
}).refine((d) => !!d.ticketId || !!d.attendee, {
|
||||
message: 'Either ticketId or attendee is required',
|
||||
path: ['ticketId'],
|
||||
});
|
||||
|
||||
/** Undo instructions recorded alongside each processed idempotency key. */
|
||||
type UndoState =
|
||||
| {
|
||||
kind: 'created';
|
||||
ticketId: string;
|
||||
paymentId: string;
|
||||
}
|
||||
| {
|
||||
kind: 'existing';
|
||||
ticketId: string;
|
||||
prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean };
|
||||
createdPaymentId?: string;
|
||||
prevPayment?: {
|
||||
id: string; provider: string; amount: number; status: string; reference: string | null;
|
||||
paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/** A replay of a key we already processed returns the original response verbatim. */
|
||||
async function findProcessedKey(key: string) {
|
||||
return dbGet<any>(
|
||||
(db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key))
|
||||
);
|
||||
}
|
||||
|
||||
doorRouter.post(
|
||||
'/:eventId/door-checkin',
|
||||
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
|
||||
zValidator('json', doorCheckinSchema),
|
||||
async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const data = c.req.valid('json');
|
||||
const adminUser = (c as any).get('user');
|
||||
|
||||
const existingKey = await findProcessedKey(data.idempotencyKey);
|
||||
if (existingKey) {
|
||||
return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
|
||||
}
|
||||
|
||||
const amountOverride = !!data.payment?.amountOverride;
|
||||
if (amountOverride && !(AMOUNT_OVERRIDE_ROLES as readonly string[]).includes(adminUser?.role)) {
|
||||
return c.json({ error: 'Only an admin can override the door amount', code: 'OVERRIDE_FORBIDDEN' }, 403);
|
||||
}
|
||||
|
||||
const event = await loadEvent(eventId);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
|
||||
const method = data.payment?.method as DoorPaymentMethod | undefined;
|
||||
if (method && !(await loadDoorMethods(event.id)).includes(method)) {
|
||||
return c.json({ error: `${DOOR_TENDERS[method].label} is not enabled for this event`, code: 'METHOD_DISABLED' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const nowIso = new Date().toISOString();
|
||||
const quantity = data.payment?.quantity ?? 1;
|
||||
// Walk-ins pay the walk-in price (falling back to the ticket price); an
|
||||
// existing unpaid ticket settles at the current ticket price (its pending
|
||||
// payment is repriced whenever the event price changes).
|
||||
const unitPrice = data.ticketId ? event.price : resolveWalkInPrice(event).unitPrice;
|
||||
const computedAmount = unitPrice * quantity;
|
||||
const requestedAmount = amountOverride ? data.payment!.amount! : computedAmount;
|
||||
|
||||
const ops: TxOp[] = [];
|
||||
let undoState: UndoState;
|
||||
let action: 'checkin' | 'walkin';
|
||||
let ticketRow: any;
|
||||
let paymentSummary: {
|
||||
id: string; method: DoorPaymentMethod; amount: number; currency: string; amountOverridden: boolean;
|
||||
} | null = null;
|
||||
let emailTicketId: string | null = null;
|
||||
|
||||
if (data.ticketId) {
|
||||
// ---- Existing ticket: settle (optionally) and check in ----
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId))
|
||||
);
|
||||
if (!ticket) return c.json({ error: 'Ticket not found' }, 404);
|
||||
if (ticket.eventId !== eventId) {
|
||||
return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400);
|
||||
}
|
||||
|
||||
action = 'checkin';
|
||||
const prevTicket = {
|
||||
status: ticket.status,
|
||||
checkinAt: iso(ticket.checkinAt),
|
||||
checkedInByAdminId: ticket.checkedInByAdminId || null,
|
||||
paymentStatus: ticket.paymentStatus,
|
||||
isGuest: !!ticket.isGuest,
|
||||
};
|
||||
const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket };
|
||||
|
||||
const ticketUpdate: Record<string, any> = {};
|
||||
|
||||
if (method) {
|
||||
const amount = amountForMethod(method, requestedAmount);
|
||||
const tender = DOOR_TENDERS[method];
|
||||
ticketUpdate.paymentStatus = paymentStatusForMethod(method);
|
||||
if (method === 'guest') ticketUpdate.isGuest = toDbBool(true);
|
||||
|
||||
const existingPayment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id))
|
||||
);
|
||||
|
||||
if (existingPayment) {
|
||||
undo.prevPayment = {
|
||||
id: existingPayment.id,
|
||||
provider: existingPayment.provider,
|
||||
amount: num(existingPayment.amount),
|
||||
status: existingPayment.status,
|
||||
reference: existingPayment.reference || null,
|
||||
paidAt: iso(existingPayment.paidAt),
|
||||
paidByAdminId: existingPayment.paidByAdminId || null,
|
||||
source: existingPayment.source || 'presale',
|
||||
method: existingPayment.method || null,
|
||||
};
|
||||
ops.push(updateOp(payments, {
|
||||
provider: tender.provider,
|
||||
amount,
|
||||
currency: event.currency,
|
||||
status: 'paid',
|
||||
reference: doorReference(method),
|
||||
paidAt: now,
|
||||
paidByAdminId: adminUser?.id || null,
|
||||
source: 'door',
|
||||
method,
|
||||
updatedAt: now,
|
||||
}, eq((payments as any).id, existingPayment.id)));
|
||||
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||
} else {
|
||||
const paymentId = generateId();
|
||||
undo.createdPaymentId = paymentId;
|
||||
ops.push(insertOp(payments, {
|
||||
id: paymentId,
|
||||
ticketId: ticket.id,
|
||||
provider: tender.provider,
|
||||
amount,
|
||||
currency: event.currency,
|
||||
status: 'paid',
|
||||
reference: doorReference(method),
|
||||
paidAt: now,
|
||||
paidByAdminId: adminUser?.id || null,
|
||||
source: 'door',
|
||||
method,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
paymentSummary = { id: paymentId, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||
}
|
||||
}
|
||||
|
||||
// Check in. An already-checked-in ticket keeps its original timestamp so
|
||||
// staff can still tell the person when they actually entered.
|
||||
if (ticket.status !== 'checked_in') {
|
||||
ticketUpdate.status = 'checked_in';
|
||||
ticketUpdate.checkinAt = now;
|
||||
ticketUpdate.checkedInByAdminId = adminUser?.id || null;
|
||||
}
|
||||
|
||||
if (Object.keys(ticketUpdate).length > 0) {
|
||||
ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id)));
|
||||
}
|
||||
|
||||
undoState = undo;
|
||||
ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt };
|
||||
} else {
|
||||
// ---- Walk-in: born confirmed, settled and checked in, in one write ----
|
||||
const attendee = data.attendee!;
|
||||
action = 'walkin';
|
||||
const tenderMethod: DoorPaymentMethod = method || 'cash';
|
||||
const tender = DOOR_TENDERS[tenderMethod];
|
||||
const amount = amountForMethod(tenderMethod, requestedAmount);
|
||||
const hasEmail = !!(attendee.email && attendee.email.trim());
|
||||
const firstNameValue = attendee.firstName.trim();
|
||||
const lastNameValue = attendee.lastName?.trim() || null;
|
||||
const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue;
|
||||
|
||||
// No email is the fast path; a placeholder keeps the users.email unique
|
||||
// constraint satisfied without ever mailing anyone.
|
||||
// Normalized: Better Auth lowercases every lookup it makes, and the
|
||||
// users.email unique index is case-sensitive, so a mixed-case address
|
||||
// written here would be invisible to sign-in and Google linking.
|
||||
const accountEmail = normalizeEmail(
|
||||
hasEmail
|
||||
? attendee.email!
|
||||
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`
|
||||
);
|
||||
|
||||
let user = hasEmail
|
||||
? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
|
||||
: null;
|
||||
|
||||
if (!user) {
|
||||
const userId = generateId();
|
||||
user = { id: userId, email: accountEmail };
|
||||
ops.push(insertOp(users, {
|
||||
id: userId,
|
||||
email: accountEmail,
|
||||
password: null,
|
||||
name: displayName,
|
||||
phone: attendee.phone?.trim() || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
isClaimed: toDbBool(false),
|
||||
accountStatus: 'unclaimed',
|
||||
emailVerified: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
}
|
||||
|
||||
const ticketId = generateId();
|
||||
const paymentId = generateId();
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
bookingId: null,
|
||||
userId: user.id,
|
||||
eventId,
|
||||
attendeeFirstName: firstNameValue,
|
||||
attendeeLastName: lastNameValue,
|
||||
attendeeEmail: hasEmail ? attendee.email!.trim() : null,
|
||||
attendeePhone: attendee.phone?.trim() || null,
|
||||
attendeeRuc: attendee.ruc?.trim() || null,
|
||||
preferredLanguage: null,
|
||||
status: 'checked_in',
|
||||
paymentStatus: paymentStatusForMethod(tenderMethod),
|
||||
isGuest: toDbBool(tenderMethod === 'guest'),
|
||||
qrCode: generateTicketCode(),
|
||||
checkinAt: now,
|
||||
checkedInByAdminId: adminUser?.id || null,
|
||||
adminNote: null,
|
||||
bookingSource: 'walk_in',
|
||||
createdAt: now,
|
||||
};
|
||||
ops.push(insertOp(tickets, newTicket));
|
||||
ops.push(insertOp(payments, {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: tender.provider,
|
||||
amount,
|
||||
currency: event.currency,
|
||||
status: 'paid',
|
||||
reference: doorReference(tenderMethod),
|
||||
paidAt: now,
|
||||
paidByAdminId: adminUser?.id || null,
|
||||
source: 'door',
|
||||
method: tenderMethod,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||
undoState = { kind: 'created', ticketId, paymentId };
|
||||
ticketRow = newTicket;
|
||||
// Only mail people who actually gave an address; no QR for the rest.
|
||||
if (hasEmail) emailTicketId = ticketId;
|
||||
}
|
||||
|
||||
// Written in the same transaction as the payment, so the log can never
|
||||
// disagree with what was recorded.
|
||||
if (paymentSummary?.amountOverridden) {
|
||||
ops.push(insertOp(auditLogs, {
|
||||
id: generateId(),
|
||||
userId: adminUser?.id || null,
|
||||
action: 'door_amount_override',
|
||||
target: 'payment',
|
||||
targetId: paymentSummary.id,
|
||||
details: JSON.stringify({
|
||||
eventId,
|
||||
ticketId: ticketRow.id,
|
||||
method: paymentSummary.method,
|
||||
quantity,
|
||||
computedAmount,
|
||||
chargedAmount: paymentSummary.amount,
|
||||
currency: event.currency,
|
||||
}),
|
||||
timestamp: now,
|
||||
}));
|
||||
}
|
||||
|
||||
// Staff at the door is the authority: a full event is a warning, never a block.
|
||||
const held = await seatsHeld(eventId);
|
||||
const atCapacity = event.capacity > 0 && held >= event.capacity;
|
||||
|
||||
const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]);
|
||||
const responseBody = {
|
||||
ok: true,
|
||||
action,
|
||||
attendee: toDoorAttendee(ticketRow, {
|
||||
price: event.price,
|
||||
groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []),
|
||||
adminNames,
|
||||
doorMethod: paymentSummary?.method || null,
|
||||
}),
|
||||
payment: paymentSummary,
|
||||
warnings: atCapacity ? ['at_capacity'] : [],
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
processedAt: nowIso,
|
||||
};
|
||||
|
||||
// The key row goes in with the writes, so two concurrent replays of the same
|
||||
// key cannot both commit — the loser hits the primary-key conflict below.
|
||||
ops.unshift(insertOp(idempotencyKeys, {
|
||||
key: data.idempotencyKey,
|
||||
scope: IDEMPOTENCY_SCOPE,
|
||||
result: JSON.stringify(responseBody),
|
||||
undoState: JSON.stringify(undoState),
|
||||
undoneAt: null,
|
||||
createdAt: now,
|
||||
}));
|
||||
|
||||
try {
|
||||
await runOps(ops);
|
||||
} catch (err: any) {
|
||||
const replay = await findProcessedKey(data.idempotencyKey);
|
||||
if (replay) {
|
||||
return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (paymentSummary?.amountOverridden) {
|
||||
console.info(
|
||||
`[Door] Amount override by ${adminUser?.id} (${adminUser?.role}) on event ${eventId}: ` +
|
||||
`${paymentSummary.amount} ${event.currency} instead of ${computedAmount} (${paymentSummary.method})`
|
||||
);
|
||||
}
|
||||
|
||||
if (emailTicketId) {
|
||||
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
|
||||
console.error('[Email] Failed to send door walk-in confirmation:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(responseBody, 201);
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== POST /:eventId/door-checkin/undo ====================
|
||||
// Reverses exactly what the keyed action did — nothing more. This is what makes
|
||||
// the door screen safe to run without a single confirm dialog.
|
||||
|
||||
doorRouter.post(
|
||||
'/:eventId/door-checkin/undo',
|
||||
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
|
||||
zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })),
|
||||
async (c) => {
|
||||
const { idempotencyKey } = c.req.valid('json');
|
||||
|
||||
const record = await findProcessedKey(idempotencyKey);
|
||||
if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404);
|
||||
if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true });
|
||||
|
||||
const undo = JSON.parse(record.undoState || 'null') as UndoState | null;
|
||||
if (!undo) return c.json({ error: 'This action cannot be undone' }, 400);
|
||||
|
||||
const now = getNow();
|
||||
const ops: TxOp[] = [];
|
||||
|
||||
if (undo.kind === 'created') {
|
||||
// Walk-ins created here are cancelled, not deleted: the row stays as an
|
||||
// audit trail and can be reactivated from the same screen.
|
||||
ops.push(updateOp(tickets, {
|
||||
status: 'cancelled',
|
||||
checkinAt: null,
|
||||
checkedInByAdminId: null,
|
||||
}, eq((tickets as any).id, undo.ticketId)));
|
||||
ops.push(updateOp(payments, {
|
||||
status: 'cancelled',
|
||||
paidAt: null,
|
||||
updatedAt: now,
|
||||
}, eq((payments as any).id, undo.paymentId)));
|
||||
} else {
|
||||
ops.push(updateOp(tickets, {
|
||||
status: undo.prevTicket.status,
|
||||
checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null,
|
||||
checkedInByAdminId: undo.prevTicket.checkedInByAdminId,
|
||||
paymentStatus: undo.prevTicket.paymentStatus,
|
||||
isGuest: toDbBool(undo.prevTicket.isGuest),
|
||||
}, eq((tickets as any).id, undo.ticketId)));
|
||||
|
||||
if (undo.createdPaymentId) {
|
||||
ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId)));
|
||||
} else if (undo.prevPayment) {
|
||||
const prev = undo.prevPayment;
|
||||
ops.push(updateOp(payments, {
|
||||
provider: prev.provider,
|
||||
amount: prev.amount,
|
||||
status: prev.status,
|
||||
reference: prev.reference,
|
||||
paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null,
|
||||
paidByAdminId: prev.paidByAdminId,
|
||||
source: prev.source,
|
||||
method: prev.method,
|
||||
updatedAt: now,
|
||||
}, eq((payments as any).id, prev.id)));
|
||||
}
|
||||
}
|
||||
|
||||
ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey)));
|
||||
|
||||
await runOps(ops);
|
||||
|
||||
return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind });
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== GET /:eventId/door-summary ====================
|
||||
// End-of-night reconciliation: what was taken at the door, by tender, plus the
|
||||
// pre-sale/door split the event dashboard shows.
|
||||
|
||||
doorRouter.get('/:eventId/door-summary', requireEventPermission('view_payments', { globalRoles: REVENUE_ROLES, eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
const event = await loadEvent(eventId);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
|
||||
// Door payments settled for this event, with the attendee attached so the
|
||||
// session feed can show who each line belongs to.
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
paymentId: (payments as any).id,
|
||||
ticketId: (tickets as any).id,
|
||||
method: (payments as any).method,
|
||||
amount: (payments as any).amount,
|
||||
paidAt: (payments as any).paidAt,
|
||||
firstName: (tickets as any).attendeeFirstName,
|
||||
lastName: (tickets as any).attendeeLastName,
|
||||
ticketStatus: (tickets as any).status,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((payments as any).source, 'door'),
|
||||
eq((payments as any).status, 'paid')
|
||||
))
|
||||
);
|
||||
|
||||
const byMethod: Record<string, { count: number; total: number }> = {};
|
||||
for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 };
|
||||
|
||||
let doorTotal = 0;
|
||||
for (const r of rows) {
|
||||
const key = (r.method && byMethod[r.method]) ? r.method : 'cash';
|
||||
const amount = num(r.amount);
|
||||
byMethod[key].count += 1;
|
||||
byMethod[key].total += amount;
|
||||
doorTotal += amount;
|
||||
}
|
||||
|
||||
// Pre-sale revenue: settled tickets that weren't taken at the door, at the
|
||||
// amount actually paid — never the current event price, which an admin can
|
||||
// change after tickets have been sold.
|
||||
const doorTicketIds = new Set(rows.map((r: any) => r.ticketId));
|
||||
const settled = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ id: (tickets as any).id, amount: (payments as any).amount })
|
||||
.from(tickets)
|
||||
.leftJoin(payments, and(
|
||||
eq((payments as any).ticketId, (tickets as any).id),
|
||||
eq((payments as any).status, 'paid')
|
||||
))
|
||||
.where(and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).paymentStatus, 'paid'),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
))
|
||||
);
|
||||
const presaleIds = new Set<string>();
|
||||
let presaleTotal = 0;
|
||||
for (const t of settled) {
|
||||
if (doorTicketIds.has(t.id)) continue;
|
||||
presaleIds.add(t.id);
|
||||
presaleTotal += num(t.amount);
|
||||
}
|
||||
const presaleCount = presaleIds.size;
|
||||
|
||||
return c.json({
|
||||
eventId,
|
||||
currency: event.currency,
|
||||
price: event.price,
|
||||
door: {
|
||||
count: rows.length,
|
||||
total: doorTotal,
|
||||
byMethod,
|
||||
lines: rows
|
||||
.map((r: any) => ({
|
||||
paymentId: r.paymentId,
|
||||
ticketId: r.ticketId,
|
||||
name: `${r.firstName} ${r.lastName || ''}`.trim(),
|
||||
method: r.method || 'cash',
|
||||
amount: num(r.amount),
|
||||
paidAt: iso(r.paidAt),
|
||||
}))
|
||||
.sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')),
|
||||
},
|
||||
presale: { count: presaleCount, total: presaleTotal },
|
||||
total: presaleTotal + doorTotal,
|
||||
});
|
||||
});
|
||||
|
||||
export default doorRouter;
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromParam, eventFromQuery } from '../lib/eventPermissions.js';
|
||||
import { getNow, generateId } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
||||
@@ -9,10 +12,54 @@ import { getQueueStatus } from '../lib/emailQueue.js';
|
||||
|
||||
const emailsRouter = new Hono();
|
||||
|
||||
const slugPattern = /^[a-z0-9-]+$/;
|
||||
|
||||
const createTemplateSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(slugPattern),
|
||||
subject: z.string().min(1).max(500),
|
||||
subjectEs: z.string().max(500).optional().nullable(),
|
||||
bodyHtml: z.string().min(1).max(200_000),
|
||||
bodyHtmlEs: z.string().max(200_000).optional().nullable(),
|
||||
bodyText: z.string().max(200_000).optional().nullable(),
|
||||
bodyTextEs: z.string().max(200_000).optional().nullable(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
variables: z.array(z.any()).max(100).optional(),
|
||||
});
|
||||
|
||||
const updateTemplateSchema = createTemplateSchema.partial().extend({
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const sendCustomEmailSchema = z.object({
|
||||
to: z.string().email().max(254),
|
||||
toName: z.string().max(200).optional(),
|
||||
subject: z.string().min(1).max(500),
|
||||
bodyHtml: z.string().min(1).max(200_000),
|
||||
bodyText: z.string().max(200_000).optional(),
|
||||
eventId: z.string().optional(),
|
||||
});
|
||||
|
||||
const emailLogsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
|
||||
offset: z.coerce.number().int().min(0).optional().default(0),
|
||||
});
|
||||
|
||||
// Safely parse a stored JSON variables column; a corrupt row must not 500 the route.
|
||||
function safeParseVariables(raw: any): any[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Template Routes ====================
|
||||
|
||||
// Get all email templates
|
||||
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.get('/templates', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const templates = await dbAll<any>(
|
||||
(db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
|
||||
);
|
||||
@@ -20,7 +67,7 @@ emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
// Parse variables JSON for each template
|
||||
const parsedTemplates = templates.map((t: any) => ({
|
||||
...t,
|
||||
variables: t.variables ? JSON.parse(t.variables) : [],
|
||||
variables: safeParseVariables(t.variables),
|
||||
isSystem: Boolean(t.isSystem),
|
||||
isActive: Boolean(t.isActive),
|
||||
}));
|
||||
@@ -46,7 +93,7 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
|
||||
return c.json({
|
||||
template: {
|
||||
...template,
|
||||
variables: template.variables ? JSON.parse(template.variables) : [],
|
||||
variables: safeParseVariables(template.variables),
|
||||
isSystem: Boolean(template.isSystem),
|
||||
isActive: Boolean(template.isActive),
|
||||
}
|
||||
@@ -54,14 +101,10 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
|
||||
});
|
||||
|
||||
// Create new email template
|
||||
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
|
||||
const body = await c.req.json();
|
||||
emailsRouter.post('/templates', requireAuth(['admin']), zValidator('json', createTemplateSchema), async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
|
||||
|
||||
if (!name || !slug || !subject || !bodyHtml) {
|
||||
return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400);
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
|
||||
@@ -104,9 +147,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
|
||||
});
|
||||
|
||||
// Update email template
|
||||
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||
emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', updateTemplateSchema), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const body = await c.req.json();
|
||||
const body = c.req.valid('json');
|
||||
|
||||
const existing = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -121,22 +164,22 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||
|
||||
const updateData: any = { updatedAt: getNow() };
|
||||
|
||||
// Only allow updating certain fields for system templates
|
||||
const systemProtectedFields = ['slug', 'isSystem'];
|
||||
|
||||
// System templates cannot have their slug or isSystem flag changed; only the
|
||||
// editable fields below are applied.
|
||||
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
|
||||
if (!existing.isSystem) {
|
||||
allowedFields.push('slug');
|
||||
}
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
const value = (body as Record<string, unknown>)[field];
|
||||
if (value !== undefined) {
|
||||
if (field === 'variables') {
|
||||
updateData[field] = JSON.stringify(body[field]);
|
||||
updateData[field] = JSON.stringify(value);
|
||||
} else if (field === 'isActive') {
|
||||
updateData[field] = body[field] ? 1 : 0;
|
||||
updateData[field] = value ? 1 : 0;
|
||||
} else {
|
||||
updateData[field] = body[field];
|
||||
updateData[field] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +199,7 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||
return c.json({
|
||||
template: {
|
||||
...updated,
|
||||
variables: updated.variables ? JSON.parse(updated.variables) : [],
|
||||
variables: safeParseVariables(updated.variables),
|
||||
isSystem: Boolean(updated.isSystem),
|
||||
isActive: Boolean(updated.isActive),
|
||||
},
|
||||
@@ -197,22 +240,28 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
|
||||
// ==================== Email Sending Routes ====================
|
||||
|
||||
// Send email using template to event attendees (non-blocking, queued)
|
||||
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.post('/send/event/:eventId', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const { eventId } = c.req.param();
|
||||
const user = (c as any).get('user');
|
||||
const body = await c.req.json();
|
||||
const { templateSlug, customVariables, recipientFilter } = body;
|
||||
|
||||
if (!templateSlug) {
|
||||
if (!templateSlug || typeof templateSlug !== 'string') {
|
||||
return c.json({ error: 'Template slug is required' }, 400);
|
||||
}
|
||||
|
||||
const allowedFilters = ['confirmed', 'pending', 'all', 'checked_in'];
|
||||
const filter = recipientFilter || 'confirmed';
|
||||
if (!allowedFilters.includes(filter)) {
|
||||
return c.json({ error: `Invalid recipientFilter. Allowed: ${allowedFilters.join(', ')}` }, 400);
|
||||
}
|
||||
|
||||
// Queue emails for background processing instead of sending synchronously
|
||||
const result = await emailService.queueEventEmails({
|
||||
eventId,
|
||||
templateSlug,
|
||||
customVariables,
|
||||
recipientFilter: recipientFilter || 'confirmed',
|
||||
recipientFilter: filter,
|
||||
sentBy: user?.id,
|
||||
});
|
||||
|
||||
@@ -220,14 +269,9 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
|
||||
});
|
||||
|
||||
// Send custom email to specific recipients
|
||||
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidator('json', sendCustomEmailSchema), async (c) => {
|
||||
const user = (c as any).get('user');
|
||||
const body = await c.req.json();
|
||||
const { to, toName, subject, bodyHtml, bodyText, eventId } = body;
|
||||
|
||||
if (!to || !subject || !bodyHtml) {
|
||||
return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400);
|
||||
}
|
||||
const { to, toName, subject, bodyHtml, bodyText, eventId } = c.req.valid('json');
|
||||
|
||||
const result = await emailService.sendCustomEmail({
|
||||
to,
|
||||
@@ -243,7 +287,7 @@ emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c)
|
||||
});
|
||||
|
||||
// Preview email (render template without sending)
|
||||
emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.post('/preview', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { templateSlug, variables, locale } = body;
|
||||
|
||||
@@ -272,7 +316,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
: template.bodyHtml;
|
||||
|
||||
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
|
||||
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
||||
|
||||
return c.json({
|
||||
@@ -284,12 +328,13 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
// ==================== Email Logs Routes ====================
|
||||
|
||||
// Get email logs
|
||||
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.get('/logs', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
const status = c.req.query('status');
|
||||
const search = c.req.query('search');
|
||||
const limit = parseInt(c.req.query('limit') || '50');
|
||||
const offset = parseInt(c.req.query('offset') || '0');
|
||||
// Clamp pagination so a NaN / out-of-range value can't produce undefined query behaviour.
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1), 200);
|
||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
||||
|
||||
let query = (db as any).select().from(emailLogs);
|
||||
|
||||
@@ -376,7 +421,7 @@ emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async
|
||||
});
|
||||
|
||||
// Get email stats
|
||||
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
emailsRouter.get('/stats', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
|
||||
@@ -441,7 +486,7 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
||||
|
||||
// Get email queue status
|
||||
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
|
||||
const status = getQueueStatus();
|
||||
const status = await getQueueStatus();
|
||||
return c.json({ status });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// Env must be pinned before the db singleton is imported (dotenv never overrides).
|
||||
// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres
|
||||
// URL to run the same suite in a throwaway schema (dropped afterwards).
|
||||
const PG_URL = process.env.FINANCE_TEST_PG_URL;
|
||||
const PG_SCHEMA = `fintest_${Date.now()}`;
|
||||
if (PG_URL) {
|
||||
process.env.DB_TYPE = 'postgres';
|
||||
process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`;
|
||||
} else {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'finance-test-'));
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = join(dir, 'test.db');
|
||||
}
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'finance-test-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
type TestUser = { id: string; name: string; role: string; languagePreference?: string | null };
|
||||
const ADMIN: TestUser = { id: randomUUID(), name: 'The Admin', role: 'admin' };
|
||||
const ORGANIZER: TestUser = { id: randomUUID(), name: 'The Organizer', role: 'organizer' };
|
||||
const COLLAB: TestUser = { id: randomUUID(), name: 'Pilates Studio', role: 'user' };
|
||||
const COMANAGER: TestUser = { id: randomUUID(), name: 'Co Manager', role: 'user' };
|
||||
const DOOR: TestUser = { id: randomUUID(), name: 'Door Helper', role: 'user' };
|
||||
const STRANGER: TestUser = { id: randomUUID(), name: 'Stranger', role: 'user' };
|
||||
|
||||
// Session auth is Better Auth's concern and has its own suite; this keeps the
|
||||
// role and membership checks real.
|
||||
let currentUser: TestUser = ADMIN;
|
||||
vi.mock('../lib/auth.js', () => ({
|
||||
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
|
||||
c.set('user', currentUser);
|
||||
await next();
|
||||
},
|
||||
getAuthUser: async () => currentUser,
|
||||
}));
|
||||
vi.mock('../lib/email.js', () => ({ default: {} }));
|
||||
|
||||
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = currentUser;
|
||||
currentUser = user;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
currentUser = previous;
|
||||
}
|
||||
}
|
||||
|
||||
let app: any;
|
||||
let dbm: any;
|
||||
|
||||
const EVENT_ID = randomUUID();
|
||||
const OTHER_EVENT_ID = randomUUID();
|
||||
const SEED_USER_ID = randomUUID();
|
||||
const PRICE = 100000;
|
||||
|
||||
async function call(method: string, path: string, body?: unknown) {
|
||||
const res = await app.request(path, {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const type = res.headers.get('content-type') || '';
|
||||
return { status: res.status, type, body: type.includes('json') ? await res.json() : Buffer.from(await res.arrayBuffer()) };
|
||||
}
|
||||
const get = (p: string) => call('GET', p);
|
||||
const post = (p: string, b: unknown = {}) => call('POST', p, b);
|
||||
const put = (p: string, b: unknown) => call('PUT', p, b);
|
||||
|
||||
// Seeded through drizzle so the same rows work on both engines.
|
||||
async function seedTicket(label: string, eventId: string, opts: { status: string; paymentStatus: string; pay?: { amount: number; provider: string; source?: string; status?: string } }) {
|
||||
const { db, tickets, payments } = dbm;
|
||||
const now = dbm.getNow();
|
||||
const id = randomUUID();
|
||||
await db.insert(tickets).values({
|
||||
id, userId: SEED_USER_ID, eventId, attendeeFirstName: `Guest ${label}`, attendeeLastName: 'Test',
|
||||
attendeeEmail: `${label}@test.py`, attendeePhone: '+595 981 000 000', status: opts.status,
|
||||
paymentStatus: opts.paymentStatus, isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now,
|
||||
});
|
||||
if (opts.pay) {
|
||||
await db.insert(payments).values({
|
||||
id: randomUUID(), ticketId: id, provider: opts.pay.provider, amount: opts.pay.amount, currency: 'PYG',
|
||||
status: opts.pay.status || 'paid', source: opts.pay.source || 'presale', paidAt: now, createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' });
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||
|
||||
return (async () => {
|
||||
const { Hono } = await import('hono');
|
||||
app = new Hono();
|
||||
app.route('/api/events', (await import('./door.js')).default);
|
||||
app.route('/api/events', (await import('./eventFinance.js')).default);
|
||||
app.route('/api/events', (await import('./events.js')).default);
|
||||
app.route('/api/finance', (await import('./finance.js')).default);
|
||||
app.route('/api/dashboard', (await import('./dashboard.js')).default);
|
||||
|
||||
dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')) };
|
||||
const { db, users, events } = dbm;
|
||||
const now = dbm.getNow();
|
||||
for (const u of [ADMIN, ORGANIZER, COLLAB, COMANAGER, DOOR, STRANGER, { id: SEED_USER_ID, name: 'Seed', role: 'user' }]) {
|
||||
await db.insert(users).values({
|
||||
id: u.id, email: `${u.name.toLowerCase().replace(/ /g, '.')}@test.py`, name: u.name, role: u.role,
|
||||
isClaimed: dbm.toDbBool(true), accountStatus: 'active', createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
for (const [id, title] of [[EVENT_ID, 'Morning Club: Pilates Edition'], [OTHER_EVENT_ID, 'Some Other Event']]) {
|
||||
await db.insert(events).values({
|
||||
id, title, description: 'desc', startDatetime: now, location: 'Studio Uno', price: PRICE, currency: 'PYG',
|
||||
capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// 10 pre-sale TPago tickets (8 checked in), 2 cash walk-ins at the door, 1 refund.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await seedTicket(`t${i}`, EVENT_ID, { status: i < 8 ? 'checked_in' : 'confirmed', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago' } });
|
||||
}
|
||||
await seedTicket('door1', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||
await seedTicket('door2', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||
await seedTicket('refunded', EVENT_ID, { status: 'cancelled', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago', status: 'refunded' } });
|
||||
await seedTicket('other1', OTHER_EVENT_ID, { status: 'confirmed', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago' } });
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' });
|
||||
});
|
||||
|
||||
describe('event finance access', () => {
|
||||
it('lets admins in and keeps organizers out until they are granted access', async () => {
|
||||
expect((await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(200);
|
||||
expect((await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(403);
|
||||
expect((await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/members`))).status).toBe(403);
|
||||
const perms = await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/my-permissions`));
|
||||
expect(perms.body.permissions).toContain('view_payments');
|
||||
expect(perms.body.permissions).not.toContain('view_finance');
|
||||
});
|
||||
|
||||
it('lets an admin add team members, and writes the audit log', async () => {
|
||||
const add = (userId: string, rolePreset: string, permissions?: Record<string, boolean>) =>
|
||||
as(ADMIN, () => post(`/api/events/${EVENT_ID}/members`, { userId, rolePreset, permissions }));
|
||||
expect((await add(COLLAB.id, 'collaborator')).status).toBe(201);
|
||||
expect((await add(COMANAGER.id, 'co_manager')).status).toBe(201);
|
||||
expect((await add(DOOR.id, 'staff')).status).toBe(201);
|
||||
expect((await add(COLLAB.id, 'staff')).status).toBe(409);
|
||||
|
||||
const candidates = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members/candidates?q=stran`));
|
||||
expect(candidates.body.users.map((u: any) => u.id)).toEqual([STRANGER.id]);
|
||||
|
||||
const log = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/audit-log`));
|
||||
expect(log.body.entries.filter((e: any) => e.entityType === 'member' && e.action === 'create')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('gives a collaborator only their events and permitted routes', async () => {
|
||||
const mine = await as(COLLAB, () => get('/api/dashboard/my-events'));
|
||||
expect(mine.status).toBe(200);
|
||||
expect(mine.body.events.map((e: any) => e.event.id)).toEqual([EVENT_ID]);
|
||||
expect(mine.body.events[0].permissions).toEqual(['view_overview', 'view_finance']);
|
||||
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(200);
|
||||
// Other events' data
|
||||
expect((await as(COLLAB, () => get(`/api/events/${OTHER_EVENT_ID}/finance`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${OTHER_EVENT_ID}/attendees`))).status).toBe(403);
|
||||
// Routes on their own event that the preset does not grant
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/attendees`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/door-attendees`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/door-summary`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/members`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/audit-log`))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'x', unitAmount: 1 }))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}`, { title: 'Hacked' }))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get('/api/finance/overview'))).status).toBe(403);
|
||||
expect((await as(COLLAB, () => get('/api/finance/settings/expense-templates'))).status).toBe(403);
|
||||
});
|
||||
|
||||
it('shows staff members attendee names without contact details', async () => {
|
||||
const res = await as(DOOR, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.attendees.length).toBeGreaterThan(0);
|
||||
expect(res.body.attendees.every((a: any) => a.attendeeEmail === null && a.attendeePhone === null)).toBe(true);
|
||||
const door = await as(DOOR, () => get(`/api/events/${EVENT_ID}/door-attendees`));
|
||||
expect(door.status).toBe(200);
|
||||
expect(door.body.attendees.every((a: any) => a.email === null)).toBe(true);
|
||||
// Admins still get everything
|
||||
const full = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||
expect(full.body.attendees.some((a: any) => a.attendeeEmail)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies per-member overrides in both directions', async () => {
|
||||
const list = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members`));
|
||||
const door = list.body.members.find((m: any) => m.userId === DOOR.id);
|
||||
await as(ADMIN, () => put(`/api/events/${EVENT_ID}/members/${door.id}`, { permissions: { view_attendees_pii: true, check_in: false } }));
|
||||
expect((await as(DOOR, () => get(`/api/events/${EVENT_ID}/door-attendees`))).status).toBe(403);
|
||||
const res = await as(DOOR, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||
expect(res.body.attendees.some((a: any) => a.attendeeEmail)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Morning Club: Pilates Edition finance flow', () => {
|
||||
let packId = '';
|
||||
let partnerId = '';
|
||||
|
||||
it('builds a template pack in settings', async () => {
|
||||
const cats = await as(ADMIN, () => get('/api/finance/settings/expense-categories'));
|
||||
const venue = cats.body.categories.find((c: any) => c.nameEn === 'Venue');
|
||||
const make = (body: any) => as(ADMIN, () => post('/api/finance/settings/expense-templates', body));
|
||||
const studio = await make({ name: 'Studio minimum spend', categoryId: venue.id, calcType: 'minimum_spend', amount: 30000, minimumAmount: 500000 });
|
||||
const mats = await make({ name: 'Mat rental', calcType: 'per_checked_in', amount: 5000 });
|
||||
const instructor = await make({ name: 'Instructor', calcType: 'fixed', amount: 300000 });
|
||||
const promo = await make({ name: 'Promo share', calcType: 'percent_of_revenue', percentBp: 500 });
|
||||
expect([studio, mats, instructor, promo].map((r) => r.status)).toEqual([201, 201, 201, 201]);
|
||||
|
||||
const pack = await as(ADMIN, () => post('/api/finance/settings/expense-template-packs', {
|
||||
name: 'Morning Club pack', templateIds: [studio.body.template.id, mats.body.template.id, instructor.body.template.id, promo.body.template.id],
|
||||
}));
|
||||
expect(pack.status).toBe(201);
|
||||
packId = pack.body.pack.id;
|
||||
expect(pack.body.pack.templateIds).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('applies the pack to the event with amounts from current counts', async () => {
|
||||
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/expenses/apply-template`, { packId }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.expenses.map((e: any) => [e.description, e.computedAmount])).toEqual([
|
||||
['Studio minimum spend', 500000], // 10 checked in x 30k = 300k < 500k minimum
|
||||
['Mat rental', 50000],
|
||||
['Instructor', 300000],
|
||||
['Promo share', 62000], // 5% of 1,240,000 sales after the refund
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a partner at a percent of profit and computes the split', async () => {
|
||||
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners`, {
|
||||
userId: COLLAB.id, roleLabel: 'Studio', shareType: 'percent_profit', percentBp: 3000, lossRule: 'proportional',
|
||||
}));
|
||||
expect(res.status).toBe(201);
|
||||
partnerId = res.body.partner.id;
|
||||
|
||||
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
const s = fin.body.summary;
|
||||
expect(s.revenue.gross).toBe(1340000);
|
||||
expect(s.revenue.refunds).toBe(100000);
|
||||
expect(s.revenue.presale).toBe(1100000);
|
||||
expect(s.revenue.door).toBe(240000);
|
||||
expect(s.expenses.total).toBe(912000);
|
||||
expect(s.profit).toBe(1240000 - 912000);
|
||||
expect(s.split.partners[0].share).toBe(Math.round(328000 * 0.3));
|
||||
expect(s.split.organization).toBe(328000 - 98400);
|
||||
expect(s.breakEven.tickets).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the collaborator their own share but not the full split', async () => {
|
||||
const fin = await as(COLLAB, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
expect(fin.body.viewer.fullSplit).toBe(false);
|
||||
expect(fin.body.summary.split.organization).toBeNull();
|
||||
expect(fin.body.summary.split.partners.map((p: any) => p.partnerId)).toEqual([partnerId]);
|
||||
expect(fin.body.summary.waterfall.some((w: any) => w.key === 'organization')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets an organizer in once they are granted finance on this event', async () => {
|
||||
await as(ADMIN, () => post(`/api/events/${EVENT_ID}/members`, { userId: ORGANIZER.id, rolePreset: 'collaborator', permissions: { view_full_split: true } }));
|
||||
const fin = await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
expect(fin.status).toBe(200);
|
||||
expect(fin.body.viewer.fullSplit).toBe(true);
|
||||
expect((await as(ORGANIZER, () => get(`/api/events/${OTHER_EVENT_ID}/finance`))).status).toBe(403);
|
||||
});
|
||||
|
||||
it('finalizes, freezes the numbers and blocks edits', async () => {
|
||||
expect((await as(COLLAB, () => post(`/api/events/${EVENT_ID}/finance/finalize`))).status).toBe(403);
|
||||
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/finance/finalize`));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// A late walk-in no longer moves the finalized numbers
|
||||
await seedTicket('late', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
expect(fin.body.status).toBe('finalized');
|
||||
expect(fin.body.live).toBe(false);
|
||||
expect(fin.body.summary.revenue.gross).toBe(1340000);
|
||||
|
||||
const blocked = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'Late cost', unitAmount: 1000 }));
|
||||
expect(blocked.status).toBe(409);
|
||||
expect(blocked.body.code).toBe('FINANCE_FINALIZED');
|
||||
});
|
||||
|
||||
it('exports the partner statement to the partner and not to others', async () => {
|
||||
const pdf = await as(COLLAB, () => get(`/api/events/${EVENT_ID}/partners/${partnerId}/statement?locale=es`));
|
||||
expect(pdf.status).toBe(200);
|
||||
expect(pdf.type).toBe('application/pdf');
|
||||
expect((pdf.body as Buffer).subarray(0, 4).toString()).toBe('%PDF');
|
||||
|
||||
const other = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners`, { externalName: 'Someone', shareType: 'fixed', fixedAmount: 1 }));
|
||||
expect(other.status).toBe(409); // finalized
|
||||
expect((await as(DOOR, () => get(`/api/events/${EVENT_ID}/partners/${partnerId}/statement`))).status).toBe(403);
|
||||
});
|
||||
|
||||
it('marks the payout and moves the event to paid out', async () => {
|
||||
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners/${partnerId}/mark-paid`, { paid: true, payoutMethod: 'transfer' }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('paid_out');
|
||||
expect(res.body.partner.payoutStatus).toBe('paid');
|
||||
});
|
||||
|
||||
it('only lets admins and co-managers unfinalize, and logs it', async () => {
|
||||
expect((await as(ORGANIZER, () => post(`/api/events/${EVENT_ID}/finance/unfinalize`))).status).toBe(403);
|
||||
expect((await as(COMANAGER, () => post(`/api/events/${EVENT_ID}/finance/unfinalize`))).status).toBe(200);
|
||||
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
expect(fin.body.status).toBe('open');
|
||||
expect(fin.body.summary.revenue.gross).toBe(1460000); // the late walk-in now counts
|
||||
const log = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/audit-log`));
|
||||
const actions = log.body.entries.filter((e: any) => e.entityType === 'finance_state').map((e: any) => e.action);
|
||||
expect(actions).toEqual(expect.arrayContaining(['finalize', 'paid_out', 'unfinalize']));
|
||||
});
|
||||
|
||||
it('limits edit_own_expenses_only members to their own rows', async () => {
|
||||
const list = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members`));
|
||||
const collab = list.body.members.find((m: any) => m.userId === COLLAB.id);
|
||||
await as(ADMIN, () => put(`/api/events/${EVENT_ID}/members/${collab.id}`, { permissions: { edit_own_expenses_only: true } }));
|
||||
const own = await as(COLLAB, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'Flowers', unitAmount: 40000, status: 'paid', paidByPartnerId: partnerId }));
|
||||
expect(own.status).toBe(201);
|
||||
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}/expenses/${own.body.expense.id}`, { unitAmount: 45000 }))).status).toBe(200);
|
||||
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||
const adminRow = fin.body.expenses.find((e: any) => e.createdBy === ADMIN.id);
|
||||
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}/expenses/${adminRow.id}`, { unitAmount: 1 }))).status).toBe(403);
|
||||
// The reimbursement shows up on the partner line
|
||||
expect(fin.body.summary.split.partners[0].reimbursement).toBe(45000);
|
||||
});
|
||||
|
||||
it('includes the event in the cross-event overview', async () => {
|
||||
await as(ADMIN, () => put(`/api/events/${EVENT_ID}`, { series: 'Morning Club' }));
|
||||
const res = await as(ADMIN, () => get('/api/finance/overview?series=Morning%20Club'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.events.map((e: any) => e.id)).toEqual([EVENT_ID]);
|
||||
expect(res.body.bySeries[0].series).toBe('Morning Club');
|
||||
expect(res.body.byPartner[0].name).toBe('Pilates Studio');
|
||||
expect(res.body.filters.series).toEqual(['Morning Club']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-event overview: ready to close', () => {
|
||||
const PAST_QUIET = 'evt-past-quiet';
|
||||
const FUTURE = 'evt-future';
|
||||
const PAST_DRAFT = 'evt-past-draft';
|
||||
|
||||
beforeAll(async () => {
|
||||
const { db, events } = dbm;
|
||||
const now = dbm.getNow();
|
||||
const at = (iso: string) => dbm.toDbDate(iso);
|
||||
for (const [id, title, start, status] of [
|
||||
[PAST_QUIET, 'Quiet past event', '2026-01-10T20:00:00Z', 'published'],
|
||||
[FUTURE, 'Future event', '2099-01-10T20:00:00Z', 'published'],
|
||||
[PAST_DRAFT, 'Past draft', '2026-01-11T20:00:00Z', 'draft'],
|
||||
] as const) {
|
||||
await db.insert(events).values({
|
||||
id, title, description: 'desc', startDatetime: at(start), location: 'Studio Uno', price: PRICE, currency: 'PYG',
|
||||
capacity: 40, status, externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('lists past events with open books even with no money, and keeps them out of the totals', async () => {
|
||||
const res = await as(ADMIN, () => get('/api/finance/overview'));
|
||||
expect(res.status).toBe(200);
|
||||
const ready = res.body.readyToClose.map((e: any) => e.id);
|
||||
expect(ready).toContain(PAST_QUIET);
|
||||
expect(ready).not.toContain(FUTURE);
|
||||
expect(ready).not.toContain(PAST_DRAFT);
|
||||
expect(res.body.events.map((e: any) => e.id)).not.toContain(PAST_QUIET);
|
||||
// Longest-waiting first
|
||||
const starts = res.body.readyToClose.map((e: any) => e.startDatetime);
|
||||
expect([...starts].sort()).toEqual(starts);
|
||||
});
|
||||
|
||||
it('respects the venue filter', async () => {
|
||||
const res = await as(ADMIN, () => get('/api/finance/overview?venue=Nowhere'));
|
||||
expect(res.body.readyToClose).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,936 @@
|
||||
// Event finance, partners and team access, all scoped to one event and
|
||||
// mounted under /api/events:
|
||||
//
|
||||
// GET /:id/my-permissions what the current user may do here
|
||||
// GET /:id/finance P&L summary, chart data and the rows behind it
|
||||
// POST /:id/finance/finalize | unfinalize freeze / reopen the numbers
|
||||
// CRUD /:id/expenses (+ /apply-template) costs, manual or from templates / packs
|
||||
// CRUD /:id/other-income sponsors, venue kickbacks, ...
|
||||
// CRUD /:id/partners (+ /:pid/mark-paid, /:pid/statement PDF)
|
||||
// CRUD /:id/members (+ /candidates) per-event team access
|
||||
// GET /:id/audit-log
|
||||
//
|
||||
// Every route is guarded by requireEventPermission; only global admins pass on
|
||||
// every event. Every write commits together with its finance_audit_log row.
|
||||
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, desc, eq, inArray, notInArray, or, sql } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbAll, dbGet, users, eventExpenses, eventOtherIncome, eventPartners, eventFinanceState, eventMembers,
|
||||
expenseCategories, expenseTemplates, expenseTemplatePackItems, financeAuditLog,
|
||||
} from '../db/index.js';
|
||||
import { requireAuth, type AuthUser } from '../lib/auth.js';
|
||||
import {
|
||||
requireEventPermission, getEventAccess, getEffectivePermissions, canUnfinalize, resolveMemberPermissions,
|
||||
parseOverrides, EVENT_PERMISSIONS, ROLE_PRESETS, type EventPermission,
|
||||
} from '../lib/eventPermissions.js';
|
||||
import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js';
|
||||
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||
import { financeAuditOp } from '../lib/finance/audit.js';
|
||||
import {
|
||||
getEventFinance, getEvent, getFinanceState, serializeExpense, serializeIncome, serializePartner, iso, pyg, bool,
|
||||
loadPartners, type SerializedExpense,
|
||||
} from '../lib/finance/load.js';
|
||||
import { expenseAmount, isAutoCalc, CALC_TYPES, SHARE_TYPES, LOSS_RULES } from '../lib/finance/calculate.js';
|
||||
import { generatePartnerStatementPDF } from '../lib/finance/statementPdf.js';
|
||||
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||
|
||||
const financeRouter = new Hono();
|
||||
|
||||
const validationHook = (result: any, c: any) => {
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
|
||||
return c.json({ error: errors }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const MAX_PYG = 2_000_000_000;
|
||||
const money = z.number().int().min(0).max(MAX_PYG);
|
||||
const bp = z.number().int().min(0).max(10000);
|
||||
// Receipts come from the media upload (/uploads/...) or an external link; never javascript: etc.
|
||||
const receiptUrl = z.string().max(500).regex(/^(https?:\/\/|\/uploads\/)/, 'must be an http(s) or /uploads/ URL');
|
||||
|
||||
const currentUser = (c: Context) => (c as any).get('user') as AuthUser;
|
||||
const can = (c: Context, key: EventPermission) => !!getEventAccess(c)?.permissions.has(key);
|
||||
|
||||
async function requireEvent(c: Context) {
|
||||
const event = await getEvent(c.req.param('id')!);
|
||||
return event || null;
|
||||
}
|
||||
|
||||
/** 409 while the numbers are frozen. */
|
||||
async function assertOpen(c: Context, eventId: string) {
|
||||
const state = await getFinanceState(eventId);
|
||||
if (state.status !== 'open') {
|
||||
return c.json({ error: 'Finance is finalized for this event. Unfinalize it to make changes.', code: 'FINANCE_FINALIZED' }, 409);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Counts and sales the auto-calculated expenses follow right now. */
|
||||
async function liveContext(eventId: string, event: any) {
|
||||
const fin = await getEventFinance(eventId, event);
|
||||
return {
|
||||
ticketsSold: fin!.result.counts.ticketsSold,
|
||||
checkedIn: fin!.result.counts.checkedIn,
|
||||
sales: fin!.result.revenue.sales,
|
||||
};
|
||||
}
|
||||
|
||||
async function partnerBelongsToEvent(partnerId: string, eventId: string) {
|
||||
const row = await dbGet<any>(
|
||||
(db as any).select({ id: (eventPartners as any).id }).from(eventPartners)
|
||||
.where(and(eq((eventPartners as any).id, partnerId), eq((eventPartners as any).eventId, eventId)))
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async function categoryExists(categoryId: string) {
|
||||
const row = await dbGet<any>((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId)));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
// ==================== Permissions for the UI ====================
|
||||
|
||||
financeRouter.get('/:id/my-permissions', requireAuth(), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const access = await getEffectivePermissions(currentUser(c), event.id);
|
||||
return c.json({
|
||||
eventId: event.id,
|
||||
global: access.global,
|
||||
role: access.role,
|
||||
rolePreset: access.membership?.rolePreset ?? null,
|
||||
permissions: EVENT_PERMISSIONS.filter((p) => access.permissions.has(p)),
|
||||
canUnfinalize: canUnfinalize(access),
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Summary ====================
|
||||
|
||||
/**
|
||||
* Collaborators without view_full_split see the event's P&L and their own
|
||||
* partner line only: other partners and the organization's remainder are removed.
|
||||
*/
|
||||
function scopeToOwnShare<T extends NonNullable<Awaited<ReturnType<typeof getEventFinance>>>>(fin: T, userId: string) {
|
||||
const own = new Set(fin.partners.filter((p) => p.userId === userId).map((p) => p.id));
|
||||
return {
|
||||
...fin,
|
||||
partners: fin.partners.filter((p) => own.has(p.id)),
|
||||
expenses: fin.expenses.map((e) => (e.paidByPartnerId && !own.has(e.paidByPartnerId) ? { ...e, paidByPartnerId: 'other' } : e)),
|
||||
result: {
|
||||
...fin.result,
|
||||
split: {
|
||||
distributable: null,
|
||||
organization: null,
|
||||
partners: fin.result.split.partners.filter((p) => own.has(p.partnerId)),
|
||||
},
|
||||
waterfall: fin.result.waterfall.filter((w) =>
|
||||
w.key === 'organization' ? false : w.key.startsWith('partner:') ? own.has(w.key.slice(8)) : true),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
financeRouter.get('/:id/finance', requireEventPermission('view_finance'), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const fin = await getEventFinance(event.id, event);
|
||||
if (!fin) return c.json({ error: 'Event not found' }, 404);
|
||||
|
||||
const lines = new Map(fin.result.expenses.lines.map((l) => [l.id, l]));
|
||||
const withAmounts = {
|
||||
...fin,
|
||||
expenses: fin.expenses.map((e) => ({
|
||||
...e,
|
||||
amount: lines.get(e.id)?.amount ?? e.computedAmount,
|
||||
liveQuantity: lines.get(e.id)?.quantity ?? e.quantity,
|
||||
auto: lines.get(e.id)?.auto ?? false,
|
||||
})),
|
||||
};
|
||||
const fullSplit = can(c, 'view_full_split');
|
||||
const scoped = fullSplit ? withAmounts : scopeToOwnShare(withAmounts, currentUser(c).id);
|
||||
|
||||
const categories = await dbAll<any>((db as any).select().from(expenseCategories));
|
||||
const access = getEventAccess(c)!;
|
||||
|
||||
return c.json({
|
||||
event: omitWalkInPrice({
|
||||
id: event.id, title: event.title, titleEs: event.titleEs, startDatetime: iso(event.startDatetime),
|
||||
endDatetime: event.endDatetime ? iso(event.endDatetime) : null,
|
||||
location: event.location, series: event.series ?? null, price: pyg(event.price), currency: event.currency,
|
||||
capacity: event.capacity,
|
||||
}),
|
||||
status: fin.state.status,
|
||||
finalizedAt: fin.state.finalizedAt,
|
||||
live: fin.live,
|
||||
computedAt: fin.computedAt,
|
||||
summary: scoped.result,
|
||||
expenses: scoped.expenses,
|
||||
otherIncome: scoped.otherIncome,
|
||||
partners: scoped.partners,
|
||||
categories: categories
|
||||
.map((cat: any) => ({ id: cat.id, nameEn: cat.nameEn, nameEs: cat.nameEs, color: cat.color, sortOrder: pyg(cat.sortOrder), archived: bool(cat.archived) }))
|
||||
.sort((a: any, b: any) => a.sortOrder - b.sortOrder),
|
||||
viewer: {
|
||||
fullSplit,
|
||||
canEditExpenses: access.permissions.has('edit_expenses'),
|
||||
canEditOwnExpenses: access.permissions.has('edit_own_expenses_only'),
|
||||
canManageSplit: fullSplit && access.permissions.has('edit_expenses'),
|
||||
canUnfinalize: canUnfinalize(access),
|
||||
userId: currentUser(c).id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Expenses ====================
|
||||
|
||||
const expenseFields = {
|
||||
description: z.string().trim().min(1).max(300),
|
||||
categoryId: z.string().nullable().optional(),
|
||||
calcType: z.enum(CALC_TYPES),
|
||||
quantity: z.number().int().min(0).max(1_000_000),
|
||||
unitAmount: money,
|
||||
percentBp: bp,
|
||||
minimumAmount: money,
|
||||
computedAmount: money.optional(),
|
||||
isLocked: z.boolean(),
|
||||
status: z.enum(['planned', 'paid']),
|
||||
paidByPartnerId: z.string().nullable().optional(),
|
||||
receiptUrl: receiptUrl.nullable().optional(),
|
||||
expenseDate: z.string().nullable().optional(),
|
||||
};
|
||||
const createExpenseSchema = z.object({
|
||||
...expenseFields,
|
||||
calcType: expenseFields.calcType.default('fixed'),
|
||||
quantity: expenseFields.quantity.default(1),
|
||||
unitAmount: money.default(0),
|
||||
percentBp: bp.default(0),
|
||||
minimumAmount: money.default(0),
|
||||
isLocked: z.boolean().default(false),
|
||||
status: expenseFields.status.default('planned'),
|
||||
});
|
||||
const updateExpenseSchema = z.object(expenseFields).partial();
|
||||
|
||||
const EXPENSE_EDITORS = ['edit_expenses', 'edit_own_expenses_only'] as const;
|
||||
|
||||
/** Members limited to their own expenses may only touch rows they created. */
|
||||
function canEditRow(c: Context, row: any) {
|
||||
return can(c, 'edit_expenses') || row.createdBy === currentUser(c).id;
|
||||
}
|
||||
|
||||
type LiveContext = { ticketsSold: number; checkedIn: number; sales: number };
|
||||
|
||||
/**
|
||||
* Quantity and amount to store for a row. Unlocked rows follow the live counts.
|
||||
* A locked row keeps its frozen amount; locking freezes the amount it shows at
|
||||
* that moment unless one is typed in (`typed`).
|
||||
*/
|
||||
function storedAmount(row: any, ctx: LiveContext, opts: { typed?: number; wasLocked?: boolean; frozen?: { quantity: number; amount: number } } = {}) {
|
||||
const live = expenseAmount({ ...row, isLocked: false }, ctx);
|
||||
const quantity = isAutoCalc(row.calcType) ? live.quantity : row.quantity;
|
||||
if (!row.isLocked) return { quantity, computedAmount: live.amount };
|
||||
if (opts.typed !== undefined) return { quantity, computedAmount: opts.typed };
|
||||
if (opts.wasLocked && opts.frozen) return { quantity: opts.frozen.quantity, computedAmount: opts.frozen.amount };
|
||||
return { quantity, computedAmount: live.amount };
|
||||
}
|
||||
|
||||
async function validateExpenseRefs(c: Context, eventId: string, data: { categoryId?: string | null; paidByPartnerId?: string | null }) {
|
||||
if (data.categoryId && !(await categoryExists(data.categoryId))) {
|
||||
return c.json({ error: 'Unknown expense category' }, 400);
|
||||
}
|
||||
if (data.paidByPartnerId && !(await partnerBelongsToEvent(data.paidByPartnerId, eventId))) {
|
||||
return c.json({ error: 'paidByPartnerId must be a partner of this event' }, 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
financeRouter.post('/:id/expenses', requireEventPermission(EXPENSE_EDITORS), zValidator('json', createExpenseSchema, validationHook), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, event.id);
|
||||
if (closed) return closed;
|
||||
const data = c.req.valid('json');
|
||||
const bad = await validateExpenseRefs(c, event.id, data);
|
||||
if (bad) return bad;
|
||||
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const ctx = await liveContext(event.id, event);
|
||||
const amounts = storedAmount(data, ctx, { typed: data.computedAmount });
|
||||
const values = {
|
||||
id: generateId(),
|
||||
eventId: event.id,
|
||||
categoryId: data.categoryId || null,
|
||||
templateId: null,
|
||||
description: data.description,
|
||||
calcType: data.calcType,
|
||||
quantity: amounts.quantity,
|
||||
unitAmount: data.unitAmount,
|
||||
percentBp: data.percentBp,
|
||||
minimumAmount: data.minimumAmount,
|
||||
computedAmount: amounts.computedAmount,
|
||||
isLocked: toDbBool(data.isLocked),
|
||||
status: data.status,
|
||||
paidByPartnerId: data.paidByPartnerId || null,
|
||||
receiptUrl: data.receiptUrl || null,
|
||||
expenseDate: data.expenseDate ? toDbDate(data.expenseDate) : null,
|
||||
createdBy: user.id,
|
||||
updatedBy: user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await runOps([
|
||||
insertOp(eventExpenses, values),
|
||||
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'expense', entityId: values.id, action: 'create', after: serializeExpense(values) }),
|
||||
]);
|
||||
return c.json({ expense: serializeExpense(values) }, 201);
|
||||
});
|
||||
|
||||
financeRouter.put('/:id/expenses/:expenseId', requireEventPermission(EXPENSE_EDITORS), zValidator('json', updateExpenseSchema, validationHook), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const event = await getEvent(eventId);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventExpenses)
|
||||
.where(and(eq((eventExpenses as any).id, c.req.param('expenseId')), eq((eventExpenses as any).eventId, eventId)))
|
||||
);
|
||||
if (!existing) return c.json({ error: 'Expense not found' }, 404);
|
||||
if (!canEditRow(c, existing)) return c.json({ error: 'You can only edit expenses you added', code: 'EVENT_PERMISSION' }, 403);
|
||||
|
||||
const data = c.req.valid('json');
|
||||
const bad = await validateExpenseRefs(c, eventId, data);
|
||||
if (bad) return bad;
|
||||
|
||||
const before = serializeExpense(existing);
|
||||
const merged = { ...before, ...Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)) } as any;
|
||||
const ctx = await liveContext(eventId, event);
|
||||
const amounts = storedAmount(merged, ctx, {
|
||||
typed: data.computedAmount,
|
||||
wasLocked: before.isLocked,
|
||||
frozen: { quantity: before.quantity, amount: before.computedAmount },
|
||||
});
|
||||
const user = currentUser(c);
|
||||
const updates: Record<string, any> = {
|
||||
description: merged.description,
|
||||
categoryId: merged.categoryId || null,
|
||||
calcType: merged.calcType,
|
||||
quantity: amounts.quantity,
|
||||
unitAmount: merged.unitAmount,
|
||||
percentBp: merged.percentBp,
|
||||
minimumAmount: merged.minimumAmount,
|
||||
computedAmount: amounts.computedAmount,
|
||||
isLocked: toDbBool(!!merged.isLocked),
|
||||
status: merged.status,
|
||||
paidByPartnerId: merged.paidByPartnerId || null,
|
||||
receiptUrl: merged.receiptUrl || null,
|
||||
expenseDate: merged.expenseDate ? toDbDate(merged.expenseDate) : null,
|
||||
updatedBy: user.id,
|
||||
updatedAt: getNow(),
|
||||
};
|
||||
const after = serializeExpense({ ...existing, ...updates });
|
||||
await runOps([
|
||||
updateOp(eventExpenses, updates, eq((eventExpenses as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'expense', entityId: existing.id, action: 'update', before, after }),
|
||||
]);
|
||||
return c.json({ expense: after });
|
||||
});
|
||||
|
||||
financeRouter.delete('/:id/expenses/:expenseId', requireEventPermission(EXPENSE_EDITORS), async (c) => {
|
||||
const eventId = c.req.param('id')!;
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventExpenses)
|
||||
.where(and(eq((eventExpenses as any).id, c.req.param('expenseId')), eq((eventExpenses as any).eventId, eventId)))
|
||||
);
|
||||
if (!existing) return c.json({ error: 'Expense not found' }, 404);
|
||||
if (!canEditRow(c, existing)) return c.json({ error: 'You can only delete expenses you added', code: 'EVENT_PERMISSION' }, 403);
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
deleteOp(eventExpenses, eq((eventExpenses as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'expense', entityId: existing.id, action: 'delete', before: serializeExpense(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Expense deleted' });
|
||||
});
|
||||
|
||||
const applyTemplateSchema = z.object({
|
||||
templateId: z.string().optional(),
|
||||
packId: z.string().optional(),
|
||||
}).refine((d) => !!d.templateId !== !!d.packId, { message: 'Provide exactly one of templateId or packId' });
|
||||
|
||||
financeRouter.post('/:id/expenses/apply-template', requireEventPermission(EXPENSE_EDITORS), zValidator('json', applyTemplateSchema, validationHook), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, event.id);
|
||||
if (closed) return closed;
|
||||
const { templateId, packId } = c.req.valid('json');
|
||||
|
||||
let templateIds: string[];
|
||||
if (packId) {
|
||||
const items = await dbAll<any>((db as any).select().from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).packId, packId)));
|
||||
if (items.length === 0) return c.json({ error: 'Template pack not found or empty' }, 404);
|
||||
templateIds = items.sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId);
|
||||
} else {
|
||||
templateIds = [templateId!];
|
||||
}
|
||||
const templates = await dbAll<any>((db as any).select().from(expenseTemplates).where(inArray((expenseTemplates as any).id, templateIds)));
|
||||
const byId = new Map(templates.filter((t: any) => !bool(t.archived)).map((t: any) => [t.id, t]));
|
||||
const ordered = templateIds.map((id) => byId.get(id)).filter(Boolean) as any[];
|
||||
if (ordered.length === 0) return c.json({ error: 'Template not found' }, 404);
|
||||
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const ctx = await liveContext(event.id, event);
|
||||
const ops: TxOp[] = [];
|
||||
const created: SerializedExpense[] = [];
|
||||
for (const t of ordered) {
|
||||
const row: any = {
|
||||
id: generateId(),
|
||||
eventId: event.id,
|
||||
categoryId: t.categoryId || null,
|
||||
templateId: t.id,
|
||||
description: t.name,
|
||||
calcType: t.calcType,
|
||||
quantity: 1,
|
||||
unitAmount: pyg(t.amount),
|
||||
percentBp: pyg(t.percentBp),
|
||||
minimumAmount: pyg(t.minimumAmount),
|
||||
computedAmount: 0,
|
||||
isLocked: toDbBool(false),
|
||||
status: 'planned',
|
||||
paidByPartnerId: null,
|
||||
receiptUrl: null,
|
||||
expenseDate: null,
|
||||
createdBy: user.id,
|
||||
updatedBy: user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const amounts = storedAmount(row, ctx);
|
||||
row.quantity = amounts.quantity;
|
||||
row.computedAmount = amounts.computedAmount;
|
||||
ops.push(insertOp(eventExpenses, row));
|
||||
const serialized = serializeExpense(row);
|
||||
created.push(serialized);
|
||||
ops.push(financeAuditOp({
|
||||
eventId: event.id, actorUserId: user.id, entityType: 'expense', entityId: row.id,
|
||||
action: packId ? 'apply_pack' : 'apply_template', after: { ...serialized, packId: packId ?? null },
|
||||
}));
|
||||
}
|
||||
await runOps(ops);
|
||||
return c.json({ expenses: created }, 201);
|
||||
});
|
||||
|
||||
// ==================== Other income ====================
|
||||
|
||||
const incomeSchema = z.object({ description: z.string().trim().min(1).max(300), amount: money });
|
||||
|
||||
financeRouter.post('/:id/other-income', requireEventPermission('edit_expenses'), zValidator('json', incomeSchema, validationHook), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, event.id);
|
||||
if (closed) return closed;
|
||||
const data = c.req.valid('json');
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const values = { id: generateId(), eventId: event.id, description: data.description, amount: data.amount, createdBy: user.id, createdAt: now, updatedAt: now };
|
||||
await runOps([
|
||||
insertOp(eventOtherIncome, values),
|
||||
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'other_income', entityId: values.id, action: 'create', after: serializeIncome(values) }),
|
||||
]);
|
||||
return c.json({ income: serializeIncome(values) }, 201);
|
||||
});
|
||||
|
||||
financeRouter.put('/:id/other-income/:incomeId', requireEventPermission('edit_expenses'), zValidator('json', incomeSchema.partial(), validationHook), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventOtherIncome)
|
||||
.where(and(eq((eventOtherIncome as any).id, c.req.param('incomeId')), eq((eventOtherIncome as any).eventId, eventId)))
|
||||
);
|
||||
if (!existing) return c.json({ error: 'Income not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||
if (data.description !== undefined) updates.description = data.description;
|
||||
if (data.amount !== undefined) updates.amount = data.amount;
|
||||
const user = currentUser(c);
|
||||
const after = serializeIncome({ ...existing, ...updates });
|
||||
await runOps([
|
||||
updateOp(eventOtherIncome, updates, eq((eventOtherIncome as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'other_income', entityId: existing.id, action: 'update', before: serializeIncome(existing), after }),
|
||||
]);
|
||||
return c.json({ income: after });
|
||||
});
|
||||
|
||||
financeRouter.delete('/:id/other-income/:incomeId', requireEventPermission('edit_expenses'), async (c) => {
|
||||
const eventId = c.req.param('id')!;
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(eventOtherIncome)
|
||||
.where(and(eq((eventOtherIncome as any).id, c.req.param('incomeId')), eq((eventOtherIncome as any).eventId, eventId)))
|
||||
);
|
||||
if (!existing) return c.json({ error: 'Income not found' }, 404);
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
deleteOp(eventOtherIncome, eq((eventOtherIncome as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'other_income', entityId: existing.id, action: 'delete', before: serializeIncome(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Income deleted' });
|
||||
});
|
||||
|
||||
// ==================== Partners ====================
|
||||
|
||||
const partnerFields = {
|
||||
userId: z.string().nullable().optional(),
|
||||
externalName: z.string().trim().max(200).nullable().optional(),
|
||||
roleLabel: z.string().trim().max(100).nullable().optional(),
|
||||
shareType: z.enum(SHARE_TYPES),
|
||||
percentBp: bp,
|
||||
fixedAmount: money,
|
||||
thresholdAmount: money,
|
||||
lossRule: z.enum(LOSS_RULES),
|
||||
lossCapAmount: money,
|
||||
};
|
||||
const createPartnerSchema = z.object({
|
||||
...partnerFields,
|
||||
percentBp: bp.default(0),
|
||||
fixedAmount: money.default(0),
|
||||
thresholdAmount: money.default(0),
|
||||
lossRule: partnerFields.lossRule.default('none'),
|
||||
lossCapAmount: money.default(0),
|
||||
}).refine((d) => !!d.userId || !!d.externalName, { message: 'A partner needs a linked user or a name' });
|
||||
const updatePartnerSchema = z.object(partnerFields).partial();
|
||||
|
||||
/** Editing the split needs both the expense editor and the full-split view. */
|
||||
function canManageSplit(c: Context) {
|
||||
return can(c, 'edit_expenses') && can(c, 'view_full_split');
|
||||
}
|
||||
const splitForbidden = (c: Context) =>
|
||||
c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: 'view_full_split' }, 403);
|
||||
|
||||
async function userExists(userId: string) {
|
||||
return !!(await dbGet<any>((db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).id, userId))));
|
||||
}
|
||||
|
||||
async function loadPartner(eventId: string, partnerId: string) {
|
||||
return dbGet<any>(
|
||||
(db as any).select().from(eventPartners)
|
||||
.where(and(eq((eventPartners as any).id, partnerId), eq((eventPartners as any).eventId, eventId)))
|
||||
);
|
||||
}
|
||||
|
||||
// Users that can be linked to a partner (so the partner can log in and see their statement).
|
||||
financeRouter.get('/:id/partners/candidates', requireEventPermission('edit_expenses'), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const q = (c.req.query('q') || '').trim().toLowerCase();
|
||||
if (q.length < 2) return c.json({ users: [] });
|
||||
return c.json({ users: await searchUsers(q, []) });
|
||||
});
|
||||
|
||||
financeRouter.post('/:id/partners', requireEventPermission('edit_expenses'), zValidator('json', createPartnerSchema, validationHook), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const closed = await assertOpen(c, event.id);
|
||||
if (closed) return closed;
|
||||
const data = c.req.valid('json');
|
||||
if (data.userId && !(await userExists(data.userId))) return c.json({ error: 'User not found' }, 400);
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const values = {
|
||||
id: generateId(),
|
||||
eventId: event.id,
|
||||
userId: data.userId || null,
|
||||
externalName: data.externalName || null,
|
||||
roleLabel: data.roleLabel || null,
|
||||
shareType: data.shareType,
|
||||
percentBp: data.percentBp,
|
||||
fixedAmount: data.fixedAmount,
|
||||
thresholdAmount: data.thresholdAmount,
|
||||
lossRule: data.lossRule,
|
||||
lossCapAmount: data.lossCapAmount,
|
||||
payoutStatus: 'pending',
|
||||
payoutDate: null,
|
||||
payoutMethod: null,
|
||||
payoutNote: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await runOps([
|
||||
insertOp(eventPartners, values),
|
||||
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'partner', entityId: values.id, action: 'create', after: serializePartner(values) }),
|
||||
]);
|
||||
const partner = (await loadPartners(event.id)).find((p) => p.id === values.id);
|
||||
return c.json({ partner }, 201);
|
||||
});
|
||||
|
||||
financeRouter.put('/:id/partners/:partnerId', requireEventPermission('edit_expenses'), zValidator('json', updatePartnerSchema, validationHook), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const eventId = c.req.param('id');
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
if (data.userId && !(await userExists(data.userId))) return c.json({ error: 'User not found' }, 400);
|
||||
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||
for (const [k, v] of Object.entries(data)) if (v !== undefined) updates[k] = v === '' ? null : v;
|
||||
const merged = { ...existing, ...updates };
|
||||
if (!merged.userId && !merged.externalName) return c.json({ error: 'A partner needs a linked user or a name' }, 400);
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
updateOp(eventPartners, updates, eq((eventPartners as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: 'update', before: serializePartner(existing), after: serializePartner(merged) }),
|
||||
]);
|
||||
const partner = (await loadPartners(eventId)).find((p) => p.id === existing.id);
|
||||
return c.json({ partner });
|
||||
});
|
||||
|
||||
financeRouter.delete('/:id/partners/:partnerId', requireEventPermission('edit_expenses'), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const eventId = c.req.param('id')!;
|
||||
const closed = await assertOpen(c, eventId);
|
||||
if (closed) return closed;
|
||||
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||
const fronted = await dbGet<any>(
|
||||
(db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).paidByPartnerId, existing.id))
|
||||
);
|
||||
if (fronted) {
|
||||
return c.json({ error: 'This partner paid for expenses. Change who paid those expenses first.', code: 'PARTNER_HAS_EXPENSES' }, 409);
|
||||
}
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
deleteOp(eventPartners, eq((eventPartners as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: 'delete', before: serializePartner(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Partner removed' });
|
||||
});
|
||||
|
||||
const markPaidSchema = z.object({
|
||||
paid: z.boolean().default(true),
|
||||
payoutDate: z.string().nullable().optional(),
|
||||
payoutMethod: z.string().trim().max(50).nullable().optional(),
|
||||
payoutNote: z.string().trim().max(1000).nullable().optional(),
|
||||
});
|
||||
|
||||
financeRouter.post('/:id/partners/:partnerId/mark-paid', requireEventPermission('edit_expenses'), zValidator('json', markPaidSchema, validationHook), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const eventId = c.req.param('id');
|
||||
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||
const state = await getFinanceState(eventId);
|
||||
if (state.status === 'open') {
|
||||
return c.json({ error: 'Finalize the event before recording payouts, so they match the frozen numbers.', code: 'FINANCE_NOT_FINALIZED' }, 409);
|
||||
}
|
||||
const data = c.req.valid('json');
|
||||
const now = getNow();
|
||||
const updates = data.paid
|
||||
? {
|
||||
payoutStatus: 'paid',
|
||||
payoutDate: data.payoutDate ? toDbDate(data.payoutDate) : now,
|
||||
payoutMethod: data.payoutMethod || null,
|
||||
payoutNote: data.payoutNote || null,
|
||||
updatedAt: now,
|
||||
}
|
||||
: { payoutStatus: 'pending', payoutDate: null, payoutMethod: null, payoutNote: data.payoutNote ?? existing.payoutNote ?? null, updatedAt: now };
|
||||
|
||||
const user = currentUser(c);
|
||||
const ops: TxOp[] = [
|
||||
updateOp(eventPartners, updates, eq((eventPartners as any).id, existing.id)),
|
||||
financeAuditOp({
|
||||
eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: data.paid ? 'mark_paid' : 'mark_unpaid',
|
||||
before: serializePartner(existing), after: serializePartner({ ...existing, ...updates }),
|
||||
}),
|
||||
];
|
||||
// The event is paid out once every partner is.
|
||||
const all = await loadPartners(eventId);
|
||||
const allPaid = all.every((p) => (p.id === existing.id ? data.paid : p.payoutStatus === 'paid'));
|
||||
const nextStatus = allPaid ? 'paid_out' : 'finalized';
|
||||
if (nextStatus !== state.status) {
|
||||
ops.push(updateOp(eventFinanceState, { status: nextStatus, updatedAt: now }, eq((eventFinanceState as any).eventId, eventId)));
|
||||
ops.push(financeAuditOp({ eventId, actorUserId: user.id, entityType: 'finance_state', entityId: eventId, action: nextStatus, before: { status: state.status }, after: { status: nextStatus } }));
|
||||
}
|
||||
await runOps(ops);
|
||||
const partner = (await loadPartners(eventId)).find((p) => p.id === existing.id);
|
||||
return c.json({ partner, status: nextStatus });
|
||||
});
|
||||
|
||||
financeRouter.get('/:id/partners/:partnerId/statement', requireEventPermission('view_finance'), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const fin = await getEventFinance(event.id, event);
|
||||
const partner = fin!.partners.find((p) => p.id === c.req.param('partnerId'));
|
||||
if (!partner) return c.json({ error: 'Partner not found' }, 404);
|
||||
if (!can(c, 'view_full_split') && partner.userId !== currentUser(c).id) {
|
||||
return c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: 'view_full_split' }, 403);
|
||||
}
|
||||
const localeParam = c.req.query('locale');
|
||||
const locale = localeParam === 'es' || (!localeParam && currentUser(c).languagePreference === 'es') ? 'es' : 'en';
|
||||
const amounts = new Map(fin!.result.expenses.lines.map((l) => [l.id, l.amount]));
|
||||
const pdf = await generatePartnerStatementPDF({
|
||||
locale,
|
||||
event: { title: (locale === 'es' && event.titleEs) || event.title, startDatetime: event.startDatetime, location: event.location },
|
||||
finalized: fin!.state.status !== 'open',
|
||||
result: fin!.result,
|
||||
partner,
|
||||
line: fin!.result.split.partners.find((p) => p.partnerId === partner.id),
|
||||
frontedExpenses: fin!.expenses.filter((e) => e.paidByPartnerId === partner.id && e.status === 'paid'),
|
||||
expenseAmounts: amounts,
|
||||
});
|
||||
const safeName = partner.name.replace(/[^a-zA-Z0-9-_]+/g, '-').replace(/^-+|-+$/g, '') || 'partner';
|
||||
const slug = (event.slug || event.id).replace(/[^a-zA-Z0-9-_]+/g, '-');
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="statement-${slug}-${safeName}.pdf"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Finalize ====================
|
||||
|
||||
financeRouter.post('/:id/finance/finalize', requireEventPermission('edit_expenses'), async (c) => {
|
||||
if (!canManageSplit(c)) return splitForbidden(c);
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const fin = await getEventFinance(event.id, event);
|
||||
if (fin!.state.status !== 'open') return c.json({ error: 'Already finalized', code: 'FINANCE_FINALIZED' }, 409);
|
||||
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const snapshot = { version: 1 as const, computedAt: new Date().toISOString(), result: fin!.result };
|
||||
const ops: TxOp[] = [];
|
||||
// Persist the amounts auto rows had at finalize, so the rows read the same as the snapshot.
|
||||
const lines = new Map(fin!.result.expenses.lines.map((l) => [l.id, l]));
|
||||
for (const e of fin!.expenses) {
|
||||
const line = lines.get(e.id);
|
||||
if (line && (line.amount !== e.computedAmount || line.quantity !== e.quantity)) {
|
||||
ops.push(updateOp(eventExpenses, { computedAmount: line.amount, quantity: line.quantity }, eq((eventExpenses as any).id, e.id)));
|
||||
}
|
||||
}
|
||||
const stateValues = { status: 'finalized', finalizedAt: now, finalizedBy: user.id, snapshotJson: JSON.stringify(snapshot), updatedAt: now };
|
||||
ops.push(fin!.state.exists
|
||||
? updateOp(eventFinanceState, stateValues, eq((eventFinanceState as any).eventId, event.id))
|
||||
: insertOp(eventFinanceState, { eventId: event.id, ...stateValues }));
|
||||
ops.push(financeAuditOp({
|
||||
eventId: event.id, actorUserId: user.id, entityType: 'finance_state', entityId: event.id, action: 'finalize',
|
||||
before: { status: 'open' }, after: { status: 'finalized', profit: snapshot.result.profit, split: snapshot.result.split },
|
||||
}));
|
||||
await runOps(ops);
|
||||
return c.json({ status: 'finalized', finalizedAt: iso(now) });
|
||||
});
|
||||
|
||||
financeRouter.post('/:id/finance/unfinalize', requireEventPermission('view_finance'), async (c) => {
|
||||
const access = getEventAccess(c)!;
|
||||
if (!canUnfinalize(access)) {
|
||||
return c.json({ error: 'Only admins and co-managers can unfinalize', code: 'EVENT_PERMISSION' }, 403);
|
||||
}
|
||||
const eventId = c.req.param('id')!;
|
||||
const state = await getFinanceState(eventId);
|
||||
if (state.status === 'open') return c.json({ error: 'Not finalized' }, 409);
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
await runOps([
|
||||
updateOp(eventFinanceState, { status: 'open', finalizedAt: null, finalizedBy: null, snapshotJson: null, updatedAt: now }, eq((eventFinanceState as any).eventId, eventId)),
|
||||
financeAuditOp({
|
||||
eventId, actorUserId: user.id, entityType: 'finance_state', entityId: eventId, action: 'unfinalize',
|
||||
before: { status: state.status, finalizedAt: state.finalizedAt, finalizedBy: state.finalizedBy, snapshot: state.snapshot },
|
||||
after: { status: 'open' },
|
||||
}),
|
||||
]);
|
||||
return c.json({ status: 'open' });
|
||||
});
|
||||
|
||||
// ==================== Audit log ====================
|
||||
|
||||
financeRouter.get('/:id/audit-log', requireEventPermission('view_full_split'), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const limit = Math.min(200, Math.max(1, parseInt(c.req.query('limit') || '100', 10) || 100));
|
||||
const offset = Math.max(0, parseInt(c.req.query('offset') || '0', 10) || 0);
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (financeAuditLog as any).id,
|
||||
actorUserId: (financeAuditLog as any).actorUserId,
|
||||
actorName: (users as any).name,
|
||||
entityType: (financeAuditLog as any).entityType,
|
||||
entityId: (financeAuditLog as any).entityId,
|
||||
action: (financeAuditLog as any).action,
|
||||
beforeJson: (financeAuditLog as any).beforeJson,
|
||||
afterJson: (financeAuditLog as any).afterJson,
|
||||
createdAt: (financeAuditLog as any).createdAt,
|
||||
})
|
||||
.from(financeAuditLog)
|
||||
.leftJoin(users, eq((financeAuditLog as any).actorUserId, (users as any).id))
|
||||
.where(eq((financeAuditLog as any).eventId, eventId))
|
||||
.orderBy(desc((financeAuditLog as any).createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
);
|
||||
const parse = (s: string | null) => { if (!s) return null; try { return JSON.parse(s); } catch { return null; } };
|
||||
return c.json({
|
||||
entries: rows.map((r: any) => ({
|
||||
id: r.id, actorUserId: r.actorUserId, actorName: r.actorName ?? null, entityType: r.entityType, entityId: r.entityId,
|
||||
action: r.action, before: parse(r.beforeJson), after: parse(r.afterJson), createdAt: iso(r.createdAt),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Team members ====================
|
||||
|
||||
const permissionOverrides = z.record(z.enum(EVENT_PERMISSIONS), z.boolean());
|
||||
const createMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
rolePreset: z.enum(ROLE_PRESETS),
|
||||
permissions: permissionOverrides.optional(),
|
||||
});
|
||||
const updateMemberSchema = z.object({
|
||||
rolePreset: z.enum(ROLE_PRESETS).optional(),
|
||||
permissions: permissionOverrides.optional(),
|
||||
});
|
||||
|
||||
function serializeMember(m: any, u: any) {
|
||||
const overrides = parseOverrides(m.permissions);
|
||||
return {
|
||||
id: m.id,
|
||||
eventId: m.eventId,
|
||||
userId: m.userId,
|
||||
name: u?.name ?? null,
|
||||
email: u?.email ?? null,
|
||||
globalRole: u?.role ?? null,
|
||||
rolePreset: m.rolePreset,
|
||||
permissions: overrides,
|
||||
effective: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(m.rolePreset, overrides).has(p)),
|
||||
createdAt: iso(m.createdAt),
|
||||
updatedAt: iso(m.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMember(eventId: string, memberId: string) {
|
||||
return dbGet<any>(
|
||||
(db as any).select().from(eventMembers)
|
||||
.where(and(eq((eventMembers as any).id, memberId), eq((eventMembers as any).eventId, eventId)))
|
||||
);
|
||||
}
|
||||
|
||||
async function loadUser(userId: string) {
|
||||
return dbGet<any>(
|
||||
(db as any).select({ id: (users as any).id, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||
.from(users).where(eq((users as any).id, userId))
|
||||
);
|
||||
}
|
||||
|
||||
financeRouter.get('/:id/members', requireEventPermission('manage_team'), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const rows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ m: eventMembers, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||
.from(eventMembers)
|
||||
.leftJoin(users, eq((eventMembers as any).userId, (users as any).id))
|
||||
.where(eq((eventMembers as any).eventId, eventId))
|
||||
);
|
||||
return c.json({
|
||||
members: rows
|
||||
.map((r: any) => serializeMember(r.m, { name: r.name, email: r.email, role: r.role }))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
|
||||
});
|
||||
});
|
||||
|
||||
/** Active users matching q by name or email, excluding some ids. */
|
||||
async function searchUsers(q: string, excludeIds: string[]) {
|
||||
const like = `%${q.replace(/[%_]/g, '')}%`;
|
||||
const conditions: any[] = [
|
||||
or(sql`lower(${(users as any).name}) like ${like}`, sql`lower(${(users as any).email}) like ${like}`),
|
||||
eq((users as any).accountStatus, 'active'),
|
||||
];
|
||||
if (excludeIds.length > 0) conditions.push(notInArray((users as any).id, excludeIds));
|
||||
return dbAll<any>(
|
||||
(db as any)
|
||||
.select({ id: (users as any).id, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||
.from(users)
|
||||
.where(and(...conditions))
|
||||
.limit(10)
|
||||
);
|
||||
}
|
||||
|
||||
financeRouter.get('/:id/members/candidates', requireEventPermission('manage_team'), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const q = (c.req.query('q') || '').trim().toLowerCase();
|
||||
if (q.length < 2) return c.json({ users: [] });
|
||||
const existing = await dbAll<any>(
|
||||
(db as any).select({ userId: (eventMembers as any).userId }).from(eventMembers).where(eq((eventMembers as any).eventId, eventId))
|
||||
);
|
||||
return c.json({ users: await searchUsers(q, existing.map((e: any) => e.userId)) });
|
||||
});
|
||||
|
||||
financeRouter.post('/:id/members', requireEventPermission('manage_team'), zValidator('json', createMemberSchema, validationHook), async (c) => {
|
||||
const event = await requireEvent(c);
|
||||
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
const target = await loadUser(data.userId);
|
||||
if (!target) return c.json({ error: 'User not found' }, 400);
|
||||
const dupe = await dbGet<any>(
|
||||
(db as any).select({ id: (eventMembers as any).id }).from(eventMembers)
|
||||
.where(and(eq((eventMembers as any).eventId, event.id), eq((eventMembers as any).userId, data.userId)))
|
||||
);
|
||||
if (dupe) return c.json({ error: 'This user is already on the team', code: 'ALREADY_MEMBER' }, 409);
|
||||
const user = currentUser(c);
|
||||
const now = getNow();
|
||||
const values = {
|
||||
id: generateId(),
|
||||
eventId: event.id,
|
||||
userId: data.userId,
|
||||
rolePreset: data.rolePreset,
|
||||
permissions: JSON.stringify(data.permissions || {}),
|
||||
createdBy: user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const member = serializeMember(values, target);
|
||||
await runOps([
|
||||
insertOp(eventMembers, values),
|
||||
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'member', entityId: values.id, action: 'create', after: member }),
|
||||
]);
|
||||
return c.json({ member }, 201);
|
||||
});
|
||||
|
||||
financeRouter.put('/:id/members/:memberId', requireEventPermission('manage_team'), zValidator('json', updateMemberSchema, validationHook), async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const existing = await loadMember(eventId, c.req.param('memberId'));
|
||||
if (!existing) return c.json({ error: 'Member not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||
if (data.rolePreset) updates.rolePreset = data.rolePreset;
|
||||
if (data.permissions) updates.permissions = JSON.stringify(data.permissions);
|
||||
const target = await loadUser(existing.userId);
|
||||
const before = serializeMember(existing, target);
|
||||
const after = serializeMember({ ...existing, ...updates }, target);
|
||||
const user = currentUser(c);
|
||||
await runOps([
|
||||
updateOp(eventMembers, updates, eq((eventMembers as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'member', entityId: existing.id, action: 'update', before, after }),
|
||||
]);
|
||||
return c.json({ member: after });
|
||||
});
|
||||
|
||||
financeRouter.delete('/:id/members/:memberId', requireEventPermission('manage_team'), async (c) => {
|
||||
const eventId = c.req.param('id')!;
|
||||
const existing = await loadMember(eventId, c.req.param('memberId'));
|
||||
if (!existing) return c.json({ error: 'Member not found' }, 404);
|
||||
const user = currentUser(c);
|
||||
const target = await loadUser(existing.userId);
|
||||
await runOps([
|
||||
deleteOp(eventMembers, eq((eventMembers as any).id, existing.id)),
|
||||
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'member', entityId: existing.id, action: 'delete', before: serializeMember(existing, target) }),
|
||||
]);
|
||||
return c.json({ message: 'Member removed' });
|
||||
});
|
||||
|
||||
export default financeRouter;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// Changing an event's ticket price must never rewrite what was already paid,
|
||||
// but anyone who booked and hasn't paid yet owes the new price. These tests pin
|
||||
// which payments follow a price change and which keep their amount.
|
||||
|
||||
// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres
|
||||
// URL to run the same suite in a throwaway schema (dropped afterwards).
|
||||
const PG_URL = process.env.FINANCE_TEST_PG_URL;
|
||||
const PG_SCHEMA = `pricetest_${Date.now()}`;
|
||||
if (PG_URL) {
|
||||
process.env.DB_TYPE = 'postgres';
|
||||
process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`;
|
||||
} else {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'events-price-test-'));
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = join(dir, 'test.db');
|
||||
}
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'events-price-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL;
|
||||
delete process.env.REVALIDATE_SECRET;
|
||||
|
||||
const ADMIN = { id: randomUUID(), name: 'The Admin', role: 'admin' };
|
||||
|
||||
vi.mock('../lib/auth.js', () => ({
|
||||
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||
if (roles && !roles.includes(ADMIN.role)) return c.json({ error: 'Forbidden' }, 403);
|
||||
c.set('user', ADMIN);
|
||||
await next();
|
||||
},
|
||||
getAuthUser: async () => ADMIN,
|
||||
}));
|
||||
|
||||
let app: any;
|
||||
let dbm: any;
|
||||
|
||||
const SEED_USER_ID = randomUUID();
|
||||
const EVENT_ID = randomUUID();
|
||||
const OTHER_EVENT_ID = randomUUID();
|
||||
const OLD_PRICE = 50000;
|
||||
const NEW_PRICE = 70000;
|
||||
// Ticket id by label, so assertions read by name.
|
||||
const ticketIds: Record<string, string> = {};
|
||||
|
||||
async function put(path: string, body: unknown) {
|
||||
const res = await app.request(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
/** One ticket with its payment row, the way a booking leaves them. Seeded through drizzle so it works on both engines. */
|
||||
async function seedBooking(label: string, eventId: string, paymentStatus: string, provider = 'bancard') {
|
||||
const { db, tickets, payments } = dbm;
|
||||
const now = dbm.getNow();
|
||||
const id = randomUUID();
|
||||
ticketIds[label] = id;
|
||||
const paid = paymentStatus === 'paid';
|
||||
await db.insert(tickets).values({
|
||||
id, userId: SEED_USER_ID, eventId, attendeeFirstName: label, status: paid ? 'confirmed' : 'pending',
|
||||
paymentStatus: paid ? 'paid' : 'unpaid', isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now,
|
||||
});
|
||||
await db.insert(payments).values({
|
||||
id: randomUUID(), ticketId: id, provider, amount: OLD_PRICE, currency: 'PYG',
|
||||
status: paymentStatus, paidAt: paid ? now : null, createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
async function amountOf(label: string): Promise<number> {
|
||||
const { db, dbGet, payments, eq } = dbm;
|
||||
const row = await dbGet(db.select({ amount: payments.amount }).from(payments).where(eq(payments.ticketId, ticketIds[label])));
|
||||
return Number(row.amount);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' });
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||
|
||||
return (async () => {
|
||||
const { Hono } = await import('hono');
|
||||
app = new Hono();
|
||||
app.route('/api/events', (await import('./events.js')).default);
|
||||
|
||||
dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')), ...(await import('drizzle-orm')) };
|
||||
const { db, users, events } = dbm;
|
||||
const now = dbm.getNow();
|
||||
await db.insert(users).values({
|
||||
id: SEED_USER_ID, email: 'seed@test.py', name: 'Seed', role: 'user',
|
||||
isClaimed: dbm.toDbBool(false), accountStatus: 'unclaimed', createdAt: now, updatedAt: now,
|
||||
});
|
||||
for (const id of [EVENT_ID, OTHER_EVENT_ID]) {
|
||||
await db.insert(events).values({
|
||||
id, title: `Event ${id}`, description: 'desc', startDatetime: now, location: 'Asuncion', price: OLD_PRICE,
|
||||
currency: 'PYG', capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await seedBooking('paid', EVENT_ID, 'paid');
|
||||
await seedBooking('refunded', EVENT_ID, 'refunded');
|
||||
await seedBooking('pending', EVENT_ID, 'pending');
|
||||
await seedBooking('pending-tpago', EVENT_ID, 'pending', 'tpago');
|
||||
await seedBooking('claimed', EVENT_ID, 'pending_approval', 'bank_transfer');
|
||||
await seedBooking('on-hold', EVENT_ID, 'on_hold');
|
||||
await seedBooking('lightning', EVENT_ID, 'pending', 'lightning');
|
||||
await seedBooking('other-event', OTHER_EVENT_ID, 'pending');
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' });
|
||||
});
|
||||
|
||||
describe('changing the ticket price', () => {
|
||||
it('leaves every payment alone when the price is not part of the edit', async () => {
|
||||
const { status } = await put(`/api/events/${EVENT_ID}`, { location: 'Encarnación' });
|
||||
expect(status).toBe(200);
|
||||
expect(await amountOf('pending')).toBe(OLD_PRICE);
|
||||
});
|
||||
|
||||
it('leaves every payment alone when the price is resent unchanged', async () => {
|
||||
const { status } = await put(`/api/events/${EVENT_ID}`, { price: OLD_PRICE, currency: 'PYG' });
|
||||
expect(status).toBe(200);
|
||||
expect(await amountOf('pending')).toBe(OLD_PRICE);
|
||||
});
|
||||
|
||||
it('reprices open payments and keeps what was already paid', async () => {
|
||||
const { status, body } = await put(`/api/events/${EVENT_ID}`, { price: NEW_PRICE });
|
||||
expect(status).toBe(200);
|
||||
expect(body.event.price).toBe(NEW_PRICE);
|
||||
|
||||
// Not yet paid: owe the current price.
|
||||
expect(await amountOf('pending')).toBe(NEW_PRICE);
|
||||
expect(await amountOf('pending-tpago')).toBe(NEW_PRICE);
|
||||
|
||||
// History stays as it happened.
|
||||
expect(await amountOf('paid')).toBe(OLD_PRICE);
|
||||
expect(await amountOf('refunded')).toBe(OLD_PRICE);
|
||||
// The customer already sent the old amount / an admin is reviewing it.
|
||||
expect(await amountOf('claimed')).toBe(OLD_PRICE);
|
||||
expect(await amountOf('on-hold')).toBe(OLD_PRICE);
|
||||
// The Lightning invoice was issued for a fixed amount.
|
||||
expect(await amountOf('lightning')).toBe(OLD_PRICE);
|
||||
// Other events are untouched.
|
||||
expect(await amountOf('other-event')).toBe(OLD_PRICE);
|
||||
});
|
||||
});
|
||||
+299
-118
@@ -2,11 +2,18 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
|
||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
||||
import { eq, ne, desc, and, gte, inArray, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { requireEventPermission, canSeeAttendeePii, redactAttendee } from '../lib/eventPermissions.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
import { resolvePresaleClosure } from '../lib/presale.js';
|
||||
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
|
||||
import { publicSalesFields } from '../lib/salesState.js';
|
||||
import { loadDoorMethods } from '../lib/doorPayments.js';
|
||||
import { runOps, updateOp, type TxOp } from '../lib/txOps.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -18,17 +25,52 @@ interface UserContext {
|
||||
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
// Helper to normalize event data for API response
|
||||
// PostgreSQL decimal returns strings, booleans are stored as integers
|
||||
function normalizeEvent(event: any) {
|
||||
// PostgreSQL decimal returns strings, booleans are stored as integers.
|
||||
// `settings` is the site_settings row; when given, the effective pre-sale
|
||||
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
|
||||
// booking API agree on when registration closes.
|
||||
// The walk-in (door) price is internal: it is dropped unless the caller is
|
||||
// admin/organizer/staff and `includeWalkInPrice` is set.
|
||||
function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?: boolean } = {}) {
|
||||
if (!event) return event;
|
||||
return {
|
||||
...event,
|
||||
const { walkInPrice, ...publicFields } = event;
|
||||
const normalized = {
|
||||
...publicFields,
|
||||
...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}),
|
||||
// Convert price from string/decimal to clean number
|
||||
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
||||
// Convert capacity from string to number if needed
|
||||
capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity),
|
||||
// Convert boolean integers to actual booleans for frontend
|
||||
externalBookingEnabled: Boolean(event.externalBookingEnabled),
|
||||
// Pre-sale overrides: null means "inherit the site default"
|
||||
presaleClosureEnabled: event.presaleClosureEnabled == null ? null : Boolean(event.presaleClosureEnabled),
|
||||
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore == null ? null : Number(event.presaleCloseMinutesBefore),
|
||||
};
|
||||
if (settings !== undefined) {
|
||||
const { closesAt } = resolvePresaleClosure(normalized, settings);
|
||||
return { ...normalized, presaleClosesAt: closesAt ? closesAt.toISOString() : null };
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Seat counts plus the public sales state for a normalized event. `raw` is the
|
||||
// DB row: doorPrice is resolved from its walk-in price, and only in the `door`
|
||||
// state (see lib/salesState.ts).
|
||||
function withSeatsAndSales(
|
||||
raw: any,
|
||||
normalized: any,
|
||||
settings: any,
|
||||
counts: { paid: number; claimed: number },
|
||||
nowMs: number = Date.now()
|
||||
) {
|
||||
const availableSeats = calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed);
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats,
|
||||
...publicSalesFields(raw, settings, availableSeats, nowMs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,6 +143,29 @@ const parsePrice = (val: unknown): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Walk-in price: empty/null means "not set" (fall back to price) and must stay
|
||||
// null, never 0 — 0 is a real value meaning a free walk-in. Unparseable input
|
||||
// fails validation instead of silently becoming 0 the way parsePrice does.
|
||||
const walkInPriceSchema = z.union([z.number(), z.string(), z.null()])
|
||||
.transform((val) => {
|
||||
if (val === null) return null;
|
||||
if (typeof val === 'number') return val;
|
||||
const trimmed = val.trim();
|
||||
if (trimmed === '') return null;
|
||||
return Number(trimmed.replace(',', '.'));
|
||||
})
|
||||
.pipe(z.number().min(0, 'Walk-in price cannot be negative').nullable())
|
||||
.optional();
|
||||
|
||||
// PYG has no minor unit, so a PYG walk-in price must be a whole number.
|
||||
function walkInPriceError(walkInPrice: number | null | undefined, currency: string | null | undefined): string | null {
|
||||
if (walkInPrice == null) return null;
|
||||
if ((currency || 'PYG') === 'PYG' && !Number.isInteger(walkInPrice)) {
|
||||
return 'walkInPrice: Walk-in price must be a whole number for PYG';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to normalize boolean (handles true/false and 0/1)
|
||||
const normalizeBoolean = (val: unknown): boolean => {
|
||||
if (typeof val === 'boolean') return val;
|
||||
@@ -124,16 +189,46 @@ const baseEventSchema = z.object({
|
||||
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
// Accept price as number or string (handles "45000" and "41,44" formats)
|
||||
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
||||
walkInPrice: walkInPriceSchema,
|
||||
// Groups recurring events for the finance overview ("" clears it)
|
||||
series: z.preprocess((v) => (typeof v === 'string' && v.trim() === '' ? null : v), z.string().trim().max(100).nullable()).optional(),
|
||||
currency: z.string().default('PYG'),
|
||||
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
||||
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||
// Accept relative paths (/uploads/...) or full URLs
|
||||
bannerUrl: z.string().optional().nullable().or(z.literal('')),
|
||||
// Accept relative paths (/uploads/...) or http(s) URLs only — reject schemes like
|
||||
// javascript:/data: that could be reflected into an href/src on the frontend.
|
||||
bannerUrl: z.string()
|
||||
.refine((v) => v === '' || v.startsWith('/') || /^https?:\/\//i.test(v), {
|
||||
message: 'Banner URL must be a relative path or an http(s) URL',
|
||||
})
|
||||
.optional().nullable().or(z.literal('')),
|
||||
// External booking support - accept boolean or number (0/1 from DB)
|
||||
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
||||
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||
// Pre-sale closure overrides - null/omitted means "inherit the site default"
|
||||
presaleClosureEnabled: z.union([z.boolean(), z.number(), z.null()])
|
||||
.transform((v) => (v === null ? null : normalizeBoolean(v)))
|
||||
.optional(),
|
||||
presaleCloseMinutesBefore: z.union([z.number(), z.string(), z.null()])
|
||||
.transform((v) => {
|
||||
if (v === null) return null;
|
||||
const n = typeof v === 'string' ? parseInt(v, 10) : v;
|
||||
return Number.isFinite(n) ? Math.floor(n) : NaN;
|
||||
})
|
||||
.pipe(z.number().int().min(0, 'Pre-sale closure time cannot be negative').nullable())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// When pre-sale closure is explicitly enabled on an event, the cutoff must be set too.
|
||||
const presaleRefine = {
|
||||
check: (data: { presaleClosureEnabled?: boolean | null; presaleCloseMinutesBefore?: number | null }) =>
|
||||
data.presaleClosureEnabled !== true || typeof data.presaleCloseMinutesBefore === 'number',
|
||||
options: {
|
||||
message: 'Pre-sale closure time is required when pre-sale closure is enabled',
|
||||
path: ['presaleCloseMinutesBefore'],
|
||||
},
|
||||
};
|
||||
|
||||
const createEventSchema = baseEventSchema.refine(
|
||||
(data) => {
|
||||
// If external booking is enabled, URL must be provided and must start with https://
|
||||
@@ -146,7 +241,7 @@ const createEventSchema = baseEventSchema.refine(
|
||||
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
|
||||
path: ['externalBookingUrl'],
|
||||
}
|
||||
);
|
||||
).refine(presaleRefine.check, presaleRefine.options);
|
||||
|
||||
const updateEventSchema = baseEventSchema.partial().refine(
|
||||
(data) => {
|
||||
@@ -160,59 +255,95 @@ const updateEventSchema = baseEventSchema.partial().refine(
|
||||
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
|
||||
path: ['externalBookingUrl'],
|
||||
}
|
||||
);
|
||||
).refine(presaleRefine.check, presaleRefine.options);
|
||||
|
||||
// Get all events (public)
|
||||
eventsRouter.get('/', async (c) => {
|
||||
const status = c.req.query('status');
|
||||
const upcoming = c.req.query('upcoming');
|
||||
// Pagination is opt-in: callers that pass neither page nor pageSize (public
|
||||
// pages, admin filter dropdowns) still get the full list.
|
||||
const pageParam = c.req.query('page');
|
||||
const pageSizeParam = c.req.query('pageSize');
|
||||
const paginated = pageParam !== undefined || pageSizeParam !== undefined;
|
||||
const page = Math.max(parseInt(pageParam || '1', 10) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(parseInt(pageSizeParam || '25', 10) || 25, 1), 200);
|
||||
|
||||
let query = (db as any).select().from(events);
|
||||
// Only privileged users may see non-public events (drafts, archived, etc.).
|
||||
// Anonymous/regular callers are restricted to published events regardless of
|
||||
// any client-supplied status filter, so drafts cannot leak.
|
||||
const authUser: any = await getAuthUser(c);
|
||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||
const includeWalkInPrice = canSeeWalkInPrice(authUser?.role);
|
||||
|
||||
if (status) {
|
||||
query = query.where(eq((events as any).status, status));
|
||||
}
|
||||
const conditions: any[] = [];
|
||||
|
||||
if (upcoming === 'true') {
|
||||
const now = getNow();
|
||||
query = query.where(
|
||||
and(
|
||||
eq((events as any).status, 'published'),
|
||||
gte((events as any).startDatetime, now)
|
||||
)
|
||||
);
|
||||
// Upcoming feed is always published + future-dated, for everyone.
|
||||
conditions.push(eq((events as any).status, 'published'));
|
||||
conditions.push(gte((events as any).startDatetime, getNow()));
|
||||
} else if (isPrivileged) {
|
||||
// Admins/staff may filter by any status (or list everything when unset).
|
||||
if (status) {
|
||||
conditions.push(eq((events as any).status, status));
|
||||
}
|
||||
} else {
|
||||
// Public listing: published events only, regardless of any status param.
|
||||
conditions.push(eq((events as any).status, 'published'));
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((events as any).startDatetime)));
|
||||
const whereClause = conditions.length === 0
|
||||
? undefined
|
||||
: conditions.length === 1 ? conditions[0] : and(...conditions);
|
||||
|
||||
// Get ticket counts for each event
|
||||
const eventsWithCounts = await Promise.all(
|
||||
result.map(async (event: any) => {
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
let query = (db as any).select().from(events);
|
||||
if (whereClause) query = query.where(whereClause);
|
||||
query = query.orderBy(desc((events as any).startDatetime));
|
||||
|
||||
let total: number | undefined;
|
||||
if (paginated) {
|
||||
let countQuery = (db as any).select({ count: sql`count(*)` }).from(events);
|
||||
if (whereClause) countQuery = countQuery.where(whereClause);
|
||||
const totalRow = await dbGet<any>(countQuery);
|
||||
total = Number(totalRow?.count || 0);
|
||||
query = query.limit(pageSize).offset((page - 1) * pageSize);
|
||||
}
|
||||
|
||||
const result = await dbAll<any>(query);
|
||||
|
||||
// Single grouped query for seat counts across all events (avoids N+1: previously
|
||||
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
||||
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
||||
// so availableSeats subtracts them together — the same formula the booking-creation
|
||||
// capacity check enforces (lib/capacity.ts).
|
||||
// Scoped to the returned events so a page of 25 does not scan every ticket.
|
||||
const eventIds = result.map((event: any) => event.id);
|
||||
const countRows = eventIds.length > 0
|
||||
? await dbAll<any>(eventSeatBreakdownQuery(db, eventIds))
|
||||
: [];
|
||||
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||
for (const row of countRows) {
|
||||
countByEvent.set(row.eventId, {
|
||||
paid: Number(row.paidCount) || 0,
|
||||
claimed: Number(row.claimedCount) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const nowMs = Date.now();
|
||||
const eventsWithCounts = result.map((event: any) =>
|
||||
withSeatsAndSales(
|
||||
event,
|
||||
normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }),
|
||||
siteSettingsRow,
|
||||
countByEvent.get(event.id) || { paid: 0, claimed: 0 },
|
||||
nowMs,
|
||||
)
|
||||
);
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
const bookedCount = ticketCount?.count || 0;
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json({ events: eventsWithCounts });
|
||||
return paginated
|
||||
? c.json({ events: eventsWithCounts, total, page, pageSize })
|
||||
: c.json({ events: eventsWithCounts });
|
||||
});
|
||||
|
||||
// Get single event (public) - resolves by id, canonical slug, or historical alias
|
||||
@@ -224,54 +355,91 @@ eventsRouter.get('/:id', async (c) => {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
const authUser: any = await getAuthUser(c);
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
const bookedCount = ticketCount?.count || 0;
|
||||
// Draft events are only visible to privileged users (admin preview); hide from public.
|
||||
if ((event as any).status === 'draft') {
|
||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||
if (!isPrivileged) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await getSiteSettingsRow();
|
||||
const normalized = normalizeEvent(event, settings, {
|
||||
includeWalkInPrice: canSeeWalkInPrice(authUser?.role),
|
||||
});
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
const publicEvent = withSeatsAndSales(event, normalized, settings, counts);
|
||||
// Door tenders (never the comp "guest" one) for the page's "pay at the door" line.
|
||||
const doorPaymentMethods = publicEvent.salesState === 'door'
|
||||
? (await loadDoorMethods(event.id)).filter((m) => m !== 'guest')
|
||||
: undefined;
|
||||
// serverTime lets the page schedule its refresh at presaleClosesAt even when
|
||||
// the visitor's clock is off.
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
},
|
||||
event: { ...publicEvent, ...(doorPaymentMethods ? { doorPaymentMethods } : {}) },
|
||||
serverTime: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
async function getSiteTimezone(): Promise<string> {
|
||||
// Single site_settings row (or null when none has been created yet)
|
||||
async function getSiteSettingsRow(): Promise<any | null> {
|
||||
const settings = await dbGet<any>(
|
||||
(db as any).select().from(siteSettings).limit(1)
|
||||
);
|
||||
return settings || null;
|
||||
}
|
||||
|
||||
function siteTimezoneOf(settings: any | null): string {
|
||||
return settings?.timezone || 'America/Asuncion';
|
||||
}
|
||||
|
||||
// Helper function to get ticket count for an event
|
||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
||||
const ticketCount = await dbGet<any>(
|
||||
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
|
||||
// counts for one event — see lib/capacity.ts for the seat-holding rule.
|
||||
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
|
||||
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
|
||||
return {
|
||||
paid: Number(row?.paidCount) || 0,
|
||||
claimed: Number(row?.claimedCount) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
||||
async function getNextChronologicalUpcoming(): Promise<any | null> {
|
||||
const now = getNow();
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
eq((events as any).status, 'published'),
|
||||
gte((events as any).startDatetime, now)
|
||||
)
|
||||
)
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(1)
|
||||
);
|
||||
return ticketCount?.count || 0;
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
const settings = await getSiteSettingsRow();
|
||||
return withSeatsAndSales(event, normalizeEvent(event, settings), settings, counts);
|
||||
}
|
||||
|
||||
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
|
||||
eventsRouter.get('/next', async (c) => {
|
||||
const event = await getNextChronologicalUpcoming();
|
||||
if (!event) {
|
||||
return c.json({ event: null });
|
||||
}
|
||||
return c.json({ event: { ...event, isFeatured: false } });
|
||||
});
|
||||
|
||||
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
|
||||
eventsRouter.get('/next/upcoming', async (c) => {
|
||||
const now = getNow();
|
||||
@@ -322,47 +490,23 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
|
||||
// If we have a valid featured event, return it
|
||||
if (featuredEvent) {
|
||||
const bookedCount = await getEventTicketCount(featuredEvent.id);
|
||||
const normalized = normalizeEvent(featuredEvent);
|
||||
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
...withSeatsAndSales(featuredEvent, normalizeEvent(featuredEvent, settings), settings, counts),
|
||||
isFeatured: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: get the next upcoming published event
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq((events as any).status, 'published'),
|
||||
gte((events as any).startDatetime, now)
|
||||
)
|
||||
)
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(1)
|
||||
);
|
||||
const event = await getNextChronologicalUpcoming();
|
||||
|
||||
if (!event) {
|
||||
return c.json({ event: null });
|
||||
}
|
||||
|
||||
const bookedCount = await getEventTicketCount(event.id);
|
||||
const normalized = normalizeEvent(event);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
isFeatured: false,
|
||||
},
|
||||
});
|
||||
return c.json({ event: { ...event, isFeatured: false } });
|
||||
});
|
||||
|
||||
// Create event (admin/organizer only)
|
||||
@@ -371,7 +515,11 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
const user = c.get('user');
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
const tz = await getSiteTimezone();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
|
||||
const walkInError = walkInPriceError(data.walkInPrice, data.currency);
|
||||
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||
|
||||
// Convert data for database compatibility
|
||||
const dbData = convertBooleansForDb(data);
|
||||
@@ -396,11 +544,11 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
||||
revalidateFrontendCache();
|
||||
|
||||
// Return normalized event data
|
||||
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
||||
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow, { includeWalkInPrice: true }) }, 201);
|
||||
});
|
||||
|
||||
// Update event (admin/organizer only)
|
||||
eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => {
|
||||
eventsRouter.put('/:id', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), zValidator('json', updateEventSchema, validationHook), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
@@ -411,8 +559,17 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
if (data.walkInPrice !== undefined || data.currency !== undefined) {
|
||||
const walkInError = walkInPriceError(
|
||||
data.walkInPrice !== undefined ? data.walkInPrice : parseWalkInPrice(existing.walkInPrice),
|
||||
data.currency ?? existing.currency,
|
||||
);
|
||||
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const tz = await getSiteTimezone();
|
||||
const siteSettingsRow = await getSiteSettingsRow();
|
||||
const tz = siteTimezoneOf(siteSettingsRow);
|
||||
// Convert data for database compatibility
|
||||
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
||||
// Slug changes are handled explicitly below to manage aliases
|
||||
@@ -459,10 +616,30 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
updateData.slug = newSlug;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(events)
|
||||
.set(updateData)
|
||||
.where(eq((events as any).id, id));
|
||||
const ops: TxOp[] = [updateOp(events, updateData, eq((events as any).id, id))];
|
||||
|
||||
// Anyone who booked but hasn't paid yet owes the current price, so open
|
||||
// payments follow a price/currency change. Settled payments keep what was
|
||||
// actually paid. Excluded: pending_approval (the customer already sent the
|
||||
// old amount), on_hold (under review) and Lightning (the invoice is fixed).
|
||||
const newPrice = data.price !== undefined ? data.price : Number(existing.price);
|
||||
const newCurrency = data.currency ?? existing.currency;
|
||||
if (newPrice !== Number(existing.price) || newCurrency !== existing.currency) {
|
||||
ops.push(updateOp(
|
||||
payments,
|
||||
{ amount: newPrice, currency: newCurrency, updatedAt: now },
|
||||
and(
|
||||
eq((payments as any).status, 'pending'),
|
||||
ne((payments as any).provider, 'lightning'),
|
||||
inArray(
|
||||
(payments as any).ticketId,
|
||||
(db as any).select({ id: (tickets as any).id }).from(tickets).where(eq((tickets as any).eventId, id))
|
||||
)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
await runOps(ops);
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||
@@ -471,7 +648,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
||||
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||
revalidateFrontendCache();
|
||||
|
||||
return c.json({ event: normalizeEvent(updated) });
|
||||
return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) });
|
||||
});
|
||||
|
||||
// Delete event (admin only)
|
||||
@@ -537,17 +714,17 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
});
|
||||
|
||||
// Get event attendees (admin/organizer only)
|
||||
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
||||
eventsRouter.get('/:id/attendees', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer', 'staff'] }), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const attendees = await dbAll(
|
||||
const attendees = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, id))
|
||||
);
|
||||
|
||||
return c.json({ attendees });
|
||||
return c.json({ attendees: canSeeAttendeePii(c) ? attendees : attendees.map(redactAttendee) });
|
||||
});
|
||||
|
||||
// Duplicate event (admin/organizer only)
|
||||
@@ -582,23 +759,27 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
||||
location: existing.location,
|
||||
locationUrl: existing.locationUrl,
|
||||
price: existing.price,
|
||||
walkInPrice: existing.walkInPrice ?? null,
|
||||
currency: existing.currency,
|
||||
capacity: existing.capacity,
|
||||
status: 'draft',
|
||||
bannerUrl: existing.bannerUrl,
|
||||
externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
|
||||
externalBookingUrl: existing.externalBookingUrl,
|
||||
presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null)
|
||||
presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null,
|
||||
series: existing.series ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(events).values(duplicatedEvent);
|
||||
|
||||
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
|
||||
return c.json({ event: normalizeEvent(duplicatedEvent, undefined, { includeWalkInPrice: true }), message: 'Event duplicated successfully' }, 201);
|
||||
});
|
||||
|
||||
// List slug aliases for an event (admin/organizer only)
|
||||
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
eventsRouter.get('/:id/slug-aliases', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await dbGet<any>(
|
||||
@@ -619,7 +800,7 @@ eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async
|
||||
});
|
||||
|
||||
// Remove a slug alias from an event (admin/organizer only)
|
||||
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
eventsRouter.delete('/:id/slug-aliases/:slug', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const slug = c.req.param('slug');
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// The walk-in price is internal: admins set it, door staff charge it, and no
|
||||
// public event response may carry it. These tests pin both halves.
|
||||
|
||||
// Env must be pinned before the db singleton is imported (dotenv never overrides).
|
||||
const dir = mkdtempSync(join(tmpdir(), 'events-walkin-test-'));
|
||||
const dbPath = join(dir, 'test.db');
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = dbPath;
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'events-test-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL;
|
||||
delete process.env.REVALIDATE_SECRET;
|
||||
|
||||
type TestUser = { id: string; name: string; role: string } | null;
|
||||
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
|
||||
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
|
||||
const MEMBER = { id: 'member-user-id', name: 'Member', role: 'user' };
|
||||
|
||||
// Anonymous by default, like the public site and its server-side fetches.
|
||||
let currentUser: TestUser = null;
|
||||
|
||||
vi.mock('../lib/auth.js', () => ({
|
||||
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||
if (!currentUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
|
||||
c.set('user', currentUser);
|
||||
await next();
|
||||
},
|
||||
getAuthUser: async () => currentUser,
|
||||
}));
|
||||
|
||||
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = currentUser;
|
||||
currentUser = user;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
currentUser = previous;
|
||||
}
|
||||
}
|
||||
|
||||
let app: any;
|
||||
let sqlite: any;
|
||||
|
||||
async function request(method: string, path: string, body?: unknown) {
|
||||
const res = await app.request(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
const baseEvent = {
|
||||
title: 'Walk-in Test',
|
||||
description: 'desc',
|
||||
startDatetime: '2099-01-10T20:00',
|
||||
location: 'Asuncion',
|
||||
price: 21000,
|
||||
currency: 'PYG',
|
||||
capacity: 40,
|
||||
status: 'published',
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||
|
||||
return (async () => {
|
||||
const { Hono } = await import('hono');
|
||||
const eventsRoutes = (await import('./events.js')).default;
|
||||
app = new Hono();
|
||||
app.route('/api/events', eventsRoutes);
|
||||
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
sqlite = new Database(dbPath);
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
describe('admin event form: walk-in price', () => {
|
||||
it('saves an empty walk-in price as null, not 0', async () => {
|
||||
const { status, body } = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Empty walk-in', walkInPrice: '' })
|
||||
);
|
||||
expect(status).toBe(201);
|
||||
expect(body.event.walkInPrice).toBeNull();
|
||||
expect(sqlite.prepare('SELECT walk_in_price FROM events WHERE id = ?').get(body.event.id).walk_in_price).toBeNull();
|
||||
});
|
||||
|
||||
it('saves 0 as a free walk-in and a number as-is', async () => {
|
||||
const free = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Free walk-in', walkInPrice: 0 })
|
||||
);
|
||||
expect(free.body.event.walkInPrice).toBe(0);
|
||||
|
||||
const priced = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Priced walk-in', walkInPrice: '25000' })
|
||||
);
|
||||
expect(priced.body.event.walkInPrice).toBe(25000);
|
||||
});
|
||||
|
||||
it('rejects negative, non-numeric and fractional PYG walk-in prices', async () => {
|
||||
for (const walkInPrice of [-1, 'abc', 25000.5]) {
|
||||
const { status } = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Bad walk-in', walkInPrice })
|
||||
);
|
||||
expect(status, `walkInPrice ${walkInPrice}`).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates and clears the walk-in price, and duplicates carry it over', async () => {
|
||||
const created = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Editable walk-in', walkInPrice: 25000 })
|
||||
);
|
||||
const id = created.body.event.id;
|
||||
|
||||
const updated = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 30000 }));
|
||||
expect(updated.body.event.walkInPrice).toBe(30000);
|
||||
|
||||
const copy = await as(ADMIN, () => request('POST', `/api/events/${id}/duplicate`));
|
||||
expect(copy.status).toBe(201);
|
||||
expect(copy.body.event.walkInPrice).toBe(30000);
|
||||
|
||||
const cleared = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: null }));
|
||||
expect(cleared.body.event.walkInPrice).toBeNull();
|
||||
|
||||
// Omitting the field leaves it untouched.
|
||||
await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 0 }));
|
||||
const untouched = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { capacity: 45 }));
|
||||
expect(untouched.body.event.walkInPrice).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public event responses', () => {
|
||||
let slug: string;
|
||||
let id: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { body } = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, title: 'Secret Door Price', walkInPrice: 25000 })
|
||||
);
|
||||
slug = body.event.slug;
|
||||
id = body.event.id;
|
||||
});
|
||||
|
||||
it('never include the walk-in price for anonymous or regular users', async () => {
|
||||
for (const user of [null, MEMBER]) {
|
||||
await as(user, async () => {
|
||||
const single = await request('GET', `/api/events/${slug}`);
|
||||
expect(single.status).toBe(200);
|
||||
expect(single.body.event).not.toHaveProperty('walkInPrice');
|
||||
expect(single.body.event.price).toBe(21000);
|
||||
|
||||
const list = await request('GET', '/api/events');
|
||||
const listed = list.body.events.find((e: any) => e.id === id);
|
||||
expect(listed).toBeTruthy();
|
||||
expect(listed).not.toHaveProperty('walkInPrice');
|
||||
|
||||
const next = await request('GET', '/api/events/next/upcoming');
|
||||
expect(next.body.event).not.toHaveProperty('walkInPrice');
|
||||
|
||||
// Belt and braces: the value must not appear anywhere in the payload.
|
||||
expect(JSON.stringify(single.body)).not.toContain('25000');
|
||||
expect(JSON.stringify(list.body)).not.toContain('25000');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('includes it for admin and door staff', async () => {
|
||||
for (const user of [ADMIN, STAFF]) {
|
||||
const single = await as(user, () => request('GET', `/api/events/${id}`));
|
||||
expect(single.body.event.walkInPrice).toBe(25000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('public sales state and door price', () => {
|
||||
const hours = (n: number) => new Date(Date.now() + n * 3_600_000).toISOString();
|
||||
|
||||
async function createEvent(fields: Record<string, unknown>) {
|
||||
const { status, body } = await as(ADMIN, () =>
|
||||
request('POST', '/api/events', { ...baseEvent, ...fields })
|
||||
);
|
||||
expect(status).toBe(201);
|
||||
return body.event as { id: string; slug: string };
|
||||
}
|
||||
|
||||
it('adds doorPrice only in the door state and never exposes the raw walk-in price', async () => {
|
||||
// Starts in 1h, pre-sale closed 2h before start (site default): door state.
|
||||
const door = await createEvent({
|
||||
title: 'Door State', walkInPrice: 31000, startDatetime: hours(1), endDatetime: hours(4),
|
||||
});
|
||||
// Starts in 3 days: still online.
|
||||
const online = await createEvent({
|
||||
title: 'Online State', walkInPrice: 31000, startDatetime: hours(72), endDatetime: hours(75),
|
||||
});
|
||||
// Already over.
|
||||
const ended = await createEvent({
|
||||
title: 'Ended State', walkInPrice: 31000, startDatetime: hours(-4), endDatetime: hours(-1),
|
||||
});
|
||||
|
||||
for (const user of [null, MEMBER]) {
|
||||
await as(user, async () => {
|
||||
const single = await request('GET', `/api/events/${door.slug}`);
|
||||
expect(single.body.event.salesState).toBe('door');
|
||||
expect(single.body.event.doorPrice).toBe(31000);
|
||||
expect(single.body.event.presaleClosesAt).toEqual(expect.any(String));
|
||||
expect(single.body.event.availableSeats).toBe(40);
|
||||
expect(single.body.event.doorPaymentMethods).toEqual(['cash', 'bitcoin', 'transfer', 'pos']);
|
||||
expect(single.body.event).not.toHaveProperty('walkInPrice');
|
||||
expect(typeof single.body.serverTime).toBe('string');
|
||||
|
||||
for (const other of [online, ended]) {
|
||||
const res = await request('GET', `/api/events/${other.slug}`);
|
||||
expect(res.body.event.salesState).toBe(other === online ? 'online' : 'ended');
|
||||
expect(res.body.event).not.toHaveProperty('doorPrice');
|
||||
expect(res.body.event).not.toHaveProperty('doorPaymentMethods');
|
||||
expect(res.body.event).not.toHaveProperty('walkInPrice');
|
||||
expect(JSON.stringify(res.body)).not.toContain('31000');
|
||||
}
|
||||
|
||||
const list = await request('GET', '/api/events');
|
||||
const byId = new Map(list.body.events.map((e: any) => [e.id, e]));
|
||||
expect((byId.get(door.id) as any).salesState).toBe('door');
|
||||
expect((byId.get(door.id) as any).doorPrice).toBe(31000);
|
||||
expect((byId.get(online.id) as any)).not.toHaveProperty('doorPrice');
|
||||
for (const e of list.body.events) expect(e).not.toHaveProperty('walkInPrice');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the ticket price when no walk-in price is set', async () => {
|
||||
const door = await createEvent({
|
||||
title: 'Door Fallback', walkInPrice: null, startDatetime: hours(1), endDatetime: hours(4),
|
||||
});
|
||||
const res = await request('GET', `/api/events/${door.slug}`);
|
||||
expect(res.body.event.salesState).toBe('door');
|
||||
expect(res.body.event.doorPrice).toBe(21000);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,22 @@ import { getNow, generateId } from '../lib/utils.js';
|
||||
|
||||
const faqRouter = new Hono();
|
||||
|
||||
// Upper bounds for admin-supplied FAQ content (guards against accidental/abusive huge payloads)
|
||||
const MAX_QUESTION_LEN = 1000;
|
||||
const MAX_ANSWER_LEN = 20000;
|
||||
|
||||
// Returns an error message if any provided field exceeds its limit, else null.
|
||||
function faqLengthError(fields: { question?: any; questionEs?: any; answer?: any; answerEs?: any }): string | null {
|
||||
const check = (v: any, max: number, label: string) =>
|
||||
typeof v === 'string' && v.length > max ? `${label} must be at most ${max} characters` : null;
|
||||
return (
|
||||
check(fields.question, MAX_QUESTION_LEN, 'Question') ||
|
||||
check(fields.questionEs, MAX_QUESTION_LEN, 'Question (ES)') ||
|
||||
check(fields.answer, MAX_ANSWER_LEN, 'Answer') ||
|
||||
check(fields.answerEs, MAX_ANSWER_LEN, 'Answer (ES)')
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Public Routes ====================
|
||||
|
||||
// Get FAQ list for public (only enabled; optional filter for homepage)
|
||||
@@ -98,6 +114,11 @@ faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
|
||||
return c.json({ error: 'Question and answer (EN) are required' }, 400);
|
||||
}
|
||||
|
||||
const lengthError = faqLengthError({ question, questionEs, answer, answerEs });
|
||||
if (lengthError) {
|
||||
return c.json({ error: lengthError }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
|
||||
@@ -157,6 +178,11 @@ faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
|
||||
return c.json({ error: 'FAQ not found' }, 404);
|
||||
}
|
||||
|
||||
const lengthError = faqLengthError({ question, questionEs, answer, answerEs });
|
||||
if (lengthError) {
|
||||
return c.json({ error: lengthError }, 400);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: getNow(),
|
||||
};
|
||||
@@ -209,6 +235,15 @@ faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
|
||||
return c.json({ error: 'ids array is required' }, 400);
|
||||
}
|
||||
|
||||
// Verify every id exists before applying ranks (prevents silent no-ops on bad input).
|
||||
const existingRows = await dbAll<any>(
|
||||
(db as any).select({ id: (faqQuestions as any).id }).from(faqQuestions)
|
||||
);
|
||||
const existingIds = new Set(existingRows.map((r: any) => r.id));
|
||||
if (ids.some((id: string) => !existingIds.has(id))) {
|
||||
return c.json({ error: 'One or more FAQ ids are invalid' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await (db as any)
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
// Global finance: expense categories, templates, template packs, payment
|
||||
// method fees (Site Settings → Expense Templates) and the cross-event overview.
|
||||
// Mounted at /api/finance.
|
||||
//
|
||||
// Writes are admin only. The read endpoints for categories/templates/packs are
|
||||
// also open to members who can add expenses on the event given as ?eventId=,
|
||||
// so the "Apply template" pickers work for them.
|
||||
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
|
||||
import {
|
||||
db, dbAll, dbGet, events, expenseCategories, expenseTemplates, expenseTemplatePacks, expenseTemplatePackItems,
|
||||
eventExpenses, paymentMethodFees,
|
||||
} from '../db/index.js';
|
||||
import { requireAuth, type AuthUser } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromQuery } from '../lib/eventPermissions.js';
|
||||
import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js';
|
||||
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||
import { financeAuditOp } from '../lib/finance/audit.js';
|
||||
import { getEventFinance, iso, pyg, bool, loadFeeRules } from '../lib/finance/load.js';
|
||||
import { CALC_TYPES } from '../lib/finance/calculate.js';
|
||||
|
||||
const financeGlobalRouter = new Hono();
|
||||
|
||||
const validationHook = (result: any, c: any) => {
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
|
||||
return c.json({ error: errors }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const money = z.number().int().min(0).max(2_000_000_000);
|
||||
const bp = z.number().int().min(0).max(10000);
|
||||
const currentUser = (c: Context) => (c as any).get('user') as AuthUser;
|
||||
const ADMIN = requireAuth(['admin']);
|
||||
const TEMPLATE_READERS = requireEventPermission(['edit_expenses', 'edit_own_expenses_only'], { eventId: eventFromQuery() });
|
||||
|
||||
// ==================== Categories ====================
|
||||
|
||||
const serializeCategory = (r: any) => ({
|
||||
id: r.id, nameEn: r.nameEn, nameEs: r.nameEs, color: r.color, sortOrder: pyg(r.sortOrder), archived: bool(r.archived),
|
||||
createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
|
||||
});
|
||||
|
||||
const categorySchema = z.object({
|
||||
nameEn: z.string().trim().min(1).max(100),
|
||||
nameEs: z.string().trim().min(1).max(100),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#6B7280'),
|
||||
sortOrder: z.number().int().min(0).max(10000).default(0),
|
||||
archived: z.boolean().default(false),
|
||||
});
|
||||
|
||||
financeGlobalRouter.get('/settings/expense-categories', TEMPLATE_READERS, async (c) => {
|
||||
const rows = await dbAll<any>((db as any).select().from(expenseCategories));
|
||||
return c.json({ categories: rows.map(serializeCategory).sort((a, b) => a.sortOrder - b.sortOrder) });
|
||||
});
|
||||
|
||||
financeGlobalRouter.post('/settings/expense-categories', ADMIN, zValidator('json', categorySchema, validationHook), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
const now = getNow();
|
||||
const values = { id: generateId(), ...data, archived: toDbBool(data.archived), createdAt: now, updatedAt: now };
|
||||
await runOps([
|
||||
insertOp(expenseCategories, values),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: values.id, action: 'create', after: serializeCategory(values) }),
|
||||
]);
|
||||
return c.json({ category: serializeCategory(values) }, 201);
|
||||
});
|
||||
|
||||
financeGlobalRouter.put('/settings/expense-categories/:id', ADMIN, zValidator('json', categorySchema.partial(), validationHook), async (c) => {
|
||||
const existing = await dbGet<any>((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, c.req.param('id'))));
|
||||
if (!existing) return c.json({ error: 'Category not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
const updates: Record<string, any> = { ...data, updatedAt: getNow() };
|
||||
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||
const after = serializeCategory({ ...existing, ...updates });
|
||||
await runOps([
|
||||
updateOp(expenseCategories, updates, eq((expenseCategories as any).id, existing.id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: existing.id, action: 'update', before: serializeCategory(existing), after }),
|
||||
]);
|
||||
return c.json({ category: after });
|
||||
});
|
||||
|
||||
financeGlobalRouter.delete('/settings/expense-categories/:id', ADMIN, async (c) => {
|
||||
const id = c.req.param('id')!;
|
||||
const existing = await dbGet<any>((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, id)));
|
||||
if (!existing) return c.json({ error: 'Category not found' }, 404);
|
||||
const [usedByExpense, usedByTemplate] = await Promise.all([
|
||||
dbGet<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).categoryId, id))),
|
||||
dbGet<any>((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(eq((expenseTemplates as any).categoryId, id))),
|
||||
]);
|
||||
if (usedByExpense || usedByTemplate) {
|
||||
return c.json({ error: 'This category is in use. Archive it instead.', code: 'IN_USE' }, 409);
|
||||
}
|
||||
await runOps([
|
||||
deleteOp(expenseCategories, eq((expenseCategories as any).id, id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: id, action: 'delete', before: serializeCategory(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Category deleted' });
|
||||
});
|
||||
|
||||
// ==================== Templates ====================
|
||||
|
||||
const serializeTemplate = (r: any) => ({
|
||||
id: r.id, name: r.name, categoryId: r.categoryId ?? null, description: r.description ?? null, calcType: r.calcType,
|
||||
amount: pyg(r.amount), percentBp: pyg(r.percentBp), minimumAmount: pyg(r.minimumAmount), archived: bool(r.archived),
|
||||
createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
|
||||
});
|
||||
|
||||
const templateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
categoryId: z.string().nullable().optional(),
|
||||
description: z.string().trim().max(1000).nullable().optional(),
|
||||
calcType: z.enum(CALC_TYPES),
|
||||
amount: money.default(0),
|
||||
percentBp: bp.default(0),
|
||||
minimumAmount: money.default(0),
|
||||
archived: z.boolean().default(false),
|
||||
});
|
||||
|
||||
async function validCategory(categoryId: string | null | undefined) {
|
||||
if (!categoryId) return true;
|
||||
return !!(await dbGet<any>((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId))));
|
||||
}
|
||||
|
||||
financeGlobalRouter.get('/settings/expense-templates', TEMPLATE_READERS, async (c) => {
|
||||
const rows = await dbAll<any>((db as any).select().from(expenseTemplates));
|
||||
return c.json({ templates: rows.map(serializeTemplate).sort((a, b) => a.name.localeCompare(b.name)) });
|
||||
});
|
||||
|
||||
financeGlobalRouter.post('/settings/expense-templates', ADMIN, zValidator('json', templateSchema, validationHook), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400);
|
||||
const now = getNow();
|
||||
const values = {
|
||||
id: generateId(), ...data, categoryId: data.categoryId || null, description: data.description || null,
|
||||
archived: toDbBool(data.archived), createdAt: now, updatedAt: now,
|
||||
};
|
||||
await runOps([
|
||||
insertOp(expenseTemplates, values),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: values.id, action: 'create', after: serializeTemplate(values) }),
|
||||
]);
|
||||
return c.json({ template: serializeTemplate(values) }, 201);
|
||||
});
|
||||
|
||||
financeGlobalRouter.put('/settings/expense-templates/:id', ADMIN, zValidator('json', templateSchema.partial(), validationHook), async (c) => {
|
||||
const existing = await dbGet<any>((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, c.req.param('id'))));
|
||||
if (!existing) return c.json({ error: 'Template not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400);
|
||||
const updates: Record<string, any> = { ...data, updatedAt: getNow() };
|
||||
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||
if (data.categoryId === '') updates.categoryId = null;
|
||||
const after = serializeTemplate({ ...existing, ...updates });
|
||||
await runOps([
|
||||
updateOp(expenseTemplates, updates, eq((expenseTemplates as any).id, existing.id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: existing.id, action: 'update', before: serializeTemplate(existing), after }),
|
||||
]);
|
||||
return c.json({ template: after });
|
||||
});
|
||||
|
||||
financeGlobalRouter.delete('/settings/expense-templates/:id', ADMIN, async (c) => {
|
||||
const id = c.req.param('id')!;
|
||||
const existing = await dbGet<any>((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, id)));
|
||||
if (!existing) return c.json({ error: 'Template not found' }, 404);
|
||||
const [usedByExpense, usedByPack] = await Promise.all([
|
||||
dbGet<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).templateId, id))),
|
||||
dbGet<any>((db as any).select({ id: (expenseTemplatePackItems as any).id }).from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).templateId, id))),
|
||||
]);
|
||||
if (usedByExpense || usedByPack) {
|
||||
return c.json({ error: 'This template has been used or is in a pack. Archive it instead.', code: 'IN_USE' }, 409);
|
||||
}
|
||||
await runOps([
|
||||
deleteOp(expenseTemplates, eq((expenseTemplates as any).id, id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: id, action: 'delete', before: serializeTemplate(existing) }),
|
||||
]);
|
||||
return c.json({ message: 'Template deleted' });
|
||||
});
|
||||
|
||||
// ==================== Template packs ====================
|
||||
|
||||
const packSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(1000).nullable().optional(),
|
||||
archived: z.boolean().default(false),
|
||||
templateIds: z.array(z.string()).max(50).default([]),
|
||||
});
|
||||
|
||||
async function loadPacks() {
|
||||
const [packs, items] = await Promise.all([
|
||||
dbAll<any>((db as any).select().from(expenseTemplatePacks)),
|
||||
dbAll<any>((db as any).select().from(expenseTemplatePackItems)),
|
||||
]);
|
||||
return packs
|
||||
.map((p: any) => ({
|
||||
id: p.id, name: p.name, description: p.description ?? null, archived: bool(p.archived),
|
||||
templateIds: items.filter((i: any) => i.packId === p.id).sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId),
|
||||
createdAt: iso(p.createdAt), updatedAt: iso(p.updatedAt),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async function validTemplates(ids: string[]) {
|
||||
if (ids.length === 0) return true;
|
||||
const rows = await dbAll<any>((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(inArray((expenseTemplates as any).id, ids)));
|
||||
return rows.length === new Set(ids).size;
|
||||
}
|
||||
|
||||
const itemOps = (packId: string, templateIds: string[]): TxOp[] =>
|
||||
[...new Set(templateIds)].map((templateId, i) => insertOp(expenseTemplatePackItems, { id: generateId(), packId, templateId, sortOrder: i }));
|
||||
|
||||
financeGlobalRouter.get('/settings/expense-template-packs', TEMPLATE_READERS, async (c) => {
|
||||
return c.json({ packs: await loadPacks() });
|
||||
});
|
||||
|
||||
financeGlobalRouter.post('/settings/expense-template-packs', ADMIN, zValidator('json', packSchema, validationHook), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
if (!(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400);
|
||||
const now = getNow();
|
||||
const values = { id: generateId(), name: data.name, description: data.description || null, archived: toDbBool(data.archived), createdAt: now, updatedAt: now };
|
||||
await runOps([
|
||||
insertOp(expenseTemplatePacks, values),
|
||||
...itemOps(values.id, data.templateIds),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: values.id, action: 'create', after: { ...data } }),
|
||||
]);
|
||||
const pack = (await loadPacks()).find((p) => p.id === values.id);
|
||||
return c.json({ pack }, 201);
|
||||
});
|
||||
|
||||
financeGlobalRouter.put('/settings/expense-template-packs/:id', ADMIN, zValidator('json', packSchema.partial(), validationHook), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const before = (await loadPacks()).find((p) => p.id === id);
|
||||
if (!before) return c.json({ error: 'Pack not found' }, 404);
|
||||
const data = c.req.valid('json');
|
||||
if (data.templateIds && !(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400);
|
||||
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||
if (data.name !== undefined) updates.name = data.name;
|
||||
if (data.description !== undefined) updates.description = data.description || null;
|
||||
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||
const ops: TxOp[] = [updateOp(expenseTemplatePacks, updates, eq((expenseTemplatePacks as any).id, id))];
|
||||
if (data.templateIds) {
|
||||
ops.push(deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)));
|
||||
ops.push(...itemOps(id, data.templateIds));
|
||||
}
|
||||
ops.push(financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'update', before, after: { ...before, ...data } }));
|
||||
await runOps(ops);
|
||||
const pack = (await loadPacks()).find((p) => p.id === id);
|
||||
return c.json({ pack });
|
||||
});
|
||||
|
||||
financeGlobalRouter.delete('/settings/expense-template-packs/:id', ADMIN, async (c) => {
|
||||
const id = c.req.param('id')!;
|
||||
const before = (await loadPacks()).find((p) => p.id === id);
|
||||
if (!before) return c.json({ error: 'Pack not found' }, 404);
|
||||
await runOps([
|
||||
deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)),
|
||||
deleteOp(expenseTemplatePacks, eq((expenseTemplatePacks as any).id, id)),
|
||||
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'delete', before }),
|
||||
]);
|
||||
return c.json({ message: 'Pack deleted' });
|
||||
});
|
||||
|
||||
// ==================== Payment method fees ====================
|
||||
|
||||
financeGlobalRouter.get('/settings/payment-fees', ADMIN, async (c) => {
|
||||
return c.json({ fees: await loadFeeRules() });
|
||||
});
|
||||
|
||||
const feeSchema = z.object({ percentBp: bp, fixedAmount: money });
|
||||
const FEE_METHODS = ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos', 'bancard'] as const;
|
||||
|
||||
financeGlobalRouter.put('/settings/payment-fees/:method', ADMIN, zValidator('json', feeSchema, validationHook), async (c) => {
|
||||
const method = c.req.param('method');
|
||||
if (!(FEE_METHODS as readonly string[]).includes(method)) return c.json({ error: 'Unknown payment method' }, 400);
|
||||
const data = c.req.valid('json');
|
||||
const existing = await dbGet<any>((db as any).select().from(paymentMethodFees).where(eq((paymentMethodFees as any).method, method)));
|
||||
const user = currentUser(c);
|
||||
const values = { percentBp: data.percentBp, fixedAmount: data.fixedAmount, updatedAt: getNow(), updatedBy: user.id };
|
||||
await runOps([
|
||||
existing
|
||||
? updateOp(paymentMethodFees, values, eq((paymentMethodFees as any).method, method))
|
||||
: insertOp(paymentMethodFees, { method, ...values }),
|
||||
financeAuditOp({
|
||||
eventId: null, actorUserId: user.id, entityType: 'payment_fee', entityId: method, action: existing ? 'update' : 'create',
|
||||
before: existing ? { percentBp: pyg(existing.percentBp), fixedAmount: pyg(existing.fixedAmount) } : null, after: data,
|
||||
}),
|
||||
]);
|
||||
return c.json({ fee: { method, ...data } });
|
||||
});
|
||||
|
||||
// ==================== Cross-event overview ====================
|
||||
|
||||
type Totals = { events: number; gross: number; fees: number; net: number; expenses: number; profit: number };
|
||||
const emptyTotals = (): Totals => ({ events: 0, gross: 0, fees: 0, net: 0, expenses: 0, profit: 0 });
|
||||
const addTo = (t: Totals, row: Omit<Totals, 'events'>) => {
|
||||
t.events += 1; t.gross += row.gross; t.fees += row.fees; t.net += row.net; t.expenses += row.expenses; t.profit += row.profit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Profit per event, per series, per venue and payouts per partner. Finalized
|
||||
* events use their snapshot. `readyToClose` lists past events whose books are
|
||||
* still open (with or without any money recorded), longest-waiting first. Filters: from / to (ISO dates on the event start),
|
||||
* series, venue (exact location text), partner (user id or external name).
|
||||
*/
|
||||
financeGlobalRouter.get('/overview', ADMIN, async (c) => {
|
||||
const isDate = (v?: string) => (v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined);
|
||||
const from = isDate(c.req.query('from'));
|
||||
const to = isDate(c.req.query('to'));
|
||||
const seriesFilter = c.req.query('series');
|
||||
const venueFilter = c.req.query('venue');
|
||||
const partnerFilter = c.req.query('partner');
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (from) conditions.push(gte((events as any).startDatetime, toDbDate(from)));
|
||||
if (to) conditions.push(lte((events as any).startDatetime, toDbDate(`${to}T23:59:59.999Z`)));
|
||||
const allEvents = await dbAll<any>(
|
||||
(db as any).select().from(events).where(conditions.length ? and(...conditions) : undefined)
|
||||
);
|
||||
|
||||
const rows: any[] = [];
|
||||
const seriesOptions = new Set<string>();
|
||||
const venueOptions = new Set<string>();
|
||||
const partnerOptions = new Map<string, string>();
|
||||
const bySeries = new Map<string, Totals>();
|
||||
const byVenue = new Map<string, Totals>();
|
||||
const byPartner = new Map<string, { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }>();
|
||||
const totals = emptyTotals();
|
||||
// Past events whose books are still open, including ones with no money in
|
||||
// or out yet (those are left out of `events` and the totals).
|
||||
const readyToClose: any[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const ev of allEvents) {
|
||||
if (ev.status === 'draft') continue;
|
||||
const fin = await getEventFinance(ev.id, ev);
|
||||
if (!fin) continue;
|
||||
const r = fin.result;
|
||||
const hasMoney = !(r.revenue.gross === 0 && r.expenses.total === 0 && r.revenue.otherIncome === 0 && fin.partners.length === 0);
|
||||
const ended = new Date(ev.endDatetime || ev.startDatetime).getTime() <= now;
|
||||
const ready = fin.state.status === 'open' && ended;
|
||||
// Nothing to report for events with no money in or out, unless they still need closing.
|
||||
if (!hasMoney && !ready) continue;
|
||||
|
||||
const series = ev.series || null;
|
||||
const venue = (ev.location || '').trim();
|
||||
const partnerKeys = fin.partners.map((p) => p.userId || `name:${p.name}`);
|
||||
if (hasMoney) {
|
||||
if (series) seriesOptions.add(series);
|
||||
if (venue) venueOptions.add(venue);
|
||||
fin.partners.forEach((p, i) => partnerOptions.set(partnerKeys[i], p.name));
|
||||
}
|
||||
|
||||
if (seriesFilter && (seriesFilter === '__none__' ? series !== null : series !== seriesFilter)) continue;
|
||||
if (venueFilter && venue !== venueFilter) continue;
|
||||
if (partnerFilter && !partnerKeys.includes(partnerFilter)) continue;
|
||||
|
||||
const row = { gross: r.revenue.gross, fees: r.revenue.fees, net: r.revenue.net, expenses: r.expenses.total, profit: r.profit };
|
||||
const eventRow = {
|
||||
id: ev.id, title: ev.title, titleEs: ev.titleEs ?? null, startDatetime: iso(ev.startDatetime),
|
||||
endDatetime: ev.endDatetime ? iso(ev.endDatetime) : null, series, location: venue,
|
||||
status: ev.status, financeStatus: fin.state.status, ticketsSold: r.counts.ticketsSold, ...row,
|
||||
organization: r.split.organization,
|
||||
};
|
||||
if (ready) readyToClose.push(eventRow);
|
||||
if (!hasMoney) continue;
|
||||
rows.push(eventRow);
|
||||
addTo(totals, row);
|
||||
const sKey = series || '';
|
||||
if (!bySeries.has(sKey)) bySeries.set(sKey, emptyTotals());
|
||||
addTo(bySeries.get(sKey)!, row);
|
||||
if (!byVenue.has(venue)) byVenue.set(venue, emptyTotals());
|
||||
addTo(byVenue.get(venue)!, row);
|
||||
|
||||
fin.partners.forEach((p, i) => {
|
||||
const key = partnerKeys[i];
|
||||
const line = r.split.partners.find((x) => x.partnerId === p.id);
|
||||
const agg = byPartner.get(key) || { key, name: p.name, userId: p.userId, events: 0, share: 0, reimbursement: 0, payout: 0, paid: 0, pending: 0 };
|
||||
agg.events += 1;
|
||||
agg.share += line?.share ?? 0;
|
||||
agg.reimbursement += line?.reimbursement ?? 0;
|
||||
agg.payout += line?.payout ?? 0;
|
||||
if (p.payoutStatus === 'paid') agg.paid += line?.payout ?? 0; else agg.pending += line?.payout ?? 0;
|
||||
byPartner.set(key, agg);
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => (b.startDatetime || '').localeCompare(a.startDatetime || ''));
|
||||
// Longest-waiting first.
|
||||
readyToClose.sort((a, b) => (a.startDatetime || '').localeCompare(b.startDatetime || ''));
|
||||
const sortByProfit = <T extends { profit: number }>(xs: T[]) => xs.sort((a, b) => b.profit - a.profit);
|
||||
return c.json({
|
||||
totals,
|
||||
events: rows,
|
||||
readyToClose,
|
||||
bySeries: sortByProfit([...bySeries.entries()].map(([series, t]) => ({ series: series || null, ...t }))),
|
||||
byVenue: sortByProfit([...byVenue.entries()].map(([venue, t]) => ({ venue, ...t }))),
|
||||
byPartner: [...byPartner.values()].sort((a, b) => b.payout - a.payout),
|
||||
filters: {
|
||||
series: [...seriesOptions].sort(),
|
||||
venues: [...venueOptions].sort(),
|
||||
partners: [...partnerOptions.entries()].map(([key, name]) => ({ key, name })).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default financeGlobalRouter;
|
||||
@@ -9,24 +9,6 @@ import path from 'path';
|
||||
|
||||
const legalPagesRouter = new Hono();
|
||||
|
||||
// Helper: Convert plain text to simple markdown
|
||||
// Preserves paragraphs and line breaks, nothing fancy
|
||||
function textToMarkdown(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
// Split into paragraphs (double newlines)
|
||||
const paragraphs = text.split(/\n\s*\n/);
|
||||
|
||||
// Process each paragraph
|
||||
const processed = paragraphs.map(para => {
|
||||
// Replace single newlines with double spaces + newline for markdown line breaks
|
||||
return para.trim().replace(/\n/g, ' \n');
|
||||
});
|
||||
|
||||
// Join paragraphs with double newlines
|
||||
return processed.join('\n\n');
|
||||
}
|
||||
|
||||
// Helper: Convert markdown to plain text for editing
|
||||
function markdownToText(markdown: string): string {
|
||||
if (!markdown) return '';
|
||||
@@ -163,6 +145,13 @@ legalPagesRouter.get('/:slug', async (c) => {
|
||||
const { slug } = c.req.param();
|
||||
const locale = c.req.query('locale') || 'en';
|
||||
|
||||
// Reject anything that isn't a simple slug. The filesystem fallback below builds a
|
||||
// path from this value, so an unconstrained slug (e.g. "../../etc/passwd") would
|
||||
// allow path traversal / arbitrary file reads.
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||
return c.json({ error: 'Legal page not found' }, 404);
|
||||
}
|
||||
|
||||
// First try to get from database
|
||||
const page = await dbGet<any>(
|
||||
(db as any).select().from(legalPages).where(eq((legalPages as any).slug, slug))
|
||||
@@ -276,6 +265,17 @@ legalPagesRouter.put('/admin/:slug', requireAuth(['admin']), async (c) => {
|
||||
return c.json({ error: 'At least one language content is required' }, 400);
|
||||
}
|
||||
|
||||
// Bound the sizes of admin-supplied content to avoid unbounded payloads.
|
||||
const MAX_CONTENT_LEN = 200000; // ~200 KB of markdown per language
|
||||
const MAX_TITLE_LEN = 255;
|
||||
const tooLong = (v: any, max: number) => typeof v === 'string' && v.length > max;
|
||||
if (tooLong(enContent, MAX_CONTENT_LEN) || tooLong(esContent, MAX_CONTENT_LEN)) {
|
||||
return c.json({ error: `Content must be at most ${MAX_CONTENT_LEN} characters` }, 400);
|
||||
}
|
||||
if (tooLong(title, MAX_TITLE_LEN) || tooLong(titleEs, MAX_TITLE_LEN)) {
|
||||
return c.json({ error: `Title must be at most ${MAX_TITLE_LEN} characters` }, 400);
|
||||
}
|
||||
|
||||
const existing = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
|
||||
+324
-51
@@ -1,19 +1,36 @@
|
||||
import { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||
import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getNow, toDbDate } from '../lib/utils.js';
|
||||
import {
|
||||
verifyWebhookPayment,
|
||||
getPaymentStatus,
|
||||
createInvoice,
|
||||
isLNbitsConfigured,
|
||||
LNBITS_INVOICE_EXPIRY_SECONDS,
|
||||
} from '../lib/lnbits.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getPubSub } from '../lib/stores/pubsub.js';
|
||||
import { getLock, LockUnavailableError } from '../lib/stores/lock.js';
|
||||
|
||||
const lnbitsRouter = new Hono();
|
||||
|
||||
// Store for active SSE connections (ticketId -> Set of response writers)
|
||||
const activeConnections = new Map<string, Set<(data: any) => void>>();
|
||||
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
|
||||
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
|
||||
const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
|
||||
|
||||
// Pub/sub unsubscribe handles per ticket (one local subscription per ticket).
|
||||
const channelUnsubs = new Map<string, () => void>();
|
||||
|
||||
// Store for active background checkers (ticketId -> intervalId)
|
||||
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
/** Pub/sub channel that carries payment events for a ticket. */
|
||||
function paymentChannel(ticketId: string): string {
|
||||
return `payment:${ticketId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* LNbits webhook payload structure
|
||||
*/
|
||||
@@ -32,32 +49,85 @@ interface LNbitsWebhookPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all connected clients for a ticket
|
||||
* Notify every client for a ticket across all instances.
|
||||
*
|
||||
* Publishes to the ticket's pub/sub channel. In single-instance / in-memory
|
||||
* mode this is an in-process broadcast; with Redis it reaches whichever
|
||||
* instance(s) actually hold the SSE socket(s) for this ticket.
|
||||
*/
|
||||
function notifyClients(ticketId: string, data: any) {
|
||||
async function notifyClients(ticketId: string, data: any) {
|
||||
await getPubSub().publish(paymentChannel(ticketId), data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an event to the SSE sockets held by THIS process for a ticket.
|
||||
* Invoked by the pub/sub subscription handler.
|
||||
*/
|
||||
async function deliverLocal(ticketId: string, data: any) {
|
||||
const connections = activeConnections.get(ticketId);
|
||||
if (connections) {
|
||||
connections.forEach(send => {
|
||||
await Promise.all(
|
||||
Array.from(connections).map(async (send) => {
|
||||
try {
|
||||
send(data);
|
||||
await send(data);
|
||||
} catch (e) {
|
||||
// Connection might be closed
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Distributed lock tokens for the per-ticket poller (ticketId -> token).
|
||||
const checkerLockTokens = new Map<string, string>();
|
||||
|
||||
/** Release the per-ticket poller lock if this process holds it. */
|
||||
function releaseCheckerLock(ticketId: string) {
|
||||
const token = checkerLockTokens.get(ticketId);
|
||||
if (token) {
|
||||
checkerLockTokens.delete(ticketId);
|
||||
void getLock().release(`checker:${ticketId}`, token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background payment checking for a ticket
|
||||
* Start background payment checking for a ticket.
|
||||
*
|
||||
* Only one instance should poll LNbits per ticket, so we take a distributed
|
||||
* lock for the lifetime of the poll. Other instances skip polling and instead
|
||||
* receive the result via pub/sub. With no Redis configured the lock is a local
|
||||
* no-op and behavior matches the original single-instance polling.
|
||||
*/
|
||||
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
||||
// Don't start if already checking
|
||||
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
||||
// Don't start if already checking on this instance
|
||||
if (activeCheckers.has(ticketId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const expiryMs = expirySeconds * 1000;
|
||||
|
||||
let lockToken: string | null = null;
|
||||
let lockUnavailable = false;
|
||||
try {
|
||||
lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
||||
} catch (err) {
|
||||
if (!(err instanceof LockUnavailableError)) throw err;
|
||||
// Fail open: in webhook-less deployments this poller is the only way a
|
||||
// Lightning payment gets confirmed, so a Redis outage must not stop it.
|
||||
// Duplicate pollers across replicas are harmless because
|
||||
// handlePaymentComplete only acts on rows it actually transitions.
|
||||
console.warn(`[lnbits] lock backend unavailable, polling ticket ${ticketId} without lock:`, err.message);
|
||||
lockUnavailable = true;
|
||||
}
|
||||
if (!lockToken && !lockUnavailable) {
|
||||
// Another instance is already polling this ticket.
|
||||
return;
|
||||
}
|
||||
if (lockToken) {
|
||||
checkerLockTokens.set(ticketId, lockToken);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let checkCount = 0;
|
||||
|
||||
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
|
||||
@@ -71,7 +141,8 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
|
||||
console.log(`Invoice expired for ticket ${ticketId}`);
|
||||
clearInterval(checkInterval);
|
||||
activeCheckers.delete(ticketId);
|
||||
notifyClients(ticketId, { type: 'expired', ticketId });
|
||||
releaseCheckerLock(ticketId);
|
||||
await notifyClients(ticketId, { type: 'expired', ticketId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,9 +153,10 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
|
||||
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
|
||||
clearInterval(checkInterval);
|
||||
activeCheckers.delete(ticketId);
|
||||
releaseCheckerLock(ticketId);
|
||||
|
||||
await handlePaymentComplete(ticketId, paymentHash);
|
||||
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
||||
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking payment for ticket ${ticketId}:`, error);
|
||||
@@ -102,6 +174,7 @@ function stopBackgroundChecker(ticketId: string) {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
activeCheckers.delete(ticketId);
|
||||
releaseCheckerLock(ticketId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,13 +184,23 @@ function stopBackgroundChecker(ticketId: string) {
|
||||
*/
|
||||
lnbitsRouter.post('/webhook', async (c) => {
|
||||
try {
|
||||
// Optional shared-secret gate: if LNBITS_WEBHOOK_SECRET is configured, the
|
||||
// webhook URL must carry a matching ?token=... (set when the invoice is created).
|
||||
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
||||
if (webhookSecret) {
|
||||
const provided = c.req.query('token') || c.req.header('x-webhook-secret') || '';
|
||||
if (provided !== webhookSecret) {
|
||||
console.warn('LNbits webhook rejected: invalid or missing secret');
|
||||
return c.json({ received: true, processed: false }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
const payload: LNbitsWebhookPayload = await c.req.json();
|
||||
|
||||
// Log identifiers only (no full payload / PII)
|
||||
console.log('LNbits webhook received:', {
|
||||
paymentHash: payload.payment_hash,
|
||||
status: payload.status,
|
||||
amount: payload.amount,
|
||||
extra: payload.extra,
|
||||
});
|
||||
|
||||
// Verify the payment is actually complete by checking with LNbits
|
||||
@@ -135,13 +218,27 @@ lnbitsRouter.post('/webhook', async (c) => {
|
||||
return c.json({ received: true, processed: false }, 200);
|
||||
}
|
||||
|
||||
// CRITICAL: bind the paid hash to this ticket's own invoice. Without this, a
|
||||
// valid paid hash from any other invoice could be replayed with an arbitrary
|
||||
// ticketId to confirm tickets for free.
|
||||
const ticketPayment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
if (!ticketPayment || ticketPayment.reference !== payload.payment_hash) {
|
||||
console.warn('LNbits webhook rejected: payment hash does not match the ticket invoice', {
|
||||
ticketId,
|
||||
paymentHash: payload.payment_hash,
|
||||
});
|
||||
return c.json({ received: true, processed: false }, 200);
|
||||
}
|
||||
|
||||
// Stop background checker since webhook confirmed payment
|
||||
stopBackgroundChecker(ticketId);
|
||||
|
||||
await handlePaymentComplete(ticketId, payload.payment_hash);
|
||||
|
||||
// Notify connected clients via SSE
|
||||
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
|
||||
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
|
||||
|
||||
return c.json({ received: true, processed: true }, 200);
|
||||
} catch (error) {
|
||||
@@ -186,15 +283,15 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking
|
||||
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
||||
let transitioned = 0;
|
||||
for (const ticket of ticketsToConfirm) {
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
const result: any = await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, ticket.id));
|
||||
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
|
||||
transitioned += result?.changes ?? result?.rowCount ?? 0;
|
||||
|
||||
// Update payment status to paid
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
@@ -203,11 +300,19 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
paidAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, ticket.id));
|
||||
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
|
||||
|
||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
}
|
||||
|
||||
// Only the caller that actually flipped rows sends the emails. The webhook
|
||||
// and the background poller (and pollers on multiple replicas during a Redis
|
||||
// outage) can all land here; without this guard they would each email.
|
||||
if (transitioned === 0) {
|
||||
console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get primary payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -242,38 +347,45 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
// If already paid, return immediately
|
||||
if (ticket.status === 'confirmed') {
|
||||
return c.json({ type: 'already_paid', ticketId }, 200);
|
||||
}
|
||||
|
||||
// Get payment to start background checker
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
// Start background checker if not already running
|
||||
if (payment?.reference && !activeCheckers.has(ticketId)) {
|
||||
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
|
||||
// Start background checker if not already running (only while still pending)
|
||||
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
|
||||
await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
|
||||
}
|
||||
|
||||
// Prevent proxies/CDNs from buffering the event stream so events flush immediately.
|
||||
c.header('Cache-Control', 'no-cache, no-transform');
|
||||
c.header('X-Accel-Buffering', 'no');
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
// Register this connection
|
||||
if (!activeConnections.has(ticketId)) {
|
||||
activeConnections.set(ticketId, new Set());
|
||||
}
|
||||
|
||||
const sendEvent = (data: any) => {
|
||||
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
|
||||
const sendEvent = async (data: any) => {
|
||||
await stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
|
||||
};
|
||||
|
||||
// If already paid, notify over SSE and close (EventSource can parse this).
|
||||
if (ticket.status === 'confirmed') {
|
||||
await sendEvent({ type: 'already_paid', ticketId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Register this connection. The first local connection for a ticket also
|
||||
// subscribes to the ticket's pub/sub channel so events published by any
|
||||
// instance (webhook or background checker) are delivered to these sockets.
|
||||
if (!activeConnections.has(ticketId)) {
|
||||
activeConnections.set(ticketId, new Set());
|
||||
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
|
||||
void deliverLocal(ticketId, data);
|
||||
});
|
||||
channelUnsubs.set(ticketId, unsub);
|
||||
}
|
||||
activeConnections.get(ticketId)!.add(sendEvent);
|
||||
|
||||
// Send initial status
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ type: 'connected', ticketId }),
|
||||
event: 'payment'
|
||||
});
|
||||
await sendEvent({ type: 'connected', ticketId });
|
||||
|
||||
// Keep connection alive with heartbeat
|
||||
const heartbeat = setInterval(async () => {
|
||||
@@ -284,23 +396,184 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Clean up on disconnect
|
||||
stream.onAbort(() => {
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
const connections = activeConnections.get(ticketId);
|
||||
if (connections) {
|
||||
connections.delete(sendEvent);
|
||||
if (connections.size === 0) {
|
||||
activeConnections.delete(ticketId);
|
||||
// Drop the pub/sub subscription once no local sockets remain.
|
||||
const unsub = channelUnsubs.get(ticketId);
|
||||
if (unsub) {
|
||||
unsub();
|
||||
channelUnsubs.delete(ticketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up on disconnect
|
||||
stream.onAbort(cleanup);
|
||||
|
||||
// Keep the stream open, and every 15s fall back to reading the ticket
|
||||
// status from the DB. Pub/sub is best-effort: a message published while the
|
||||
// subscriber connection was reconnecting is lost, and without this check
|
||||
// the client would wait forever on a payment that already confirmed.
|
||||
try {
|
||||
while (true) {
|
||||
await stream.sleep(15000);
|
||||
const current = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
if (current?.status === 'confirmed') {
|
||||
await sendEvent({ type: 'paid', ticketId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Keep the stream open
|
||||
while (true) {
|
||||
await stream.sleep(30000);
|
||||
/**
|
||||
* Get a Lightning invoice for a ticket to pay or re-pay.
|
||||
*
|
||||
* Reuses the stored invoice if it still has more than 5 minutes of validity
|
||||
* left; otherwise generates a fresh one from LNbits. This is what lets a user
|
||||
* come back to an unpaid Lightning booking later (e.g. from "Pay now" on the
|
||||
* dashboard) instead of hitting a dead end.
|
||||
*/
|
||||
lnbitsRouter.post('/invoice/:ticketId', async (c) => {
|
||||
const ticketId = c.req.param('ticketId');
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (payment.provider !== 'lightning') {
|
||||
return c.json({ error: 'This booking is not a Lightning payment' }, 400);
|
||||
}
|
||||
|
||||
if (ticket.status === 'confirmed' || payment.status === 'paid') {
|
||||
return c.json({ alreadyPaid: true });
|
||||
}
|
||||
|
||||
if (ticket.status !== 'pending' || payment.status !== 'pending') {
|
||||
return c.json({
|
||||
error: 'This booking is no longer active. Please make a new booking.',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Gather every ticket/payment in the booking group - a multi-ticket booking
|
||||
// shares a single Lightning invoice for the combined total.
|
||||
let groupTickets: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
groupTickets = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
const groupPayments = await dbAll<any>(
|
||||
(db as any).select().from(payments).where(inArray((payments as any).ticketId, groupTickets.map((t: any) => t.id)))
|
||||
);
|
||||
const invoiceHolder = groupPayments.find((p: any) => p.reference) || payment;
|
||||
const totalAmount = groupPayments.reduce((sum: number, p: any) => sum + Number(p.amount), 0);
|
||||
const currency = invoiceHolder.currency;
|
||||
|
||||
const now = Date.now();
|
||||
const FIVE_MIN_MS = 5 * 60 * 1000;
|
||||
const expiresAtMs = invoiceHolder.lnbitsExpiresAt ? new Date(invoiceHolder.lnbitsExpiresAt).getTime() : 0;
|
||||
|
||||
if (invoiceHolder.reference && invoiceHolder.lnbitsInvoice && expiresAtMs - now > FIVE_MIN_MS) {
|
||||
return c.json({
|
||||
invoice: {
|
||||
paymentHash: invoiceHolder.reference,
|
||||
paymentRequest: invoiceHolder.lnbitsInvoice,
|
||||
amount: invoiceHolder.lnbitsAmountSats || 0,
|
||||
fiatAmount: totalAmount,
|
||||
fiatCurrency: currency,
|
||||
expiresAt: invoiceHolder.lnbitsExpiresAt,
|
||||
},
|
||||
reused: true,
|
||||
});
|
||||
}
|
||||
|
||||
// No usable stored invoice (never created, expired, or expiring soon) - get a fresh one.
|
||||
if (!isLNbitsConfigured()) {
|
||||
return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400);
|
||||
}
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
||||
const webhookUrl = webhookSecret
|
||||
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
|
||||
: `${apiUrl}/api/lnbits/webhook`;
|
||||
const attendeeName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
try {
|
||||
const lnbitsInvoice = await createInvoice({
|
||||
amount: totalAmount,
|
||||
unit: currency,
|
||||
memo: `Spanglish: ${event?.title || 'Event'} - ${attendeeName}${groupTickets.length > 1 ? ` (${groupTickets.length} tickets)` : ''}`,
|
||||
webhookUrl,
|
||||
expiry: LNBITS_INVOICE_EXPIRY_SECONDS,
|
||||
extra: {
|
||||
ticketId: invoiceHolder.ticketId,
|
||||
bookingId: ticket.bookingId || null,
|
||||
ticketIds: groupTickets.map((t: any) => t.id),
|
||||
eventId: ticket.eventId,
|
||||
eventTitle: event?.title,
|
||||
attendeeName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketCount: groupTickets.length,
|
||||
},
|
||||
});
|
||||
|
||||
const lnbitsExpiresAt = toDbDate(new Date(now + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
reference: lnbitsInvoice.paymentHash,
|
||||
lnbitsInvoice: lnbitsInvoice.paymentRequest,
|
||||
lnbitsExpiresAt,
|
||||
lnbitsAmountSats: lnbitsInvoice.amount,
|
||||
updatedAt: getNow(),
|
||||
})
|
||||
.where(eq((payments as any).id, invoiceHolder.id));
|
||||
|
||||
return c.json({
|
||||
invoice: {
|
||||
paymentHash: lnbitsInvoice.paymentHash,
|
||||
paymentRequest: lnbitsInvoice.paymentRequest,
|
||||
amount: lnbitsInvoice.amount,
|
||||
fiatAmount: lnbitsInvoice.fiatAmount ?? totalAmount,
|
||||
fiatCurrency: lnbitsInvoice.fiatCurrency ?? currency,
|
||||
expiresAt: lnbitsExpiresAt,
|
||||
},
|
||||
reused: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create Lightning invoice:', error);
|
||||
return c.json({
|
||||
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`,
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+66
-39
@@ -1,24 +1,51 @@
|
||||
import { Hono } from 'hono';
|
||||
import { db, dbGet, dbAll, media } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import { getStorage, keyFromUrl } from '../lib/storage.js';
|
||||
|
||||
const mediaRouter = new Hono();
|
||||
|
||||
const UPLOAD_DIR = './uploads';
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
|
||||
const MAX_FILE_SIZE =
|
||||
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
|
||||
|
||||
// Ensure upload directory exists
|
||||
async function ensureUploadDir() {
|
||||
if (!existsSync(UPLOAD_DIR)) {
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
/**
|
||||
* Detect a real image type from the file's magic bytes (content sniffing).
|
||||
* Returns the canonical mime + extension, or null if the content is not an
|
||||
* allowed image. We deliberately ignore the client-supplied filename and
|
||||
* Content-Type so an attacker cannot store e.g. an .html/.svg payload.
|
||||
*/
|
||||
function detectImageType(buf: Buffer): { mime: string; ext: string } | null {
|
||||
if (buf.length < 12) return null;
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
return { mime: 'image/jpeg', ext: '.jpg' };
|
||||
}
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (
|
||||
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
|
||||
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
|
||||
) {
|
||||
return { mime: 'image/png', ext: '.png' };
|
||||
}
|
||||
// GIF: "GIF87a" / "GIF89a"
|
||||
if (buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a') {
|
||||
return { mime: 'image/gif', ext: '.gif' };
|
||||
}
|
||||
// WEBP: "RIFF"...."WEBP"
|
||||
if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
|
||||
return { mime: 'image/webp', ext: '.webp' };
|
||||
}
|
||||
// AVIF / HEIF: "....ftyp" with an avif/heic brand
|
||||
if (buf.toString('ascii', 4, 8) === 'ftyp') {
|
||||
const brand = buf.toString('ascii', 8, 12);
|
||||
if (brand === 'avif' || brand === 'avis') {
|
||||
return { mime: 'image/avif', ext: '.avif' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Upload image
|
||||
@@ -31,28 +58,28 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
return c.json({ error: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
// Validate file size (cheap check before reading the whole buffer)
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
|
||||
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
|
||||
}
|
||||
|
||||
await ensureUploadDir();
|
||||
|
||||
// Generate unique filename
|
||||
const id = generateId();
|
||||
const ext = extname(file.name) || '.jpg';
|
||||
const filename = `${id}${ext}`;
|
||||
const filepath = join(UPLOAD_DIR, filename);
|
||||
|
||||
// Write file
|
||||
// Read the bytes and validate the *content* (not the client-provided type/name)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await writeFile(filepath, Buffer.from(arrayBuffer));
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const detected = detectImageType(buffer);
|
||||
if (!detected) {
|
||||
return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
|
||||
}
|
||||
|
||||
// Generate unique filename using the *detected* extension (ignore client filename)
|
||||
const id = generateId();
|
||||
const filename = `${id}${detected.ext}`;
|
||||
|
||||
// Persist via the storage backend (local disk or S3-compatible object store).
|
||||
const storage = getStorage();
|
||||
await storage.put(filename, buffer, detected.mime);
|
||||
|
||||
// Get related info from form data
|
||||
const relatedId = body['relatedId'] as string | undefined;
|
||||
@@ -62,7 +89,7 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const now = getNow();
|
||||
const mediaRecord = {
|
||||
id,
|
||||
fileUrl: `/uploads/${filename}`,
|
||||
fileUrl: storage.publicUrl(filename),
|
||||
type: 'image' as const,
|
||||
relatedId: relatedId || null,
|
||||
relatedType: relatedType || null,
|
||||
@@ -108,12 +135,9 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
return c.json({ error: 'Media not found' }, 404);
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
// Delete the underlying object from the storage backend.
|
||||
try {
|
||||
const filepath = join('.', mediaRecord.fileUrl);
|
||||
if (existsSync(filepath)) {
|
||||
await unlink(filepath);
|
||||
}
|
||||
await getStorage().delete(keyFromUrl(mediaRecord.fileUrl));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
}
|
||||
@@ -128,17 +152,20 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const relatedType = c.req.query('relatedType');
|
||||
const relatedId = c.req.query('relatedId');
|
||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '200', 10) || 200, 1), 500);
|
||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
||||
|
||||
// Combine filters into a single where() — chaining .where() replaces the prior condition in Drizzle.
|
||||
const conditions: any[] = [];
|
||||
if (relatedType) conditions.push(eq((media as any).relatedType, relatedType));
|
||||
if (relatedId) conditions.push(eq((media as any).relatedId, relatedId));
|
||||
|
||||
let query = (db as any).select().from(media);
|
||||
|
||||
if (relatedType) {
|
||||
query = query.where(eq((media as any).relatedType, relatedType));
|
||||
}
|
||||
if (relatedId) {
|
||||
query = query.where(eq((media as any).relatedId, relatedId));
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
||||
}
|
||||
|
||||
const result = await dbAll(query);
|
||||
const result = await dbAll(query.limit(limit).offset(offset));
|
||||
|
||||
return c.json({ media: result });
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
|
||||
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { requireEventPermission, eventFromParam } from '../lib/eventPermissions.js';
|
||||
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
||||
|
||||
const paymentOptionsRouter = new Hono();
|
||||
@@ -36,10 +37,29 @@ const updatePaymentOptionsSchema = z.object({
|
||||
cashEnabled: booleanOrNumber.optional(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
// POS terminal on the door screen
|
||||
posEnabled: booleanOrNumber.optional(),
|
||||
// Booking settings
|
||||
allowDuplicateBookings: booleanOrNumber.optional(),
|
||||
});
|
||||
|
||||
/** Strip bank account numbers from payment options for anonymous callers. */
|
||||
function publicPaymentOptions(merged: Record<string, any>) {
|
||||
return {
|
||||
...merged,
|
||||
bankName: null,
|
||||
bankAccountHolder: null,
|
||||
bankAccountNumber: null,
|
||||
bankAlias: null,
|
||||
bankPhone: null,
|
||||
tpagoLink: null,
|
||||
tpagoLink2: null,
|
||||
tpagoLink3: null,
|
||||
tpagoLink4: null,
|
||||
tpagoLink5: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Schema for event-level overrides
|
||||
const updateEventOverridesSchema = z.object({
|
||||
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
||||
@@ -62,6 +82,7 @@ const updateEventOverridesSchema = z.object({
|
||||
cashEnabled: booleanOrNumber.optional().nullable(),
|
||||
cashInstructions: z.string().optional().nullable(),
|
||||
cashInstructionsEs: z.string().optional().nullable(),
|
||||
posEnabled: booleanOrNumber.optional().nullable(),
|
||||
});
|
||||
|
||||
// Get global payment options
|
||||
@@ -94,6 +115,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
posEnabled: true,
|
||||
allowDuplicateBookings: false,
|
||||
},
|
||||
});
|
||||
@@ -151,6 +173,7 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
|
||||
// Get payment options for a specific event (merged with global)
|
||||
paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const ticketId = c.req.query('ticketId');
|
||||
|
||||
// Get the event first to verify it exists
|
||||
const event = await dbGet(
|
||||
@@ -201,6 +224,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
posEnabled: true,
|
||||
};
|
||||
|
||||
const global = globalOptions || defaults;
|
||||
@@ -227,16 +251,33 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
|
||||
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
|
||||
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
||||
posEnabled: overrides?.posEnabled ?? global.posEnabled ?? true,
|
||||
};
|
||||
|
||||
// Full bank/TPago credentials are only returned when the caller proves they hold
|
||||
// a valid ticket for this event (the ticket UUID is the booking capability token),
|
||||
// or when an authenticated admin/organizer requests them.
|
||||
let revealSensitive = false;
|
||||
const authUser: any = await getAuthUser(c);
|
||||
if (authUser && ['admin', 'organizer'].includes(authUser.role)) {
|
||||
revealSensitive = true;
|
||||
} else if (ticketId) {
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
if (ticket && ticket.eventId === eventId && ticket.status !== 'cancelled') {
|
||||
revealSensitive = true;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
paymentOptions: merged,
|
||||
paymentOptions: revealSensitive ? merged : publicPaymentOptions(merged),
|
||||
hasOverrides: !!overrides,
|
||||
});
|
||||
});
|
||||
|
||||
// Get event payment overrides (admin only)
|
||||
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
paymentOptionsRouter.get('/event/:eventId/overrides', requireEventPermission('view_payments', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
const overrides = await dbGet<any>(
|
||||
@@ -247,7 +288,7 @@ paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'org
|
||||
});
|
||||
|
||||
// Update event payment overrides
|
||||
paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), zValidator('json', updateEventOverridesSchema), async (c) => {
|
||||
paymentOptionsRouter.put('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), zValidator('json', updateEventOverridesSchema), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
const data = c.req.valid('json');
|
||||
const now = getNow();
|
||||
@@ -299,7 +340,7 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
|
||||
});
|
||||
|
||||
// Delete event payment overrides (revert to global)
|
||||
paymentOptionsRouter.delete('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
paymentOptionsRouter.delete('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||
const eventId = c.req.param('eventId');
|
||||
|
||||
await (db as any)
|
||||
|
||||
+271
-66
@@ -2,15 +2,16 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
|
||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||
import { eq, desc, and, or, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
|
||||
const paymentsRouter = new Hono();
|
||||
|
||||
const updatePaymentSchema = z.object({
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']),
|
||||
reference: z.string().optional(),
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
@@ -18,6 +19,9 @@ const updatePaymentSchema = z.object({
|
||||
const approvePaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
// Admin override: confirm the booking even when it puts the event over
|
||||
// capacity. The UI asks for explicit confirmation before sending this.
|
||||
allowOverCapacity: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const rejectPaymentSchema = z.object({
|
||||
@@ -25,6 +29,10 @@ const rejectPaymentSchema = z.object({
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
const reopenPaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
|
||||
// Get all payments (admin) - with ticket and event details
|
||||
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const status = c.req.query('status');
|
||||
@@ -85,6 +93,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
attendeeRuc: ticket.attendeeRuc,
|
||||
status: ticket.status,
|
||||
} : null,
|
||||
event: event ? {
|
||||
@@ -162,6 +171,31 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
||||
return c.json({ payments: enrichedPayments });
|
||||
});
|
||||
|
||||
// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
|
||||
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, onHoldRow, revenueRow] = await Promise.all([
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))),
|
||||
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))),
|
||||
dbGet<any>((db as any).select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
stats: {
|
||||
total: Number(totalRow?.count || 0),
|
||||
pending: Number(pendingRow?.count || 0),
|
||||
paid: Number(paidRow?.count || 0),
|
||||
refunded: Number(refundedRow?.count || 0),
|
||||
failed: Number(failedRow?.count || 0),
|
||||
onHold: Number(onHoldRow?.count || 0),
|
||||
totalRevenue: Number(revenueRow?.total || 0),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Get payment by ID (admin)
|
||||
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
@@ -205,6 +239,12 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Confirming a failed payment must go through /approve, which re-checks event
|
||||
// capacity before re-reserving the (previously released) seat. Block the raw path.
|
||||
if (data.status === 'paid' && existing.status === 'failed') {
|
||||
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
const updateData: any = { ...data, updatedAt: now };
|
||||
@@ -248,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
@@ -280,7 +320,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
// Approve payment (admin) - specifically for pending_approval payments
|
||||
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { adminNote, sendEmail } = c.req.valid('json');
|
||||
const { adminNote, sendEmail, allowOverCapacity } = c.req.valid('json');
|
||||
const user = (c as any).get('user');
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
@@ -294,13 +334,14 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Can approve pending or pending_approval payments
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
// Can approve pending, pending_approval, on_hold, or failed payments.
|
||||
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
|
||||
// in error; its tickets are cancelled, so recovery re-checks capacity below.
|
||||
// Bare 'pending' covers customers who paid but never clicked "I've paid".
|
||||
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -323,24 +364,54 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
// For a failed payment, only recover tickets whose own payment is also failed.
|
||||
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
|
||||
// resurrected or having its payment flipped to paid.
|
||||
if (payment.status === 'failed') {
|
||||
const bookingPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
||||
.from(payments)
|
||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
|
||||
);
|
||||
const failedTicketIds = new Set(
|
||||
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
||||
);
|
||||
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
|
||||
if (ticketsToConfirm.length === 0) {
|
||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
// Confirm the booking through the shared capacity-checked reservation.
|
||||
// Tickets that already hold a seat ('pending_approval' claims) cost no new
|
||||
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
|
||||
// When the event is full, the admin gets a structured over-capacity error and
|
||||
// may retry with allowOverCapacity to knowingly overbook.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{
|
||||
paidByAdminId: user.id,
|
||||
fromTicketStatuses:
|
||||
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'],
|
||||
skipCapacityCheck: allowOverCapacity,
|
||||
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'Approving this payment puts the event over capacity.',
|
||||
code: 'EVENT_OVER_CAPACITY',
|
||||
availableSeats: err.available,
|
||||
requestedSeats: ticketsToConfirm.length,
|
||||
}, 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||
@@ -382,13 +453,26 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status to failed
|
||||
// Determine all tickets in this booking (multi-ticket bookings must be rejected together)
|
||||
const rejectTicket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
let ticketsToReject: any[] = rejectTicket ? [rejectTicket] : [];
|
||||
if (rejectTicket?.bookingId) {
|
||||
ticketsToReject = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, rejectTicket.bookingId))
|
||||
);
|
||||
console.log(`[Payment] Rejecting multi-ticket booking: ${rejectTicket.bookingId}, ${ticketsToReject.length} tickets`);
|
||||
}
|
||||
|
||||
for (const t of ticketsToReject) {
|
||||
// Fail the payment for each ticket in the booking
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
@@ -397,7 +481,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).id, id));
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Cancel the ticket - booking is no longer valid after rejection
|
||||
await (db as any)
|
||||
@@ -406,7 +490,8 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
status: 'cancelled',
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((tickets as any).id, payment.ticketId));
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
|
||||
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||
@@ -427,6 +512,140 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
||||
});
|
||||
|
||||
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
|
||||
paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (payment.status !== 'on_hold') {
|
||||
return c.json({ error: 'Only on-hold payments can be reactivated' }, 400);
|
||||
}
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
let ticketsToReactivate: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToReactivate = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToReactivate.map((t: any) => t.id),
|
||||
'pending',
|
||||
'pending_approval'
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
|
||||
});
|
||||
|
||||
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
|
||||
// For when a payment was failed in error (auto-fail or rejection) and should
|
||||
// return to the normal pending flow (reminders, "I've paid", approve/reject).
|
||||
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { adminNote } = c.req.valid('json');
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (payment.status !== 'failed') {
|
||||
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
||||
}
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
let ticketsToReopen: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToReopen = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
|
||||
const bookingPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
||||
.from(payments)
|
||||
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
|
||||
);
|
||||
const failedTicketIds = new Set(
|
||||
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
||||
);
|
||||
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
|
||||
if (ticketsToReopen.length === 0) {
|
||||
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
||||
}
|
||||
|
||||
const reopenIds = ticketsToReopen.map((t: any) => t.id);
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
reopenIds,
|
||||
'pending',
|
||||
'pending',
|
||||
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (adminNote) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ adminNote })
|
||||
.where(inArray((payments as any).ticketId, reopenIds));
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
|
||||
});
|
||||
|
||||
// Send payment reminder email
|
||||
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
@@ -448,7 +667,7 @@ paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), a
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await emailService.sendPaymentReminder(id);
|
||||
const result = await emailService.sendPaymentReminder(payment.id);
|
||||
|
||||
if (result.success) {
|
||||
const now = getNow();
|
||||
@@ -530,55 +749,41 @@ paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status
|
||||
// Refund all tickets/payments in the booking (multi-ticket bookings refund together)
|
||||
const refundTicket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
let ticketsToRefund: any[] = refundTicket ? [refundTicket] : [];
|
||||
if (refundTicket?.bookingId) {
|
||||
ticketsToRefund = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, refundTicket.bookingId))
|
||||
);
|
||||
console.log(`[Payment] Refunding multi-ticket booking: ${refundTicket.bookingId}, ${ticketsToRefund.length} tickets`);
|
||||
}
|
||||
|
||||
for (const t of ticketsToRefund) {
|
||||
// Only refund payments that were actually paid; leave others untouched
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'refunded', updatedAt: now })
|
||||
.where(eq((payments as any).id, id));
|
||||
.where(and(eq((payments as any).ticketId, (t as any).id), eq((payments as any).status, 'paid')));
|
||||
|
||||
// Cancel associated ticket
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(eq((tickets as any).id, payment.ticketId));
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
return c.json({ message: 'Refund processed successfully' });
|
||||
});
|
||||
|
||||
// Payment webhook (for Stripe/MercadoPago)
|
||||
// Not implemented: there is deliberately NO status mutation here. Until provider
|
||||
// signature verification is implemented, accepting webhooks would let anyone forge
|
||||
// a "paid" status. Returns 501 and never updates payments/tickets.
|
||||
paymentsRouter.post('/webhook', async (c) => {
|
||||
// This would handle webhook notifications from payment providers
|
||||
// Implementation depends on which provider is used
|
||||
|
||||
const body = await c.req.json();
|
||||
|
||||
// Log webhook for debugging
|
||||
console.log('Payment webhook received:', body);
|
||||
|
||||
// TODO: Implement provider-specific webhook handling
|
||||
// - Verify webhook signature
|
||||
// - Update payment status
|
||||
// - Update ticket status
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
|
||||
// Get payment statistics (admin)
|
||||
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
const allPayments = await dbAll<any>((db as any).select().from(payments));
|
||||
|
||||
const stats = {
|
||||
total: allPayments.length,
|
||||
pending: allPayments.filter((p: any) => p.status === 'pending').length,
|
||||
paid: allPayments.filter((p: any) => p.status === 'paid').length,
|
||||
refunded: allPayments.filter((p: any) => p.status === 'refunded').length,
|
||||
failed: allPayments.filter((p: any) => p.status === 'failed').length,
|
||||
totalRevenue: allPayments
|
||||
.filter((p: any) => p.status === 'paid')
|
||||
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0),
|
||||
};
|
||||
|
||||
return c.json({ stats });
|
||||
console.warn('Payment webhook received but provider webhooks are not implemented (no signature verification).');
|
||||
return c.json({ error: 'Webhook handling is not implemented' }, 501);
|
||||
});
|
||||
|
||||
export default paymentsRouter;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq, and, gte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { DEFAULT_PRESALE_CLOSURE_ENABLED, DEFAULT_PRESALE_CLOSE_MINUTES } from '../lib/presale.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -17,9 +18,20 @@ interface UserContext {
|
||||
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||
|
||||
// Validation schema for updating site settings
|
||||
// Validate against the runtime's IANA timezone database (rejects arbitrary strings
|
||||
// that later get fed into Intl.DateTimeFormat on the frontend).
|
||||
const isValidTimezone = (tz: string): boolean => {
|
||||
try {
|
||||
Intl.DateTimeFormat('en-US', { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateSiteSettingsSchema = z.object({
|
||||
timezone: z.string().optional(),
|
||||
siteName: z.string().optional(),
|
||||
timezone: z.string().refine(isValidTimezone, { message: 'Invalid timezone' }).optional(),
|
||||
siteName: z.string().max(255).optional(),
|
||||
siteDescription: z.string().optional().nullable(),
|
||||
siteDescriptionEs: z.string().optional().nullable(),
|
||||
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
|
||||
@@ -32,8 +44,26 @@ const updateSiteSettingsSchema = z.object({
|
||||
maintenanceMode: z.boolean().optional(),
|
||||
maintenanceMessage: z.string().optional().nullable(),
|
||||
maintenanceMessageEs: z.string().optional().nullable(),
|
||||
// Pre-sale closure defaults inherited by events that don't override them
|
||||
presaleClosureEnabled: z.boolean().optional(),
|
||||
presaleCloseMinutesBefore: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
// Booleans are stored as 0/1 integers on PostgreSQL; hand the frontend real booleans.
|
||||
function normalizeSettings(row: any) {
|
||||
if (!row) return row;
|
||||
return {
|
||||
...row,
|
||||
maintenanceMode: Boolean(row.maintenanceMode),
|
||||
presaleClosureEnabled: row.presaleClosureEnabled == null
|
||||
? DEFAULT_PRESALE_CLOSURE_ENABLED
|
||||
: Boolean(row.presaleClosureEnabled),
|
||||
presaleCloseMinutesBefore: row.presaleCloseMinutesBefore == null
|
||||
? DEFAULT_PRESALE_CLOSE_MINUTES
|
||||
: Number(row.presaleCloseMinutesBefore),
|
||||
};
|
||||
}
|
||||
|
||||
// Get site settings (public - needed for frontend timezone)
|
||||
siteSettingsRouter.get('/', async (c) => {
|
||||
const settings = await dbGet(
|
||||
@@ -58,11 +88,13 @@ siteSettingsRouter.get('/', async (c) => {
|
||||
maintenanceMode: false,
|
||||
maintenanceMessage: null,
|
||||
maintenanceMessageEs: null,
|
||||
presaleClosureEnabled: DEFAULT_PRESALE_CLOSURE_ENABLED,
|
||||
presaleCloseMinutesBefore: DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ settings });
|
||||
return c.json({ settings: normalizeSettings(settings) });
|
||||
});
|
||||
|
||||
// Get available timezones
|
||||
@@ -134,13 +166,15 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
maintenanceMode: toDbBool(data.maintenanceMode || false),
|
||||
maintenanceMessage: data.maintenanceMessage || null,
|
||||
maintenanceMessageEs: data.maintenanceMessageEs || null,
|
||||
presaleClosureEnabled: toDbBool(data.presaleClosureEnabled ?? DEFAULT_PRESALE_CLOSURE_ENABLED),
|
||||
presaleCloseMinutesBefore: data.presaleCloseMinutesBefore ?? DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||
updatedAt: now,
|
||||
updatedBy: user.id,
|
||||
};
|
||||
|
||||
await (db as any).insert(siteSettings).values(newSettings);
|
||||
|
||||
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
|
||||
return c.json({ settings: normalizeSettings(newSettings), message: 'Settings created successfully' }, 201);
|
||||
}
|
||||
|
||||
// Validate featured event if provided
|
||||
@@ -163,6 +197,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
if (typeof data.maintenanceMode === 'boolean') {
|
||||
updateData.maintenanceMode = toDbBool(data.maintenanceMode);
|
||||
}
|
||||
if (typeof data.presaleClosureEnabled === 'boolean') {
|
||||
updateData.presaleClosureEnabled = toDbBool(data.presaleClosureEnabled);
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(siteSettings)
|
||||
@@ -173,12 +210,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
||||
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
||||
);
|
||||
|
||||
// Revalidate frontend cache if featured event changed
|
||||
if (data.featuredEventId !== undefined) {
|
||||
// Revalidate frontend cache if featured event changed or the pre-sale
|
||||
// defaults changed (public event pages embed the computed cutoff).
|
||||
if (
|
||||
data.featuredEventId !== undefined ||
|
||||
data.presaleClosureEnabled !== undefined ||
|
||||
data.presaleCloseMinutesBefore !== undefined
|
||||
) {
|
||||
revalidateFrontendCache();
|
||||
}
|
||||
|
||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
||||
return c.json({ settings: normalizeSettings(updated), message: 'Settings updated successfully' });
|
||||
});
|
||||
|
||||
// Set featured event (admin only) - convenience endpoint for event editor
|
||||
|
||||
+470
-250
File diff suppressed because it is too large
Load Diff
+88
-31
@@ -2,9 +2,11 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { authSessions } from '../db/auth-schema.js';
|
||||
import { getNow, toDbDate } from '../lib/utils.js';
|
||||
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -27,6 +29,42 @@ const updateUserSchema = z.object({
|
||||
// Get all users (admin only)
|
||||
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const role = c.req.query('role');
|
||||
const search = c.req.query('search');
|
||||
const accountStatus = c.req.query('accountStatus');
|
||||
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
|
||||
const registeredAfter = c.req.query('registeredAfter');
|
||||
const registeredBefore = c.req.query('registeredBefore');
|
||||
const eventId = c.req.query('eventId');
|
||||
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (role) conditions.push(eq((users as any).role, role));
|
||||
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
|
||||
if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter)));
|
||||
if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore)));
|
||||
if (search) {
|
||||
const like = `%${search.toLowerCase()}%`;
|
||||
conditions.push(sql`(
|
||||
LOWER(${(users as any).name}) LIKE ${like}
|
||||
OR LOWER(${(users as any).email}) LIKE ${like}
|
||||
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
|
||||
)`);
|
||||
}
|
||||
if (hasBookings === 'yes') {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
} else if (hasBookings === 'no') {
|
||||
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
}
|
||||
if (eventId) {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
|
||||
}
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const totalQuery = whereClause
|
||||
? (db as any).select({ count: sql<number>`count(*)` }).from(users).where(whereClause)
|
||||
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
|
||||
const totalRow = await dbGet<any>(totalQuery);
|
||||
|
||||
let query = (db as any).select({
|
||||
id: (users as any).id,
|
||||
@@ -41,13 +79,38 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
createdAt: (users as any).createdAt,
|
||||
}).from(users);
|
||||
|
||||
if (role) {
|
||||
query = query.where(eq((users as any).role, role));
|
||||
if (whereClause) {
|
||||
query = query.where(whereClause);
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
|
||||
const result = await dbAll(
|
||||
query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize)
|
||||
);
|
||||
|
||||
return c.json({ users: result });
|
||||
return c.json({ users: result, total: Number(totalRow?.count || 0), page, pageSize });
|
||||
});
|
||||
|
||||
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
|
||||
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
const totalUsers = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
);
|
||||
|
||||
const adminCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(eq((users as any).role, 'admin'))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
stats: {
|
||||
total: totalUsers?.count || 0,
|
||||
admins: adminCount?.count || 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Get user by ID (admin or self)
|
||||
@@ -114,11 +177,28 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
// Keep the Better Auth admin `banned` flag in sync with accountStatus so
|
||||
// sign-in is blocked at the auth layer too, and kill live sessions on
|
||||
// suspension so it takes effect immediately (sessions are DB-validated on
|
||||
// every request by both the backend and the photo API).
|
||||
const statusMirror: Record<string, any> = {};
|
||||
if (data.accountStatus === 'suspended') {
|
||||
statusMirror.banned = true;
|
||||
statusMirror.banReason = 'Suspended by admin';
|
||||
} else if (data.accountStatus) {
|
||||
statusMirror.banned = false;
|
||||
statusMirror.banReason = null;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ ...data, updatedAt: getNow() })
|
||||
.set({ ...data, ...statusMirror, updatedAt: getNow() })
|
||||
.where(eq((users as any).id, id));
|
||||
|
||||
if (data.accountStatus === 'suspended') {
|
||||
await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id));
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select({
|
||||
@@ -170,7 +250,7 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
|
||||
|
||||
return {
|
||||
...ticket,
|
||||
event,
|
||||
event: omitWalkInPrice(event as any),
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -286,27 +366,4 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get user statistics (admin)
|
||||
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
const totalUsers = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
);
|
||||
|
||||
const adminCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(eq((users as any).role, 'admin'))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
stats: {
|
||||
total: totalUsers?.count || 0,
|
||||
admins: adminCount?.count || 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default usersRouter;
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declaration": false,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -63,6 +63,29 @@ server {
|
||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||
}
|
||||
|
||||
# Photo gallery service (photo-api, port 3020). ^~ wins over the /
|
||||
# prefix below; bigger body cap and unbuffered uploads for photo batches.
|
||||
location ^~ /api/photos/ {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
proxy_pass http://spanglish_photos;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Strip CORS headers from the service (nginx handles CORS here)
|
||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Example docker-compose for running the Spanglish API as multiple replicas
|
||||
# behind nginx, with Redis for shared state and Postgres as the database.
|
||||
#
|
||||
# This is a starting point, not a turnkey production setup. It expects a
|
||||
# Dockerfile at backend/Dockerfile that builds the API and runs it on PORT.
|
||||
#
|
||||
# Bring it up with N API replicas:
|
||||
# docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
|
||||
#
|
||||
# No em dashes are used in this file by design.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: spanglish
|
||||
POSTGRES_PASSWORD: spanglish
|
||||
POSTGRES_DB: spanglish
|
||||
# Raise max_connections if DB_POOL_MAX * replicas approaches the default 100.
|
||||
command: ["postgres", "-c", "max_connections=200"]
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U spanglish"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
# noeviction is deliberate: rate-limit, lock, and lockout keys must never
|
||||
# be evicted for correctness. The cache workload is a single short-TTL key,
|
||||
# so memory pressure is negligible; if caching ever grows, revisit this or
|
||||
# move the cache to its own DB index.
|
||||
command:
|
||||
[
|
||||
"redis-server",
|
||||
"--appendonly", "yes",
|
||||
"--requirepass", "${REDIS_PASSWORD:-change-me-redis-password}",
|
||||
"--maxmemory", "256mb",
|
||||
"--maxmemory-policy", "noeviction",
|
||||
]
|
||||
environment:
|
||||
REDISCLI_AUTH: "${REDIS_PASSWORD:-change-me-redis-password}"
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ../backend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
DB_TYPE: postgres
|
||||
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
|
||||
DB_POOL_MAX: "15"
|
||||
REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379"
|
||||
JWT_SECRET: change-me-to-a-strong-secret
|
||||
FRONTEND_URL: http://localhost:8080
|
||||
# Optional S3-compatible storage so uploads are shared across replicas.
|
||||
# If you omit these, mount a shared volume at /app/uploads on every replica.
|
||||
# S3_ENDPOINT: http://garage:3900
|
||||
# S3_REGION: garage
|
||||
# S3_BUCKET: spanglish-media
|
||||
# S3_ACCESS_KEY_ID: ""
|
||||
# S3_SECRET_ACCESS_KEY: ""
|
||||
# S3_PUBLIC_URL: http://localhost:8080/media
|
||||
expose:
|
||||
- "3001"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Load balancer across the scaled api replicas. nginx resolves the "api"
|
||||
# service name via Docker's embedded DNS, which round-robins across replicas.
|
||||
lb:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./nginx.scale.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
@@ -18,11 +18,28 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a
|
||||
# 301 so only one host is served and indexed.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||
server_name www.spanglishcommunity.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
return 301 https://spanglishcommunity.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name spanglishcommunity.com;
|
||||
|
||||
# Upload size limit (covers same-origin /api uploads via this vhost)
|
||||
client_max_body_size 20m;
|
||||
@@ -43,6 +60,43 @@ server {
|
||||
access_log /var/log/nginx/spanglish_frontend_access.log;
|
||||
error_log /var/log/nginx/spanglish_frontend_error.log;
|
||||
|
||||
# LNbits payment SSE stream - must not be buffered or events won't flush in
|
||||
# real time. Regex location takes precedence over the /api prefix below.
|
||||
location ~ ^/api/lnbits/stream/ {
|
||||
proxy_pass http://spanglish_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection '';
|
||||
|
||||
# Disable buffering/caching for Server-Sent Events
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
# Photo gallery service (photo-api, port 3020). ^~ wins over the /api
|
||||
# prefix below. Batch photo uploads are large and slow, so the body cap
|
||||
# is raised and request buffering is off for this location only.
|
||||
location ^~ /api/photos/ {
|
||||
proxy_pass http://spanglish_photos;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
# Proxy /api to backend
|
||||
location /api {
|
||||
proxy_pass http://spanglish_backend;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# nginx load balancer for the scaled "api" service in docker-compose.scale.yml.
|
||||
# Uses Docker's embedded DNS resolver so newly scaled replicas are discovered
|
||||
# without editing a static upstream list.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Docker embedded DNS. valid=10s re-resolves so scaling up/down is picked up.
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
location / {
|
||||
# Use a variable so nginx defers resolution to request time (round-robin
|
||||
# across all replicas of the "api" service).
|
||||
set $api_upstream http://api:3001;
|
||||
proxy_pass $api_upstream;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Server-Sent Events: do not buffer the payment status stream.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_set_header Connection "";
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# ============================================================
|
||||
# Spanglish Community - Photo Gallery API
|
||||
# photos.spanglishcommunity.com
|
||||
# ============================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name photos.spanglishcommunity.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://photos.spanglishcommunity.com$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name photos.spanglishcommunity.com;
|
||||
|
||||
# Photos can be larger than typical JSON payloads
|
||||
client_max_body_size 25m;
|
||||
|
||||
# SSL
|
||||
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
|
||||
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Security
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/spanglish_photo_access.log;
|
||||
error_log /var/log/nginx/spanglish_photo_error.log;
|
||||
|
||||
# CORS Configuration
|
||||
set $cors_origin "";
|
||||
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
||||
set $cors_origin $http_origin;
|
||||
}
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
|
||||
# Ensure 413 returns JSON + CORS
|
||||
error_page 413 = @payload_too_large;
|
||||
location @payload_too_large {
|
||||
default_type application/json;
|
||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||
}
|
||||
|
||||
# Ensure 429 (rate limited) returns JSON + CORS
|
||||
error_page 429 = @rate_limited;
|
||||
location @rate_limited {
|
||||
default_type application/json;
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
return 429 '{"error":"Too many requests. Please slow down."}';
|
||||
}
|
||||
|
||||
location / {
|
||||
limit_req zone=spanglish_photo_limit burst=40 nodelay;
|
||||
|
||||
# Preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://spanglish_photo_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
|
||||
# Buffer large image uploads to disk rather than memory
|
||||
proxy_request_buffering on;
|
||||
proxy_max_temp_file_size 1024m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
# ============================================================
|
||||
# Spanglish Community - Backend API
|
||||
# api.spanglishcommunity.com
|
||||
# ============================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.spanglishcommunity.com;
|
||||
|
||||
# ACME
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
# Force HTTPS
|
||||
location / {
|
||||
return 301 https://api.spanglishcommunity.com$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name api.spanglishcommunity.com;
|
||||
|
||||
# Upload size limit (avoid nginx 413 on media uploads)
|
||||
# Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB).
|
||||
client_max_body_size 20m;
|
||||
|
||||
# SSL
|
||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Security (API)
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/spanglish_api_access.log;
|
||||
error_log /var/log/nginx/spanglish_api_error.log;
|
||||
|
||||
# CORS Configuration (set once, used everywhere)
|
||||
set $cors_origin "";
|
||||
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
||||
set $cors_origin $http_origin;
|
||||
}
|
||||
|
||||
# Add CORS headers to all responses (including nginx-generated errors)
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
|
||||
# Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked")
|
||||
error_page 413 = @payload_too_large;
|
||||
location @payload_too_large {
|
||||
default_type application/json;
|
||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||
}
|
||||
|
||||
# Photo gallery service (photo-api, port 3003). ^~ wins over the "/"
|
||||
# prefix below, so /api/photos/* reaches the Go photo-api instead of the
|
||||
# Node backend (which has no photo routes and would 404). The admin UI
|
||||
# calls this cross-origin via NEXT_PUBLIC_API_URL, so preflight + CORS
|
||||
# must be handled here just like location / below.
|
||||
location ^~ /api/photos/ {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
# Handle preflight OPTIONS requests (add_header inside if{} does NOT
|
||||
# inherit server-level headers, so repeat all CORS headers here).
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://spanglish_photo_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Strip CORS headers from the service (nginx handles CORS here)
|
||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||
|
||||
# Photo batches can be large; allow bigger bodies + unbuffered upload.
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
# Handle preflight OPTIONS requests
|
||||
# NOTE: add_header inside if{} does NOT inherit server-level headers,
|
||||
# so we must repeat all CORS headers here.
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://spanglish_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Strip CORS headers from backend (nginx handles CORS at server level)
|
||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# ============================================================
|
||||
# Spanglish Community - Frontend
|
||||
# spanglishcommunity.com / www
|
||||
# ============================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||
|
||||
# ACME
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
# Force HTTPS
|
||||
location / {
|
||||
return 301 https://spanglishcommunity.com$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||
|
||||
# Upload size limit (covers same-origin /api uploads via this vhost)
|
||||
client_max_body_size 20m;
|
||||
|
||||
# SSL
|
||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Security
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/spanglish_frontend_access.log;
|
||||
error_log /var/log/nginx/spanglish_frontend_error.log;
|
||||
|
||||
# Proxy /api/photos to the photo-api (Go service, port 3003). ^~ wins over
|
||||
# the /api prefix below so same-origin image/gallery requests reach the
|
||||
# photo-api instead of the Node backend (which has no photo routes -> 404).
|
||||
location ^~ /api/photos/ {
|
||||
proxy_pass http://spanglish_photo_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Photo uploads/batches can be large; allow bigger bodies.
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
|
||||
# Let the photo-api set Cache-Control per image visibility (public vs.
|
||||
# token-gated) rather than forcing a cache policy here.
|
||||
}
|
||||
|
||||
# Proxy /api to backend
|
||||
location /api {
|
||||
proxy_pass http://spanglish_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
# Proxy /uploads to backend
|
||||
location /uploads {
|
||||
proxy_pass http://spanglish_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Cache static files
|
||||
proxy_cache_valid 200 1d;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Frontend App
|
||||
location / {
|
||||
proxy_pass http://spanglish_frontend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket / HMR
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_read_timeout 60s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
# ============================================================
|
||||
# Spanglish Community - Photo Gallery API
|
||||
# photos.spanglishcommunity.com
|
||||
# ============================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name photos.spanglishcommunity.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://photos.spanglishcommunity.com$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name photos.spanglishcommunity.com;
|
||||
|
||||
# Photos can be larger than typical JSON payloads
|
||||
client_max_body_size 25m;
|
||||
|
||||
# SSL
|
||||
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
|
||||
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Security
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/spanglish_photo_access.log;
|
||||
error_log /var/log/nginx/spanglish_photo_error.log;
|
||||
|
||||
# CORS Configuration
|
||||
set $cors_origin "";
|
||||
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
||||
set $cors_origin $http_origin;
|
||||
}
|
||||
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
|
||||
# Ensure 413 returns JSON + CORS
|
||||
error_page 413 = @payload_too_large;
|
||||
location @payload_too_large {
|
||||
default_type application/json;
|
||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||
}
|
||||
|
||||
# Ensure 429 (rate limited) returns JSON + CORS
|
||||
error_page 429 = @rate_limited;
|
||||
location @rate_limited {
|
||||
default_type application/json;
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
return 429 '{"error":"Too many requests. Please slow down."}';
|
||||
}
|
||||
|
||||
location / {
|
||||
limit_req zone=spanglish_photo_limit burst=40 nodelay;
|
||||
|
||||
# Preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://spanglish_photo_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
|
||||
# Buffer large image uploads to disk rather than memory
|
||||
proxy_request_buffering on;
|
||||
proxy_max_temp_file_size 1024m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
upstream spanglish_frontend {
|
||||
server 127.0.0.1:3019;
|
||||
}
|
||||
upstream spanglish_backend {
|
||||
server 127.0.0.1:3018;
|
||||
}
|
||||
upstream spanglish_photo_api {
|
||||
server 127.0.0.1:3003;
|
||||
}
|
||||
|
||||
limit_req_zone $binary_remote_addr zone=spanglish_photo_limit:10m rate=20r/s;
|
||||
limit_req_zone $binary_remote_addr zone=spanglish_api_limit:10m rate=30r/s;
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Spanglish Photo Gallery API
|
||||
Documentation=https://git.azzamo.net/Michilis/Spanglish
|
||||
After=network.target spanglish-backend.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spanglish
|
||||
Group=spanglish
|
||||
WorkingDirectory=/home/spanglish/Spanglish/photo-api
|
||||
EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env
|
||||
Environment=PORT=3020
|
||||
# Apply pending photos_* migrations before serving (idempotent, advisory-locked)
|
||||
ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate
|
||||
ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=spanglish-photos
|
||||
|
||||
# Security hardening (mirrors spanglish-backend.service)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/spanglish/Spanglish/photo-api/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -5,3 +5,7 @@ upstream spanglish_frontend {
|
||||
upstream spanglish_backend {
|
||||
server 127.0.0.1:3018;
|
||||
}
|
||||
|
||||
upstream spanglish_photos {
|
||||
server 127.0.0.1:3020;
|
||||
}
|
||||
@@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com
|
||||
# API URL (leave empty for same-origin proxy)
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
# Photo gallery service (photo-api) origin, used by the /api/photos rewrite
|
||||
# and server-side rendering of /photos pages.
|
||||
# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020
|
||||
PHOTO_API_URL=
|
||||
|
||||
# Google OAuth (optional - leave empty to hide Google Sign-In button)
|
||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
||||
|
||||
+46
-12
@@ -1,28 +1,62 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
// Backend origin for API/upload proxying. Configurable per environment instead of
|
||||
// being hardcoded to localhost.
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
// The standalone photo gallery service (photo-api/). Must be rewritten
|
||||
// before the generic /api rule below — Next rewrites are order-sensitive.
|
||||
const PHOTO_API_URL = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
|
||||
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
|
||||
.split(',')
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)
|
||||
.map((hostname) => ({ protocol: 'https', hostname }));
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'X-XSS-Protection', value: '0' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
|
||||
];
|
||||
|
||||
const nextConfig = {
|
||||
// Overridable so a production build can run while `next dev` holds .next
|
||||
// (e.g. NEXT_DIST_DIR=.next-build npm run build).
|
||||
distDir: process.env.NEXT_DIST_DIR || '.next',
|
||||
images: {
|
||||
domains: ['localhost', 'images.unsplash.com'],
|
||||
// Restrict remote image sources to a known allowlist instead of allowing any
|
||||
// https host (which let the Next image optimizer be used as an open proxy).
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '3001',
|
||||
},
|
||||
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||
{ protocol: 'http', hostname: 'localhost', port: '3001' },
|
||||
...extraImageHosts,
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/photos/:path*',
|
||||
destination: `${PHOTO_API_URL}/api/photos/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://localhost:3001/api/:path*',
|
||||
destination: `${BACKEND_URL}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/uploads/:path*',
|
||||
destination: 'http://localhost:3001/uploads/:path*',
|
||||
destination: `${BACKEND_URL}/uploads/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"dev": "dotenv -e .env -- next dev",
|
||||
"build": "next build",
|
||||
"start": "dotenv -e .env -- next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.1.4",
|
||||
@@ -14,6 +15,7 @@
|
||||
"@tiptap/pm": "^3.18.0",
|
||||
"@tiptap/react": "^3.18.0",
|
||||
"@tiptap/starter-kit": "^3.18.0",
|
||||
"better-auth": "1.6.25",
|
||||
"clsx": "^2.1.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"next": "^14.2.4",
|
||||
@@ -22,8 +24,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"swr": "^2.2.5"
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.9",
|
||||
@@ -33,6 +35,7 @@
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.5.2"
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user