Compare commits
19
Commits
backup-prod12
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87bf9a6151 | ||
|
|
3f7b2d51db | ||
|
|
5970d707af | ||
|
|
e296e80e48 | ||
|
|
a0161a67d2 | ||
|
|
be4dd5b47f | ||
|
|
390e1dc0ea | ||
|
|
fa8686276d | ||
|
|
498d7d8a7d | ||
|
|
dafa3711f8 | ||
|
|
733d2459df | ||
|
|
4afa5d6fa0 | ||
|
|
617c884012 | ||
|
|
93476ac72a | ||
|
|
c4094630d9 | ||
|
|
9b2668f498 | ||
|
|
e38d14970d | ||
|
|
71c277045b | ||
|
|
c9a600b6d6 |
@@ -7,6 +7,7 @@ package-lock.json
|
|||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
|
.next-build/
|
||||||
out/
|
out/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ yarn-error.log*
|
|||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Go photo-api service
|
||||||
|
photo-api/bin/
|
||||||
|
photo-api/data/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,24 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
|
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
|
||||||
- **User dashboard**: profile, tickets, payments, sessions/security
|
- Stable `/next` and `/featured` URLs that redirect to the current event
|
||||||
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
|
- 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`
|
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
- **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
|
- **Email**: `nodemailer` (SMTP) with optional provider config
|
||||||
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
|
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
@@ -22,6 +29,8 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- npm
|
- npm
|
||||||
|
- Go 1.26+ (for the `photo-api` service)
|
||||||
|
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -29,12 +38,14 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
npm install
|
npm install
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
cp frontend/.env.example frontend/.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)
|
### Initialize database (SQLite by default)
|
||||||
|
|
||||||
```bash
|
```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
|
### Run
|
||||||
@@ -43,10 +54,13 @@ npm run db:migrate
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
|
||||||
|
|
||||||
Default URLs:
|
Default URLs:
|
||||||
|
|
||||||
- Frontend: `http://localhost:3002`
|
- Frontend: `http://localhost:3002`
|
||||||
- Backend API: `http://localhost:3001`
|
- 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`
|
- API docs: `http://localhost:3001/api-docs`
|
||||||
|
|
||||||
### First user becomes admin
|
### 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:
|
Run these from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev # backend + frontend + photo-api
|
||||||
npm run build
|
npm run build # build all workspaces
|
||||||
npm run start
|
npm run start
|
||||||
npm run db:generate
|
npm run db:generate
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run db:studio
|
npm run db:studio
|
||||||
npm run db:export # Backup database
|
npm run db:export # Backup database
|
||||||
npm run db:import # Restore from backup
|
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:
|
You can also run per workspace:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -82,20 +117,34 @@ npm run dev --workspace=frontend
|
|||||||
Key settings (see `backend/.env.example` for the full list):
|
Key settings (see `backend/.env.example` for the full list):
|
||||||
|
|
||||||
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
|
- **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`
|
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
||||||
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
- **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)
|
- **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`)
|
### Frontend (`frontend/.env`)
|
||||||
|
|
||||||
Key settings (see `frontend/.env.example`):
|
Key settings (see `frontend/.env.example`):
|
||||||
|
|
||||||
- **Server port**: `PORT=3002`
|
- **Server port**: `PORT=3002`
|
||||||
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
|
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
|
||||||
- Leave empty to use same-origin `/api` (recommended when running behind nginx)
|
- 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.
|
||||||
- In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend
|
- 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.
|
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
@@ -143,12 +192,13 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
|
|||||||
|
|
||||||
This repo includes example configs in `deploy/`:
|
This repo includes example configs in `deploy/`:
|
||||||
|
|
||||||
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
|
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
|
||||||
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
|
- 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`
|
- 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**:
|
- **nginx**:
|
||||||
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
|
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
|
||||||
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
|
- `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
|
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
|
||||||
|
|
||||||
Typical production flow:
|
Typical production flow:
|
||||||
@@ -156,7 +206,9 @@ Typical production flow:
|
|||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run build:photos
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
|
npm run migrate:photos
|
||||||
```
|
```
|
||||||
|
|
||||||
Then install/enable the systemd services and nginx configs for your server.
|
Then install/enable the systemd services and nginx configs for your server.
|
||||||
|
|||||||
+40
-4
@@ -22,10 +22,18 @@ DATABASE_URL=./data/spanglish.db
|
|||||||
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
|
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
|
||||||
# single local file and cannot be shared safely across instances.
|
# single local file and cannot be shared safely across instances.
|
||||||
|
|
||||||
# Redis connection URL. When set, the cache, rate limiter, pub/sub (real-time
|
# Redis connection URL. When set, the cache, rate limiter, login lockout,
|
||||||
# payment events), distributed locks, and the email hourly cap are shared across
|
# pub/sub (real-time payment events), distributed locks, and the email hourly
|
||||||
# all instances. When unset, each instance uses in-memory equivalents.
|
# 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://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,
|
# 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
|
# AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and
|
||||||
@@ -42,13 +50,41 @@ DATABASE_URL=./data/spanglish.db
|
|||||||
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
|
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
|
||||||
# S3_FORCE_PATH_STYLE=true
|
# S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
# JWT Secret (change in production!)
|
# 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
|
JWT_SECRET=your-super-secret-key-change-in-production
|
||||||
|
|
||||||
# Google OAuth (optional - for Google Sign-In)
|
# Google OAuth (optional - for Google Sign-In)
|
||||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||||
# Note: The same Client ID should be used in frontend/.env
|
# Note: The same Client ID should be used in frontend/.env
|
||||||
GOOGLE_CLIENT_ID=
|
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
|
# Server Configuration
|
||||||
PORT=3001
|
PORT=3001
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx src/db/migrate.ts",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
@@ -19,9 +20,10 @@
|
|||||||
"@hono/zod-openapi": "^0.14.4",
|
"@hono/zod-openapi": "^0.14.4",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-auth": "1.6.25",
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.31.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"hono": "^4.4.7",
|
"hono": "^4.4.7",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"jose": "^5.4.0",
|
"jose": "^5.4.0",
|
||||||
@@ -41,8 +43,10 @@
|
|||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"@types/pg": "^8.11.6",
|
"@types/pg": "^8.11.6",
|
||||||
"@types/qrcode": "^1.5.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",
|
"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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
+357
-5
@@ -1,6 +1,6 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { db, dbAll, events } from './index.js';
|
import { db, dbAll, dbGet, events, users } from './index.js';
|
||||||
import { sql, eq } from 'drizzle-orm';
|
import { sql, eq, ne } from 'drizzle-orm';
|
||||||
import { uniqueSlug } from '../lib/slugify.js';
|
import { uniqueSlug } from '../lib/slugify.js';
|
||||||
|
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
@@ -133,17 +133,25 @@ async function migrate() {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await (db as any).run(sql`
|
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 (
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
event_id TEXT NOT NULL REFERENCES events(id),
|
event_id TEXT NOT NULL REFERENCES events(id),
|
||||||
attendee_name TEXT NOT NULL,
|
attendee_first_name TEXT NOT NULL,
|
||||||
attendee_email TEXT NOT NULL,
|
attendee_last_name TEXT,
|
||||||
attendee_phone TEXT NOT NULL,
|
attendee_email TEXT,
|
||||||
|
attendee_phone TEXT,
|
||||||
|
attendee_ruc TEXT,
|
||||||
preferred_language TEXT,
|
preferred_language TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
checkin_at TEXT,
|
checkin_at TEXT,
|
||||||
qr_code TEXT,
|
qr_code TEXT,
|
||||||
|
admin_note TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
@@ -200,6 +208,18 @@ async function migrate() {
|
|||||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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)
|
// 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
|
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||||
|
|
||||||
@@ -247,6 +267,25 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// Invoices table
|
// Invoices table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
@@ -532,6 +571,81 @@ async function migrate() {
|
|||||||
updated_by TEXT REFERENCES users(id)
|
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 {
|
} else {
|
||||||
// PostgreSQL migrations
|
// PostgreSQL migrations
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -702,6 +816,18 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -737,6 +863,25 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// Invoices table
|
// Invoices table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -1020,6 +1165,80 @@ async function migrate() {
|
|||||||
updated_by UUID REFERENCES users(id)
|
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
|
||||||
|
)
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
||||||
@@ -1030,8 +1249,15 @@ async function migrate() {
|
|||||||
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
|
`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_ticket_id_idx ON payments(ticket_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
|
`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 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 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)`,
|
||||||
];
|
];
|
||||||
for (const stmt of indexStatements) {
|
for (const stmt of indexStatements) {
|
||||||
try {
|
try {
|
||||||
@@ -1043,6 +1269,132 @@ async function migrate() {
|
|||||||
} catch (e) { /* index may already exist */ }
|
} 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).
|
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
||||||
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
||||||
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
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
|
// Type to determine which schema to use
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
@@ -20,6 +20,12 @@ export const sqliteUsers = sqliteTable('users', {
|
|||||||
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
||||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
||||||
tokenVersion: integer('token_version').notNull().default(0),
|
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(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -110,6 +116,8 @@ export const sqliteTickets = sqliteTable('tickets', {
|
|||||||
qrCode: text('qr_code'),
|
qrCode: text('qr_code'),
|
||||||
adminNote: text('admin_note'),
|
adminNote: text('admin_note'),
|
||||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
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'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,10 +138,32 @@ export const sqlitePayments = sqliteTable('payments', {
|
|||||||
paidByAdminId: text('paid_by_admin_id'),
|
paidByAdminId: text('paid_by_admin_id'),
|
||||||
adminNote: text('admin_note'), // Internal admin notes
|
adminNote: text('admin_note'), // Internal admin notes
|
||||||
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
|
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', 'guest'] }),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_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)
|
// Payment Options Configuration Table (global settings)
|
||||||
export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -378,6 +408,12 @@ export const pgUsers = pgTable('users', {
|
|||||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
||||||
tokenVersion: pgInteger('token_version').notNull().default(0),
|
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(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -468,6 +504,8 @@ export const pgTickets = pgTable('tickets', {
|
|||||||
qrCode: varchar('qr_code', { length: 255 }),
|
qrCode: varchar('qr_code', { length: 255 }),
|
||||||
adminNote: pgText('admin_note'),
|
adminNote: pgText('admin_note'),
|
||||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
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'),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -488,10 +526,26 @@ export const pgPayments = pgTable('payments', {
|
|||||||
paidByAdminId: uuid('paid_by_admin_id'),
|
paidByAdminId: uuid('paid_by_admin_id'),
|
||||||
adminNote: pgText('admin_note'),
|
adminNote: pgText('admin_note'),
|
||||||
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
|
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(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_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)
|
// Payment Options Configuration Table (global settings)
|
||||||
export const pgPaymentOptions = pgTable('payment_options', {
|
export const pgPaymentOptions = pgTable('payment_options', {
|
||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
@@ -718,6 +772,7 @@ export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
|||||||
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
|
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
|
||||||
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
|
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
|
||||||
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
|
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
|
||||||
|
export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys;
|
||||||
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
|
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
|
||||||
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
|
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
|
||||||
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
|
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
|
||||||
|
|||||||
+292
-130
@@ -7,9 +7,12 @@ import { logger } from 'hono/logger';
|
|||||||
import { swaggerUI } from '@hono/swagger-ui';
|
import { swaggerUI } from '@hono/swagger-ui';
|
||||||
|
|
||||||
import { serveStatic } from '@hono/node-server/serve-static';
|
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 eventsRoutes from './routes/events.js';
|
||||||
import ticketsRoutes from './routes/tickets.js';
|
import ticketsRoutes from './routes/tickets.js';
|
||||||
|
import doorRoutes from './routes/door.js';
|
||||||
import usersRoutes from './routes/users.js';
|
import usersRoutes from './routes/users.js';
|
||||||
import contactsRoutes from './routes/contacts.js';
|
import contactsRoutes from './routes/contacts.js';
|
||||||
import paymentsRoutes from './routes/payments.js';
|
import paymentsRoutes from './routes/payments.js';
|
||||||
@@ -24,10 +27,11 @@ import legalPagesRoutes from './routes/legal-pages.js';
|
|||||||
import legalSettingsRoutes from './routes/legal-settings.js';
|
import legalSettingsRoutes from './routes/legal-settings.js';
|
||||||
import faqRoutes from './routes/faq.js';
|
import faqRoutes from './routes/faq.js';
|
||||||
import emailService from './lib/email.js';
|
import emailService from './lib/email.js';
|
||||||
import { initEmailQueue } from './lib/emailQueue.js';
|
import { initEmailQueue, stopQueue } from './lib/emailQueue.js';
|
||||||
import { startBookingCleanup } from './lib/bookingCleanup.js';
|
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
|
||||||
import { startHoldSweep } from './lib/holdSweep.js';
|
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
|
||||||
import { startEventEndSweep } from './lib/eventEndSweep.js';
|
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
|
||||||
|
import { closeRedis } from './lib/redis.js';
|
||||||
import { getLock } from './lib/stores/lock.js';
|
import { getLock } from './lib/stores/lock.js';
|
||||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
||||||
|
|
||||||
@@ -56,7 +60,7 @@ app.use(
|
|||||||
if (!origin) return frontendUrl;
|
if (!origin) return frontendUrl;
|
||||||
return allowedOrigins.has(origin) ? origin : null;
|
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,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -109,11 +113,15 @@ const openApiSpec = {
|
|||||||
],
|
],
|
||||||
paths: {
|
paths: {
|
||||||
// ==================== Auth Endpoints ====================
|
// ==================== 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: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Register a new user',
|
summary: 'Register a new user (Better Auth)',
|
||||||
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
|
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: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -123,8 +131,8 @@ const openApiSpec = {
|
|||||||
required: ['email', 'password', 'name'],
|
required: ['email', 'password', 'name'],
|
||||||
properties: {
|
properties: {
|
||||||
email: { type: 'string', format: 'email' },
|
email: { type: 'string', format: 'email' },
|
||||||
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
|
password: { type: 'string', minLength: 10 },
|
||||||
name: { type: 'string', minLength: 2 },
|
name: { type: 'string' },
|
||||||
phone: { type: 'string' },
|
phone: { type: 'string' },
|
||||||
languagePreference: { type: 'string', enum: ['en', 'es'] },
|
languagePreference: { type: 'string', enum: ['en', 'es'] },
|
||||||
},
|
},
|
||||||
@@ -133,16 +141,17 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
201: { description: 'User created successfully' },
|
200: { description: 'User created; session cookie set' },
|
||||||
400: { description: 'Email already registered or validation error' },
|
422: { description: 'Email already registered' },
|
||||||
|
400: { description: 'Validation error' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/login': {
|
'/api/auth/sign-in/email': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Login with email and password',
|
summary: 'Login with email and password (Better Auth)',
|
||||||
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
|
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -159,42 +168,46 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Login successful, returns JWT token' },
|
200: { description: 'Login successful; session cookie set' },
|
||||||
401: { description: 'Invalid credentials' },
|
401: { description: 'Invalid credentials' },
|
||||||
429: { description: 'Too many login attempts' },
|
429: { description: 'Too many attempts' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/google': {
|
'/api/auth/sign-in/social': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Login or register with Google',
|
summary: 'Login or register with Google (Better Auth)',
|
||||||
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
|
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['credential'],
|
required: ['provider'],
|
||||||
properties: {
|
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: {
|
responses: {
|
||||||
200: { description: 'Login successful' },
|
200: { description: 'Login successful; session cookie set' },
|
||||||
400: { description: 'Invalid Google token' },
|
401: { description: 'Invalid Google token' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/magic-link/request': {
|
'/api/auth/sign-in/magic-link': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Request magic link login',
|
summary: 'Request magic link login (Better Auth)',
|
||||||
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
|
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -204,46 +217,33 @@ const openApiSpec = {
|
|||||||
required: ['email'],
|
required: ['email'],
|
||||||
properties: {
|
properties: {
|
||||||
email: { type: 'string', format: 'email' },
|
email: { type: 'string', format: 'email' },
|
||||||
|
callbackURL: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: { 200: { description: 'Magic link sent (if account exists)' } },
|
||||||
200: { description: 'Magic link sent (if account exists)' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/magic-link/verify': {
|
'/api/auth/magic-link/verify': {
|
||||||
post: {
|
get: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Verify magic link token',
|
summary: 'Verify magic link token (Better Auth)',
|
||||||
description: 'Verify the magic link token and login user.',
|
parameters: [
|
||||||
requestBody: {
|
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } },
|
||||||
required: true,
|
],
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['token'],
|
|
||||||
properties: {
|
|
||||||
token: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Login successful' },
|
200: { description: 'Login successful; session cookie set' },
|
||||||
400: { description: 'Invalid or expired token' },
|
400: { description: 'Invalid or expired token' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/password-reset/request': {
|
'/api/auth/request-password-reset': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Request password reset',
|
summary: 'Request password reset (Better Auth)',
|
||||||
description: 'Send a password reset link to email. Link expires in 30 minutes.',
|
description: 'Emails a reset link. Token expires in 30 minutes.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -253,31 +253,30 @@ const openApiSpec = {
|
|||||||
required: ['email'],
|
required: ['email'],
|
||||||
properties: {
|
properties: {
|
||||||
email: { type: 'string', format: 'email' },
|
email: { type: 'string', format: 'email' },
|
||||||
|
redirectTo: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: { 200: { description: 'Reset link sent (if account exists)' } },
|
||||||
200: { description: 'Reset link sent (if account exists)' },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
'/api/auth/reset-password': {
|
||||||
'/api/auth/password-reset/confirm': {
|
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Confirm password reset',
|
summary: 'Reset password with token (Better Auth)',
|
||||||
description: 'Reset password using the token from email.',
|
description: 'Sets a new password and revokes all existing sessions.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['token', 'password'],
|
required: ['newPassword', 'token'],
|
||||||
properties: {
|
properties: {
|
||||||
|
newPassword: { type: 'string', minLength: 10 },
|
||||||
token: { type: 'string' },
|
token: { type: 'string' },
|
||||||
password: { type: 'string', minLength: 10 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -289,62 +288,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': {
|
'/api/auth/change-password': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Change password',
|
summary: 'Change password (Better Auth)',
|
||||||
description: 'Change password for authenticated user.',
|
|
||||||
security: [{ bearerAuth: [] }],
|
security: [{ bearerAuth: [] }],
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -356,6 +303,7 @@ const openApiSpec = {
|
|||||||
properties: {
|
properties: {
|
||||||
currentPassword: { type: 'string' },
|
currentPassword: { type: 'string' },
|
||||||
newPassword: { type: 'string', minLength: 10 },
|
newPassword: { type: 'string', minLength: 10 },
|
||||||
|
revokeOtherSessions: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -368,27 +316,67 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/me': {
|
'/api/auth/get-session': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Get current user',
|
summary: 'Get current session and user (Better Auth)',
|
||||||
description: 'Get the currently authenticated user profile.',
|
|
||||||
security: [{ bearerAuth: [] }],
|
security: [{ bearerAuth: [] }],
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Current user data' },
|
200: { description: '{ session, user } or null when not authenticated' },
|
||||||
401: { description: 'Unauthorized' },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/logout': {
|
'/api/auth/sign-out': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Logout',
|
summary: 'Logout (Better Auth)',
|
||||||
description: 'Logout current user (client-side token removal).',
|
description: 'Revokes the current session and clears the session cookie.',
|
||||||
responses: {
|
security: [{ bearerAuth: [] }],
|
||||||
200: { description: 'Logged out' },
|
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 ====================
|
// ==================== User Dashboard Endpoints ====================
|
||||||
@@ -780,6 +768,116 @@ 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. 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', 'guest'] },
|
||||||
|
amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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' },
|
||||||
|
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': {
|
'/api/tickets/{id}/checkin': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Tickets'],
|
tags: ['Tickets'],
|
||||||
@@ -1761,10 +1859,10 @@ const openApiSpec = {
|
|||||||
components: {
|
components: {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
bearerAuth: {
|
bearerAuth: {
|
||||||
type: 'http',
|
type: 'apiKey',
|
||||||
scheme: 'bearer',
|
in: 'cookie',
|
||||||
bearerFormat: 'JWT',
|
name: 'spanglish.session_token',
|
||||||
description: 'JWT token obtained from login endpoint',
|
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -1898,7 +1996,32 @@ app.get('/health', (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// API Routes
|
// 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);
|
||||||
app.route('/api/events', eventsRoutes);
|
app.route('/api/events', eventsRoutes);
|
||||||
app.route('/api/tickets', ticketsRoutes);
|
app.route('/api/tickets', ticketsRoutes);
|
||||||
app.route('/api/users', usersRoutes);
|
app.route('/api/users', usersRoutes);
|
||||||
@@ -1943,8 +2066,11 @@ startEventEndSweep();
|
|||||||
// Initialize email templates on startup.
|
// Initialize email templates on startup.
|
||||||
// Guarded by a distributed lock so that, when running multiple replicas, only
|
// 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.
|
// 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()
|
getLock()
|
||||||
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
|
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
console.log('[Email] Template seeding skipped (another instance holds the lock)');
|
console.log('[Email] Template seeding skipped (another instance holds the lock)');
|
||||||
@@ -1961,7 +2087,43 @@ console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
|
|||||||
// Log which backend (memory/redis, local/s3) each subsystem selected.
|
// Log which backend (memory/redis, local/s3) each subsystem selected.
|
||||||
logSelectedBackends();
|
logSelectedBackends();
|
||||||
|
|
||||||
serve({
|
const server = serve({
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port,
|
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'));
|
||||||
|
|||||||
+82
-321
@@ -1,332 +1,76 @@
|
|||||||
import * as jose from 'jose';
|
|
||||||
import * as argon2 from 'argon2';
|
|
||||||
import bcrypt from 'bcryptjs';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import { Context } from 'hono';
|
import { Context } from 'hono';
|
||||||
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
|
import { auth } from './betterAuth.js';
|
||||||
import { generateId, getNow, toDbDate } from './utils.js';
|
import { db, dbGet } from '../db/index.js';
|
||||||
|
import { authAccounts } from '../db/auth-schema.js';
|
||||||
|
|
||||||
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
|
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
|
||||||
const rawJwtSecret = process.env.JWT_SECRET;
|
// 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.
|
||||||
|
|
||||||
// Never allow the insecure default in production: forgeable tokens = full account takeover.
|
// Re-exported for routes that hash/validate passwords outside Better Auth
|
||||||
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
|
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
||||||
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
|
|
||||||
}
|
|
||||||
if (!rawJwtSecret) {
|
|
||||||
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
|
export interface AuthUser {
|
||||||
const JWT_ISSUER = 'spanglish';
|
id: string;
|
||||||
const JWT_AUDIENCE = 'spanglish-app';
|
|
||||||
|
|
||||||
export interface JWTPayload {
|
|
||||||
sub: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
|
name: string;
|
||||||
|
phone: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
tokenVersion?: number;
|
languagePreference: string | null;
|
||||||
iat: number;
|
isClaimed: boolean;
|
||||||
exp: number;
|
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> {
|
* Resolve the authenticated user for a request from its Better Auth session
|
||||||
return argon2.hash(password, {
|
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
|
||||||
type: argon2.argon2id,
|
* accounts never get API access even with a live session cookie.
|
||||||
memoryCost: 65536, // 64 MB
|
*/
|
||||||
timeCost: 3,
|
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
|
||||||
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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use a single generic error for all invalid states to avoid leaking token state
|
|
||||||
const genericError = 'Invalid or expired token';
|
|
||||||
|
|
||||||
if (!tokenRecord) {
|
|
||||||
return { valid: false, error: genericError };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tokenRecord.usedAt) {
|
|
||||||
return { valid: false, error: genericError };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
|
||||||
return { valid: false, error: genericError };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomically consume the token: only the request that flips used_at from NULL wins.
|
|
||||||
// This prevents a double-spend race where two concurrent requests both pass the
|
|
||||||
// read-time "not used" check above.
|
|
||||||
const result: any = await (db as any)
|
|
||||||
.update(magicLinkTokens)
|
|
||||||
.set({ usedAt: now })
|
|
||||||
.where(and(
|
|
||||||
eq((magicLinkTokens as any).id, tokenRecord.id),
|
|
||||||
isNull((magicLinkTokens as any).usedAt)
|
|
||||||
));
|
|
||||||
|
|
||||||
const affected = result?.changes ?? result?.rowCount ?? 0;
|
|
||||||
if (affected === 0) {
|
|
||||||
return { valid: false, error: genericError };
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
|
|
||||||
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
|
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setIssuer(JWT_ISSUER)
|
|
||||||
.setAudience(JWT_AUDIENCE)
|
|
||||||
.setExpirationTime('1d')
|
|
||||||
.sign(JWT_SECRET);
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
|
|
||||||
export async function bumpTokenVersion(userId: string): Promise<void> {
|
|
||||||
await (db as any)
|
|
||||||
.update(users)
|
|
||||||
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
|
|
||||||
.where(eq((users as any).id, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
try {
|
try {
|
||||||
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||||
issuer: JWT_ISSUER,
|
if (!session?.user) {
|
||||||
audience: JWT_AUDIENCE,
|
|
||||||
});
|
|
||||||
return payload as unknown as JWTPayload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuthUser(c: Context): Promise<any | null> {
|
|
||||||
const authHeader = c.req.header('Authorization');
|
|
||||||
if (!authHeader?.startsWith('Bearer ')) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.slice(7);
|
const user = session.user as any;
|
||||||
const payload = await verifyToken(token);
|
|
||||||
|
|
||||||
if (!payload) {
|
// Suspended (banned) or unclaimed accounts must not retain API access
|
||||||
|
if (user.banned) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never load the password hash into request context — it is only needed for
|
|
||||||
// explicit password-verification routes that query it separately.
|
|
||||||
const user = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({
|
|
||||||
id: (users as any).id,
|
|
||||||
email: (users as any).email,
|
|
||||||
name: (users as any).name,
|
|
||||||
phone: (users as any).phone,
|
|
||||||
role: (users as any).role,
|
|
||||||
languagePreference: (users as any).languagePreference,
|
|
||||||
isClaimed: (users as any).isClaimed,
|
|
||||||
googleId: (users as any).googleId,
|
|
||||||
rucNumber: (users as any).rucNumber,
|
|
||||||
accountStatus: (users as any).accountStatus,
|
|
||||||
tokenVersion: (users as any).tokenVersion,
|
|
||||||
createdAt: (users as any).createdAt,
|
|
||||||
updatedAt: (users as any).updatedAt,
|
|
||||||
})
|
|
||||||
.from(users)
|
|
||||||
.where(eq((users as any).id, payload.sub))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject tokens issued before a logout-everywhere / password change
|
|
||||||
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suspended/unclaimed accounts must not retain API access via an old JWT
|
|
||||||
if (user.accountStatus && user.accountStatus !== 'active') {
|
if (user.accountStatus && user.accountStatus !== 'active') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
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 function requireAuth(roles?: string[]) {
|
export function requireAuth(roles?: string[]) {
|
||||||
@@ -346,21 +90,38 @@ export function requireAuth(roles?: string[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isFirstUser(): Promise<boolean> {
|
/**
|
||||||
const result = await dbAll(
|
* Fetch only the credential password hash (never exposed via getAuthUser).
|
||||||
(db as any).select().from(users).limit(1)
|
* Returns null when the user has no password set (Google-only or unclaimed).
|
||||||
);
|
*/
|
||||||
return !result || result.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch only the password hash column (never expose via getAuthUser). */
|
|
||||||
export async function getUserPasswordHash(userId: string): Promise<string | null> {
|
export async function getUserPasswordHash(userId: string): Promise<string | null> {
|
||||||
const row = await dbGet<any>(
|
const row = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ password: (users as any).password })
|
.select({ password: (authAccounts as any).password })
|
||||||
.from(users)
|
.from(authAccounts)
|
||||||
.where(eq((users as any).id, userId))
|
.where(
|
||||||
|
and(
|
||||||
|
eq((authAccounts as any).userId, userId),
|
||||||
|
eq((authAccounts as any).providerId, 'credential')
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const hash = row?.password;
|
const hash = row?.password;
|
||||||
return hash && String(hash).length > 0 ? String(hash) : null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Reports which backend each scalable subsystem is using, for the health
|
// Reports which backend each scalable subsystem is using, for the health
|
||||||
// endpoint and startup logging.
|
// endpoint and startup logging.
|
||||||
|
|
||||||
import { isRedisEnabled, isRedisHealthy } from './redis.js';
|
import { isRedisEnabled, isRedisHealthy, getRedisHealthDetail } from './redis.js';
|
||||||
import { getRateLimiter } from './stores/rateLimiter.js';
|
import { getRateLimiter } from './stores/rateLimiter.js';
|
||||||
import { getPubSub } from './stores/pubsub.js';
|
import { getPubSub } from './stores/pubsub.js';
|
||||||
import { getCache } from './stores/cache.js';
|
import { getCache } from './stores/cache.js';
|
||||||
import { getLock } from './stores/lock.js';
|
import { getLock } from './stores/lock.js';
|
||||||
|
import { getLoginLockout } from './stores/loginLockout.js';
|
||||||
import { getStorage } from './storage.js';
|
import { getStorage } from './storage.js';
|
||||||
|
|
||||||
export function describeBackends() {
|
export function describeBackends() {
|
||||||
@@ -14,12 +15,13 @@ export function describeBackends() {
|
|||||||
rateLimiter: getRateLimiter().backend,
|
rateLimiter: getRateLimiter().backend,
|
||||||
pubsub: getPubSub().backend,
|
pubsub: getPubSub().backend,
|
||||||
lock: getLock().backend,
|
lock: getLock().backend,
|
||||||
|
loginLockout: getLoginLockout().backend,
|
||||||
storage: getStorage().backend,
|
storage: getStorage().backend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeRedis() {
|
export function describeRedis() {
|
||||||
return { enabled: isRedisEnabled(), healthy: isRedisHealthy() };
|
return { enabled: isRedisEnabled(), healthy: isRedisHealthy(), ...getRedisHealthDetail() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Log one line per subsystem at startup so the active backend is obvious. */
|
/** Log one line per subsystem at startup so the active backend is obvious. */
|
||||||
@@ -32,5 +34,6 @@ export function logSelectedBackends(): void {
|
|||||||
console.log(` rate limiter: ${b.rateLimiter}`);
|
console.log(` rate limiter: ${b.rateLimiter}`);
|
||||||
console.log(` pub/sub: ${b.pubsub}`);
|
console.log(` pub/sub: ${b.pubsub}`);
|
||||||
console.log(` lock: ${b.lock}`);
|
console.log(` lock: ${b.lock}`);
|
||||||
|
console.log(` login lockout:${b.loginLockout}`);
|
||||||
console.log(` storage: ${b.storage}`);
|
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;
|
||||||
@@ -18,7 +18,7 @@ import { and, eq, lt, inArray, notInArray } from 'drizzle-orm';
|
|||||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||||
import { getNow, toDbDate } from './utils.js';
|
import { getNow, toDbDate } from './utils.js';
|
||||||
import { getLock } from './stores/lock.js';
|
import { getLock } from './stores/lock.js';
|
||||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||||
|
|
||||||
function getTtlMs(): number {
|
function getTtlMs(): number {
|
||||||
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
|
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 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).
|
||||||
|
*/
|
||||||
|
export function eventSeatBreakdownQuery(executor: any, eventId?: 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));
|
||||||
|
return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query)
|
||||||
|
.groupBy((tickets as any).eventId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Door payment tenders.
|
||||||
|
//
|
||||||
|
// The door check-in screen offers four one-tap 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.
|
||||||
|
|
||||||
|
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', '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';
|
||||||
|
/** Human label used in payment references and toasts. */
|
||||||
|
label: string;
|
||||||
|
/** Comp tenders carry no revenue and always record a zero amount. */
|
||||||
|
isComp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
|
||||||
|
cash: { provider: 'cash', label: 'cash', isComp: false },
|
||||||
|
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false },
|
||||||
|
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false },
|
||||||
|
guest: { provider: 'cash', label: 'guest', isComp: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
// Shared capacity-checked recovery for released bookings.
|
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
|
||||||
//
|
//
|
||||||
// When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the
|
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
|
||||||
// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat.
|
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
|
||||||
// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking
|
// states — user clicking "I've paid", an admin approving/reactivating a payment —
|
||||||
// it paid / reopening a failed payment) must atomically re-check that the event still has
|
// must atomically re-check that the event still has room, exactly like the original
|
||||||
// room before re-reserving the seat, exactly like the original booking-creation flow in
|
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
|
||||||
// routes/tickets.ts.
|
// (e.g. reopening a failed payment) claims no seat and skips the check.
|
||||||
//
|
//
|
||||||
// Callers choose which ticket statuses are eligible to be re-reserved via
|
// Callers choose which ticket statuses are eligible to be flipped via
|
||||||
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and
|
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
|
||||||
// tickets that already hold a seat — are left untouched and don't consume capacity.
|
// 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, sql } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
||||||
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
||||||
|
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
|
||||||
|
|
||||||
export class HoldCapacityError extends Error {
|
export class HoldCapacityError extends Error {
|
||||||
constructor(public available: number) {
|
constructor(public available: number) {
|
||||||
@@ -26,6 +29,8 @@ interface ReserveOptions {
|
|||||||
extraPaymentFields?: Record<string, any>;
|
extraPaymentFields?: Record<string, any>;
|
||||||
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
||||||
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
|
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
|
||||||
|
/** Admin override: reserve even when it puts the event over capacity. */
|
||||||
|
skipCapacityCheck?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +39,8 @@ interface ReserveOptions {
|
|||||||
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
|
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
|
||||||
* Capacity is asserted against the number of those tickets that don't already
|
* 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.
|
* hold a seat, so re-reserving tickets that are already seated is a no-op.
|
||||||
* Throws HoldCapacityError if the event no longer has room.
|
* Throws HoldCapacityError if the event no longer has room (unless the target
|
||||||
|
* state holds no seat, or skipCapacityCheck is set).
|
||||||
*/
|
*/
|
||||||
export async function reserveOnHoldBooking(
|
export async function reserveOnHoldBooking(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
@@ -46,6 +52,10 @@ export async function reserveOnHoldBooking(
|
|||||||
if (ticketIds.length === 0) return;
|
if (ticketIds.length === 0) return;
|
||||||
|
|
||||||
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
|
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>(
|
const event = await dbGet<any>(
|
||||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||||
@@ -65,8 +75,14 @@ export async function reserveOnHoldBooking(
|
|||||||
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
|
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
|
// `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 status cost nothing.
|
// be found new capacity; tickets already in a seat-holding state cost nothing.
|
||||||
const assertCapacity = (reserved: number, needed: number) => {
|
const assertCapacity = (reserved: number, needed: number) => {
|
||||||
if (needed <= 0) return;
|
if (needed <= 0) return;
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
@@ -80,26 +96,14 @@ export async function reserveOnHoldBooking(
|
|||||||
|
|
||||||
if (isSqlite()) {
|
if (isSqlite()) {
|
||||||
(db as any).transaction((tx: any) => {
|
(db as any).transaction((tx: any) => {
|
||||||
const countRow = tx
|
if (checkCapacity) {
|
||||||
.select({ count: sql<number>`count(*)` })
|
const countRow = seatHolderCountQuery(tx, eventId).get();
|
||||||
.from(tickets)
|
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
|
||||||
.where(and(
|
|
||||||
eq((tickets as any).eventId, eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
))
|
|
||||||
.get();
|
|
||||||
const neededRow = tx
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(and(
|
|
||||||
inArray((tickets as any).id, ticketIds),
|
|
||||||
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
))
|
|
||||||
.get();
|
|
||||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||||
|
}
|
||||||
|
|
||||||
tx.update(tickets)
|
tx.update(tickets)
|
||||||
.set({ status: targetTicketStatus })
|
.set(ticketUpdate)
|
||||||
.where(and(
|
.where(and(
|
||||||
inArray((tickets as any).id, ticketIds),
|
inArray((tickets as any).id, ticketIds),
|
||||||
inArray((tickets as any).status, fromTicketStatuses)
|
inArray((tickets as any).status, fromTicketStatuses)
|
||||||
@@ -113,28 +117,14 @@ export async function reserveOnHoldBooking(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await (db as any).transaction(async (tx: any) => {
|
await (db as any).transaction(async (tx: any) => {
|
||||||
const countRow = await dbGet<any>(
|
if (checkCapacity) {
|
||||||
tx
|
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
|
||||||
.select({ count: sql<number>`count(*)` })
|
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
|
||||||
.from(tickets)
|
|
||||||
.where(and(
|
|
||||||
eq((tickets as any).eventId, eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
))
|
|
||||||
);
|
|
||||||
const neededRow = await dbGet<any>(
|
|
||||||
tx
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(and(
|
|
||||||
inArray((tickets as any).id, ticketIds),
|
|
||||||
sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
))
|
|
||||||
);
|
|
||||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||||
|
}
|
||||||
|
|
||||||
await tx.update(tickets)
|
await tx.update(tickets)
|
||||||
.set({ status: targetTicketStatus })
|
.set(ticketUpdate)
|
||||||
.where(and(
|
.where(and(
|
||||||
inArray((tickets as any).id, ticketIds),
|
inArray((tickets as any).id, ticketIds),
|
||||||
inArray((tickets as any).status, fromTicketStatuses)
|
inArray((tickets as any).status, fromTicketStatuses)
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
// Auto-hold stale manual-payment bookings.
|
// Auto-hold stale unsettled manual-payment bookings.
|
||||||
//
|
//
|
||||||
// This job releases the seat held by an abandoned manual-payment booking (bank
|
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
|
||||||
// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of
|
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
|
||||||
// which keep a seat reserved while awaiting a human:
|
// the customer never clicked "I've paid" and no admin settled them. These are exempt
|
||||||
// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin.
|
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
|
||||||
// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was
|
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
|
||||||
// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts,
|
// dead checkouts out of the admin's pending queues.
|
||||||
// so this is their only seat-release path).
|
//
|
||||||
// In either case the payment (and its ticket) is silently moved to 'on_hold', which
|
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
|
||||||
// drops it out of the capacity-counting statuses ('pending', 'confirmed', 'checked_in')
|
// claimed payment keeps its seat until an admin approves or rejects it — the admin
|
||||||
// and so releases the seat back to the event. The user receives no notification — they
|
// UI surfaces aging claims instead of silently releasing them.
|
||||||
// can recover via "I've paid" again, and an admin can reactivate or mark it paid
|
//
|
||||||
// directly, both re-checking capacity.
|
// 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, or, eq, lt, inArray } from 'drizzle-orm';
|
import { and, eq, lt, inArray } from 'drizzle-orm';
|
||||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||||
import { getNow, toDbDate } from './utils.js';
|
import { getNow, toDbDate } from './utils.js';
|
||||||
import { getLock } from './stores/lock.js';
|
import { getLock } from './stores/lock.js';
|
||||||
import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js';
|
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||||
|
|
||||||
function getThresholdMs(): number {
|
function getThresholdMs(): number {
|
||||||
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
||||||
@@ -25,9 +26,9 @@ function getThresholdMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move stale awaiting-verification payments (and their tickets) to 'on_hold'.
|
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
|
||||||
* Covers 'pending_approval' payments and 'pending' payments on manual providers.
|
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
|
||||||
* Returns the number of payments put on hold.
|
* is never swept. Returns the number of payments put on hold.
|
||||||
*/
|
*/
|
||||||
export async function sweepStaleApprovals(): Promise<number> {
|
export async function sweepStaleApprovals(): Promise<number> {
|
||||||
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
||||||
@@ -40,13 +41,8 @@ export async function sweepStaleApprovals(): Promise<number> {
|
|||||||
})
|
})
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(and(
|
.where(and(
|
||||||
or(
|
|
||||||
eq((payments as any).status, 'pending_approval'),
|
|
||||||
and(
|
|
||||||
eq((payments as any).status, 'pending'),
|
eq((payments as any).status, 'pending'),
|
||||||
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS])
|
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||||
)
|
|
||||||
),
|
|
||||||
lt((payments as any).createdAt, cutoff)
|
lt((payments as any).createdAt, cutoff)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
@@ -72,7 +68,7 @@ export async function sweepStaleApprovals(): Promise<number> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[HoldSweep] Put ${stale.length} stale awaiting-verification payment(s) on hold.`);
|
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
|
||||||
return stale.length;
|
return stale.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
// Payment providers that require a human to verify the money arrived.
|
|
||||||
//
|
|
||||||
// These methods (bank transfer / TPago / cash) are never auto-confirmed and —
|
|
||||||
// crucially — are never auto-failed by the stale-booking cleanup: an admin settles
|
|
||||||
// them by hand. Note this is broader than the set of methods that expose an online
|
|
||||||
// "I've paid" step (bank transfer / TPago only); cash is settled at the door.
|
|
||||||
export const MANUAL_PAYMENT_PROVIDERS = ['bank_transfer', 'tpago', 'cash'] as const;
|
|
||||||
@@ -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,37 @@
|
|||||||
|
// 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 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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
// PDF Ticket Generation Service
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
interface TicketData {
|
interface TicketData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,235 +17,390 @@ interface TicketData {
|
|||||||
locationUrl?: string;
|
locationUrl?: string;
|
||||||
};
|
};
|
||||||
timezone?: 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 ====================
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
navy: '#002F44',
|
||||||
|
orange: '#F5821F',
|
||||||
|
cream: '#FDF8F0',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
cardBorder: '#EFE6D8',
|
||||||
|
divider: '#E7DFD1',
|
||||||
|
label: '#9AA3AC',
|
||||||
|
muted: '#6B7580',
|
||||||
|
footerMuted: '#7FA3B5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_W = 595.28;
|
||||||
|
const PAGE_H = 841.89;
|
||||||
|
const MARGIN = 48;
|
||||||
|
const CONTENT_W = PAGE_W - MARGIN * 2;
|
||||||
|
const ACCENT_H = 10;
|
||||||
|
const FOOTER_H = 48;
|
||||||
|
|
||||||
|
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;
|
||||||
|
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> {
|
async function generateQRCode(data: string): Promise<Buffer> {
|
||||||
return QRCode.toBuffer(data, {
|
return QRCode.toBuffer(data, {
|
||||||
type: 'png',
|
type: 'png',
|
||||||
width: 200,
|
width: 600,
|
||||||
margin: 2,
|
margin: 1,
|
||||||
errorCorrectionLevel: 'M',
|
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 {
|
function formatWhen(
|
||||||
const date = new Date(dateStr);
|
startStr: string,
|
||||||
return date.toLocaleDateString('en-US', {
|
endStr: string | undefined,
|
||||||
weekday: 'long',
|
timezone: string,
|
||||||
year: 'numeric',
|
locale: string
|
||||||
month: 'long',
|
): string {
|
||||||
day: 'numeric',
|
const isEs = locale === 'es';
|
||||||
timeZone: timezone,
|
const start = new Date(startStr);
|
||||||
});
|
const tag = isEs ? 'es-ES' : 'en-US';
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone });
|
||||||
* Format time for display using site timezone
|
const month = start
|
||||||
*/
|
.toLocaleDateString(tag, { month: 'short', timeZone: timezone })
|
||||||
function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string {
|
.replace(/\.$/, '')
|
||||||
const date = new Date(dateStr);
|
.toUpperCase();
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: '2-digit',
|
const time = (d: Date) =>
|
||||||
|
d
|
||||||
|
.toLocaleTimeString(tag, {
|
||||||
|
hour: isEs ? '2-digit' : 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: true,
|
hour12: !isEs,
|
||||||
timeZone: timezone,
|
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 ====================
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDoc(): PDFKit.PDFDocument {
|
||||||
|
return new PDFDocument({ size: 'A4', margin: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
* Generate a PDF ticket for a single ticket
|
||||||
*/
|
*/
|
||||||
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return generateCombinedTicketsPDF([ticket]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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> {
|
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 {
|
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++) {
|
for (let i = 0; i < tickets.length; i++) {
|
||||||
const ticket = tickets[i];
|
const ticket = tickets[i];
|
||||||
|
if (i > 0) doc.addPage();
|
||||||
|
|
||||||
if (i > 0) {
|
const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`);
|
||||||
doc.addPage();
|
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();
|
doc.end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
doc.end();
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
return done;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,11 +10,68 @@ import { getRateLimiter } from './stores/rateLimiter.js';
|
|||||||
* (horizontal scaling). See lib/stores/rateLimiter.ts.
|
* (horizontal scaling). See lib/stores/rateLimiter.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
|
// 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 {
|
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');
|
const forwarded = c.req.header('x-forwarded-for');
|
||||||
if (forwarded) return forwarded.split(',')[0].trim();
|
if (forwarded) {
|
||||||
return c.req.header('x-real-ip') || 'unknown';
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+80
-10
@@ -4,7 +4,15 @@
|
|||||||
// before with in-memory backends. When set, this module owns a single shared
|
// before with in-memory backends. When set, this module owns a single shared
|
||||||
// command connection plus a dedicated subscriber connection (a connection in
|
// command connection plus a dedicated subscriber connection (a connection in
|
||||||
// subscribe mode cannot run normal commands), with auto-reconnect, capped
|
// subscribe mode cannot run normal commands), with auto-reconnect, capped
|
||||||
// backoff, and a health flag that callers and the health endpoint can read.
|
// 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';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
@@ -12,6 +20,14 @@ let client: Redis | null = null;
|
|||||||
let subscriber: Redis | null = null;
|
let subscriber: Redis | null = null;
|
||||||
let healthy = false;
|
let healthy = false;
|
||||||
let initialized = 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. */
|
/** Whether Redis is configured via REDIS_URL. */
|
||||||
export function isRedisEnabled(): boolean {
|
export function isRedisEnabled(): boolean {
|
||||||
@@ -23,14 +39,41 @@ export function isRedisHealthy(): boolean {
|
|||||||
return isRedisEnabled() && healthy;
|
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 {
|
function buildClient(label: string): Redis {
|
||||||
const url = process.env.REDIS_URL as string;
|
const url = process.env.REDIS_URL as string;
|
||||||
const instance = new Redis(url, {
|
const instance = new Redis(url, {
|
||||||
// Keep the process responsive: fail fast on a per-command basis and let the
|
// Keep the process responsive: fail fast on a per-command basis and let the
|
||||||
// callers degrade to their in-memory fallback rather than hanging.
|
// 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,
|
maxRetriesPerRequest: 1,
|
||||||
enableOfflineQueue: false,
|
enableOfflineQueue: false,
|
||||||
lazyConnect: false,
|
lazyConnect: false,
|
||||||
|
connectTimeout: 5000,
|
||||||
retryStrategy(times) {
|
retryStrategy(times) {
|
||||||
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
|
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
|
||||||
const delay = Math.min(times * 200, 5000);
|
const delay = Math.min(times * 200, 5000);
|
||||||
@@ -42,30 +85,53 @@ function buildClient(label: string): Redis {
|
|||||||
console.log(`[redis] (${label}) connecting`);
|
console.log(`[redis] (${label}) connecting`);
|
||||||
});
|
});
|
||||||
instance.on('ready', () => {
|
instance.on('ready', () => {
|
||||||
healthy = true;
|
setHealthy(true, `${label} ready`);
|
||||||
console.log(`[redis] (${label}) ready`);
|
|
||||||
});
|
});
|
||||||
instance.on('error', (err) => {
|
instance.on('error', (err) => {
|
||||||
healthy = false;
|
setHealthy(false, `${label} error`);
|
||||||
console.error(`[redis] (${label}) error:`, err?.message || err);
|
logErrorDebounced(label, err);
|
||||||
});
|
});
|
||||||
instance.on('reconnecting', () => {
|
instance.on('reconnecting', () => {
|
||||||
healthy = false;
|
setHealthy(false, `${label} reconnecting`);
|
||||||
console.warn(`[redis] (${label}) reconnecting`);
|
|
||||||
});
|
});
|
||||||
instance.on('end', () => {
|
instance.on('end', () => {
|
||||||
healthy = false;
|
setHealthy(false, `${label} connection closed`);
|
||||||
console.warn(`[redis] (${label}) connection closed`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return instance;
|
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 {
|
function ensureInit(): void {
|
||||||
if (initialized || !isRedisEnabled()) return;
|
if (initialized || !isRedisEnabled()) return;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
client = buildClient('commands');
|
client = buildClient('commands');
|
||||||
subscriber = buildClient('subscriber');
|
subscriber = buildClient('subscriber');
|
||||||
|
pingTimer = setInterval(() => {
|
||||||
|
void pingOnce();
|
||||||
|
}, 10_000);
|
||||||
|
(pingTimer as any).unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared command connection, or null when Redis is not configured. */
|
/** Shared command connection, or null when Redis is not configured. */
|
||||||
@@ -82,6 +148,10 @@ export function getSubscriber(): Redis | null {
|
|||||||
|
|
||||||
/** Close connections (used for graceful shutdown). */
|
/** Close connections (used for graceful shutdown). */
|
||||||
export async function closeRedis(): Promise<void> {
|
export async function closeRedis(): Promise<void> {
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
const tasks: Promise<unknown>[] = [];
|
const tasks: Promise<unknown>[] = [];
|
||||||
if (client) tasks.push(client.quit().catch(() => undefined));
|
if (client) tasks.push(client.quit().catch(() => undefined));
|
||||||
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
|
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,22 +5,45 @@
|
|||||||
//
|
//
|
||||||
// Use acquire/release for long-lived ownership (e.g. a background poller) and
|
// 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.
|
// 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 { randomUUID } from 'crypto';
|
||||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
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 {
|
export interface Lock {
|
||||||
readonly backend: 'memory' | 'redis';
|
readonly backend: 'memory' | 'redis';
|
||||||
// Returns a token when the lock was acquired, or null when already held.
|
// 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>;
|
acquire(key: string, ttlMs: number): Promise<string | null>;
|
||||||
release(key: string, token: string): Promise<void>;
|
release(key: string, token: string): Promise<void>;
|
||||||
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
|
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
|
||||||
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null>;
|
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Memory implementation ====================
|
// ==================== Memory implementation ====================
|
||||||
|
|
||||||
class MemoryLock implements Lock {
|
export class MemoryLock implements Lock {
|
||||||
readonly backend = 'memory' as const;
|
readonly backend = 'memory' as const;
|
||||||
private held = new Map<string, { token: string; expiresAt: number }>();
|
private held = new Map<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
@@ -58,23 +81,23 @@ class MemoryLock implements Lock {
|
|||||||
const RELEASE_SCRIPT =
|
const RELEASE_SCRIPT =
|
||||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
||||||
|
|
||||||
class RedisLock implements Lock {
|
export class RedisLock implements Lock {
|
||||||
readonly backend = 'redis' as const;
|
readonly backend = 'redis' as const;
|
||||||
|
|
||||||
async acquire(key: string, ttlMs: number): Promise<string | null> {
|
async acquire(key: string, ttlMs: number): Promise<string | null> {
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
if (!redis) {
|
if (!redis) {
|
||||||
// Redis configured but unavailable: do not block critical sections.
|
throw new LockUnavailableError('redis client not initialized');
|
||||||
return randomUUID();
|
|
||||||
}
|
}
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
try {
|
try {
|
||||||
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
|
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
|
||||||
return result === 'OK' ? token : null;
|
return result === 'OK' ? token : null;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err);
|
// Do NOT fabricate a token here: during an outage every replica would
|
||||||
// Fail open so a Redis outage does not deadlock startup or jobs.
|
// "acquire" every lock and run the guarded sections concurrently. Let the
|
||||||
return randomUUID();
|
// caller choose between skipping the run and running unlocked.
|
||||||
|
throw new LockUnavailableError(err?.message || String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +111,19 @@ class RedisLock implements Lock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
|
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null> {
|
||||||
const token = await this.acquire(key, ttlMs);
|
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;
|
if (!token) return null;
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
// Rate limiter abstraction with two implementations:
|
// Rate limiter abstraction with two implementations:
|
||||||
// - memory: per-process fixed window (the original behavior)
|
// - memory: per-process fixed window (the original behavior)
|
||||||
// - redis: shared fixed window across all instances (INCR + PEXPIRE)
|
// - 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
|
// 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.
|
// 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';
|
import { getRedis, isRedisEnabled } from '../redis.js';
|
||||||
|
|
||||||
export interface RateLimitResult {
|
export interface RateLimitResult {
|
||||||
@@ -24,7 +27,7 @@ interface Bucket {
|
|||||||
resetAt: number;
|
resetAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemoryRateLimiter implements RateLimiter {
|
export class MemoryRateLimiter implements RateLimiter {
|
||||||
readonly backend = 'memory' as const;
|
readonly backend = 'memory' as const;
|
||||||
private buckets = new Map<string, Bucket>();
|
private buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
@@ -58,22 +61,44 @@ class MemoryRateLimiter implements RateLimiter {
|
|||||||
|
|
||||||
// ==================== Redis implementation ====================
|
// ==================== Redis implementation ====================
|
||||||
|
|
||||||
class RedisRateLimiter implements RateLimiter {
|
// 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;
|
readonly backend = 'redis' as const;
|
||||||
|
|
||||||
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
if (!redis) return { allowed: true };
|
if (!redis) return { allowed: true };
|
||||||
|
|
||||||
const redisKey = `rl:${key}`;
|
|
||||||
try {
|
try {
|
||||||
const count = await redis.incr(redisKey);
|
const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
|
||||||
if (count === 1) {
|
|
||||||
// First hit in this window: set the expiry that defines the window.
|
|
||||||
await redis.pexpire(redisKey, windowMs);
|
|
||||||
}
|
|
||||||
if (count > max) {
|
if (count > max) {
|
||||||
const ttl = await redis.pttl(redisKey);
|
|
||||||
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
|
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
|
||||||
return { allowed: false, retryAfter };
|
return { allowed: false, retryAfter };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,6 +22,19 @@ export function generateTicketCode(): string {
|
|||||||
return `TKT-${nanoid(8).toUpperCase()}`;
|
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.
|
* Get current timestamp in the format appropriate for the database type.
|
||||||
* - SQLite: returns ISO string
|
* - SQLite: returns ISO string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs
|
|||||||
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
const adminRouter = new Hono();
|
const adminRouter = new Hono();
|
||||||
|
|
||||||
@@ -20,8 +21,9 @@ const csvEscape = (value: string) => {
|
|||||||
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Get upcoming events
|
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
|
||||||
const upcomingEvents = await dbAll(
|
// so the dashboard's capacity alerts reflect real availability.
|
||||||
|
const upcomingEventsRaw = await dbAll<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
@@ -35,6 +37,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.limit(5)
|
.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
|
// Get recent tickets
|
||||||
const recentTickets = await dbAll(
|
const recentTickets = await dbAll(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -70,6 +90,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.where(eq((tickets as any).status, 'confirmed'))
|
.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>(
|
const pendingPayments = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.where(eq((payments as any).status, 'pending'))
|
.where(eq((payments as any).status, 'pending'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const awaitingApprovalPayments = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).status, 'pending_approval'))
|
||||||
|
);
|
||||||
|
|
||||||
const onHoldPayments = await dbGet<any>(
|
const onHoldPayments = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -115,6 +144,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
totalTickets: totalTickets?.count || 0,
|
totalTickets: totalTickets?.count || 0,
|
||||||
confirmedTickets: confirmedTickets?.count || 0,
|
confirmedTickets: confirmedTickets?.count || 0,
|
||||||
pendingPayments: pendingPayments?.count || 0,
|
pendingPayments: pendingPayments?.count || 0,
|
||||||
|
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
|
||||||
onHoldPayments: onHoldPayments?.count || 0,
|
onHoldPayments: onHoldPayments?.count || 0,
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
newContacts: newContacts?.count || 0,
|
newContacts: newContacts?.count || 0,
|
||||||
|
|||||||
@@ -1,719 +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,
|
|
||||||
bumpTokenVersion,
|
|
||||||
requireAuth,
|
|
||||||
getUserPasswordHash,
|
|
||||||
} from '../lib/auth.js';
|
|
||||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
|
||||||
import { sendEmail } from '../lib/email.js';
|
|
||||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
|
||||||
|
|
||||||
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
|
|
||||||
// email-dispatching flows) to curb credential stuffing and email flooding.
|
|
||||||
const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' });
|
|
||||||
|
|
||||||
// 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'),
|
|
||||||
});
|
|
||||||
|
|
||||||
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', authRateLimit, 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, 0);
|
|
||||||
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', authRateLimit, 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);
|
|
||||||
|
|
||||||
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
|
|
||||||
// plaintext and have verified it. Best-effort: a failure here must not block
|
|
||||||
// the login.
|
|
||||||
if (!String(user.password).startsWith('$argon2')) {
|
|
||||||
try {
|
|
||||||
const upgradedHash = await hashPassword(data.password);
|
|
||||||
await (db as any)
|
|
||||||
.update(users)
|
|
||||||
.set({ password: upgradedHash })
|
|
||||||
.where(eq((users as any).id, user.id));
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
|
||||||
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', authRateLimit, 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', authRateLimit, 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, user.tokenVersion ?? 0);
|
|
||||||
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', authRateLimit, 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', authRateLimit, 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/JWTs for security
|
|
||||||
await invalidateAllUserSessions(verification.userId!);
|
|
||||||
await bumpTokenVersion(verification.userId!);
|
|
||||||
|
|
||||||
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Claim unclaimed account
|
|
||||||
auth.post('/claim-account/request', authRateLimit, 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 1 hour)
|
|
||||||
const token = await createMagicLinkToken(user.id, 'claim_account', 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 1 hour.</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', authRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
|
||||||
const { token, password } = c.req.valid('json');
|
|
||||||
|
|
||||||
const verification = await verifyMagicLinkToken(token, 'claim_account');
|
|
||||||
|
|
||||||
if (!verification.valid) {
|
|
||||||
return c.json({ error: verification.error }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordValidation = validatePassword(password);
|
|
||||||
if (!passwordValidation.valid) {
|
|
||||||
return c.json({ error: passwordValidation.error }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
|
||||||
// Only set a password here. Linking a Google account requires a verified Google
|
|
||||||
// ID token via /google; we never trust a client-supplied googleId.
|
|
||||||
const updates: Record<string, any> = {
|
|
||||||
isClaimed: toDbBool(true),
|
|
||||||
accountStatus: 'active',
|
|
||||||
password: await hashPassword(password),
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
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, user.tokenVersion ?? 0);
|
|
||||||
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', authRateLimit, zValidator('json', googleAuthSchema), async (c) => {
|
|
||||||
const { credential } = c.req.valid('json');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify the Google ID token. Google's tokeninfo endpoint validates the
|
|
||||||
// signature and expiry server-side; we additionally enforce the audience so a
|
|
||||||
// token minted for a different OAuth client cannot be replayed against us.
|
|
||||||
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(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;
|
|
||||||
aud?: string;
|
|
||||||
exp?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// email_verified can be returned as boolean true or string "true"
|
|
||||||
if (String(googleData.email_verified) !== 'true') {
|
|
||||||
return c.json({ error: 'Google email not verified' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce audience when a client ID is configured (closes token-confusion attacks)
|
|
||||||
const expectedAud = process.env.GOOGLE_CLIENT_ID;
|
|
||||||
if (expectedAud) {
|
|
||||||
if (googleData.aud !== expectedAud) {
|
|
||||||
return c.json({ error: 'Invalid Google token audience' }, 400);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject expired tokens (defense-in-depth; tokeninfo also rejects them)
|
|
||||||
if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) {
|
|
||||||
return c.json({ error: 'Google token expired' }, 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, user.tokenVersion ?? 0);
|
|
||||||
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
|
|
||||||
const existingHash = await getUserPasswordHash(user.id);
|
|
||||||
if (existingHash) {
|
|
||||||
const validPassword = await verifyPassword(currentPassword, existingHash);
|
|
||||||
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));
|
|
||||||
|
|
||||||
// Invalidate all previously issued JWTs so a stolen old token can't outlive the change,
|
|
||||||
// then hand the current client a fresh token so it stays logged in on this device.
|
|
||||||
await bumpTokenVersion(user.id);
|
|
||||||
const refreshedUser = await dbGet<any>(
|
|
||||||
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
|
||||||
);
|
|
||||||
const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0);
|
|
||||||
|
|
||||||
return c.json({ message: 'Password changed successfully', token: newToken });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Logout - invalidate all previously issued JWTs for this user (logout everywhere)
|
|
||||||
auth.post('/logout', async (c) => {
|
|
||||||
const user = await getAuthUser(c);
|
|
||||||
if (user) {
|
|
||||||
await invalidateAllUserSessions(user.id);
|
|
||||||
await bumpTokenVersion(user.id);
|
|
||||||
}
|
|
||||||
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;
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
|
||||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
|
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
||||||
import { generateId, getNow } from '../lib/utils.js';
|
import { auth } from '../lib/betterAuth.js';
|
||||||
|
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||||
// User type that includes all fields (some added in schema updates)
|
import { getNow } from '../lib/utils.js';
|
||||||
type AuthUser = User & {
|
|
||||||
isClaimed: boolean;
|
|
||||||
googleId: string | null;
|
|
||||||
rucNumber: string | null;
|
|
||||||
accountStatus: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dashboard = new Hono();
|
const dashboard = new Hono();
|
||||||
|
|
||||||
@@ -50,7 +44,7 @@ dashboard.get('/profile', async (c) => {
|
|||||||
isClaimed: user.isClaimed,
|
isClaimed: user.isClaimed,
|
||||||
accountStatus: user.accountStatus,
|
accountStatus: user.accountStatus,
|
||||||
hasPassword,
|
hasPassword,
|
||||||
hasGoogleLinked: !!user.googleId,
|
hasGoogleLinked: await hasGoogleAccount(user.id),
|
||||||
memberSince: user.createdAt,
|
memberSince: user.createdAt,
|
||||||
membershipDays,
|
membershipDays,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
@@ -423,49 +417,77 @@ dashboard.get('/invoices', async (c) => {
|
|||||||
|
|
||||||
// ==================== Security Routes ====================
|
// ==================== 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) => {
|
dashboard.get('/sessions', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
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({
|
return c.json({
|
||||||
sessions: sessions.map((s: any) => ({
|
sessions: sessions.map((s: any) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
userAgent: s.userAgent,
|
userAgent: s.userAgent,
|
||||||
ipAddress: s.ipAddress,
|
ipAddress: s.ipAddress,
|
||||||
lastActiveAt: s.lastActiveAt,
|
lastActiveAt: s.updatedAt,
|
||||||
createdAt: s.createdAt,
|
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) => {
|
dashboard.delete('/sessions/:id', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
const user = (c as any).get('user') as AuthUser;
|
||||||
const sessionId = c.req.param('id');
|
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' });
|
return c.json({ message: 'Session revoked' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
|
// Revoke all other sessions (logout everywhere else); the current session
|
||||||
// every previously issued JWT for this user, which is the actual enforcement
|
// stays valid so this device remains signed in.
|
||||||
// mechanism (auth is stateless JWT, not DB-session based).
|
|
||||||
dashboard.post('/sessions/revoke-all', async (c) => {
|
dashboard.post('/sessions/revoke-all', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
await invalidateAllUserSessions(user.id);
|
await (db as any)
|
||||||
await bumpTokenVersion(user.id);
|
.delete(authSessions)
|
||||||
|
.where(
|
||||||
// Issue a fresh token so the current device stays signed in
|
and(
|
||||||
const refreshed = await dbGet<any>(
|
eq((authSessions as any).userId, user.id),
|
||||||
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
sql`${(authSessions as any).id} != ${user.sessionId}`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
|
|
||||||
|
|
||||||
return c.json({ message: 'All other sessions revoked.', token });
|
return c.json({ message: 'All other sessions revoked.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set password (for users without one)
|
// Set password (for users without one)
|
||||||
@@ -482,21 +504,21 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
|
|||||||
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
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);
|
const passwordValidation = validatePassword(password);
|
||||||
if (!passwordValidation.valid) {
|
if (!passwordValidation.valid) {
|
||||||
return c.json({ error: passwordValidation.error }, 400);
|
return c.json({ error: passwordValidation.error }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(password);
|
try {
|
||||||
const now = getNow();
|
await auth.api.setPassword({
|
||||||
|
body: { newPassword: password },
|
||||||
await (db as any)
|
headers: c.req.raw.headers,
|
||||||
.update(users)
|
});
|
||||||
.set({
|
} catch (err: any) {
|
||||||
password: hashedPassword,
|
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
|
||||||
updatedAt: now,
|
}
|
||||||
})
|
|
||||||
.where(eq((users as any).id, user.id));
|
|
||||||
|
|
||||||
return c.json({ message: 'Password set successfully' });
|
return c.json({ message: 'Password set successfully' });
|
||||||
});
|
});
|
||||||
@@ -505,7 +527,7 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
|
|||||||
dashboard.post('/unlink-google', async (c) => {
|
dashboard.post('/unlink-google', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
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);
|
return c.json({ error: 'Google account not linked' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,14 +535,18 @@ dashboard.post('/unlink-google', async (c) => {
|
|||||||
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
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)
|
await (db as any)
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({ updatedAt: getNow() })
|
||||||
googleId: null,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((users as any).id, user.id));
|
.where(eq((users as any).id, user.id));
|
||||||
|
|
||||||
return c.json({ message: 'Google account unlinked' });
|
return c.json({ message: 'Google account unlinked' });
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
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' };
|
||||||
|
|
||||||
|
// The door endpoints are behind staff auth; the flows under test are the writes,
|
||||||
|
// not Better Auth, which has its own integration suite.
|
||||||
|
vi.mock('../lib/auth.js', () => ({
|
||||||
|
requireAuth: () => async (c: any, next: any) => {
|
||||||
|
c.set('user', STAFF);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
getAuthUser: async () => STAFF,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}) {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
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 = ?').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', amount: PRICE * 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' });
|
||||||
|
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', () => {
|
||||||
|
it('totals door takings by tender and splits them from pre-sale', async () => {
|
||||||
|
const { status, body } = await 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
// 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
|
||||||
|
//
|
||||||
|
// 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,
|
||||||
|
} from '../db/index.js';
|
||||||
|
import { requireAuth } from '../lib/auth.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, type DoorPaymentMethod,
|
||||||
|
} from '../lib/doorPayments.js';
|
||||||
|
import emailService from '../lib/email.js';
|
||||||
|
|
||||||
|
const doorRouter = new Hono();
|
||||||
|
|
||||||
|
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
|
||||||
|
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) {
|
||||||
|
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),
|
||||||
|
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', requireAuth([...STAFF_ROLES]), 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 attendees = rows
|
||||||
|
.map((t: any) => toDoorAttendee(t, {
|
||||||
|
price: event.price,
|
||||||
|
groupBookingIds,
|
||||||
|
adminNames,
|
||||||
|
doorMethod: doorMethods.get(t.id) || 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,
|
||||||
|
currency: event.currency,
|
||||||
|
capacity: event.capacity,
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
// Omitted means "one ticket at event price"; a multiple covers someone
|
||||||
|
// paying for their whole group in one go.
|
||||||
|
amount: z.number().min(0).optional(),
|
||||||
|
}).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',
|
||||||
|
requireAuth([...STAFF_ROLES]),
|
||||||
|
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 event = await loadEvent(eventId);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const method = data.payment?.method as DoorPaymentMethod | undefined;
|
||||||
|
const requestedAmount = data.payment?.amount ?? event.price;
|
||||||
|
|
||||||
|
const ops: TxOp[] = [];
|
||||||
|
let undoState: UndoState;
|
||||||
|
let action: 'checkin' | 'walkin';
|
||||||
|
let ticketRow: any;
|
||||||
|
let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | 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 };
|
||||||
|
} 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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 };
|
||||||
|
undoState = { kind: 'created', ticketId, paymentId };
|
||||||
|
ticketRow = newTicket;
|
||||||
|
// Only mail people who actually gave an address; no QR for the rest.
|
||||||
|
if (hasEmail) emailTicketId = ticketId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (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',
|
||||||
|
requireAuth([...STAFF_ROLES]),
|
||||||
|
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', requireAuth([...STAFF_ROLES]), 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 keeps the dashboard's existing definition — settled tickets
|
||||||
|
// at event price — minus anything that was actually taken at the door.
|
||||||
|
const doorTicketIds = new Set(rows.map((r: any) => r.ticketId));
|
||||||
|
const settled = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ id: (tickets as any).id })
|
||||||
|
.from(tickets)
|
||||||
|
.where(and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
eq((tickets as any).paymentStatus, 'paid'),
|
||||||
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
|
))
|
||||||
|
);
|
||||||
|
const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length;
|
||||||
|
const presaleTotal = presaleCount * event.price;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
|
|||||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -171,6 +172,13 @@ const updateEventSchema = baseEventSchema.partial().refine(
|
|||||||
eventsRouter.get('/', async (c) => {
|
eventsRouter.get('/', async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const upcoming = c.req.query('upcoming');
|
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);
|
||||||
|
|
||||||
// Only privileged users may see non-public events (drafts, archived, etc.).
|
// Only privileged users may see non-public events (drafts, archived, etc.).
|
||||||
// Anonymous/regular callers are restricted to published events regardless of
|
// Anonymous/regular callers are restricted to published events regardless of
|
||||||
@@ -194,38 +202,53 @@ eventsRouter.get('/', async (c) => {
|
|||||||
conditions.push(eq((events as any).status, 'published'));
|
conditions.push(eq((events as any).status, 'published'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length === 0
|
||||||
|
? undefined
|
||||||
|
: conditions.length === 1 ? conditions[0] : and(...conditions);
|
||||||
|
|
||||||
let query = (db as any).select().from(events);
|
let query = (db as any).select().from(events);
|
||||||
if (conditions.length > 0) {
|
if (whereClause) query = query.where(whereClause);
|
||||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
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.orderBy(desc((events as any).startDatetime)));
|
const result = await dbAll<any>(query);
|
||||||
|
|
||||||
// Single grouped query for booked counts across all events (avoids N+1: previously
|
// Single grouped query for seat counts across all events (avoids N+1: previously
|
||||||
// this ran one COUNT query per event).
|
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
||||||
const countRows = await dbAll<any>(
|
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
||||||
(db as any)
|
// so availableSeats subtracts them together — the same formula the booking-creation
|
||||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
// capacity check enforces (lib/capacity.ts).
|
||||||
.from(tickets)
|
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||||
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
|
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||||
.groupBy((tickets as any).eventId)
|
|
||||||
);
|
|
||||||
const countByEvent = new Map<string, number>();
|
|
||||||
for (const row of countRows) {
|
for (const row of countRows) {
|
||||||
countByEvent.set(row.eventId, Number(row.count) || 0);
|
countByEvent.set(row.eventId, {
|
||||||
|
paid: Number(row.paidCount) || 0,
|
||||||
|
claimed: Number(row.claimedCount) || 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsWithCounts = result.map((event: any) => {
|
const eventsWithCounts = result.map((event: any) => {
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
const bookedCount = countByEvent.get(event.id) || 0;
|
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
claimedCount: counts.claimed,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// Get single event (public) - resolves by id, canonical slug, or historical alias
|
||||||
@@ -246,27 +269,14 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
const bookedCount = ticketCount?.count || 0;
|
const counts = await getEventSeatCounts(event.id);
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
claimedCount: counts.claimed,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -278,20 +288,14 @@ async function getSiteTimezone(): Promise<string> {
|
|||||||
return settings?.timezone || 'America/Asuncion';
|
return settings?.timezone || 'America/Asuncion';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get ticket count for an event
|
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
|
||||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
// counts for one event — see lib/capacity.ts for the seat-holding rule.
|
||||||
const ticketCount = await dbGet<any>(
|
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
|
||||||
(db as any)
|
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
|
||||||
.select({ count: sql<number>`count(*)` })
|
return {
|
||||||
.from(tickets)
|
paid: Number(row?.paidCount) || 0,
|
||||||
.where(
|
claimed: Number(row?.claimedCount) || 0,
|
||||||
and(
|
};
|
||||||
eq((tickets as any).eventId, eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return ticketCount?.count || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
||||||
@@ -315,12 +319,13 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookedCount = await getEventTicketCount(event.id);
|
const counts = await getEventSeatCounts(event.id);
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
claimedCount: counts.claimed,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,13 +388,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
|
|
||||||
// If we have a valid featured event, return it
|
// If we have a valid featured event, return it
|
||||||
if (featuredEvent) {
|
if (featuredEvent) {
|
||||||
const bookedCount = await getEventTicketCount(featuredEvent.id);
|
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||||
const normalized = normalizeEvent(featuredEvent);
|
const normalized = normalizeEvent(featuredEvent);
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount,
|
bookedCount: counts.paid,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
claimedCount: counts.claimed,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../lib/lnbits.js';
|
} from '../lib/lnbits.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { getPubSub } from '../lib/stores/pubsub.js';
|
import { getPubSub } from '../lib/stores/pubsub.js';
|
||||||
import { getLock } from '../lib/stores/lock.js';
|
import { getLock, LockUnavailableError } from '../lib/stores/lock.js';
|
||||||
|
|
||||||
const lnbitsRouter = new Hono();
|
const lnbitsRouter = new Hono();
|
||||||
|
|
||||||
@@ -106,12 +106,26 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
|
|||||||
|
|
||||||
const expiryMs = expirySeconds * 1000;
|
const expiryMs = expirySeconds * 1000;
|
||||||
|
|
||||||
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
let lockToken: string | null = null;
|
||||||
if (!lockToken) {
|
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.
|
// Another instance is already polling this ticket.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (lockToken) {
|
||||||
checkerLockTokens.set(ticketId, lockToken);
|
checkerLockTokens.set(ticketId, lockToken);
|
||||||
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let checkCount = 0;
|
let checkCount = 0;
|
||||||
@@ -270,11 +284,13 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
||||||
|
let transitioned = 0;
|
||||||
for (const ticket of ticketsToConfirm) {
|
for (const ticket of ticketsToConfirm) {
|
||||||
await (db as any)
|
const result: any = await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed' })
|
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||||
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
|
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
|
||||||
|
transitioned += result?.changes ?? result?.rowCount ?? 0;
|
||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(payments)
|
.update(payments)
|
||||||
@@ -289,6 +305,14 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
|||||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
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
|
// Get primary payment for sending receipt
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -372,8 +396,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
|||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
// Clean up on disconnect
|
const cleanup = () => {
|
||||||
stream.onAbort(() => {
|
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
const connections = activeConnections.get(ticketId);
|
const connections = activeConnections.get(ticketId);
|
||||||
if (connections) {
|
if (connections) {
|
||||||
@@ -388,11 +411,28 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
// Keep the stream open
|
// 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) {
|
while (true) {
|
||||||
await stream.sleep(30000);
|
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const updatePaymentSchema = z.object({
|
|||||||
const approvePaymentSchema = z.object({
|
const approvePaymentSchema = z.object({
|
||||||
adminNote: z.string().optional(),
|
adminNote: z.string().optional(),
|
||||||
sendEmail: z.boolean().optional().default(true),
|
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({
|
const rejectPaymentSchema = z.object({
|
||||||
@@ -285,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed' })
|
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||||
.where(eq((tickets as any).id, (t as any).id));
|
.where(eq((tickets as any).id, (t as any).id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +320,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
// Approve payment (admin) - specifically for pending_approval payments
|
// Approve payment (admin) - specifically for pending_approval payments
|
||||||
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||||
const id = c.req.param('id');
|
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 user = (c as any).get('user');
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
@@ -334,12 +337,11 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
// Can approve pending, pending_approval, on_hold, or failed payments.
|
// Can approve pending, pending_approval, on_hold, or failed payments.
|
||||||
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
|
// '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.
|
// 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)) {
|
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
|
||||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = getNow();
|
|
||||||
|
|
||||||
// Get the ticket associated with this payment
|
// Get the ticket associated with this payment
|
||||||
const ticket = await dbGet<any>(
|
const ticket = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -381,9 +383,11 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payment.status === 'on_hold' || payment.status === 'failed') {
|
// Confirm the booking through the shared capacity-checked reservation.
|
||||||
// The seat was released when this booking went on hold or failed - re-check
|
// Tickets that already hold a seat ('pending_approval' claims) cost no new
|
||||||
// capacity before confirming it directly.
|
// 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 {
|
try {
|
||||||
await reserveOnHoldBooking(
|
await reserveOnHoldBooking(
|
||||||
ticket.eventId,
|
ticket.eventId,
|
||||||
@@ -393,44 +397,22 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
{
|
{
|
||||||
paidByAdminId: user.id,
|
paidByAdminId: user.id,
|
||||||
fromTicketStatuses:
|
fromTicketStatuses:
|
||||||
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold'],
|
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'],
|
||||||
|
skipCapacityCheck: allowOverCapacity,
|
||||||
|
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HoldCapacityError) {
|
if (err instanceof HoldCapacityError) {
|
||||||
return c.json({
|
return c.json({
|
||||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
error: 'Approving this payment puts the event over capacity.',
|
||||||
}, 400);
|
code: 'EVENT_OVER_CAPACITY',
|
||||||
|
availableSeats: err.available,
|
||||||
|
requestedSeats: ticketsToConfirm.length,
|
||||||
|
}, 409);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (adminNote) {
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({ adminNote })
|
|
||||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 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));
|
|
||||||
|
|
||||||
// Update ticket status to confirmed
|
|
||||||
await (db as any)
|
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'confirmed' })
|
|
||||||
.where(eq((tickets as any).id, (t as any).id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||||
if (sendEmail !== false) {
|
if (sendEmail !== false) {
|
||||||
|
|||||||
+148
-216
@@ -4,12 +4,13 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
|
||||||
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, toDbDate, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
|
||||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||||
|
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
const ticketsRouter = new Hono();
|
const ticketsRouter = new Hono();
|
||||||
|
|
||||||
@@ -128,19 +129,10 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check capacity - count pending, confirmed AND checked_in tickets.
|
// Check capacity against held seats (paid/checked-in tickets plus claimed
|
||||||
// Pending reservations must hold seats to prevent overselling via unpaid bookings
|
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat.
|
||||||
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
|
|
||||||
const existingTicketCount = await dbGet<any>(
|
const existingTicketCount = await dbGet<any>(
|
||||||
(db as any)
|
seatHolderCountQuery(db, data.eventId)
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((tickets as any).eventId, data.eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmedCount = existingTicketCount?.count || 0;
|
const confirmedCount = existingTicketCount?.count || 0;
|
||||||
@@ -156,9 +148,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create user
|
// Find or create user. The account row is keyed on the normalized address so
|
||||||
|
// it stays reachable from Better Auth (which lowercases every lookup) —
|
||||||
|
// tickets.attendeeEmail below keeps the address exactly as the buyer typed it.
|
||||||
|
const accountEmail = normalizeEmail(data.email);
|
||||||
let user = await dbGet<any>(
|
let user = await dbGet<any>(
|
||||||
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
@@ -171,13 +166,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
const userId = generateId();
|
const userId = generateId();
|
||||||
user = {
|
user = {
|
||||||
id: userId,
|
id: userId,
|
||||||
email: data.email,
|
email: accountEmail,
|
||||||
password: '', // No password for guest bookings
|
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
|
||||||
name: fullName,
|
name: fullName,
|
||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
languagePreference: null,
|
languagePreference: null,
|
||||||
rucNumber,
|
rucNumber,
|
||||||
|
isClaimed: toDbBool(false),
|
||||||
|
accountStatus: 'unclaimed',
|
||||||
|
emailVerified: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -230,16 +228,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
try {
|
try {
|
||||||
if (isSqlite()) {
|
if (isSqlite()) {
|
||||||
(db as any).transaction((tx: any) => {
|
(db as any).transaction((tx: any) => {
|
||||||
const countRow = tx
|
const countRow = seatHolderCountQuery(tx, data.eventId).get();
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((tickets as any).eventId, data.eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
const reserved = Number(countRow?.count || 0);
|
const reserved = Number(countRow?.count || 0);
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
throw new BookingCapacityError('SOLD_OUT');
|
throw new BookingCapacityError('SOLD_OUT');
|
||||||
@@ -290,17 +279,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await (db as any).transaction(async (tx: any) => {
|
await (db as any).transaction(async (tx: any) => {
|
||||||
const countRow = await dbGet<any>(
|
const countRow = await dbGet<any>(seatHolderCountQuery(tx, data.eventId));
|
||||||
tx
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((tickets as any).eventId, data.eventId),
|
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const reserved = Number(countRow?.count || 0);
|
const reserved = Number(countRow?.count || 0);
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
if (isEventSoldOut(event.capacity, reserved)) {
|
||||||
throw new BookingCapacityError('SOLD_OUT');
|
throw new BookingCapacityError('SOLD_OUT');
|
||||||
@@ -388,7 +367,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
for (const t of createdTickets) {
|
for (const t of createdTickets) {
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed' })
|
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||||
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
|
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(payments)
|
.update(payments)
|
||||||
@@ -572,20 +551,24 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
|
|||||||
);
|
);
|
||||||
const timezone = settings?.timezone || 'America/Asuncion';
|
const timezone = settings?.timezone || 'America/Asuncion';
|
||||||
|
|
||||||
const ticketsData = confirmedTickets.map((ticket: any) => ({
|
const ticketsData = confirmedTickets.map((ticket: any) => {
|
||||||
|
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
|
||||||
|
return {
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
qrCode: ticket.qrCode,
|
qrCode: ticket.qrCode,
|
||||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
event: {
|
event: {
|
||||||
title: event.title,
|
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
|
||||||
startDatetime: event.startDatetime,
|
startDatetime: event.startDatetime,
|
||||||
endDatetime: event.endDatetime,
|
endDatetime: event.endDatetime,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
locationUrl: event.locationUrl,
|
locationUrl: event.locationUrl,
|
||||||
},
|
},
|
||||||
timezone,
|
timezone,
|
||||||
}));
|
locale,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
|
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
|
||||||
|
|
||||||
@@ -649,19 +632,22 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
|||||||
);
|
);
|
||||||
const timezone = settings?.timezone || 'America/Asuncion';
|
const timezone = settings?.timezone || 'America/Asuncion';
|
||||||
|
|
||||||
|
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
|
||||||
|
|
||||||
const pdfBuffer = await generateTicketPDF({
|
const pdfBuffer = await generateTicketPDF({
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
qrCode: ticket.qrCode,
|
qrCode: ticket.qrCode,
|
||||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
event: {
|
event: {
|
||||||
title: event.title,
|
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
|
||||||
startDatetime: event.startDatetime,
|
startDatetime: event.startDatetime,
|
||||||
endDatetime: event.endDatetime,
|
endDatetime: event.endDatetime,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
locationUrl: event.locationUrl,
|
locationUrl: event.locationUrl,
|
||||||
},
|
},
|
||||||
timezone,
|
timezone,
|
||||||
|
locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set response headers for PDF download
|
// Set response headers for PDF download
|
||||||
@@ -1029,6 +1015,9 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
|
|||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
attendeePhone: ticket.attendeePhone,
|
attendeePhone: ticket.attendeePhone,
|
||||||
status: ticket.status,
|
status: ticket.status,
|
||||||
|
paymentStatus: ticket.paymentStatus,
|
||||||
|
// Balance to collect at the door for unpaid tickets
|
||||||
|
amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0,
|
||||||
checkinAt: ticket.checkinAt,
|
checkinAt: ticket.checkinAt,
|
||||||
checkedInBy,
|
checkedInBy,
|
||||||
},
|
},
|
||||||
@@ -1109,7 +1098,9 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
|||||||
return c.json({ error: 'Ticket not found' }, 404);
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.status === 'confirmed') {
|
// Confirmed/checked-in tickets can still be marked paid when they carry an
|
||||||
|
// unpaid balance (admin-added unpaid tickets collected at the door)
|
||||||
|
if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') {
|
||||||
return c.json({ error: 'Ticket already confirmed' }, 400);
|
return c.json({ error: 'Ticket already confirmed' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1152,12 +1143,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Confirm all tickets in the booking
|
// Confirm all tickets in the booking (checked-in tickets keep their status)
|
||||||
for (const t of ticketsToConfirm) {
|
for (const t of ticketsToConfirm) {
|
||||||
// Update ticket status
|
// Update ticket status
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed' })
|
.set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' })
|
||||||
.where(eq((tickets as any).id, t.id));
|
.where(eq((tickets as any).id, t.id));
|
||||||
|
|
||||||
// Update payment status
|
// Update payment status
|
||||||
@@ -1437,9 +1428,10 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
? data.email.trim()
|
? data.email.trim()
|
||||||
: `door-${generateId()}@doorentry.local`;
|
: `door-${generateId()}@doorentry.local`;
|
||||||
|
|
||||||
// Find or create user
|
// Find or create user (see the note on `accountEmail` in the booking route)
|
||||||
|
const accountEmail = normalizeEmail(attendeeEmail);
|
||||||
let user = await dbGet<any>(
|
let user = await dbGet<any>(
|
||||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
|
||||||
);
|
);
|
||||||
|
|
||||||
const adminFullName = data.lastName && data.lastName.trim()
|
const adminFullName = data.lastName && data.lastName.trim()
|
||||||
@@ -1450,12 +1442,15 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
const userId = generateId();
|
const userId = generateId();
|
||||||
user = {
|
user = {
|
||||||
id: userId,
|
id: userId,
|
||||||
email: attendeeEmail,
|
email: accountEmail,
|
||||||
password: '',
|
password: null,
|
||||||
name: adminFullName,
|
name: adminFullName,
|
||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
languagePreference: null,
|
languagePreference: null,
|
||||||
|
isClaimed: toDbBool(false),
|
||||||
|
accountStatus: 'unclaimed',
|
||||||
|
emailVerified: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -1498,6 +1493,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: ticketStatus,
|
status: ticketStatus,
|
||||||
|
paymentStatus: 'paid',
|
||||||
qrCode,
|
qrCode,
|
||||||
checkinAt: data.autoCheckin ? now : null,
|
checkinAt: data.autoCheckin ? now : null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
@@ -1541,148 +1537,33 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin create manual ticket (sends confirmation email + ticket to attendee)
|
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
|
||||||
ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
// type drives payment handling:
|
||||||
|
// paid — email required; paid cash payment; confirmation email + QR sent
|
||||||
|
// door — paid in cash at the door; all fields optional; counts toward revenue;
|
||||||
|
// confirmation email only when an email is provided
|
||||||
|
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
|
||||||
|
// pay-link (Bancard/TPago) email sent when an email is provided
|
||||||
|
// guest — free comp ticket, not counted in revenue; confirmation email only
|
||||||
|
// when an email is provided
|
||||||
|
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||||
eventId: z.string(),
|
eventId: z.string(),
|
||||||
firstName: z.string().min(2),
|
type: z.enum(['paid', 'door', 'unpaid', 'guest']),
|
||||||
lastName: z.string().optional().or(z.literal('')),
|
// Door walk-ins can be logged with nothing filled in, so firstName is only
|
||||||
email: z.string().email('Valid email is required for manual tickets'),
|
// required for the other types
|
||||||
phone: z.string().optional().or(z.literal('')),
|
firstName: z.string().optional().or(z.literal('')),
|
||||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
|
||||||
adminNote: z.string().max(1000).optional(),
|
|
||||||
})), async (c) => {
|
|
||||||
const data = c.req.valid('json');
|
|
||||||
|
|
||||||
// Get event
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
|
||||||
);
|
|
||||||
if (!event) {
|
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets)
|
|
||||||
|
|
||||||
const now = getNow();
|
|
||||||
const attendeeEmail = data.email.trim();
|
|
||||||
|
|
||||||
// Find or create user
|
|
||||||
let user = await dbGet<any>(
|
|
||||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
|
||||||
);
|
|
||||||
|
|
||||||
const fullName = data.lastName && data.lastName.trim()
|
|
||||||
? `${data.firstName} ${data.lastName}`.trim()
|
|
||||||
: data.firstName;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
const userId = generateId();
|
|
||||||
user = {
|
|
||||||
id: userId,
|
|
||||||
email: attendeeEmail,
|
|
||||||
password: '',
|
|
||||||
name: fullName,
|
|
||||||
phone: data.phone || null,
|
|
||||||
role: 'user',
|
|
||||||
languagePreference: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
await (db as any).insert(users).values(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for existing active ticket for this user and event
|
|
||||||
const existingTicket = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((tickets as any).userId, user.id),
|
|
||||||
eq((tickets as any).eventId, data.eventId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
|
||||||
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create ticket as confirmed
|
|
||||||
const ticketId = generateId();
|
|
||||||
const qrCode = generateTicketCode();
|
|
||||||
|
|
||||||
const newTicket = {
|
|
||||||
id: ticketId,
|
|
||||||
userId: user.id,
|
|
||||||
eventId: data.eventId,
|
|
||||||
attendeeFirstName: data.firstName,
|
|
||||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
|
||||||
attendeeEmail: attendeeEmail,
|
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
|
||||||
status: 'confirmed',
|
|
||||||
qrCode,
|
|
||||||
checkinAt: null,
|
|
||||||
adminNote: data.adminNote || null,
|
|
||||||
createdAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
await (db as any).insert(tickets).values(newTicket);
|
|
||||||
|
|
||||||
// Create payment record (marked as paid - manual entry)
|
|
||||||
const paymentId = generateId();
|
|
||||||
const adminUser = (c as any).get('user');
|
|
||||||
const newPayment = {
|
|
||||||
id: paymentId,
|
|
||||||
ticketId,
|
|
||||||
provider: 'cash',
|
|
||||||
amount: event.price,
|
|
||||||
currency: event.currency,
|
|
||||||
status: 'paid',
|
|
||||||
reference: 'Manual ticket',
|
|
||||||
paidAt: now,
|
|
||||||
paidByAdminId: adminUser?.id || null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
await (db as any).insert(payments).values(newPayment);
|
|
||||||
|
|
||||||
// Send booking confirmation email + ticket (asynchronously)
|
|
||||||
emailService.sendBookingConfirmation(ticketId).then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`);
|
|
||||||
} else {
|
|
||||||
console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error);
|
|
||||||
}
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('[Email] Exception sending booking confirmation for manual ticket:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
ticket: {
|
|
||||||
...newTicket,
|
|
||||||
event: {
|
|
||||||
title: event.title,
|
|
||||||
startDatetime: event.startDatetime,
|
|
||||||
location: event.location,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
payment: newPayment,
|
|
||||||
message: 'Manual ticket created and confirmation email sent',
|
|
||||||
}, 201);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Admin invite guest ticket (free, confirmed, not counted in revenue)
|
|
||||||
ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
|
||||||
eventId: z.string(),
|
|
||||||
firstName: z.string().min(1),
|
|
||||||
lastName: z.string().optional().or(z.literal('')),
|
lastName: z.string().optional().or(z.literal('')),
|
||||||
email: z.string().email().optional().or(z.literal('')),
|
email: z.string().email().optional().or(z.literal('')),
|
||||||
phone: z.string().optional().or(z.literal('')),
|
phone: z.string().optional().or(z.literal('')),
|
||||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
|
checkinNow: z.boolean().optional().default(false),
|
||||||
adminNote: z.string().max(1000).optional(),
|
adminNote: z.string().max(1000).optional(),
|
||||||
|
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
|
||||||
|
message: 'Email is required for paid tickets',
|
||||||
|
path: ['email'],
|
||||||
|
}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), {
|
||||||
|
message: 'First name is required',
|
||||||
|
path: ['firstName'],
|
||||||
})), async (c) => {
|
})), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
@@ -1693,40 +1574,48 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin-added tickets bypass the capacity check (intentional over-capacity)
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const adminUser = (c as any).get('user');
|
const adminUser = (c as any).get('user');
|
||||||
|
const hasEmail = !!(data.email && data.email.trim());
|
||||||
|
const attendeeEmail = hasEmail
|
||||||
|
? data.email!.trim()
|
||||||
|
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
|
||||||
|
|
||||||
// Find or create user (use placeholder email if none provided)
|
// Nameless door walk-ins still need a display name on the ticket
|
||||||
const attendeeEmail = data.email && data.email.trim()
|
const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in';
|
||||||
? data.email.trim()
|
|
||||||
: `guest-${generateId()}@guestinvite.local`;
|
|
||||||
|
|
||||||
const fullName = data.lastName && data.lastName.trim()
|
const fullName = data.lastName && data.lastName.trim()
|
||||||
? `${data.firstName} ${data.lastName}`.trim()
|
? `${firstName} ${data.lastName.trim()}`
|
||||||
: data.firstName;
|
: firstName;
|
||||||
|
|
||||||
|
// Find or create user (see the note on `accountEmail` in the booking route)
|
||||||
|
const accountEmail = normalizeEmail(attendeeEmail);
|
||||||
let user = await dbGet<any>(
|
let user = await dbGet<any>(
|
||||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const userId = generateId();
|
const userId = generateId();
|
||||||
user = {
|
user = {
|
||||||
id: userId,
|
id: userId,
|
||||||
email: attendeeEmail,
|
email: accountEmail,
|
||||||
password: '',
|
password: null,
|
||||||
name: fullName,
|
name: fullName,
|
||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
languagePreference: null,
|
languagePreference: null,
|
||||||
|
isClaimed: toDbBool(false),
|
||||||
|
accountStatus: 'unclaimed',
|
||||||
|
emailVerified: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await (db as any).insert(users).values(user);
|
await (db as any).insert(users).values(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing active ticket (only for real emails, not placeholder)
|
// Check for existing active ticket (only when a real email was provided)
|
||||||
if (data.email && data.email.trim()) {
|
if (hasEmail) {
|
||||||
const existingTicket = await dbGet<any>(
|
const existingTicket = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
@@ -1745,36 +1634,57 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
|
|
||||||
const ticketId = generateId();
|
const ticketId = generateId();
|
||||||
const qrCode = generateTicketCode();
|
const qrCode = generateTicketCode();
|
||||||
|
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid';
|
||||||
|
|
||||||
const newTicket = {
|
const newTicket = {
|
||||||
id: ticketId,
|
id: ticketId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
eventId: data.eventId,
|
eventId: data.eventId,
|
||||||
attendeeFirstName: data.firstName,
|
attendeeFirstName: firstName,
|
||||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||||
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
|
attendeeEmail: hasEmail ? data.email!.trim() : null,
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'confirmed',
|
status: data.checkinNow ? 'checked_in' : 'confirmed',
|
||||||
isGuest: 1,
|
isGuest: data.type === 'guest' ? 1 : 0,
|
||||||
|
paymentStatus,
|
||||||
qrCode,
|
qrCode,
|
||||||
checkinAt: null,
|
checkinAt: data.checkinNow ? now : null,
|
||||||
|
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await (db as any).insert(tickets).values(newTicket);
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
|
|
||||||
// Create a $0 payment record to track the invite
|
// Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid
|
||||||
const paymentId = generateId();
|
const paymentId = generateId();
|
||||||
const newPayment = {
|
const newPayment = data.type === 'unpaid'
|
||||||
|
? {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId,
|
||||||
|
provider: 'tpago',
|
||||||
|
amount: event.price,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'pending',
|
||||||
|
reference: 'Unpaid ticket — collect at door',
|
||||||
|
paidAt: null,
|
||||||
|
paidByAdminId: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: {
|
||||||
id: paymentId,
|
id: paymentId,
|
||||||
ticketId,
|
ticketId,
|
||||||
provider: 'cash',
|
provider: 'cash',
|
||||||
amount: 0,
|
amount: data.type === 'guest' ? 0 : event.price,
|
||||||
currency: event.currency,
|
currency: event.currency,
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
reference: 'Guest invite',
|
reference: data.type === 'guest'
|
||||||
|
? 'Guest invite'
|
||||||
|
: data.type === 'door'
|
||||||
|
? 'Paid at door'
|
||||||
|
: 'Manual ticket',
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
paidByAdminId: adminUser?.id || null,
|
paidByAdminId: adminUser?.id || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -1783,18 +1693,40 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
|
|
||||||
await (db as any).insert(payments).values(newPayment);
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
|
||||||
// Send booking confirmation email if a real email was provided
|
// Emails (asynchronous): paid always confirms; door/guest confirm only when an
|
||||||
if (data.email && data.email.trim()) {
|
// email exists; unpaid sends the TPago (Bancard) pay-link instructions instead
|
||||||
emailService.sendBookingConfirmation(ticketId).then(result => {
|
if (data.type === 'unpaid') {
|
||||||
if (result.success) {
|
if (hasEmail) {
|
||||||
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
|
emailService.sendPaymentInstructions(ticketId).then(result => {
|
||||||
} else {
|
if (!result.success) {
|
||||||
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
|
console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error);
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
|
console.error('[Email] Exception sending pay link for unpaid ticket:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (data.type === 'paid' || hasEmail) {
|
||||||
|
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
paid: 'Ticket created — confirmation email sent',
|
||||||
|
door: hasEmail
|
||||||
|
? 'Ticket created — paid at the door, confirmation email sent'
|
||||||
|
: 'Ticket created — paid at the door',
|
||||||
|
unpaid: hasEmail
|
||||||
|
? 'Unpaid ticket created — payment link sent'
|
||||||
|
: 'Unpaid ticket created — collect payment at the door',
|
||||||
|
guest: hasEmail
|
||||||
|
? 'Guest invited — confirmation email sent'
|
||||||
|
: 'Guest invited',
|
||||||
|
};
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
ticket: {
|
ticket: {
|
||||||
@@ -1806,7 +1738,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
payment: newPayment,
|
payment: newPayment,
|
||||||
message: 'Guest ticket created successfully',
|
message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type],
|
||||||
}, 201);
|
}, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
||||||
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { authSessions } from '../db/auth-schema.js';
|
||||||
import { getNow, toDbDate } from '../lib/utils.js';
|
import { getNow, toDbDate } from '../lib/utils.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
@@ -175,11 +176,28 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
return c.json({ error: 'User not found' }, 404);
|
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)
|
await (db as any)
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ ...data, updatedAt: getNow() })
|
.set({ ...data, ...statusMirror, updatedAt: getNow() })
|
||||||
.where(eq((users as any).id, id));
|
.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(
|
const updated = await dbGet(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"declaration": true,
|
"declaration": false,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"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."}';
|
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 / {
|
location / {
|
||||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,20 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
# 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:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -46,7 +59,7 @@ services:
|
|||||||
DB_TYPE: postgres
|
DB_TYPE: postgres
|
||||||
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
|
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
|
||||||
DB_POOL_MAX: "15"
|
DB_POOL_MAX: "15"
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379"
|
||||||
JWT_SECRET: change-me-to-a-strong-secret
|
JWT_SECRET: change-me-to-a-strong-secret
|
||||||
FRONTEND_URL: http://localhost:8080
|
FRONTEND_URL: http://localhost:8080
|
||||||
# Optional S3-compatible storage so uploads are shared across replicas.
|
# Optional S3-compatible storage so uploads are shared across replicas.
|
||||||
|
|||||||
@@ -79,6 +79,24 @@ server {
|
|||||||
proxy_connect_timeout 300s;
|
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
|
# Proxy /api to backend
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://spanglish_backend;
|
proxy_pass http://spanglish_backend;
|
||||||
|
|||||||
@@ -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 {
|
upstream spanglish_backend {
|
||||||
server 127.0.0.1:3018;
|
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)
|
# API URL (leave empty for same-origin proxy)
|
||||||
NEXT_PUBLIC_API_URL=
|
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)
|
# Google OAuth (optional - leave empty to hide Google Sign-In button)
|
||||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||||
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
// being hardcoded to localhost.
|
// being hardcoded to localhost.
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
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).
|
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
|
||||||
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
|
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -20,6 +24,9 @@ const securityHeaders = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const nextConfig = {
|
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: {
|
images: {
|
||||||
// Restrict remote image sources to a known allowlist instead of allowing any
|
// 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).
|
// https host (which let the Next image optimizer be used as an open proxy).
|
||||||
@@ -39,6 +46,10 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/photos/:path*',
|
||||||
|
destination: `${PHOTO_API_URL}/api/photos/:path*`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${BACKEND_URL}/api/:path*`,
|
destination: `${BACKEND_URL}/api/:path*`,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@tiptap/pm": "^3.18.0",
|
"@tiptap/pm": "^3.18.0",
|
||||||
"@tiptap/react": "^3.18.0",
|
"@tiptap/react": "^3.18.0",
|
||||||
"@tiptap/starter-kit": "^3.18.0",
|
"@tiptap/starter-kit": "^3.18.0",
|
||||||
|
"better-auth": "1.6.25",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, Suspense } from 'react';
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -9,29 +9,48 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { authApi } from '@/lib/api';
|
import { authApi } from '@/lib/api';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progressive-account claim. The claim email contains a magic link that signs
|
||||||
|
* the user in (via /auth/magic-link) and redirects here; this page then asks
|
||||||
|
* for a password and completes the claim against /api/auth-ext/claim-account.
|
||||||
|
*/
|
||||||
function ClaimAccountContent() {
|
function ClaimAccountContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { locale: language } = useLanguage();
|
const { locale: language } = useLanguage();
|
||||||
const { setAuthData } = useAuth();
|
const { refreshUser } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
const [hasSession, setHasSession] = useState(false);
|
||||||
|
const [alreadyClaimed, setAlreadyClaimed] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = searchParams.get('token');
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
authClient
|
||||||
|
.getSession()
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const user: any = data?.user;
|
||||||
|
setHasSession(!!user);
|
||||||
|
setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setChecking(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
if (formData.password !== formData.confirmPassword) {
|
||||||
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||||
return;
|
return;
|
||||||
@@ -49,8 +68,8 @@ function ClaimAccountContent() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
|
await authApi.confirmClaimAccount(formData.password);
|
||||||
setAuthData({ user: result.user, token: result.token });
|
await refreshUser();
|
||||||
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
|
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -60,7 +79,21 @@ function ClaimAccountContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!token) {
|
if (checking) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alreadyClaimed) {
|
||||||
|
// Signed-in and already active: nothing to claim
|
||||||
|
router.push('/dashboard');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSession) {
|
||||||
return (
|
return (
|
||||||
<div className="section-padding min-h-[70vh] flex items-center">
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
<div className="container-page">
|
<div className="container-page">
|
||||||
@@ -76,8 +109,8 @@ function ClaimAccountContent() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
{language === 'es'
|
{language === 'es'
|
||||||
? 'Este enlace de activación no es válido o ha expirado.'
|
? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.'
|
||||||
: 'This activation link is invalid or has expired.'}
|
: 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/login">
|
<Link href="/login">
|
||||||
<Button>
|
<Button>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||||
|
import { redirectAfterAuth } from '@/lib/authRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
function MagicLinkContent() {
|
function MagicLinkContent() {
|
||||||
@@ -18,6 +20,7 @@ function MagicLinkContent() {
|
|||||||
const verificationAttempted = useRef(false);
|
const verificationAttempted = useRef(false);
|
||||||
|
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
|
const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
|
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
|
||||||
@@ -34,11 +37,17 @@ function MagicLinkContent() {
|
|||||||
|
|
||||||
const verifyToken = async () => {
|
const verifyToken = async () => {
|
||||||
try {
|
try {
|
||||||
await loginWithMagicLink(token!);
|
const user = await loginWithMagicLink(token!);
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
||||||
|
// Unclaimed accounts must finish the claim (set a password) before the
|
||||||
|
// rest of the API will accept their session.
|
||||||
|
const destination =
|
||||||
|
user && (user.isClaimed === false || user.accountStatus === 'unclaimed')
|
||||||
|
? '/auth/claim-account'
|
||||||
|
: callbackURL;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/dashboard');
|
redirectAfterAuth(destination);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatDateLong, formatTime, formatRucDisplay } from '@/lib/utils';
|
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type {
|
import type {
|
||||||
@@ -108,16 +108,15 @@ export default function BookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookedCount = eventRes.event.bookedCount ?? 0;
|
// Server-authoritative availability — same formula the booking API
|
||||||
const capacity = eventRes.event.capacity ?? 0;
|
// enforces, so a sold-out event is caught here, not at submit time.
|
||||||
const soldOut = bookedCount >= capacity;
|
if (isEventSoldOut(eventRes.event)) {
|
||||||
if (soldOut) {
|
|
||||||
toast.error(t('events.details.soldOut'));
|
toast.error(t('events.details.soldOut'));
|
||||||
router.push(`/events/${eventRes.event.slug}`);
|
router.push(`/events/${eventRes.event.slug}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spotsLeft = Math.max(0, capacity - bookedCount);
|
const spotsLeft = eventSpotsLeft(eventRes.event);
|
||||||
setEvent(eventRes.event);
|
setEvent(eventRes.event);
|
||||||
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
||||||
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
||||||
@@ -366,6 +365,23 @@ export default function BookingPage() {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || t('booking.form.errors.bookingFailed'));
|
toast.error(error.message || t('booking.form.errors.bookingFailed'));
|
||||||
|
// Capacity race on the last seats: refresh availability so the page
|
||||||
|
// reflects reality (sold-out block / lower quantity cap) instead of the
|
||||||
|
// stale counts loaded when the form was opened.
|
||||||
|
const message = String(error?.message || '');
|
||||||
|
if (/sold out|seats available/i.test(message)) {
|
||||||
|
try {
|
||||||
|
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
|
||||||
|
setEvent(freshEvent);
|
||||||
|
const freshSpots = eventSpotsLeft(freshEvent);
|
||||||
|
if (freshSpots > 0) {
|
||||||
|
setTicketQuantity((q) => Math.min(q, freshSpots));
|
||||||
|
setAttendees((prev) => prev.slice(0, Math.max(0, freshSpots - 1)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the stale event state if the refresh fails
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -388,8 +404,8 @@ export default function BookingPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
const isSoldOut = isEventSoldOut(event);
|
||||||
|
|
||||||
// Paying step - waiting for Lightning payment (compact design)
|
// Paying step - waiting for Lightning payment (compact design)
|
||||||
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
|
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface AccountTabProps {
|
|||||||
*/
|
*/
|
||||||
export default function AccountTab({ onUpdate }: AccountTabProps) {
|
export default function AccountTab({ onUpdate }: AccountTabProps) {
|
||||||
const { locale } = useLanguage();
|
const { locale } = useLanguage();
|
||||||
const { user, updateUser, logout } = useAuth();
|
const { user, updateUser } = useAuth();
|
||||||
|
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
@@ -187,15 +187,17 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
|
|||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
locale === 'es'
|
locale === 'es'
|
||||||
? '¿Cerrar todas las sesiones? Serás desconectado.'
|
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
|
||||||
: 'Log out of all sessions? You will be logged out.'
|
: 'Log out of all other sessions? This session stays signed in.'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
try {
|
try {
|
||||||
await dashboardApi.revokeAllSessions();
|
await dashboardApi.revokeAllSessions();
|
||||||
toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
|
toast.success(
|
||||||
logout();
|
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
|
||||||
|
);
|
||||||
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(locale === 'es' ? 'Error' : 'Failed');
|
toast.error(locale === 'es' ? 'Error' : 'Failed');
|
||||||
}
|
}
|
||||||
@@ -477,7 +479,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sessions.map((session, index) => (
|
{sessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
|
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
|
||||||
@@ -494,7 +496,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
|
|||||||
{session.ipAddress && ` • ${session.ipAddress}`}
|
{session.ipAddress && ` • ${session.ipAddress}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{index === 0 ? (
|
{session.current ? (
|
||||||
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
|
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
|
||||||
{locale === 'es' ? 'Esta sesión' : 'This session'}
|
{locale === 'es' ? 'Esta sesión' : 'This session'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type Tab = 'overview' | 'tickets' | 'payments' | 'account';
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { locale } = useLanguage();
|
const { locale } = useLanguage();
|
||||||
const { user, isLoading: authLoading, token } = useAuth();
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
|
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
|
||||||
@@ -36,11 +36,13 @@ export default function DashboardPage() {
|
|||||||
router.push('/login');
|
router.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user && token) {
|
// Auth rides on the httpOnly session cookie; once the user has resolved
|
||||||
|
// the API calls are authenticated automatically.
|
||||||
|
if (user) {
|
||||||
loadDashboardData();
|
loadDashboardData();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, authLoading, token]);
|
}, [user, authLoading]);
|
||||||
|
|
||||||
const loadDashboardData = async () => {
|
const loadDashboardData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import ShareButtons from '@/components/ShareButtons';
|
import ShareButtons from '@/components/ShareButtons';
|
||||||
@@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
// Spots left: never negative; sold out when confirmed >= capacity
|
// Server-authoritative availability (paid + claimed seats count; abandoned
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
// pending bookings don't) — matches the booking API's sold-out check exactly.
|
||||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
|
const isSoldOut = isEventSoldOut(event);
|
||||||
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
|
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
|
||||||
|
|
||||||
|
// Shown while the server component fetches the event gallery.
|
||||||
|
export default function Loading() {
|
||||||
|
return <GallerySkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||||
|
import GalleryClient from '@/app/(public)/photos/[slug]/GalleryClient';
|
||||||
|
|
||||||
|
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: { id: string }; // event slug, same param name as the parent route
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public event galleries render server-side; link/ticket galleries return
|
||||||
|
// null here and are fetched client-side with the viewer's token.
|
||||||
|
async function getEventGallery(
|
||||||
|
eventSlug: string
|
||||||
|
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${photoApiUrl}/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery`,
|
||||||
|
{ next: { revalidate: 300 } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const data = await getEventGallery(params.id);
|
||||||
|
if (!data) {
|
||||||
|
return { title: 'Event Photos', robots: { index: false } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: `${data.gallery.title} – Photos`,
|
||||||
|
description:
|
||||||
|
data.gallery.description ||
|
||||||
|
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EventGalleryPage({ params }: PageProps) {
|
||||||
|
const initial = await getEventGallery(params.id);
|
||||||
|
return (
|
||||||
|
// Suspense boundary required by useSearchParams() in the client child.
|
||||||
|
<Suspense>
|
||||||
|
<GalleryClient eventSlug={params.id} initial={initial} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,7 +117,10 @@ function generateEventJsonLd(event: Event) {
|
|||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: event.price,
|
price: event.price,
|
||||||
priceCurrency: event.currency,
|
priceCurrency: event.currency,
|
||||||
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
|
availability:
|
||||||
|
(typeof event.availableSeats === 'number'
|
||||||
|
? event.availableSeats
|
||||||
|
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0
|
||||||
? 'https://schema.org/InStock'
|
? 'https://schema.org/InStock'
|
||||||
: 'https://schema.org/SoldOut',
|
: 'https://schema.org/SoldOut',
|
||||||
url: `${siteUrl}/events/${event.slug}`,
|
url: `${siteUrl}/events/${event.slug}`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, Suspense } from 'react';
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -11,14 +11,20 @@ import Input from '@/components/ui/Input';
|
|||||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||||
import { authApi } from '@/lib/api';
|
import { authApi } from '@/lib/api';
|
||||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||||
|
import {
|
||||||
|
clearRedirectAttempt,
|
||||||
|
didRedirectBounce,
|
||||||
|
redirectAfterAuth,
|
||||||
|
} from '@/lib/authRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
function LoginContent() {
|
function LoginContent() {
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { t, locale: language } = useLanguage();
|
const { t, locale: language } = useLanguage();
|
||||||
const { login } = useAuth();
|
const { login, user, isLoading: authLoading } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [redirecting, setRedirecting] = useState(false);
|
||||||
|
const [bounced, setBounced] = useState(false);
|
||||||
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
|
const [loginMode, setLoginMode] = useState<'password' | 'magic-link'>('password');
|
||||||
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
const [magicLinkSent, setMagicLinkSent] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -29,6 +35,27 @@ function LoginContent() {
|
|||||||
// Check for redirect after login (only same-origin relative paths are honoured)
|
// Check for redirect after login (only same-origin relative paths are honoured)
|
||||||
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
|
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
|
||||||
|
|
||||||
|
// Send an already-signed-in visitor on to their destination — and detect the case
|
||||||
|
// where that destination bounced them back here, which otherwise looks like the
|
||||||
|
// login page silently ignoring a successful sign-in.
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading || redirecting) return;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Signed out on the login page is a clean slate.
|
||||||
|
clearRedirectAttempt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didRedirectBounce(redirectTo)) {
|
||||||
|
setBounced(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRedirecting(true);
|
||||||
|
redirectAfterAuth(redirectTo);
|
||||||
|
}, [authLoading, redirecting, user, redirectTo]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -36,10 +63,12 @@ function LoginContent() {
|
|||||||
try {
|
try {
|
||||||
await login(formData.email, formData.password);
|
await login(formData.email, formData.password);
|
||||||
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
|
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome back!');
|
||||||
router.push(redirectTo);
|
// Deliberately leaves `loading` set: the button must stay disabled until the
|
||||||
|
// browser replaces this page.
|
||||||
|
setRedirecting(true);
|
||||||
|
redirectAfterAuth(redirectTo);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || t('auth.errors.invalidCredentials'));
|
toast.error(error.message || t('auth.errors.invalidCredentials'));
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -67,6 +96,38 @@ function LoginContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The destination sent us back here even though the session is valid. Say so, rather
|
||||||
|
// than re-showing a form that appears to do nothing. The retry link is a plain <a> so
|
||||||
|
// it is a full page load, like every other navigation out of this page.
|
||||||
|
if (bounced) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<h1 className="text-2xl font-bold">{t('auth.login.redirectBlocked')}</h1>
|
||||||
|
{user && <p className="mt-2 text-sm text-gray-600">{user.email}</p>}
|
||||||
|
<p className="mt-4 text-sm text-gray-600">
|
||||||
|
<code className="px-1.5 py-0.5 bg-gray-100 rounded">{redirectTo}</code>
|
||||||
|
</p>
|
||||||
|
<a href={redirectTo} className="mt-6 block">
|
||||||
|
<Button className="w-full" size="lg">
|
||||||
|
{t('auth.login.redirectRetry')}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-3 inline-block text-sm text-secondary-blue hover:underline"
|
||||||
|
>
|
||||||
|
{t('nav.home')}
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding min-h-[70vh] flex items-center">
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
<div className="container-page">
|
<div className="container-page">
|
||||||
@@ -151,9 +212,25 @@ function LoginContent() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
isLoading={loading || redirecting}
|
||||||
|
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
|
||||||
|
>
|
||||||
{t('auth.login.submit')}
|
{t('auth.login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{redirecting && (
|
||||||
|
<p
|
||||||
|
className="text-center text-sm text-gray-600"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{t('auth.login.redirecting')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
) : magicLinkSent ? (
|
) : magicLinkSent ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import type { PhotoGallery } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import { CameraIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const es = locale === 'es';
|
||||||
|
|
||||||
|
const formatDate = (iso?: string) => {
|
||||||
|
if (!iso) return null;
|
||||||
|
return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page">
|
||||||
|
<h1 className="section-title text-center mb-2">
|
||||||
|
{es ? 'Galerías de Fotos' : 'Photo Galleries'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-gray-600 mb-10">
|
||||||
|
{es
|
||||||
|
? 'Recuerdos de nuestros intercambios de idiomas en Asunción'
|
||||||
|
: 'Memories from our language exchanges in Asunción'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{galleries.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{galleries.map((g) => (
|
||||||
|
// Event-linked galleries live under the event's URL.
|
||||||
|
<Link key={g.id} href={g.event ? `/events/${g.event.slug}/gallery` : `/photos/${g.slug}`}>
|
||||||
|
<Card variant="elevated" className="overflow-hidden hover:shadow-card-hover transition-shadow h-full">
|
||||||
|
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||||
|
{g.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<CameraIcon className="w-12 h-12 text-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="font-heading font-semibold text-lg text-primary-dark">
|
||||||
|
{es && g.titleEs ? g.titleEs : g.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{g.photoCount} {es ? 'fotos' : 'photos'}
|
||||||
|
{g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
|
||||||
|
import PhotoTile from '@/components/gallery/PhotoTile';
|
||||||
|
import {
|
||||||
|
GalleryContainer,
|
||||||
|
GalleryHeroFrame,
|
||||||
|
MasonryGrid,
|
||||||
|
} from '@/components/gallery/GalleryLayout';
|
||||||
|
import { useDownloads } from '@/components/gallery/useDownloads';
|
||||||
|
import SaveSheet, { SavePhoto, useMobileSave } from '@/components/gallery/SaveSheet';
|
||||||
|
import Lightbox from '@/components/Lightbox';
|
||||||
|
import LoginModal from '@/components/LoginModal';
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
CameraIcon,
|
||||||
|
LinkIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
TicketIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface GalleryClientProps {
|
||||||
|
/** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */
|
||||||
|
slug?: string;
|
||||||
|
/** … or by event slug (event galleries at /events/[slug]/gallery). */
|
||||||
|
eventSlug?: string;
|
||||||
|
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeniedState = 'login' | 'ticket' | 'private' | 'link' | 'notfound' | null;
|
||||||
|
|
||||||
|
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const es = locale === 'es';
|
||||||
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const shareToken = searchParams.get('token') || undefined;
|
||||||
|
|
||||||
|
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
|
||||||
|
const [photos, setPhotos] = useState<Photo[]>(initial?.photos ?? []);
|
||||||
|
const [loading, setLoading] = useState(!initial);
|
||||||
|
const [denied, setDenied] = useState<DeniedState>(null);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
const downloads = useDownloads(es);
|
||||||
|
const downloadLabels = {
|
||||||
|
download: es ? 'Descargar' : 'Download',
|
||||||
|
downloading: es ? 'Descargando…' : 'Downloading…',
|
||||||
|
};
|
||||||
|
|
||||||
|
// On a phone a download cannot reach the photo library, so the button
|
||||||
|
// opens a sheet offering the shareable preview instead (SaveSheet).
|
||||||
|
// Desktop keeps downloading the original straight away.
|
||||||
|
const mobileSave = useMobileSave();
|
||||||
|
const [savePhoto, setSavePhoto] = useState<SavePhoto | null>(null);
|
||||||
|
|
||||||
|
const downloadPhoto = (photo: Photo) => {
|
||||||
|
if (mobileSave) {
|
||||||
|
setSavePhoto({
|
||||||
|
id: photo.id,
|
||||||
|
previewUrl: photo.urls.download || photo.urls.preview || photo.urls.original,
|
||||||
|
originalUrl: photo.urls.downloadOriginal || photo.urls.original,
|
||||||
|
previewSize: photo.previewSizeBytes,
|
||||||
|
originalSize: photo.sizeBytes,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloads.start({
|
||||||
|
id: photo.id,
|
||||||
|
url: photo.urls.original,
|
||||||
|
filename: photo.originalFilename,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-rendered public galleries need no client fetch. Everything else
|
||||||
|
// (link/ticket/private) is fetched here with the share token and/or the
|
||||||
|
// viewer's own auth token attached.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) return;
|
||||||
|
if (authLoading) return; // wait until the session state has resolved
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
const fetcher = eventSlug
|
||||||
|
? photosApi.getEventGallery(eventSlug, shareToken)
|
||||||
|
: photosApi.getPublicGallery(slug || '', shareToken);
|
||||||
|
fetcher
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setGallery(data.gallery);
|
||||||
|
setPhotos(data.photos);
|
||||||
|
setDenied(null);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
// The photo-api returns a distinct message per visibility mode so
|
||||||
|
// each gets its own gate page (see accessDenial in access.go).
|
||||||
|
const msg = err.message || '';
|
||||||
|
if (msg.includes('Authentication required')) setDenied('login');
|
||||||
|
else if (msg.includes('attendees')) setDenied('ticket');
|
||||||
|
else if (msg.includes('private')) setDenied('private');
|
||||||
|
else if (msg.includes('share link')) setDenied('link');
|
||||||
|
else setDenied('notfound');
|
||||||
|
})
|
||||||
|
.finally(() => !cancelled && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
|
||||||
|
|
||||||
|
// Mirrors the hero + masonry layout below, so the real page drops straight
|
||||||
|
// into the placeholder's geometry instead of replacing it.
|
||||||
|
if (loading || (authLoading && !initial)) {
|
||||||
|
return <GallerySkeleton count={gallery?.photoCount || undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate pages for restricted galleries. After a successful login in the
|
||||||
|
// pop-up, AuthContext's user changes, which re-runs the fetch effect —
|
||||||
|
// the gate resolves by itself when access is granted.
|
||||||
|
const loginModal = (
|
||||||
|
<LoginModal
|
||||||
|
open={loginOpen}
|
||||||
|
onClose={() => setLoginOpen(false)}
|
||||||
|
message={
|
||||||
|
es
|
||||||
|
? 'Inicia sesión para ver esta galería.'
|
||||||
|
: 'Log in to view this gallery.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (denied === 'login') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GateMessage
|
||||||
|
icon={TicketIcon}
|
||||||
|
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||||
|
body={
|
||||||
|
es
|
||||||
|
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
|
||||||
|
: 'This gallery is for event attendees. Log in with the account you used to book.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{loginModal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denied === 'ticket') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GateMessage
|
||||||
|
icon={TicketIcon}
|
||||||
|
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||||
|
body={
|
||||||
|
es
|
||||||
|
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada. ¿Reservaste con otra cuenta?'
|
||||||
|
: 'This gallery is only available to people who attended the event with a confirmed ticket. Booked with a different account?'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
{(eventSlug || gallery?.event) && (
|
||||||
|
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
|
||||||
|
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => setLoginOpen(true)}>
|
||||||
|
{es ? 'Cambiar de cuenta' : 'Switch account'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{loginModal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denied === 'private') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GateMessage
|
||||||
|
icon={LockClosedIcon}
|
||||||
|
title={es ? 'Esta galería es privada' : 'This gallery is private'}
|
||||||
|
body={
|
||||||
|
es
|
||||||
|
? 'Solo los organizadores pueden verla. Si eres parte del equipo, inicia sesión.'
|
||||||
|
: 'Only the organizers can see it. If that’s you, log in.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
<Link href="/photos">
|
||||||
|
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||||
|
</Link>
|
||||||
|
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{loginModal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denied === 'link') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GateMessage
|
||||||
|
icon={LinkIcon}
|
||||||
|
title={es ? 'Esta galería necesita su enlace' : 'This gallery needs its share link'}
|
||||||
|
body={
|
||||||
|
es
|
||||||
|
? 'Solo se puede abrir con el enlace que compartieron los organizadores. Pídeles el enlace completo, o inicia sesión si eres parte del equipo.'
|
||||||
|
: 'It can only be opened with the link the organizers shared. Ask them for the full link, or log in if you’re part of the team.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
<Link href="/photos">
|
||||||
|
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||||
|
</Link>
|
||||||
|
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{loginModal}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denied === 'notfound' || !gallery) {
|
||||||
|
return (
|
||||||
|
<GateMessage
|
||||||
|
icon={CameraIcon}
|
||||||
|
title={es ? 'Galería no encontrada' : 'Gallery not found'}
|
||||||
|
body={
|
||||||
|
es
|
||||||
|
? 'Este enlace no existe o ya no está disponible.'
|
||||||
|
: 'This link does not exist or is no longer available.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Link href="/photos">
|
||||||
|
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||||
|
// previewUrl is the download endpoint's preview: rendering and saving from
|
||||||
|
// the same URL is what lets "Save photo" be answered by the HTTP cache.
|
||||||
|
const lightboxItems = readyPhotos.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
previewUrl: p.urls.download || p.urls.preview || p.urls.original,
|
||||||
|
downloadUrl: p.urls.downloadOriginal || p.urls.original,
|
||||||
|
filename: p.originalFilename,
|
||||||
|
thumbUrl: p.urls.thumb,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
|
||||||
|
const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description;
|
||||||
|
const eventTitle = gallery.event
|
||||||
|
? es && gallery.event.titleEs
|
||||||
|
? gallery.event.titleEs
|
||||||
|
: gallery.event.title
|
||||||
|
: null;
|
||||||
|
const eventDate = gallery.event?.startDatetime
|
||||||
|
? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Asuncion',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Hero backdrop: the cover photo's preview when available, else the
|
||||||
|
// first photo. Falls back to a navy gradient with no image.
|
||||||
|
const coverPhoto =
|
||||||
|
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
||||||
|
// Same preview URL the lightbox uses, so the cover photo's bytes are
|
||||||
|
// fetched once for both.
|
||||||
|
const heroUrl = coverPhoto
|
||||||
|
? coverPhoto.urls.download || coverPhoto.urls.preview || coverPhoto.urls.thumb
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Hero — same frame the skeleton renders (GalleryLayout). */}
|
||||||
|
<GalleryHeroFrame
|
||||||
|
backdrop={
|
||||||
|
heroUrl ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={heroUrl}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
|
||||||
|
<CameraIcon className="w-4 h-4" />
|
||||||
|
{readyPhotos.length} {es ? 'fotos' : 'photos'}
|
||||||
|
</span>
|
||||||
|
{gallery.event && (
|
||||||
|
<Link
|
||||||
|
href={`/events/${gallery.event.slug}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
{eventTitle}
|
||||||
|
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GalleryHeroFrame>
|
||||||
|
|
||||||
|
{/* Masonry grid */}
|
||||||
|
<GalleryContainer>
|
||||||
|
{readyPhotos.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MasonryGrid>
|
||||||
|
{readyPhotos.map((photo, i) => (
|
||||||
|
<PhotoTile
|
||||||
|
key={photo.id}
|
||||||
|
photo={photo}
|
||||||
|
eager={i < 8}
|
||||||
|
onOpen={() => setLightboxIndex(i)}
|
||||||
|
onDownload={() => downloadPhoto(photo)}
|
||||||
|
downloading={downloads.isPending(photo.id)}
|
||||||
|
labels={downloadLabels}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MasonryGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lightboxIndex !== null && (
|
||||||
|
<Lightbox
|
||||||
|
items={lightboxItems}
|
||||||
|
index={lightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
onNavigate={setLightboxIndex}
|
||||||
|
onDownload={(it) => {
|
||||||
|
const photo = readyPhotos.find((p) => p.id === it.id);
|
||||||
|
if (photo) downloadPhoto(photo);
|
||||||
|
}}
|
||||||
|
downloadingId={
|
||||||
|
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
|
||||||
|
}
|
||||||
|
downloadLabels={downloadLabels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shared by both entry points: the tile button and the lightbox's.
|
||||||
|
Fetches nothing until it is open. */}
|
||||||
|
<SaveSheet photo={savePhoto} onClose={() => setSavePhoto(null)} />
|
||||||
|
</GalleryContainer>
|
||||||
|
|
||||||
|
{/* Call to action: send attendees to their dashboard, everyone else to
|
||||||
|
the next event. Auth state comes from the same useAuth() the gate
|
||||||
|
pages use. */}
|
||||||
|
<div className="bg-brand-navy">
|
||||||
|
<div className="container-page px-4 py-12 md:py-16 text-center">
|
||||||
|
<h2 className="font-heading font-bold text-2xl md:text-3xl text-white">
|
||||||
|
{user
|
||||||
|
? es
|
||||||
|
? '¿Listo para lo que sigue?'
|
||||||
|
: 'Ready for what’s next?'
|
||||||
|
: es
|
||||||
|
? '¿Te gustó lo que viste?'
|
||||||
|
: 'Liked what you saw?'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-xl mx-auto text-white/80 text-sm md:text-base">
|
||||||
|
{user
|
||||||
|
? es
|
||||||
|
? 'Revisa tus entradas y próximos eventos en tu panel.'
|
||||||
|
: 'Check your tickets and upcoming events from your dashboard.'
|
||||||
|
: es
|
||||||
|
? 'Únete a nuestro próximo evento y sé parte de las próximas fotos.'
|
||||||
|
: 'Join our next event and be part of the next set of photos.'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link href={user ? '/dashboard' : '/next'}>
|
||||||
|
<Button size="lg">
|
||||||
|
{user
|
||||||
|
? es
|
||||||
|
? 'Ir a mi panel'
|
||||||
|
: 'Go to dashboard'
|
||||||
|
: es
|
||||||
|
? 'Únete al próximo evento'
|
||||||
|
: 'Join the next event'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GateMessage({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon: typeof CameraIcon;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section-padding">
|
||||||
|
<div className="container-page max-w-lg mx-auto text-center py-16">
|
||||||
|
<Icon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark mb-2">{title}</h1>
|
||||||
|
<p className="text-gray-600 mb-6">{body}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
|
||||||
|
|
||||||
|
// Shown while the server component fetches the gallery, so the first paint is
|
||||||
|
// already the gallery's layout rather than an empty page.
|
||||||
|
export default function Loading() {
|
||||||
|
return <GallerySkeleton />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||||
|
import GalleryClient from './GalleryClient';
|
||||||
|
|
||||||
|
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: { slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public galleries render server-side (SEO + fast first paint); link and
|
||||||
|
// ticket galleries return null here and are fetched client-side with the
|
||||||
|
// viewer's token / share token.
|
||||||
|
async function getPublicGallery(
|
||||||
|
slug: string
|
||||||
|
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`,
|
||||||
|
{ next: { revalidate: 300 } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const data = await getPublicGallery(params.slug);
|
||||||
|
if (!data) {
|
||||||
|
return { title: 'Photo Gallery', robots: { index: false } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: `${data.gallery.title} – Photos`,
|
||||||
|
description:
|
||||||
|
data.gallery.description ||
|
||||||
|
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GalleryPage({ params }: PageProps) {
|
||||||
|
const initial = await getPublicGallery(params.slug);
|
||||||
|
return (
|
||||||
|
// Suspense boundary required by useSearchParams() in the client child.
|
||||||
|
<Suspense>
|
||||||
|
<GalleryClient slug={params.slug} initial={initial} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import type { PhotoGallery } from '@/lib/api';
|
||||||
|
import PhotosIndexClient from './PhotosIndexClient';
|
||||||
|
|
||||||
|
// Server-side calls go straight to the photo-api service; the browser uses
|
||||||
|
// the same-origin /api/photos path via the Next rewrite / nginx.
|
||||||
|
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Event Photo Galleries',
|
||||||
|
description: 'Photos from past Spanglish language exchange events in Asunción.',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getGalleries(): Promise<PhotoGallery[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.galleries || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PhotosPage() {
|
||||||
|
const galleries = await getGalleries();
|
||||||
|
return <PhotosIndexClient galleries={galleries} />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -9,13 +8,16 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||||
|
import { redirectAfterAuth } from '@/lib/authRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const REDIRECT_TO = '/dashboard';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
|
||||||
const { t, locale: language } = useLanguage();
|
const { t, locale: language } = useLanguage();
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [redirecting, setRedirecting] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -30,10 +32,12 @@ export default function RegisterPage() {
|
|||||||
try {
|
try {
|
||||||
await register(formData);
|
await register(formData);
|
||||||
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
|
toast.success(language === 'es' ? 'Cuenta creada exitosamente!' : 'Account created successfully!');
|
||||||
router.push('/dashboard');
|
// Deliberately leaves `loading` set: the button must stay disabled until the
|
||||||
|
// browser replaces this page.
|
||||||
|
setRedirecting(true);
|
||||||
|
redirectAfterAuth(REDIRECT_TO);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || t('auth.errors.emailExists'));
|
toast.error(error.message || t('auth.errors.emailExists'));
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -104,9 +108,25 @@ export default function RegisterPage() {
|
|||||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
isLoading={loading || redirecting}
|
||||||
|
loadingText={redirecting ? t('auth.login.redirecting') : t('common.loading')}
|
||||||
|
>
|
||||||
{t('auth.register.submit')}
|
{t('auth.register.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{redirecting && (
|
||||||
|
<p
|
||||||
|
className="text-center text-sm text-gray-600"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{t('auth.login.redirecting')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-gray-600">
|
<p className="mt-6 text-center text-sm text-gray-600">
|
||||||
|
|||||||
@@ -107,11 +107,7 @@ export default function AdminEmailsPage() {
|
|||||||
|
|
||||||
const loadEvents = async () => {
|
const loadEvents = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/events', {
|
const res = await fetch('/api/events', { credentials: 'same-origin' });
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setEvents(data.events || []);
|
setEvents(data.events || []);
|
||||||
@@ -169,9 +165,7 @@ export default function AdminEmailsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
|
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
|
||||||
headers: {
|
credentials: 'same-origin',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@@ -18,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket payment status: Paid (revenue), Unpaid (balance due at door), Comp (free guest).
|
||||||
|
// Tickets created before the payment_status column default to unpaid via backfill.
|
||||||
|
export function PaymentBadge({ paymentStatus, compact = false }: { paymentStatus?: string; compact?: boolean }) {
|
||||||
|
if (!paymentStatus) return null;
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
paid: 'bg-emerald-100 text-emerald-700',
|
||||||
|
unpaid: 'bg-orange-100 text-orange-700',
|
||||||
|
comp: 'bg-amber-100 text-amber-700',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={clsx(
|
||||||
|
'inline-flex items-center rounded-full font-medium',
|
||||||
|
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
|
||||||
|
styles[paymentStatus] || 'bg-gray-100 text-gray-800'
|
||||||
|
)}>
|
||||||
|
{paymentStatus === 'comp' ? 'Comp' : paymentStatus === 'unpaid' ? 'Unpaid' : 'Paid'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
|
import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary } from '@/lib/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the core data for the admin event detail page (event, tickets, active
|
* Loads the core data for the admin event detail page (event, tickets, active
|
||||||
* email templates) and exposes a reload function used after mutations.
|
* email templates, door takings) and exposes a reload function used after
|
||||||
|
* mutations.
|
||||||
*/
|
*/
|
||||||
export function useEventDetailData(eventId: string) {
|
export function useEventDetailData(eventId: string) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [event, setEvent] = useState<Event | null>(null);
|
const [event, setEvent] = useState<Event | null>(null);
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
|
const [doorSummary, setDoorSummary] = useState<DoorSummary | null>(null);
|
||||||
|
|
||||||
const loadEventData = async () => {
|
const loadEventData = async () => {
|
||||||
try {
|
try {
|
||||||
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
|
const [eventRes, ticketsRes, templatesRes, doorRes] = await Promise.all([
|
||||||
eventsApi.getById(eventId),
|
eventsApi.getById(eventId),
|
||||||
ticketsApi.getAll({ eventId }),
|
ticketsApi.getAll({ eventId }),
|
||||||
emailsApi.getTemplates(),
|
emailsApi.getTemplates(),
|
||||||
|
// Door takings split pre-sale from cash/bitcoin/transfer taken on the
|
||||||
|
// night. It is supporting detail, so a failure here must not blank the page.
|
||||||
|
doorApi.summary(eventId).catch(() => null),
|
||||||
]);
|
]);
|
||||||
setEvent(eventRes.event);
|
setEvent(eventRes.event);
|
||||||
setTickets(ticketsRes.tickets);
|
setTickets(ticketsRes.tickets);
|
||||||
setTemplates(templatesRes.templates.filter(t => t.isActive));
|
setTemplates(templatesRes.templates.filter(t => t.isActive));
|
||||||
|
setDoorSummary(doorRes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load event data');
|
toast.error('Failed to load event data');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -33,5 +39,5 @@ export function useEventDetailData(eventId: string) {
|
|||||||
loadEventData();
|
loadEventData();
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
return { loading, event, tickets, templates, loadEventData };
|
return { loading, event, tickets, templates, doorSummary, loadEventData };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { Dispatch, FormEvent, SetStateAction } from 'react';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
BanknotesIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
LinkIcon,
|
||||||
|
StarIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import type { AddTicketType, AddTicketFormState } from '../_types';
|
||||||
|
|
||||||
|
interface AddTicketModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
form: AddTicketFormState;
|
||||||
|
setForm: Dispatch<SetStateAction<AddTicketFormState>>;
|
||||||
|
onSubmit: (e: FormEvent) => void;
|
||||||
|
submitting: boolean;
|
||||||
|
eventPriceLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
{ value: 'door', label: 'At Door' },
|
||||||
|
{ value: 'unpaid', label: 'Unpaid' },
|
||||||
|
{ value: 'guest', label: 'Guest' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUBMIT_LABELS: Record<AddTicketType, string> = {
|
||||||
|
paid: 'Create & send ticket',
|
||||||
|
door: 'Record door payment',
|
||||||
|
unpaid: 'Create & send pay link',
|
||||||
|
guest: 'Invite guest',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
|
||||||
|
paid: EnvelopeIcon,
|
||||||
|
door: BanknotesIcon,
|
||||||
|
unpaid: LinkIcon,
|
||||||
|
guest: StarIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live "what happens" preview lines for the selected type / email / check-in combo
|
||||||
|
function previewLines(form: AddTicketFormState, eventPriceLabel: string): string[] {
|
||||||
|
const hasEmail = !!form.email.trim();
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (form.type === 'paid') {
|
||||||
|
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
|
||||||
|
lines.push('Confirmation email with QR ticket sent');
|
||||||
|
} else if (form.type === 'door') {
|
||||||
|
lines.push(`Cash payment of ${eventPriceLabel} recorded as paid at the door — counts toward revenue`);
|
||||||
|
lines.push('QR code issued');
|
||||||
|
if (!form.firstName.trim()) {
|
||||||
|
lines.push('No name — the ticket is logged as a "Walk-in"');
|
||||||
|
}
|
||||||
|
lines.push(hasEmail
|
||||||
|
? 'Confirmation email with QR ticket sent'
|
||||||
|
: 'No email — nothing is sent, walk-in kept on the list only');
|
||||||
|
} else if (form.type === 'unpaid') {
|
||||||
|
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
|
||||||
|
lines.push('QR code issued, flagged "unpaid" for door staff');
|
||||||
|
lines.push(hasEmail
|
||||||
|
? 'Bancard (TPago) payment link emailed to the attendee'
|
||||||
|
: 'No email — no pay link sent, payment collected at the door');
|
||||||
|
} else {
|
||||||
|
lines.push('Free guest ticket (comp) — not counted in revenue');
|
||||||
|
lines.push('Auto-confirmed with QR code');
|
||||||
|
lines.push(hasEmail ? 'Confirmation email sent' : 'No email — nothing is sent');
|
||||||
|
}
|
||||||
|
if (form.checkinNow) {
|
||||||
|
lines.push('Checked in immediately');
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
|
||||||
|
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
|
||||||
|
door: { box: 'bg-emerald-50 border-emerald-200', icon: 'text-emerald-500', text: 'text-emerald-800' },
|
||||||
|
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
|
||||||
|
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
|
||||||
|
paid: CheckCircleIcon,
|
||||||
|
door: BanknotesIcon,
|
||||||
|
unpaid: BanknotesIcon,
|
||||||
|
guest: StarIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddTicketModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
eventPriceLabel,
|
||||||
|
}: AddTicketModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const emailRequired = form.type === 'paid';
|
||||||
|
// Door walk-ins can be logged with nothing filled in
|
||||||
|
const nameRequired = form.type !== 'door';
|
||||||
|
const style = PREVIEW_STYLES[form.type];
|
||||||
|
const PreviewIcon = PREVIEW_ICONS[form.type];
|
||||||
|
const SubmitIcon = SUBMIT_ICONS[form.type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
|
<h2 className="text-base font-bold">Add Ticket</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onSubmit} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||||
|
{/* Segmented ticket-type control */}
|
||||||
|
<div className="flex rounded-btn bg-gray-100 p-1">
|
||||||
|
{TYPE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 px-2 py-2 text-xs sm:text-sm font-medium rounded-btn min-h-[36px] whitespace-nowrap transition-colors',
|
||||||
|
form.type === option.value
|
||||||
|
? 'bg-white shadow-sm text-primary-dark'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">First Name {nameRequired && '*'}</label>
|
||||||
|
<input type="text" required={nameRequired} value={form.firstName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder={nameRequired ? 'First name' : 'First name (optional)'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||||
|
<input type="text" value={form.lastName}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="Last name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">Email {emailRequired && '*'}</label>
|
||||||
|
<input type="email" required={emailRequired} value={form.email}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
|
||||||
|
<p className="text-[10px] text-gray-500 mt-1">
|
||||||
|
{form.type === 'paid' && 'Ticket will be sent to this email'}
|
||||||
|
{form.type === 'door' && 'Optional — if provided, the ticket confirmation is sent here'}
|
||||||
|
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
|
||||||
|
{form.type === 'guest' && 'If provided, a confirmation email will be sent'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||||
|
<input type="tel" value={form.phone}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
placeholder="+595 981 123456" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||||
|
<textarea value={form.adminNote}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, adminNote: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
rows={2} placeholder="Internal note..." />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input type="checkbox" id="checkinNow" checked={form.checkinNow}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, checkinNow: e.target.checked }))}
|
||||||
|
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
||||||
|
<label htmlFor="checkinNow" className="text-sm font-medium">Check in now</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live preview of what this submission does */}
|
||||||
|
<div className={clsx('border rounded-lg p-3', style.box)}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<PreviewIcon className={clsx('w-4 h-4 mt-0.5 flex-shrink-0', style.icon)} />
|
||||||
|
<div className={clsx('text-xs', style.text)}>
|
||||||
|
<p className="font-medium">What happens:</p>
|
||||||
|
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
|
||||||
|
{previewLines(form, eventPriceLabel).map((line) => (
|
||||||
|
<li key={line}>{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||||
|
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||||
|
<SubmitIcon className="w-4 h-4 mr-1.5" />
|
||||||
|
{SUBMIT_LABELS[form.type]}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import type { Dispatch, FormEvent, SetStateAction } from 'react';
|
|
||||||
import { Ticket } from '@/lib/api';
|
import { Ticket } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { BottomSheet } from '@/components/admin/MobileComponents';
|
import { BottomSheet } from '@/components/admin/MobileComponents';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
|
BanknotesIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
PlusIcon,
|
|
||||||
StarIcon,
|
StarIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
|
import type { AttendeeStatusFilter, AddTicketType } from '../_types';
|
||||||
|
|
||||||
interface EventModalsProps {
|
interface EventModalsProps {
|
||||||
// counts + filter
|
// counts + filter
|
||||||
@@ -29,6 +28,7 @@ interface EventModalsProps {
|
|||||||
// add ticket sheet
|
// add ticket sheet
|
||||||
showAddTicketSheet: boolean;
|
showAddTicketSheet: boolean;
|
||||||
setShowAddTicketSheet: (value: boolean) => void;
|
setShowAddTicketSheet: (value: boolean) => void;
|
||||||
|
openAddTicket: (type: AddTicketType) => void;
|
||||||
// export sheets
|
// export sheets
|
||||||
showExportSheet: boolean;
|
showExportSheet: boolean;
|
||||||
setShowExportSheet: (value: boolean) => void;
|
setShowExportSheet: (value: boolean) => void;
|
||||||
@@ -36,24 +36,6 @@ interface EventModalsProps {
|
|||||||
showTicketExportSheet: boolean;
|
showTicketExportSheet: boolean;
|
||||||
setShowTicketExportSheet: (value: boolean) => void;
|
setShowTicketExportSheet: (value: boolean) => void;
|
||||||
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
||||||
// add at door
|
|
||||||
showAddAtDoorModal: boolean;
|
|
||||||
setShowAddAtDoorModal: (value: boolean) => void;
|
|
||||||
addAtDoorForm: AddAtDoorFormState;
|
|
||||||
setAddAtDoorForm: Dispatch<SetStateAction<AddAtDoorFormState>>;
|
|
||||||
handleAddAtDoor: (e: FormEvent) => void;
|
|
||||||
// manual ticket
|
|
||||||
showManualTicketModal: boolean;
|
|
||||||
setShowManualTicketModal: (value: boolean) => void;
|
|
||||||
manualTicketForm: AttendeeFormState;
|
|
||||||
setManualTicketForm: Dispatch<SetStateAction<AttendeeFormState>>;
|
|
||||||
handleManualTicket: (e: FormEvent) => void;
|
|
||||||
// invite guest
|
|
||||||
showInviteGuestModal: boolean;
|
|
||||||
setShowInviteGuestModal: (value: boolean) => void;
|
|
||||||
inviteGuestForm: AttendeeFormState;
|
|
||||||
setInviteGuestForm: Dispatch<SetStateAction<AttendeeFormState>>;
|
|
||||||
handleInviteGuest: (e: FormEvent) => void;
|
|
||||||
// shared submit flag
|
// shared submit flag
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
// note modal
|
// note modal
|
||||||
@@ -83,27 +65,13 @@ export function EventModals(props: EventModalsProps) {
|
|||||||
setMobileFilterOpen,
|
setMobileFilterOpen,
|
||||||
showAddTicketSheet,
|
showAddTicketSheet,
|
||||||
setShowAddTicketSheet,
|
setShowAddTicketSheet,
|
||||||
|
openAddTicket,
|
||||||
showExportSheet,
|
showExportSheet,
|
||||||
setShowExportSheet,
|
setShowExportSheet,
|
||||||
handleExportAttendees,
|
handleExportAttendees,
|
||||||
showTicketExportSheet,
|
showTicketExportSheet,
|
||||||
setShowTicketExportSheet,
|
setShowTicketExportSheet,
|
||||||
handleExportTickets,
|
handleExportTickets,
|
||||||
showAddAtDoorModal,
|
|
||||||
setShowAddAtDoorModal,
|
|
||||||
addAtDoorForm,
|
|
||||||
setAddAtDoorForm,
|
|
||||||
handleAddAtDoor,
|
|
||||||
showManualTicketModal,
|
|
||||||
setShowManualTicketModal,
|
|
||||||
manualTicketForm,
|
|
||||||
setManualTicketForm,
|
|
||||||
handleManualTicket,
|
|
||||||
showInviteGuestModal,
|
|
||||||
setShowInviteGuestModal,
|
|
||||||
inviteGuestForm,
|
|
||||||
setInviteGuestForm,
|
|
||||||
handleInviteGuest,
|
|
||||||
submitting,
|
submitting,
|
||||||
showNoteModal,
|
showNoteModal,
|
||||||
setShowNoteModal,
|
setShowNoteModal,
|
||||||
@@ -156,27 +124,37 @@ export function EventModals(props: EventModalsProps) {
|
|||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowManualTicketModal(true); setShowAddTicketSheet(false); }}
|
onClick={() => { openAddTicket('paid'); setShowAddTicketSheet(false); }}
|
||||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
|
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Manual Ticket</p>
|
<p className="font-medium">Paid Ticket</p>
|
||||||
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
|
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketSheet(false); }}
|
onClick={() => { openAddTicket('door'); setShowAddTicketSheet(false); }}
|
||||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-5 h-5 text-gray-500" />
|
<BanknotesIcon className="w-5 h-5 text-gray-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Add at Door</p>
|
<p className="font-medium">Paid at Door</p>
|
||||||
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
<p className="text-xs text-gray-500">Cash taken at the door, all fields optional</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
|
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<BanknotesIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Unpaid Ticket</p>
|
||||||
|
<p className="text-xs text-gray-500">Pay link now or collect at the door</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { openAddTicket('guest'); setShowAddTicketSheet(false); }}
|
||||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<StarIcon className="w-5 h-5 text-gray-500" />
|
<StarIcon className="w-5 h-5 text-gray-500" />
|
||||||
@@ -237,243 +215,6 @@ export function EventModals(props: EventModalsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
{/* Add at Door Modal */}
|
|
||||||
{showAddAtDoorModal && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
|
||||||
onClick={() => setShowAddAtDoorModal(false)}
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
|
||||||
<h2 className="text-base font-bold">Add Attendee at Door</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddAtDoorModal(false)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<XMarkIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleAddAtDoor} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
|
||||||
<input type="text" required value={addAtDoorForm.firstName}
|
|
||||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, firstName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="First name" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
|
||||||
<input type="text" value={addAtDoorForm.lastName}
|
|
||||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, lastName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="Last name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Email</label>
|
|
||||||
<input type="email" value={addAtDoorForm.email}
|
|
||||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, email: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="email@example.com" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
|
||||||
<input type="tel" value={addAtDoorForm.phone}
|
|
||||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, phone: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="+595 981 123456" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
|
||||||
<textarea value={addAtDoorForm.adminNote}
|
|
||||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
rows={2} placeholder="Internal note..." />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input type="checkbox" id="autoCheckin" checked={addAtDoorForm.autoCheckin}
|
|
||||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, autoCheckin: e.target.checked })}
|
|
||||||
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
|
||||||
<label htmlFor="autoCheckin" className="text-sm font-medium">Auto check-in immediately</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button type="button" variant="outline" onClick={() => setShowAddAtDoorModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
|
||||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">Add Attendee</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Manual Ticket Modal */}
|
|
||||||
{showManualTicketModal && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
|
||||||
onClick={() => setShowManualTicketModal(false)}
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold">Create Manual Ticket</h2>
|
|
||||||
<p className="text-xs text-gray-500">Confirmation email will be sent</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowManualTicketModal(false)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
|
||||||
<XMarkIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleManualTicket} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
|
||||||
<input type="text" required value={manualTicketForm.firstName}
|
|
||||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="First name" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
|
||||||
<input type="text" value={manualTicketForm.lastName}
|
|
||||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="Last name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Email *</label>
|
|
||||||
<input type="email" required value={manualTicketForm.email}
|
|
||||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="email@example.com" />
|
|
||||||
<p className="text-[10px] text-gray-500 mt-1">Ticket will be sent to this email</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
|
||||||
<input type="tel" value={manualTicketForm.phone}
|
|
||||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="+595 981 123456" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
|
||||||
<textarea value={manualTicketForm.adminNote}
|
|
||||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
rows={2} placeholder="Internal note..." />
|
|
||||||
</div>
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<EnvelopeIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="text-xs text-blue-800">
|
|
||||||
<p className="font-medium">This will send:</p>
|
|
||||||
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
|
|
||||||
<li>Booking confirmation email</li>
|
|
||||||
<li>Ticket with QR code</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button type="button" variant="outline" onClick={() => setShowManualTicketModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
|
||||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
|
||||||
<EnvelopeIcon className="w-4 h-4 mr-1.5" />
|
|
||||||
Create & Send
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Invite Guest Modal */}
|
|
||||||
{showInviteGuestModal && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
|
||||||
onClick={() => setShowInviteGuestModal(false)}
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold">Invite Guest</h2>
|
|
||||||
<p className="text-xs text-gray-500">Free ticket — not counted in revenue</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowInviteGuestModal(false)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
|
||||||
<XMarkIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
|
||||||
<input type="text" required value={inviteGuestForm.firstName}
|
|
||||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, firstName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="First name" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
|
||||||
<input type="text" value={inviteGuestForm.lastName}
|
|
||||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="Last name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Email</label>
|
|
||||||
<input type="email" value={inviteGuestForm.email}
|
|
||||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="email@example.com (optional)" />
|
|
||||||
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
|
||||||
<input type="tel" value={inviteGuestForm.phone}
|
|
||||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
placeholder="+595 981 123456" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
|
||||||
<textarea value={inviteGuestForm.adminNote}
|
|
||||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: e.target.value })}
|
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
rows={2} placeholder="Internal note..." />
|
|
||||||
</div>
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<p className="text-xs text-amber-800">
|
|
||||||
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
|
||||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
|
||||||
<StarIcon className="w-4 h-4 mr-1.5" />
|
|
||||||
Invite Guest
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Note Modal */}
|
{/* Note Modal */}
|
||||||
{showNoteModal && selectedTicket && (
|
{showNoteModal && selectedTicket && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
FunnelIcon,
|
FunnelIcon,
|
||||||
ChatBubbleLeftIcon,
|
ChatBubbleLeftIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
|
BanknotesIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { StatusBadge } from '../_components/StatusBadge';
|
import { StatusBadge, PaymentBadge } from '../_components/StatusBadge';
|
||||||
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
|
import type { AttendeeStatusFilter, AddTicketType, PrimaryAction } from '../_types';
|
||||||
|
|
||||||
interface AttendeesTabProps {
|
interface AttendeesTabProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -37,15 +39,15 @@ interface AttendeesTabProps {
|
|||||||
showAddTicketDropdown: boolean;
|
showAddTicketDropdown: boolean;
|
||||||
setShowAddTicketDropdown: (value: boolean) => void;
|
setShowAddTicketDropdown: (value: boolean) => void;
|
||||||
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
||||||
setShowManualTicketModal: (value: boolean) => void;
|
openAddTicket: (type: AddTicketType) => void;
|
||||||
setShowAddAtDoorModal: (value: boolean) => void;
|
|
||||||
setShowInviteGuestModal: (value: boolean) => void;
|
|
||||||
setMobileFilterOpen: (value: boolean) => void;
|
setMobileFilterOpen: (value: boolean) => void;
|
||||||
setShowExportSheet: (value: boolean) => void;
|
setShowExportSheet: (value: boolean) => void;
|
||||||
setShowAddTicketSheet: (value: boolean) => void;
|
setShowAddTicketSheet: (value: boolean) => void;
|
||||||
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
||||||
handleOpenNoteModal: (ticket: Ticket) => void;
|
handleOpenNoteModal: (ticket: Ticket) => void;
|
||||||
handleReactivate: (ticket: Ticket) => void;
|
handleReactivate: (ticket: Ticket) => void;
|
||||||
|
handleMarkPaid: (ticketId: string) => void;
|
||||||
|
handleCheckin: (ticketId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttendeesTab({
|
export function AttendeesTab({
|
||||||
@@ -67,15 +69,15 @@ export function AttendeesTab({
|
|||||||
showAddTicketDropdown,
|
showAddTicketDropdown,
|
||||||
setShowAddTicketDropdown,
|
setShowAddTicketDropdown,
|
||||||
handleExportAttendees,
|
handleExportAttendees,
|
||||||
setShowManualTicketModal,
|
openAddTicket,
|
||||||
setShowAddAtDoorModal,
|
|
||||||
setShowInviteGuestModal,
|
|
||||||
setMobileFilterOpen,
|
setMobileFilterOpen,
|
||||||
setShowExportSheet,
|
setShowExportSheet,
|
||||||
setShowAddTicketSheet,
|
setShowAddTicketSheet,
|
||||||
getPrimaryAction,
|
getPrimaryAction,
|
||||||
handleOpenNoteModal,
|
handleOpenNoteModal,
|
||||||
handleReactivate,
|
handleReactivate,
|
||||||
|
handleMarkPaid,
|
||||||
|
handleCheckin,
|
||||||
}: AttendeesTabProps) {
|
}: AttendeesTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -143,13 +145,16 @@ export function AttendeesTab({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
|
||||||
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
|
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { openAddTicket('door'); setShowAddTicketDropdown(false); }}>
|
||||||
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Paid at Door
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
|
||||||
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => { openAddTicket('guest'); setShowAddTicketDropdown(false); }}>
|
||||||
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -252,9 +257,7 @@ export function AttendeesTab({
|
|||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
<StatusBadge status={ticket.status} compact />
|
<StatusBadge status={ticket.status} compact />
|
||||||
{!!ticket.isGuest && (
|
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{ticket.checkinAt && (
|
{ticket.checkinAt && (
|
||||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||||
@@ -279,6 +282,16 @@ export function AttendeesTab({
|
|||||||
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
|
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
|
||||||
|
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
|
||||||
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
|
||||||
|
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
|
||||||
|
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||||
@@ -323,9 +336,7 @@ export function AttendeesTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
||||||
<StatusBadge status={ticket.status} compact />
|
<StatusBadge status={ticket.status} compact />
|
||||||
{!!ticket.isGuest && (
|
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
@@ -346,6 +357,16 @@ export function AttendeesTab({
|
|||||||
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
|
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
|
||||||
|
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
|
||||||
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
|
||||||
|
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
|
||||||
|
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Event } from '@/lib/api';
|
import { Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
|
import SensitiveValue from '@/components/admin/SensitiveValue';
|
||||||
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
interface OverviewTabProps {
|
interface OverviewTabProps {
|
||||||
@@ -48,8 +49,8 @@ export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confir
|
|||||||
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Capacity</p>
|
<p className="font-medium text-sm">Capacity</p>
|
||||||
<p className="text-sm text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
|
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
|
||||||
<p className="text-xs text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
|
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PaymentOptionsConfig } from '@/lib/api';
|
import { PaymentOptionsConfig, DOOR_PAYMENT_METHODS, type DoorPaymentMethod, type DoorSummary } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -12,13 +12,85 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
|
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
|
||||||
|
import { formatCurrency } from '../_utils/format';
|
||||||
|
|
||||||
interface PaymentsTabProps {
|
interface PaymentsTabProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
payments: PaymentOverridesController;
|
payments: PaymentOverridesController;
|
||||||
|
/** Takings recorded on the door check-in screen; null while loading or unavailable. */
|
||||||
|
doorSummary: DoorSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }> = {
|
||||||
|
cash: { en: 'Cash', es: 'Efectivo' },
|
||||||
|
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
|
||||||
|
transfer: { en: 'Transfer', es: 'Transferencia' },
|
||||||
|
guest: { en: 'Guests', es: 'Invitados' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-of-night reconciliation for this event: what staff took on the door, split
|
||||||
|
* by tender, next to the pre-sale total. Guests are counted, not totalled — they
|
||||||
|
* are free and carry no revenue.
|
||||||
|
*/
|
||||||
|
function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary }) {
|
||||||
|
const es = locale === 'es';
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 md:p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<BanknotesIcon className="w-4 h-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm">{es ? 'Ventas en Puerta' : 'Door Sales'}</h4>
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
{es ? 'Cobrado por el staff en la entrada' : 'Taken by staff at the door'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-3 border-t">
|
||||||
|
{DOOR_PAYMENT_METHODS.map((method) => {
|
||||||
|
const entry = summary.door.byMethod[method];
|
||||||
|
return (
|
||||||
|
<div key={method} className="bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-gray-500">
|
||||||
|
{es ? DOOR_METHOD_LABELS[method].es : DOOR_METHOD_LABELS[method].en}
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-sm leading-tight">
|
||||||
|
{method === 'guest'
|
||||||
|
? `${entry.count}`
|
||||||
|
: formatCurrency(entry.total, summary.currency)}
|
||||||
|
</p>
|
||||||
|
{method !== 'guest' && (
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
{entry.count} {es ? (entry.count === 1 ? 'pago' : 'pagos') : (entry.count === 1 ? 'payment' : 'payments')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-600 pt-3 mt-3 border-t">
|
||||||
|
<span>
|
||||||
|
{es ? 'Preventa' : 'Pre-sale'}: <strong>{formatCurrency(summary.presale.total, summary.currency)}</strong>
|
||||||
|
{' '}({summary.presale.count})
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency)}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) {
|
||||||
const {
|
const {
|
||||||
loadingPayments,
|
loadingPayments,
|
||||||
hasPaymentOverrides,
|
hasPaymentOverrides,
|
||||||
@@ -39,6 +111,11 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Door takings — reconciliation first, configuration below */}
|
||||||
|
{doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && (
|
||||||
|
<DoorTakings locale={locale} summary={doorSummary} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,14 +13,19 @@ export interface PrimaryAction {
|
|||||||
icon?: ComponentType<{ className?: string }>;
|
icon?: ComponentType<{ className?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttendeeFormState {
|
// Ticket type in the unified Add Ticket modal:
|
||||||
|
// paid = confirmation + QR emailed, counts toward revenue
|
||||||
|
// door = already paid in cash at the door, counts toward revenue, every field optional
|
||||||
|
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
|
||||||
|
// guest = free comp ticket, auto-confirmed, no revenue
|
||||||
|
export type AddTicketType = 'paid' | 'door' | 'unpaid' | 'guest';
|
||||||
|
|
||||||
|
export interface AddTicketFormState {
|
||||||
|
type: AddTicketType;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
adminNote: string;
|
adminNote: string;
|
||||||
}
|
checkinNow: boolean;
|
||||||
|
|
||||||
export interface AddAtDoorFormState extends AttendeeFormState {
|
|
||||||
autoCheckin: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
EyeSlashIcon,
|
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -30,14 +29,14 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
import type {
|
import type {
|
||||||
TabType,
|
TabType,
|
||||||
AttendeeStatusFilter,
|
AttendeeStatusFilter,
|
||||||
TicketStatusFilter,
|
TicketStatusFilter,
|
||||||
RecipientFilter,
|
RecipientFilter,
|
||||||
AttendeeFormState,
|
AddTicketType,
|
||||||
AddAtDoorFormState,
|
AddTicketFormState,
|
||||||
PrimaryAction,
|
PrimaryAction,
|
||||||
} from './_types';
|
} from './_types';
|
||||||
import { formatCurrency, downloadBlob } from './_utils/format';
|
import { formatCurrency, downloadBlob } from './_utils/format';
|
||||||
@@ -49,14 +48,25 @@ import { TicketsTab } from './_tabs/TicketsTab';
|
|||||||
import { EmailTab } from './_tabs/EmailTab';
|
import { EmailTab } from './_tabs/EmailTab';
|
||||||
import { PaymentsTab } from './_tabs/PaymentsTab';
|
import { PaymentsTab } from './_tabs/PaymentsTab';
|
||||||
import { EventModals } from './_modals/EventModals';
|
import { EventModals } from './_modals/EventModals';
|
||||||
|
import { AddTicketModal } from './_modals/AddTicketModal';
|
||||||
import EventFormModal from '../_components/EventFormModal';
|
import EventFormModal from '../_components/EventFormModal';
|
||||||
|
|
||||||
|
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
|
||||||
|
type: 'paid',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
adminNote: '',
|
||||||
|
checkinNow: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminEventDetailPage() {
|
export default function AdminEventDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const eventId = params.id as string;
|
const eventId = params.id as string;
|
||||||
const { locale } = useLanguage();
|
const { locale } = useLanguage();
|
||||||
|
|
||||||
const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId);
|
const { loading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId);
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
|
|
||||||
// Email state
|
// Email state
|
||||||
@@ -69,37 +79,21 @@ export default function AdminEventDetailPage() {
|
|||||||
// Attendees tab state
|
// Attendees tab state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
|
||||||
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
|
const { privacyMode } = usePrivacy();
|
||||||
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
|
const showStats = !privacyMode;
|
||||||
const [showStats, , toggleStats] = useStatsPrivacy();
|
|
||||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
const [addAtDoorForm, setAddAtDoorForm] = useState<AddAtDoorFormState>({
|
// Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
|
||||||
firstName: '',
|
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
|
||||||
lastName: '',
|
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
autoCheckin: true,
|
|
||||||
adminNote: '',
|
|
||||||
});
|
|
||||||
const [manualTicketForm, setManualTicketForm] = useState<AttendeeFormState>({
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
adminNote: '',
|
|
||||||
});
|
|
||||||
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
|
|
||||||
const [inviteGuestForm, setInviteGuestForm] = useState<AttendeeFormState>({
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
adminNote: '',
|
|
||||||
});
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const openAddTicket = (type: AddTicketType) => {
|
||||||
|
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
|
||||||
|
setShowAddTicketModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
||||||
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
|
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
|
||||||
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
|
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
|
||||||
@@ -220,74 +214,27 @@ export default function AdminEventDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddAtDoor = async (e: React.FormEvent) => {
|
const handleAddTicket = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await ticketsApi.adminCreate({
|
const res = await ticketsApi.adminAdd({
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
firstName: addAtDoorForm.firstName,
|
type: addTicketForm.type,
|
||||||
lastName: addAtDoorForm.lastName || undefined,
|
firstName: addTicketForm.firstName || undefined,
|
||||||
email: addAtDoorForm.email,
|
lastName: addTicketForm.lastName || undefined,
|
||||||
phone: addAtDoorForm.phone,
|
email: addTicketForm.email || undefined,
|
||||||
autoCheckin: addAtDoorForm.autoCheckin,
|
phone: addTicketForm.phone || undefined,
|
||||||
adminNote: addAtDoorForm.adminNote || undefined,
|
checkinNow: addTicketForm.checkinNow,
|
||||||
|
adminNote: addTicketForm.adminNote || undefined,
|
||||||
});
|
});
|
||||||
toast.success(addAtDoorForm.autoCheckin ? 'Attendee added and checked in' : 'Attendee added');
|
toast.success(res.message || 'Ticket created');
|
||||||
setShowAddAtDoorModal(false);
|
setShowAddTicketModal(false);
|
||||||
setAddAtDoorForm({ firstName: '', lastName: '', email: '', phone: '', autoCheckin: true, adminNote: '' });
|
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
|
||||||
loadEventData();
|
loadEventData();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to add attendee');
|
toast.error(error.message || 'Failed to add ticket');
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManualTicket = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!event) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await ticketsApi.manualCreate({
|
|
||||||
eventId: event.id,
|
|
||||||
firstName: manualTicketForm.firstName,
|
|
||||||
lastName: manualTicketForm.lastName || undefined,
|
|
||||||
email: manualTicketForm.email,
|
|
||||||
phone: manualTicketForm.phone || undefined,
|
|
||||||
adminNote: manualTicketForm.adminNote || undefined,
|
|
||||||
});
|
|
||||||
toast.success('Manual ticket created — confirmation email sent');
|
|
||||||
setShowManualTicketModal(false);
|
|
||||||
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
|
|
||||||
loadEventData();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to create manual ticket');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInviteGuest = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!event) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await ticketsApi.guestCreate({
|
|
||||||
eventId: event.id,
|
|
||||||
firstName: inviteGuestForm.firstName,
|
|
||||||
lastName: inviteGuestForm.lastName || undefined,
|
|
||||||
email: inviteGuestForm.email || undefined,
|
|
||||||
phone: inviteGuestForm.phone || undefined,
|
|
||||||
adminNote: inviteGuestForm.adminNote || undefined,
|
|
||||||
});
|
|
||||||
toast.success('Guest invited successfully');
|
|
||||||
setShowInviteGuestModal(false);
|
|
||||||
setInviteGuestForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
|
|
||||||
loadEventData();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to invite guest');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -449,9 +396,19 @@ export default function AdminEventDetailPage() {
|
|||||||
const checkedInCount = getTicketsByStatus('checked_in').length;
|
const checkedInCount = getTicketsByStatus('checked_in').length;
|
||||||
const cancelledCount = getTicketsByStatus('cancelled').length;
|
const cancelledCount = getTicketsByStatus('cancelled').length;
|
||||||
const onHoldCount = getTicketsByStatus('on_hold').length;
|
const onHoldCount = getTicketsByStatus('on_hold').length;
|
||||||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
|
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
|
||||||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
|
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
|
||||||
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
|
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
|
||||||
|
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
|
||||||
|
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
|
||||||
|
// Door sales can be taken at a custom amount (someone paying for their whole
|
||||||
|
// group), so once the door summary is loaded it is the authority on the total:
|
||||||
|
// pre-sale tickets at face value plus whatever was actually taken on the night.
|
||||||
|
const presaleRevenue = doorSummary
|
||||||
|
? doorSummary.presale.total
|
||||||
|
: (paidConfirmedCount + paidCheckedInCount) * event.price;
|
||||||
|
const doorRevenue = doorSummary?.door.total ?? 0;
|
||||||
|
const revenue = presaleRevenue + doorRevenue;
|
||||||
|
|
||||||
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
||||||
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
|
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
|
||||||
@@ -467,6 +424,10 @@ export default function AdminEventDetailPage() {
|
|||||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||||||
}
|
}
|
||||||
if (ticket.status === 'confirmed') {
|
if (ticket.status === 'confirmed') {
|
||||||
|
// Unpaid tickets resolve their balance first; check-in stays available via scanner
|
||||||
|
if (ticket.paymentStatus === 'unpaid') {
|
||||||
|
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||||||
|
}
|
||||||
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
|
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
|
||||||
}
|
}
|
||||||
if (ticket.status === 'checked_in') {
|
if (ticket.status === 'checked_in') {
|
||||||
@@ -490,10 +451,6 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Desktop header actions */}
|
{/* Desktop header actions */}
|
||||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||||||
<Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
|
|
||||||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
|
|
||||||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
|
||||||
</Button>
|
|
||||||
<Link href={`/events/${event.slug}`} target="_blank">
|
<Link href={`/events/${event.slug}`} target="_blank">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||||
@@ -522,10 +479,6 @@ export default function AdminEventDetailPage() {
|
|||||||
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
||||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
|
|
||||||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
|
|
||||||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
|
||||||
</DropdownItem>
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -561,7 +514,15 @@ export default function AdminEventDetailPage() {
|
|||||||
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
|
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
|
||||||
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
|
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
|
||||||
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
|
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
|
||||||
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' },
|
{
|
||||||
|
label: 'Revenue',
|
||||||
|
value: formatCurrency(revenue, event.currency),
|
||||||
|
icon: CurrencyDollarIcon,
|
||||||
|
color: 'bg-gray-50 text-gray-600',
|
||||||
|
detail: doorSummary
|
||||||
|
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
|
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
|
||||||
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
||||||
@@ -569,7 +530,7 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
|
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
|
||||||
<p className="text-xs text-gray-500">{stat.label}</p>
|
<p className="text-xs text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -601,7 +562,15 @@ export default function AdminEventDetailPage() {
|
|||||||
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
|
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
|
||||||
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
|
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
|
||||||
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
|
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
|
||||||
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' },
|
{
|
||||||
|
label: 'Revenue',
|
||||||
|
value: formatCurrency(revenue, event.currency),
|
||||||
|
icon: CurrencyDollarIcon,
|
||||||
|
color: 'text-gray-600 bg-gray-50',
|
||||||
|
detail: doorSummary
|
||||||
|
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
|
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
|
||||||
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
||||||
@@ -609,7 +578,7 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
|
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
|
||||||
<p className="text-[10px] text-gray-500">{stat.label}</p>
|
<p className="text-[10px] text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -704,15 +673,15 @@ export default function AdminEventDetailPage() {
|
|||||||
showAddTicketDropdown={showAddTicketDropdown}
|
showAddTicketDropdown={showAddTicketDropdown}
|
||||||
setShowAddTicketDropdown={setShowAddTicketDropdown}
|
setShowAddTicketDropdown={setShowAddTicketDropdown}
|
||||||
handleExportAttendees={handleExportAttendees}
|
handleExportAttendees={handleExportAttendees}
|
||||||
setShowManualTicketModal={setShowManualTicketModal}
|
openAddTicket={openAddTicket}
|
||||||
setShowAddAtDoorModal={setShowAddAtDoorModal}
|
|
||||||
setShowInviteGuestModal={setShowInviteGuestModal}
|
|
||||||
setMobileFilterOpen={setMobileFilterOpen}
|
setMobileFilterOpen={setMobileFilterOpen}
|
||||||
setShowExportSheet={setShowExportSheet}
|
setShowExportSheet={setShowExportSheet}
|
||||||
setShowAddTicketSheet={setShowAddTicketSheet}
|
setShowAddTicketSheet={setShowAddTicketSheet}
|
||||||
getPrimaryAction={getPrimaryAction}
|
getPrimaryAction={getPrimaryAction}
|
||||||
handleOpenNoteModal={handleOpenNoteModal}
|
handleOpenNoteModal={handleOpenNoteModal}
|
||||||
handleReactivate={handleReactivate}
|
handleReactivate={handleReactivate}
|
||||||
|
handleMarkPaid={handleMarkPaid}
|
||||||
|
handleCheckin={handleCheckin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -759,7 +728,7 @@ export default function AdminEventDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'payments' && (
|
{activeTab === 'payments' && (
|
||||||
<PaymentsTab locale={locale} payments={payments} />
|
<PaymentsTab locale={locale} payments={payments} doorSummary={doorSummary} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -777,27 +746,13 @@ export default function AdminEventDetailPage() {
|
|||||||
setMobileFilterOpen={setMobileFilterOpen}
|
setMobileFilterOpen={setMobileFilterOpen}
|
||||||
showAddTicketSheet={showAddTicketSheet}
|
showAddTicketSheet={showAddTicketSheet}
|
||||||
setShowAddTicketSheet={setShowAddTicketSheet}
|
setShowAddTicketSheet={setShowAddTicketSheet}
|
||||||
|
openAddTicket={openAddTicket}
|
||||||
showExportSheet={showExportSheet}
|
showExportSheet={showExportSheet}
|
||||||
setShowExportSheet={setShowExportSheet}
|
setShowExportSheet={setShowExportSheet}
|
||||||
handleExportAttendees={handleExportAttendees}
|
handleExportAttendees={handleExportAttendees}
|
||||||
showTicketExportSheet={showTicketExportSheet}
|
showTicketExportSheet={showTicketExportSheet}
|
||||||
setShowTicketExportSheet={setShowTicketExportSheet}
|
setShowTicketExportSheet={setShowTicketExportSheet}
|
||||||
handleExportTickets={handleExportTickets}
|
handleExportTickets={handleExportTickets}
|
||||||
showAddAtDoorModal={showAddAtDoorModal}
|
|
||||||
setShowAddAtDoorModal={setShowAddAtDoorModal}
|
|
||||||
addAtDoorForm={addAtDoorForm}
|
|
||||||
setAddAtDoorForm={setAddAtDoorForm}
|
|
||||||
handleAddAtDoor={handleAddAtDoor}
|
|
||||||
showManualTicketModal={showManualTicketModal}
|
|
||||||
setShowManualTicketModal={setShowManualTicketModal}
|
|
||||||
manualTicketForm={manualTicketForm}
|
|
||||||
setManualTicketForm={setManualTicketForm}
|
|
||||||
handleManualTicket={handleManualTicket}
|
|
||||||
showInviteGuestModal={showInviteGuestModal}
|
|
||||||
setShowInviteGuestModal={setShowInviteGuestModal}
|
|
||||||
inviteGuestForm={inviteGuestForm}
|
|
||||||
setInviteGuestForm={setInviteGuestForm}
|
|
||||||
handleInviteGuest={handleInviteGuest}
|
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
showNoteModal={showNoteModal}
|
showNoteModal={showNoteModal}
|
||||||
setShowNoteModal={setShowNoteModal}
|
setShowNoteModal={setShowNoteModal}
|
||||||
@@ -810,6 +765,16 @@ export default function AdminEventDetailPage() {
|
|||||||
setPreviewHtml={setPreviewHtml}
|
setPreviewHtml={setPreviewHtml}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddTicketModal
|
||||||
|
open={showAddTicketModal}
|
||||||
|
onClose={() => setShowAddTicketModal(false)}
|
||||||
|
form={addTicketForm}
|
||||||
|
setForm={setAddTicketForm}
|
||||||
|
onSubmit={handleAddTicket}
|
||||||
|
submitting={submitting}
|
||||||
|
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
||||||
|
/>
|
||||||
|
|
||||||
<EventFormModal
|
<EventFormModal
|
||||||
open={showEditForm}
|
open={showEditForm}
|
||||||
event={event}
|
event={event}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
@@ -9,18 +9,35 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import EventFormModal from './_components/EventFormModal';
|
import EventFormModal from './_components/EventFormModal';
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
function getPageNumbers(current: number, totalPages: number): (number | '...')[] {
|
||||||
|
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
const pages: (number | '...')[] = [1];
|
||||||
|
const start = Math.max(2, current - 1);
|
||||||
|
const end = Math.min(totalPages - 1, current + 1);
|
||||||
|
if (start > 2) pages.push('...');
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
if (end < totalPages - 1) pages.push('...');
|
||||||
|
pages.push(totalPages);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminEventsPage() {
|
export default function AdminEventsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||||
@@ -28,22 +45,42 @@ export default function AdminEventsPage() {
|
|||||||
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEvents();
|
|
||||||
loadFeaturedEvent();
|
loadFeaturedEvent();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
// The ?edit=<id> deep link may point at an event that is not on the current
|
||||||
|
// page, so fall back to fetching it directly instead of only scanning the page.
|
||||||
|
const handledEditId = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editId = searchParams.get('edit');
|
const editId = searchParams.get('edit');
|
||||||
if (editId && events.length > 0) {
|
if (!editId || handledEditId.current === editId) return;
|
||||||
const event = events.find(e => e.id === editId);
|
const event = events.find(e => e.id === editId);
|
||||||
if (event) handleEdit(event);
|
if (event) {
|
||||||
|
handledEditId.current = editId;
|
||||||
|
handleEdit(event);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [searchParams, events]);
|
if (loading) return;
|
||||||
|
handledEditId.current = editId;
|
||||||
|
eventsApi.getById(editId)
|
||||||
|
.then(({ event }) => handleEdit(event))
|
||||||
|
.catch(() => toast.error('Event not found'));
|
||||||
|
}, [searchParams, events, loading]);
|
||||||
|
|
||||||
const loadEvents = async () => {
|
const loadEvents = async () => {
|
||||||
try {
|
try {
|
||||||
const { events } = await eventsApi.getAll();
|
const { events, total } = await eventsApi.getAll({ page, pageSize });
|
||||||
setEvents(events);
|
setEvents(events);
|
||||||
|
setTotal(total ?? events.length);
|
||||||
|
// If the current page emptied out (e.g. after deleting its last event),
|
||||||
|
// fall back to the new last page.
|
||||||
|
if (events.length === 0 && (total ?? 0) > 0 && page > 1) {
|
||||||
|
setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize)));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load events');
|
toast.error('Failed to load events');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -222,7 +259,14 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
|
||||||
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
|
<td className="px-4 py-3 text-sm">
|
||||||
|
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity}
|
||||||
|
{(event.claimedCount || 0) > 0 && (
|
||||||
|
<span className="block text-[11px] text-yellow-600">
|
||||||
|
{event.claimedCount} pending approval
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getStatusBadge(event.status)}
|
{getStatusBadge(event.status)}
|
||||||
@@ -332,7 +376,12 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
|
<p className="text-xs text-gray-500">
|
||||||
|
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots
|
||||||
|
{(event.claimedCount || 0) > 0 && (
|
||||||
|
<span className="text-yellow-600"> · {event.claimedCount} pending</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
<Link href={`/admin/events/${event.id}`}
|
<Link href={`/admin/events/${event.id}`}
|
||||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
||||||
@@ -389,6 +438,65 @@ export default function AdminEventsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="mt-4 mb-20 md:mb-0 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<label htmlFor="events-page-size" className="whitespace-nowrap">Per page</label>
|
||||||
|
<select
|
||||||
|
id="events-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||||
|
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
|
||||||
|
p === '...' ? (
|
||||||
|
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPage(p)}
|
||||||
|
className={clsx(
|
||||||
|
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
|
||||||
|
p === page
|
||||||
|
? 'bg-primary-yellow text-primary-dark font-semibold'
|
||||||
|
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
aria-current={p === page ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= Math.ceil(total / pageSize)}
|
||||||
|
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile FAB */}
|
{/* Mobile FAB */}
|
||||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
||||||
|
|||||||
@@ -35,11 +35,7 @@ export default function AdminGalleryPage() {
|
|||||||
const loadMedia = async () => {
|
const loadMedia = async () => {
|
||||||
try {
|
try {
|
||||||
// We need to call the media API - let's add it if it doesn't exist
|
// We need to call the media API - let's add it if it doesn't exist
|
||||||
const res = await fetch('/api/media', {
|
const res = await fetch('/api/media', { credentials: 'same-origin' });
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setMedia(data.media || []);
|
setMedia(data.media || []);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { PrivacyProvider, usePrivacy } from '@/context/PrivacyContext';
|
||||||
import LanguageToggle from '@/components/LanguageToggle';
|
import LanguageToggle from '@/components/LanguageToggle';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
InboxIcon,
|
InboxIcon,
|
||||||
PhotoIcon,
|
PhotoIcon,
|
||||||
|
CameraIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
ArrowLeftOnRectangleIcon,
|
ArrowLeftOnRectangleIcon,
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
@@ -25,6 +27,8 @@ import {
|
|||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
QuestionMarkCircleIcon,
|
QuestionMarkCircleIcon,
|
||||||
|
EyeIcon,
|
||||||
|
EyeSlashIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -33,11 +37,24 @@ export default function AdminLayout({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PrivacyProvider>
|
||||||
|
<AdminLayoutInner>{children}</AdminLayoutInner>
|
||||||
|
</PrivacyProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminLayoutInner({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
||||||
|
const { privacyMode, togglePrivacyMode } = usePrivacy();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
||||||
@@ -54,6 +71,7 @@ export default function AdminLayout({
|
|||||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
||||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
||||||
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
||||||
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
||||||
@@ -218,6 +236,21 @@ export default function AdminLayout({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 ml-auto">
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={togglePrivacyMode}
|
||||||
|
title={privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
|
||||||
|
>
|
||||||
|
{privacyMode ? (
|
||||||
|
<EyeIcon className="w-4 h-4 sm:mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<EyeSlashIcon className="w-4 h-4 sm:mr-1.5" />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
import { adminApi, DashboardData } from '@/lib/api';
|
import { adminApi, DashboardData } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
|
import SensitiveValue from '@/components/admin/SensitiveValue';
|
||||||
import {
|
import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@@ -15,11 +17,12 @@ import {
|
|||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
const [data, setData] = useState<DashboardData | null>(null);
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
|
{!privacyMode && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
{statCards.map((stat) => (
|
{statCards.map((stat) => (
|
||||||
<Link key={stat.label} href={stat.href}>
|
<Link key={stat.label} href={stat.href}>
|
||||||
@@ -106,22 +110,22 @@ export default function AdminDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Alerts */}
|
{/* Alerts */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Low capacity warnings */}
|
{/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => {
|
.filter(event => {
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2;
|
||||||
return percentFull >= 80 && spotsLeft > 0;
|
|
||||||
})
|
})
|
||||||
.map(event => {
|
.map(event => {
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@@ -132,7 +136,7 @@ export default function AdminDashboardPage() {
|
|||||||
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">{event.title}</span>
|
<span className="text-sm font-medium">{event.title}</span>
|
||||||
<p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
|
<p className="text-xs text-gray-500"><SensitiveValue>Only {spotsLeft} spots left ({percentFull}% full)</SensitiveValue></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge badge-warning">Low capacity</span>
|
<span className="badge badge-warning">Low capacity</span>
|
||||||
@@ -142,7 +146,7 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
{/* Sold out events */}
|
{/* Sold out events */}
|
||||||
{data?.upcomingEvents
|
{data?.upcomingEvents
|
||||||
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
|
.filter(event => isEventSoldOut(event))
|
||||||
.map(event => (
|
.map(event => (
|
||||||
<Link
|
<Link
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@@ -160,16 +164,30 @@ export default function AdminDashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{data && data.stats.pendingPayments > 0 && (
|
{/* Actionable: customer says they paid, needs verification */}
|
||||||
|
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/payments"
|
href="/admin/payments"
|
||||||
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
||||||
<span className="text-sm">Pending payments</span>
|
<span className="text-sm">Payments awaiting verification</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
|
<span className="badge badge-warning"><SensitiveValue>{data.stats.awaitingApprovalPayments}</SensitiveValue></span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{/* Informational: opened checkouts that never paid — hold no seats */}
|
||||||
|
{data && data.stats.pendingPayments > 0 && (
|
||||||
|
<Link
|
||||||
|
href="/admin/payments"
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-btn hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-500">Unpaid started bookings</span>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-info"><SensitiveValue>{data.stats.pendingPayments}</SensitiveValue></span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{data && data.stats.newContacts > 0 && (
|
{data && data.stats.newContacts > 0 && (
|
||||||
@@ -188,8 +206,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* No alerts */}
|
{/* No alerts */}
|
||||||
{data &&
|
{data &&
|
||||||
data.stats.pendingPayments === 0 &&
|
data.stats.pendingPayments === 0 &&
|
||||||
|
(data.stats.awaitingApprovalPayments ?? 0) === 0 &&
|
||||||
data.stats.newContacts === 0 &&
|
data.stats.newContacts === 0 &&
|
||||||
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
|
!data.upcomingEvents.some(e => e.capacity > 0 && eventSpotsLeft(e) / e.capacity <= 0.2) && (
|
||||||
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -217,9 +236,16 @@ export default function AdminDashboardPage() {
|
|||||||
<p className="font-medium text-sm">{event.title}</p>
|
<p className="font-medium text-sm">{event.title}</p>
|
||||||
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!privacyMode && (
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
{event.bookedCount || 0}/{event.capacity}
|
{(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity}
|
||||||
|
{(event.claimedCount || 0) > 0 && (
|
||||||
|
<span className="text-xs text-yellow-600 block text-right">
|
||||||
|
{event.claimedCount} pending approval
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,6 +253,7 @@ export default function AdminDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
|
{!privacyMode && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
|
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -242,6 +269,7 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||||
|
import { isManualProvider } from '@/lib/api/payments';
|
||||||
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
BoltIcon,
|
BoltIcon,
|
||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
BuildingLibraryIcon,
|
BuildingLibraryIcon,
|
||||||
|
CalendarDaysIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
FunnelIcon,
|
FunnelIcon,
|
||||||
@@ -34,8 +37,12 @@ type Tab = 'pending_approval' | 'all';
|
|||||||
|
|
||||||
export default function AdminPaymentsPage() {
|
export default function AdminPaymentsPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
|
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
|
// Manual-gateway payments still in bare 'pending': the customer may have paid
|
||||||
|
// without clicking "I've paid" — approvable directly from the approval tab.
|
||||||
|
const [unclaimedManualPayments, setUnclaimedManualPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
||||||
@@ -69,17 +76,19 @@ export default function AdminPaymentsPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [pendingRes, allRes, eventsRes] = await Promise.all([
|
const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([
|
||||||
paymentsApi.getPendingApproval(),
|
paymentsApi.getPendingApproval(),
|
||||||
paymentsApi.getAll({
|
paymentsApi.getAll({
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
provider: providerFilter || undefined,
|
provider: providerFilter || undefined,
|
||||||
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
|
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
|
||||||
}),
|
}),
|
||||||
|
paymentsApi.getAll({ status: 'pending' }),
|
||||||
eventsApi.getAll(),
|
eventsApi.getAll(),
|
||||||
]);
|
]);
|
||||||
setPendingApprovalPayments(pendingRes.payments);
|
setPendingApprovalPayments(pendingRes.payments);
|
||||||
setPayments(allRes.payments);
|
setPayments(allRes.payments);
|
||||||
|
setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider)));
|
||||||
setEvents(eventsRes.events);
|
setEvents(eventsRes.events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load payments');
|
toast.error('Failed to load payments');
|
||||||
@@ -88,15 +97,35 @@ export default function AdminPaymentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Approve with over-capacity confirmation: the backend rejects an approval
|
||||||
|
// that would overbook the event unless the admin explicitly allows it.
|
||||||
|
const approveWithCapacityConfirm = async (id: string, note?: string, email?: boolean) => {
|
||||||
|
try {
|
||||||
|
await paymentsApi.approve(id, note, email);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code !== 'EVENT_OVER_CAPACITY') throw error;
|
||||||
|
const seatsLeft = error?.data?.availableSeats ?? 0;
|
||||||
|
const requested = error?.data?.requestedSeats ?? 1;
|
||||||
|
const message = locale === 'es'
|
||||||
|
? `El evento está lleno (quedan ${seatsLeft} lugares, esta reserva necesita ${requested}). ¿Aprobar de todas formas y sobrevender?`
|
||||||
|
: `This event is full (${seatsLeft} seat(s) left, this booking needs ${requested}). Approve anyway and overbook?`;
|
||||||
|
if (!confirm(message)) return false;
|
||||||
|
await paymentsApi.approve(id, note, email, true);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const handleApprove = async (payment: PaymentWithDetails) => {
|
const handleApprove = async (payment: PaymentWithDetails) => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
try {
|
try {
|
||||||
await paymentsApi.approve(payment.id, noteText, sendEmail);
|
const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail);
|
||||||
|
if (approved) {
|
||||||
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
||||||
setSelectedPayment(null);
|
setSelectedPayment(null);
|
||||||
setNoteText('');
|
setNoteText('');
|
||||||
setSendEmail(true);
|
setSendEmail(true);
|
||||||
loadData();
|
loadData();
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to approve payment');
|
toast.error(error.message || 'Failed to approve payment');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,11 +169,13 @@ export default function AdminPaymentsPage() {
|
|||||||
|
|
||||||
const handleConfirmPayment = async (id: string) => {
|
const handleConfirmPayment = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await paymentsApi.approve(id);
|
const approved = await approveWithCapacityConfirm(id);
|
||||||
|
if (approved) {
|
||||||
toast.success('Payment confirmed');
|
toast.success('Payment confirmed');
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
}
|
||||||
toast.error('Failed to confirm payment');
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to confirm payment');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,6 +329,33 @@ export default function AdminPaymentsPage() {
|
|||||||
return labels[provider] || provider;
|
return labels[provider] || provider;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Manual gateways need admin verification; automatic ones confirm themselves.
|
||||||
|
const getProviderKindBadge = (provider: string) => (
|
||||||
|
isManualProvider(provider) ? (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-orange-50 text-orange-600">
|
||||||
|
{locale === 'es' ? 'Manual' : 'Manual'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-blue-50 text-blue-600">
|
||||||
|
{locale === 'es' ? 'Automático' : 'Auto'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Age of a claim/booking, e.g. "3h" / "2d"; used to surface rotting approvals.
|
||||||
|
const getAgeInfo = (dateStr?: string | null) => {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const ms = Date.now() - parseDate(dateStr).getTime();
|
||||||
|
if (ms < 0) return null;
|
||||||
|
const hours = Math.floor(ms / (60 * 60 * 1000));
|
||||||
|
const label = hours < 1
|
||||||
|
? (locale === 'es' ? 'hace <1 h' : '<1h ago')
|
||||||
|
: hours < 48
|
||||||
|
? (locale === 'es' ? `hace ${hours} h` : `${hours}h ago`)
|
||||||
|
: (locale === 'es' ? `hace ${Math.floor(hours / 24)} días` : `${Math.floor(hours / 24)}d ago`);
|
||||||
|
return { hours, label, stale: hours >= 48 };
|
||||||
|
};
|
||||||
|
|
||||||
// Helper to get booking info for a payment (ticket count and total)
|
// Helper to get booking info for a payment (ticket count and total)
|
||||||
const getBookingInfo = (payment: PaymentWithDetails) => {
|
const getBookingInfo = (payment: PaymentWithDetails) => {
|
||||||
if (!payment.ticket?.bookingId) {
|
if (!payment.ticket?.bookingId) {
|
||||||
@@ -331,6 +389,22 @@ export default function AdminPaymentsPage() {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Manual payments never claimed by the customer — they may have paid and
|
||||||
|
// forgotten to press "I've paid", so they stay directly approvable here.
|
||||||
|
// Hidden once the event has ended (same rule as pending approvals above).
|
||||||
|
const visibleUnclaimedManualPayments = (() => {
|
||||||
|
const now = new Date();
|
||||||
|
return unclaimedManualPayments.filter((payment) => {
|
||||||
|
const eventId = payment.event?.id;
|
||||||
|
const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined;
|
||||||
|
const endIso = fullEvent?.endDatetime
|
||||||
|
|| fullEvent?.startDatetime
|
||||||
|
|| payment.event?.startDatetime;
|
||||||
|
if (!endIso) return true;
|
||||||
|
return parseDate(endIso).getTime() >= now.getTime();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// Get booking info for pending approval payments
|
// Get booking info for pending approval payments
|
||||||
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
|
||||||
if (!payment.ticket?.bookingId) {
|
if (!payment.ticket?.bookingId) {
|
||||||
@@ -348,9 +422,15 @@ export default function AdminPaymentsPage() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate totals (sum all individual payment amounts)
|
// Calculate totals (sum all individual payment amounts).
|
||||||
const totalPending = payments
|
// Claimed ('pending_approval') money is probably already in the account and
|
||||||
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
// just needs verification; bare 'pending' money may never arrive — keep the
|
||||||
|
// two apart so the totals don't overstate what's owed.
|
||||||
|
const totalAwaitingVerification = payments
|
||||||
|
.filter(p => p.status === 'pending_approval')
|
||||||
|
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||||
|
const totalUnclaimed = payments
|
||||||
|
.filter(p => p.status === 'pending')
|
||||||
.reduce((sum, p) => sum + Number(p.amount), 0);
|
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||||
const totalPaid = payments
|
const totalPaid = payments
|
||||||
.filter(p => p.status === 'paid')
|
.filter(p => p.status === 'paid')
|
||||||
@@ -370,9 +450,6 @@ export default function AdminPaymentsPage() {
|
|||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingBookingsCount = getUniqueBookingsCount(
|
|
||||||
payments.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
|
||||||
);
|
|
||||||
const paidBookingsCount = getUniqueBookingsCount(
|
const paidBookingsCount = getUniqueBookingsCount(
|
||||||
payments.filter(p => p.status === 'paid')
|
payments.filter(p => p.status === 'paid')
|
||||||
);
|
);
|
||||||
@@ -460,12 +537,29 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedPayment.userMarkedPaidAt && (
|
{/* Always shown — for payments the customer never confirmed, this
|
||||||
|
is the only timestamp there is. */}
|
||||||
|
{(() => {
|
||||||
|
const age = getAgeInfo(selectedPayment.createdAt);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<CalendarDaysIcon className="w-4 h-4" />
|
||||||
|
{locale === 'es' ? 'Reserva realizada:' : 'Booking made:'} {formatDate(selectedPayment.createdAt)}
|
||||||
|
{age && <span className="text-gray-400">({age.label})</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{selectedPayment.userMarkedPaidAt && (() => {
|
||||||
|
const age = getAgeInfo(selectedPayment.userMarkedPaidAt);
|
||||||
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
<ClockIcon className="w-4 h-4" />
|
<ClockIcon className="w-4 h-4" />
|
||||||
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
|
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
|
||||||
|
{age && <span className="text-gray-400">({age.label})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{selectedPayment.reminderSentAt && (
|
{selectedPayment.reminderSentAt && (
|
||||||
<div className="flex items-center gap-2 text-sm text-amber-600">
|
<div className="flex items-center gap-2 text-sm text-amber-600">
|
||||||
@@ -686,6 +780,7 @@ export default function AdminPaymentsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
|
{!privacyMode && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -721,9 +816,12 @@ export default function AdminPaymentsPage() {
|
|||||||
<ClockIcon className="w-5 h-5 text-gray-600" />
|
<ClockIcon className="w-5 h-5 text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
<p className="text-sm text-gray-500">{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}</p>
|
||||||
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
|
<p className="text-xl font-bold text-yellow-600">{formatCurrency(totalAwaitingVerification, 'PYG')}</p>
|
||||||
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
|
<p className="text-xs text-gray-400">
|
||||||
|
{locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '}
|
||||||
|
{formatCurrency(totalUnclaimed, 'PYG')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -751,6 +849,7 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
|
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
|
||||||
@@ -817,13 +916,18 @@ export default function AdminPaymentsPage() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
{getProviderIcon(payment.provider)}
|
{getProviderIcon(payment.provider)}
|
||||||
{getProviderLabel(payment.provider)}
|
{getProviderLabel(payment.provider)}
|
||||||
|
{getProviderKindBadge(payment.provider)}
|
||||||
</span>
|
</span>
|
||||||
{payment.userMarkedPaidAt && (
|
{payment.userMarkedPaidAt && (() => {
|
||||||
<span className="flex items-center gap-1">
|
const age = getAgeInfo(payment.userMarkedPaidAt);
|
||||||
|
return (
|
||||||
|
<span className={clsx('flex items-center gap-1', age?.stale && 'text-amber-600 font-medium')}>
|
||||||
<ClockIcon className="w-3 h-3" />
|
<ClockIcon className="w-3 h-3" />
|
||||||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||||||
|
{age && <span>({age.label})</span>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{payment.payerName && (
|
{payment.payerName && (
|
||||||
<p className="text-xs text-amber-600 mt-1 font-medium">
|
<p className="text-xs text-amber-600 mt-1 font-medium">
|
||||||
@@ -841,6 +945,55 @@ export default function AdminPaymentsPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Manual payments the customer never confirmed — they may have paid
|
||||||
|
(bank transfer / TPago received) without pressing "I've paid".
|
||||||
|
Approving one claims a seat, so it goes through the same
|
||||||
|
over-capacity confirmation as any approval. */}
|
||||||
|
{visibleUnclaimedManualPayments.length > 0 && (
|
||||||
|
<details className="mt-8">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium text-gray-600 select-none">
|
||||||
|
{locale === 'es'
|
||||||
|
? `Pagos manuales sin confirmar por el cliente (${visibleUnclaimedManualPayments.length})`
|
||||||
|
: `Manual payments not yet confirmed by customer (${visibleUnclaimedManualPayments.length})`}
|
||||||
|
<span className="block text-xs font-normal text-gray-400 mt-0.5">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Puede que hayan pagado sin presionar "Ya pagué". No reservan lugar hasta ser aprobados.'
|
||||||
|
: 'They may have paid without pressing "I\'ve paid". These hold no seat until approved.'}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="space-y-3 mt-4">
|
||||||
|
{visibleUnclaimedManualPayments.map((payment) => {
|
||||||
|
const age = getAgeInfo(payment.createdAt);
|
||||||
|
return (
|
||||||
|
<Card key={payment.id} className="p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
{getProviderIcon(payment.provider)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{payment.ticket?.attendeeFirstName} {payment.ticket?.attendeeLastName}
|
||||||
|
<span className="text-gray-400 font-normal"> · {formatCurrency(payment.amount, payment.currency)}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 truncate">
|
||||||
|
{payment.event?.title}
|
||||||
|
{' · '}{getProviderLabel(payment.provider)}
|
||||||
|
{age && <span> · {age.label}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setSelectedPayment(payment)} size="sm" variant="outline" className="flex-shrink-0 min-h-[40px]">
|
||||||
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1004,6 +1157,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||||
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
||||||
|
{getProviderKindBadge(payment.provider)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
|
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
|
||||||
@@ -1063,7 +1217,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
|
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
|
||||||
<span className="text-gray-300">|</span>
|
<span className="text-gray-300">|</span>
|
||||||
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
|
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} {getProviderKindBadge(payment.provider)}</span>
|
||||||
{bookingInfo.ticketCount > 1 && (
|
{bookingInfo.ticketCount > 1 && (
|
||||||
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import type { GalleryVisibility } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
GlobeAltIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
LinkIcon,
|
||||||
|
TicketIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
const visibilityMeta: Record<
|
||||||
|
GalleryVisibility,
|
||||||
|
{ icon: typeof GlobeAltIcon; className: string; en: string; es: string }
|
||||||
|
> = {
|
||||||
|
public: { icon: GlobeAltIcon, className: 'bg-green-100 text-green-700', en: 'Public', es: 'Pública' },
|
||||||
|
private: { icon: LockClosedIcon, className: 'bg-gray-200 text-gray-700', en: 'Private', es: 'Privada' },
|
||||||
|
link: { icon: LinkIcon, className: 'bg-blue-100 text-blue-700', en: 'Link only', es: 'Solo enlace' },
|
||||||
|
ticket: { icon: TicketIcon, className: 'bg-yellow-100 text-yellow-800', en: 'Ticket holders', es: 'Con entrada' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VisibilityBadge({ visibility }: { visibility: GalleryVisibility }) {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const meta = visibilityMeta[visibility] || visibilityMeta.private;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${meta.className}`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{locale === 'es' ? meta.es : meta.en}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { photosApi, eventsApi, PhotoGallery, Photo, Event, GalleryVisibility } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
import Lightbox from '@/components/Lightbox';
|
||||||
|
import VisibilityBadge from '../VisibilityBadge';
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ArrowUpTrayIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
ExclamationCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
LinkIcon,
|
||||||
|
PhotoIcon,
|
||||||
|
StarIcon,
|
||||||
|
TrashIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface UploadItem {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
progress: number; // 0..1 while uploading
|
||||||
|
// 'duplicate' is terminal: the gallery already held these bytes, so nothing
|
||||||
|
// was stored and no new tile appears in the grid.
|
||||||
|
status: 'queued' | 'uploading' | 'processing' | 'error' | 'duplicate';
|
||||||
|
error?: string;
|
||||||
|
photoId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number) {
|
||||||
|
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||||
|
if (n >= 1 << 10) return `${Math.round(n / (1 << 10))} KB`;
|
||||||
|
return `${n} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminGalleryDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const es = locale === 'es';
|
||||||
|
|
||||||
|
const [gallery, setGallery] = useState<PhotoGallery | null>(null);
|
||||||
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Uploader panel state (Google Drive style: one row per file).
|
||||||
|
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||||
|
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||||
|
const uploadQueue = useRef<{ key: string; file: File }[]>([]);
|
||||||
|
const pumpRunning = useRef(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [detail, ev] = await Promise.all([photosApi.getGallery(id), eventsApi.getAll()]);
|
||||||
|
setGallery(detail.gallery);
|
||||||
|
setPhotos(detail.photos);
|
||||||
|
setEvents(ev.events);
|
||||||
|
} catch {
|
||||||
|
toast.error(es ? 'No se pudo cargar la galería' : 'Failed to load gallery');
|
||||||
|
router.push('/admin/photos');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Poll while any photo is still processing so thumbnails (and the
|
||||||
|
// uploader panel rows) update as the worker finishes them.
|
||||||
|
const processingCount = photos.filter((p) => p.status === 'queued' || p.status === 'processing').length;
|
||||||
|
useEffect(() => {
|
||||||
|
if (processingCount === 0) return;
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const detail = await photosApi.getGallery(id);
|
||||||
|
setGallery(detail.gallery);
|
||||||
|
setPhotos(detail.photos);
|
||||||
|
} catch {
|
||||||
|
/* transient; next tick retries */
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [processingCount, id]);
|
||||||
|
|
||||||
|
const patchUpload = (key: string, patch: Partial<UploadItem>) => {
|
||||||
|
setUploads((prev) => prev.map((u) => (u.key === key ? { ...u, ...patch } : u)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sequential upload pump: one file at a time, byte progress per file.
|
||||||
|
const pump = useCallback(async () => {
|
||||||
|
if (pumpRunning.current) return;
|
||||||
|
pumpRunning.current = true;
|
||||||
|
while (uploadQueue.current.length > 0) {
|
||||||
|
const { key, file } = uploadQueue.current.shift()!;
|
||||||
|
patchUpload(key, { status: 'uploading', progress: 0 });
|
||||||
|
try {
|
||||||
|
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
|
||||||
|
patchUpload(key, { progress: fraction })
|
||||||
|
);
|
||||||
|
// A duplicate echoes back a photo already in the grid, so merge by id
|
||||||
|
// instead of appending (`duplicate` is an upload outcome, not photo
|
||||||
|
// state, so it is dropped here).
|
||||||
|
setPhotos((prev) => {
|
||||||
|
const byId = new Map(prev.map((p) => [p.id, p]));
|
||||||
|
added.forEach(({ duplicate: _duplicate, ...photo }) => byId.set(photo.id, photo));
|
||||||
|
return Array.from(byId.values());
|
||||||
|
});
|
||||||
|
patchUpload(key, {
|
||||||
|
status: added[0]?.duplicate ? 'duplicate' : 'processing',
|
||||||
|
progress: 1,
|
||||||
|
photoId: added[0]?.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
patchUpload(key, {
|
||||||
|
status: 'error',
|
||||||
|
error: err instanceof Error ? err.message : 'Upload failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pumpRunning.current = false;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleUpload = (files: FileList | File[]) => {
|
||||||
|
const list = Array.from(files);
|
||||||
|
if (list.length === 0) return;
|
||||||
|
const items: UploadItem[] = list.map((file) => ({
|
||||||
|
key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
name: file.name,
|
||||||
|
sizeBytes: file.size,
|
||||||
|
progress: 0,
|
||||||
|
status: 'queued',
|
||||||
|
}));
|
||||||
|
uploadQueue.current.push(...items.map((item, i) => ({ key: item.key, file: list[i] })));
|
||||||
|
setUploads((prev) => [...prev, ...items]);
|
||||||
|
setPanelCollapsed(false);
|
||||||
|
pump();
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// A row is "done" once its photo finished processing; the panel derives
|
||||||
|
// this from the photos list instead of tracking it separately. 'duplicate'
|
||||||
|
// is terminal and never reconciled — its photo was already there.
|
||||||
|
const displayStatus = (u: UploadItem): { state: string; error?: string } => {
|
||||||
|
if (u.status === 'processing' && u.photoId) {
|
||||||
|
const photo = photos.find((p) => p.id === u.photoId);
|
||||||
|
if (photo?.status === 'ready') return { state: 'done' };
|
||||||
|
if (photo?.status === 'failed') return { state: 'error', error: photo.lastError || 'Processing failed' };
|
||||||
|
}
|
||||||
|
return { state: u.status, error: u.error };
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeUploads = uploads.filter((u) => {
|
||||||
|
const s = displayStatus(u).state;
|
||||||
|
return s === 'queued' || s === 'uploading' || s === 'processing';
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const saveField = async (patch: Parameters<typeof photosApi.updateGallery>[1], okMsg?: string) => {
|
||||||
|
try {
|
||||||
|
const { gallery: updated } = await photosApi.updateGallery(id, patch);
|
||||||
|
setGallery(updated);
|
||||||
|
if (okMsg) toast.success(okMsg);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Update failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShareLink = async () => {
|
||||||
|
if (!gallery?.shareUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(gallery.shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success(es ? 'Enlace copiado' : 'Share link copied');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast.error(es ? 'No se pudo copiar' : 'Failed to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateToken = async () => {
|
||||||
|
if (!confirm(es ? '¿Invalidar el enlace actual y generar uno nuevo?' : 'Invalidate the current link and generate a new one?'))
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const { gallery: updated } = await photosApi.rotateShareToken(id);
|
||||||
|
setGallery(updated);
|
||||||
|
toast.success(es ? 'Nuevo enlace generado' : 'New share link generated');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePhoto = async (photoId: string): Promise<boolean> => {
|
||||||
|
if (!confirm(es ? '¿Eliminar esta foto?' : 'Delete this photo?')) return false;
|
||||||
|
try {
|
||||||
|
await photosApi.deletePhoto(photoId);
|
||||||
|
setPhotos((prev) => prev.filter((p) => p.id !== photoId));
|
||||||
|
toast.success(es ? 'Foto eliminada' : 'Photo deleted');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryPhoto = async (photoId: string) => {
|
||||||
|
try {
|
||||||
|
const { photo } = await photosApi.retryPhoto(photoId);
|
||||||
|
setPhotos((prev) => prev.map((p) => (p.id === photoId ? photo : p)));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to retry');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGallery = async () => {
|
||||||
|
if (!confirm(es ? '¿Eliminar toda la galería y sus fotos?' : 'Delete the whole gallery and its photos?')) return;
|
||||||
|
try {
|
||||||
|
await photosApi.deleteGallery(id);
|
||||||
|
toast.success(es ? 'Galería eliminada' : 'Gallery deleted');
|
||||||
|
router.push('/admin/photos');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTML5 drag-and-drop reorder; persisted on drop.
|
||||||
|
const onDropReorder = async (targetId: string) => {
|
||||||
|
if (!dragId || dragId === targetId) return;
|
||||||
|
const ids = photos.map((p) => p.id);
|
||||||
|
const from = ids.indexOf(dragId);
|
||||||
|
const to = ids.indexOf(targetId);
|
||||||
|
if (from < 0 || to < 0) return;
|
||||||
|
ids.splice(to, 0, ids.splice(from, 1)[0]);
|
||||||
|
const reordered = ids
|
||||||
|
.map((pid) => photos.find((p) => p.id === pid))
|
||||||
|
.filter((p): p is Photo => !!p);
|
||||||
|
setPhotos(reordered);
|
||||||
|
setDragId(null);
|
||||||
|
try {
|
||||||
|
await photosApi.reorderPhotos(id, ids);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to save order');
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading || !gallery) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-8 w-64 mb-6" />
|
||||||
|
<ImageGridSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||||
|
const lightboxItems = readyPhotos.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
previewUrl: p.urls.preview || p.urls.original,
|
||||||
|
downloadUrl: p.urls.original,
|
||||||
|
filename: p.originalFilename,
|
||||||
|
thumbUrl: p.urls.thumb,
|
||||||
|
}));
|
||||||
|
const openLightboxFor = (photoId: string) => {
|
||||||
|
const idx = readyPhotos.findIndex((p) => p.id === photoId);
|
||||||
|
if (idx >= 0) setLightboxIndex(idx);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (e.dataTransfer.types.includes('Files')) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUpload(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link href="/admin/photos" className="text-gray-400 hover:text-gray-600">
|
||||||
|
<ArrowLeftIcon className="w-6 h-6" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark truncate">
|
||||||
|
{es && gallery.titleEs ? gallery.titleEs : gallery.title}
|
||||||
|
</h1>
|
||||||
|
<VisibilityBadge visibility={gallery.visibility} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
||||||
|
{es ? 'Subir fotos' : 'Upload photos'}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp,image/heic,.heic"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<Card className="p-4 mb-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{es ? 'Visibilidad' : 'Visibility'}</label>
|
||||||
|
<select
|
||||||
|
value={gallery.visibility}
|
||||||
|
onChange={(e) =>
|
||||||
|
saveField(
|
||||||
|
{ visibility: e.target.value as GalleryVisibility },
|
||||||
|
es ? 'Visibilidad actualizada' : 'Visibility updated'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="private">{es ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
|
||||||
|
<option value="public">{es ? 'Pública (listada)' : 'Public (listed)'}</option>
|
||||||
|
<option value="link">{es ? 'Solo con enlace' : 'Link only'}</option>
|
||||||
|
<option value="ticket">{es ? 'Con entrada del evento' : 'Ticket holders of the event'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{es ? 'Evento vinculado' : 'Linked event'}</label>
|
||||||
|
<select
|
||||||
|
value={gallery.eventId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
saveField({ eventId: e.target.value }, es ? 'Evento actualizado' : 'Event updated')
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">{es ? 'Ninguno' : 'None'}</option>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<option key={ev.id} value={ev.id}>
|
||||||
|
{ev.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{gallery.visibility === 'ticket' && !gallery.eventId && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
{es
|
||||||
|
? 'El modo "con entrada" necesita un evento vinculado'
|
||||||
|
: 'Ticket mode needs a linked event'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{es ? 'Enlace para compartir' : 'Share link'}</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={gallery.shareUrl || ''}
|
||||||
|
className="flex-1 min-w-0 px-3 py-2 text-sm border rounded-btn bg-gray-50"
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={copyShareLink} title={es ? 'Copiar' : 'Copy'}>
|
||||||
|
{copied ? <CheckIcon className="w-4 h-4" /> : <LinkIcon className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={rotateToken} title={es ? 'Regenerar' : 'Regenerate'}>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{gallery.event && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{es
|
||||||
|
? 'Publicada en la página del evento: '
|
||||||
|
: 'Published on the event page: '}
|
||||||
|
<span className="font-mono">/events/{gallery.event.slug}/gallery</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{processingCount > 0 && (
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{es
|
||||||
|
? `Procesando ${processingCount} foto(s)…`
|
||||||
|
: `Processing ${processingCount} photo(s)…`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo grid */}
|
||||||
|
{photos.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<ArrowUpTrayIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{es
|
||||||
|
? 'Arrastra fotos aquí o usa el botón de subir'
|
||||||
|
: 'Drag photos here or use the upload button'}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{photos.map((photo) => (
|
||||||
|
<Card
|
||||||
|
key={photo.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragId(photo.id)}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (dragId) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (dragId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDropReorder(photo.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`group relative overflow-hidden aspect-square cursor-grab ${
|
||||||
|
dragId === photo.id ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{photo.status === 'ready' && photo.urls.thumb ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={photo.urls.thumb}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover cursor-pointer"
|
||||||
|
draggable={false}
|
||||||
|
onClick={() => openLightboxFor(photo.id)}
|
||||||
|
/>
|
||||||
|
) : photo.status === 'failed' ? (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 text-red-600 p-2 text-center">
|
||||||
|
<ExclamationTriangleIcon className="w-8 h-8 mb-1" />
|
||||||
|
<span className="text-xs line-clamp-3">{photo.lastError || 'Failed'}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gallery.coverPhotoId === photo.id && (
|
||||||
|
<StarIconSolid className="absolute top-2 left-2 w-5 h-5 text-primary-yellow drop-shadow" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 py-1.5">
|
||||||
|
{photo.status === 'ready' && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
saveField({ coverPhotoId: photo.id }, es ? 'Portada actualizada' : 'Cover updated')
|
||||||
|
}
|
||||||
|
className="p-1.5 text-white hover:text-primary-yellow"
|
||||||
|
title={es ? 'Usar como portada' : 'Set as cover'}
|
||||||
|
>
|
||||||
|
<StarIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{photo.status === 'failed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => retryPhoto(photo.id)}
|
||||||
|
className="p-1.5 text-white hover:text-primary-yellow"
|
||||||
|
title={es ? 'Reintentar' : 'Retry'}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => deletePhoto(photo.id)}
|
||||||
|
className="p-1.5 text-white hover:text-red-400"
|
||||||
|
title={es ? 'Eliminar' : 'Delete'}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="mt-10 pt-6 border-t border-secondary-light-gray flex justify-end">
|
||||||
|
<Button variant="danger" onClick={deleteGallery}>
|
||||||
|
<TrashIcon className="w-5 h-5 mr-2" />
|
||||||
|
{es ? 'Eliminar galería' : 'Delete gallery'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lightbox with admin actions */}
|
||||||
|
{lightboxIndex !== null && lightboxItems.length > 0 && (
|
||||||
|
<Lightbox
|
||||||
|
items={lightboxItems}
|
||||||
|
index={Math.min(lightboxIndex, lightboxItems.length - 1)}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
onNavigate={setLightboxIndex}
|
||||||
|
renderActions={(item) => (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
saveField({ coverPhotoId: item.id }, es ? 'Portada actualizada' : 'Cover updated')
|
||||||
|
}
|
||||||
|
className="text-white hover:text-primary-yellow"
|
||||||
|
title={es ? 'Usar como portada' : 'Set as cover'}
|
||||||
|
>
|
||||||
|
{gallery.coverPhotoId === item.id ? (
|
||||||
|
<StarIconSolid className="w-7 h-7 text-primary-yellow" />
|
||||||
|
) : (
|
||||||
|
<StarIcon className="w-7 h-7" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const before = readyPhotos.length;
|
||||||
|
const deleted = await deletePhoto(item.id);
|
||||||
|
if (!deleted) return;
|
||||||
|
if (before <= 1) setLightboxIndex(null);
|
||||||
|
else setLightboxIndex((i) => (i === null ? null : Math.max(0, Math.min(i, before - 2))));
|
||||||
|
}}
|
||||||
|
className="text-white hover:text-red-400"
|
||||||
|
title={es ? 'Eliminar' : 'Delete'}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-7 h-7" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uploader panel (Google Drive style) */}
|
||||||
|
{uploads.length > 0 && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-40 w-96 max-w-[calc(100vw-2rem)]">
|
||||||
|
<div className="bg-white rounded-card shadow-card-hover border border-secondary-light-gray overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-primary-dark text-white">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{activeUploads > 0
|
||||||
|
? es
|
||||||
|
? `Subiendo ${uploads.length - activeUploads}/${uploads.length}…`
|
||||||
|
: `Uploading ${uploads.length - activeUploads}/${uploads.length}…`
|
||||||
|
: es
|
||||||
|
? `${uploads.length} subida(s) completada(s)`
|
||||||
|
: `${uploads.length} upload(s) complete`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelCollapsed((c) => !c)}
|
||||||
|
className="p-1 hover:text-primary-yellow"
|
||||||
|
aria-label={panelCollapsed ? 'Expand' : 'Collapse'}
|
||||||
|
>
|
||||||
|
{panelCollapsed ? <ChevronUpIcon className="w-5 h-5" /> : <ChevronDownIcon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
{activeUploads === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setUploads([])}
|
||||||
|
className="p-1 hover:text-primary-yellow"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!panelCollapsed && (
|
||||||
|
<ul className="max-h-72 overflow-y-auto divide-y divide-secondary-light-gray">
|
||||||
|
{uploads.map((u) => {
|
||||||
|
const ds = displayStatus(u);
|
||||||
|
return (
|
||||||
|
<li key={u.key} className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<PhotoIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-primary-dark truncate" title={u.name}>
|
||||||
|
{u.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{ds.state === 'uploading' && `${Math.round(u.progress * 100)}% · ${formatBytes(u.sizeBytes)}`}
|
||||||
|
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
|
||||||
|
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
|
||||||
|
{ds.state === 'done' && formatBytes(u.sizeBytes)}
|
||||||
|
{ds.state === 'duplicate' && (
|
||||||
|
<span className="text-amber-600">
|
||||||
|
{es ? 'Ya está en esta galería' : 'Already in this gallery'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ds.state === 'error' && (
|
||||||
|
<span className="text-red-600" title={ds.error}>
|
||||||
|
{ds.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
|
||||||
|
{ds.state === 'duplicate' && (
|
||||||
|
<DocumentDuplicateIcon className="w-6 h-6 text-amber-600" />
|
||||||
|
)}
|
||||||
|
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
|
||||||
|
{ds.state === 'processing' && (
|
||||||
|
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
|
||||||
|
)}
|
||||||
|
{ds.state === 'uploading' && (
|
||||||
|
<span className="text-xs text-gray-600 tabular-nums">{Math.round(u.progress * 100)}%</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ds.state === 'uploading' && (
|
||||||
|
<div className="mt-1.5 h-1 rounded-full bg-secondary-gray overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-yellow transition-[width] duration-200"
|
||||||
|
style={{ width: `${Math.round(u.progress * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { photosApi, eventsApi, PhotoGallery, Event, GalleryVisibility } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
import VisibilityBadge from './VisibilityBadge';
|
||||||
|
import {
|
||||||
|
CameraIcon,
|
||||||
|
PlusIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AdminPhotosPage() {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const [galleries, setGalleries] = useState<PhotoGallery[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '',
|
||||||
|
titleEs: '',
|
||||||
|
eventId: '',
|
||||||
|
visibility: 'private' as GalleryVisibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([photosApi.getGalleries(), eventsApi.getAll()])
|
||||||
|
.then(([g, e]) => {
|
||||||
|
setGalleries(g.galleries);
|
||||||
|
setEvents(e.events);
|
||||||
|
})
|
||||||
|
.catch(() => toast.error(locale === 'es' ? 'Error al cargar galerías' : 'Failed to load galleries'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.title.trim()) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const { gallery } = await photosApi.createGallery({
|
||||||
|
title: form.title.trim(),
|
||||||
|
titleEs: form.titleEs.trim() || undefined,
|
||||||
|
eventId: form.eventId || undefined,
|
||||||
|
visibility: form.visibility,
|
||||||
|
});
|
||||||
|
setGalleries((prev) => [gallery, ...prev]);
|
||||||
|
setShowCreate(false);
|
||||||
|
setForm({ title: '', titleEs: '', eventId: '', visibility: 'private' });
|
||||||
|
toast.success(locale === 'es' ? 'Galería creada' : 'Gallery created');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create gallery');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<ImageGridSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark">
|
||||||
|
{locale === 'es' ? 'Fotos de Eventos' : 'Event Photos'}
|
||||||
|
</h1>
|
||||||
|
<Button onClick={() => setShowCreate(true)}>
|
||||||
|
<PlusIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{galleries.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-600 mb-2">
|
||||||
|
{locale === 'es' ? 'Sin galerías todavía' : 'No galleries yet'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Crea una galería para compartir las fotos de un evento'
|
||||||
|
: 'Create a gallery to share photos from a past event'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowCreate(true)}>
|
||||||
|
<PlusIcon className="w-5 h-5 mr-2" />
|
||||||
|
{locale === 'es' ? 'Crear la primera' : 'Create the first one'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{galleries.map((g) => (
|
||||||
|
<Link key={g.id} href={`/admin/photos/${g.id}`}>
|
||||||
|
<Card className="overflow-hidden hover:shadow-card-hover transition-shadow cursor-pointer h-full">
|
||||||
|
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||||
|
{g.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<CameraIcon className="w-12 h-12 text-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="font-semibold text-primary-dark truncate">
|
||||||
|
{locale === 'es' && g.titleEs ? g.titleEs : g.title}
|
||||||
|
</h3>
|
||||||
|
<VisibilityBadge visibility={g.visibility} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{g.photoCount} {locale === 'es' ? 'fotos' : 'photos'}
|
||||||
|
{g.event && <> · {locale === 'es' && g.event.titleEs ? g.event.titleEs : g.event.title}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-primary-dark">
|
||||||
|
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<XMarkIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Título (inglés)' : 'Title (English)'} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
placeholder="June Language Exchange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Título (español)' : 'Title (Spanish)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.titleEs}
|
||||||
|
onChange={(e) => setForm({ ...form, titleEs: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
placeholder="Intercambio de Junio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Evento vinculado' : 'Linked event'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.eventId}
|
||||||
|
onChange={(e) => setForm({ ...form, eventId: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="">{locale === 'es' ? 'Ninguno' : 'None'}</option>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<option key={ev.id} value={ev.id}>
|
||||||
|
{ev.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{locale === 'es' ? 'Visibilidad' : 'Visibility'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.visibility}
|
||||||
|
onChange={(e) => setForm({ ...form, visibility: e.target.value as GalleryVisibility })}
|
||||||
|
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||||
|
>
|
||||||
|
<option value="private">{locale === 'es' ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
|
||||||
|
<option value="public">{locale === 'es' ? 'Pública (listada)' : 'Public (listed)'}</option>
|
||||||
|
<option value="link">{locale === 'es' ? 'Solo con enlace' : 'Link only'}</option>
|
||||||
|
<option value="ticket">
|
||||||
|
{locale === 'es' ? 'Con entrada del evento' : 'Ticket holders of the event'}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
|
||||||
|
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={creating}>
|
||||||
|
{locale === 'es' ? 'Crear' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
ArrowUturnLeftIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
|
||||||
|
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
|
import { PaymentButtons } from './PaymentButtons';
|
||||||
|
|
||||||
|
function checkinTime(checkinAt: string | null): string {
|
||||||
|
if (!checkinAt) return '';
|
||||||
|
return parseDate(checkinAt).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: EVENT_TIMEZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
||||||
|
cash: 'cash',
|
||||||
|
bitcoin: 'bitcoin',
|
||||||
|
transfer: 'transfer',
|
||||||
|
guest: 'guest',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The second line of a row: everything staff needs to decide in one glance. */
|
||||||
|
function statusLine(attendee: DoorAttendee, currency: string): string {
|
||||||
|
if (attendee.status === 'cancelled') return 'Cancelled';
|
||||||
|
if (attendee.checkedIn) {
|
||||||
|
const time = checkinTime(attendee.checkinAt);
|
||||||
|
const how = attendee.doorMethod ? ` · paid ${METHOD_LABELS[attendee.doorMethod]}` : '';
|
||||||
|
return time ? `Checked in ${time}${how}` : `Checked in${how}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (attendee.paymentStatus === 'comp') parts.push('Guest');
|
||||||
|
else if (attendee.paymentStatus === 'paid') parts.push('Paid');
|
||||||
|
else parts.push(`Unpaid · ${formatCurrency(attendee.amountDue, currency)} due`);
|
||||||
|
|
||||||
|
if (attendee.isGroupBooking) parts.push('group booking');
|
||||||
|
if (attendee.status === 'pending') parts.push('pending');
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendeeRow({
|
||||||
|
attendee,
|
||||||
|
currency,
|
||||||
|
price,
|
||||||
|
expanded,
|
||||||
|
flashing,
|
||||||
|
busy,
|
||||||
|
onTap,
|
||||||
|
onPay,
|
||||||
|
}: {
|
||||||
|
attendee: DoorAttendee;
|
||||||
|
currency: string;
|
||||||
|
price: number;
|
||||||
|
expanded: boolean;
|
||||||
|
flashing: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onTap: () => void;
|
||||||
|
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||||
|
}) {
|
||||||
|
const isCancelled = attendee.status === 'cancelled';
|
||||||
|
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
|
||||||
|
// A settled, not-yet-arrived attendee is the one-tap case: the whole row checks
|
||||||
|
// them in. Everyone else opens the tenders inline instead.
|
||||||
|
const isOneTap = !isCancelled && !attendee.checkedIn && settled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-2xl border transition-colors',
|
||||||
|
flashing
|
||||||
|
? 'bg-emerald-600 border-emerald-400'
|
||||||
|
: attendee.checkedIn || isCancelled
|
||||||
|
? 'bg-gray-900 border-gray-800'
|
||||||
|
: 'bg-gray-800 border-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onTap}
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full text-left px-4 py-3 min-h-[64px] flex items-center gap-3 active:scale-[0.99] transition-transform disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'font-bold text-lg truncate',
|
||||||
|
flashing ? 'text-white' : attendee.checkedIn || isCancelled ? 'text-gray-400' : 'text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{attendee.fullName}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'text-sm truncate flex items-center gap-1.5',
|
||||||
|
flashing
|
||||||
|
? 'text-emerald-50'
|
||||||
|
: isCancelled
|
||||||
|
? 'text-red-400'
|
||||||
|
: attendee.checkedIn
|
||||||
|
? 'text-gray-500'
|
||||||
|
: attendee.paymentStatus === 'unpaid'
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-gray-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{attendee.isGroupBooking && !attendee.checkedIn && <UserGroupIcon className="w-4 h-4 flex-shrink-0" />}
|
||||||
|
{statusLine(attendee, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{flashing ? (
|
||||||
|
<CheckCircleIcon className="w-8 h-8 text-white flex-shrink-0" />
|
||||||
|
) : attendee.checkedIn ? (
|
||||||
|
<CheckCircleIcon className="w-7 h-7 text-emerald-500/60 flex-shrink-0" />
|
||||||
|
) : isCancelled ? (
|
||||||
|
<span className="flex-shrink-0 text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded-full bg-red-950 text-red-400">
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
) : isOneTap ? (
|
||||||
|
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-primary-yellow">
|
||||||
|
Check in
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-amber-400">
|
||||||
|
Collect
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && !attendee.checkedIn && (
|
||||||
|
<div className="px-3 pb-3 pt-1 space-y-2">
|
||||||
|
{isCancelled && (
|
||||||
|
<p className="text-xs text-gray-400 px-1 flex items-center gap-1.5">
|
||||||
|
<ArrowUturnLeftIcon className="w-4 h-4" />
|
||||||
|
Reactivate as a walk-in — pick how they are paying.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && attendee.checkedIn && (
|
||||||
|
<div className="px-4 pb-3 -mt-1">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Already checked in
|
||||||
|
{attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''}
|
||||||
|
{attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
BanknotesIcon,
|
||||||
|
BoltIcon,
|
||||||
|
BuildingLibraryIcon,
|
||||||
|
GiftIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import type { DoorPaymentMethod } from '@/lib/api';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
|
// The four tenders staff can take at the door. One tap settles and checks in;
|
||||||
|
// long-press (or the chevron) opens multiples for someone paying for their group.
|
||||||
|
|
||||||
|
const TENDERS: {
|
||||||
|
method: DoorPaymentMethod;
|
||||||
|
label: string;
|
||||||
|
icon: typeof BanknotesIcon;
|
||||||
|
className: string;
|
||||||
|
}[] = [
|
||||||
|
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
|
||||||
|
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
|
||||||
|
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
|
||||||
|
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LONG_PRESS_MS = 450;
|
||||||
|
|
||||||
|
export function PaymentButtons({
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
onPay,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
|
||||||
|
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
|
||||||
|
const [customOpen, setCustomOpen] = useState(false);
|
||||||
|
const [customValue, setCustomValue] = useState('');
|
||||||
|
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [longPressed, setLongPressed] = useState(false);
|
||||||
|
|
||||||
|
const openAmounts = (method: DoorPaymentMethod) => {
|
||||||
|
if (method === 'guest') return;
|
||||||
|
setAmountsFor(method);
|
||||||
|
setCustomOpen(false);
|
||||||
|
setCustomValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPress = (method: DoorPaymentMethod) => {
|
||||||
|
setLongPressed(false);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setLongPressed(true);
|
||||||
|
openAmounts(method);
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
setPressTimer(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endPress = (method: DoorPaymentMethod) => {
|
||||||
|
if (pressTimer) clearTimeout(pressTimer);
|
||||||
|
setPressTimer(null);
|
||||||
|
// A long press already opened the multiples; don't also charge 1x on release.
|
||||||
|
if (longPressed) {
|
||||||
|
setLongPressed(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disabled) return;
|
||||||
|
onPay(method, method === 'guest' ? 0 : price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPress = () => {
|
||||||
|
if (pressTimer) clearTimeout(pressTimer);
|
||||||
|
setPressTimer(null);
|
||||||
|
setLongPressed(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (amountsFor) {
|
||||||
|
const tender = TENDERS.find((t) => t.method === amountsFor)!;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<p className="text-sm font-semibold text-white">{tender.label} — how many?</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setAmountsFor(null); setCustomOpen(false); }}
|
||||||
|
className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{[1, 2, 3].map((qty) => (
|
||||||
|
<button
|
||||||
|
key={qty}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onPay(tender.method, price * qty)}
|
||||||
|
className={clsx(
|
||||||
|
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||||
|
tender.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{qty}x
|
||||||
|
<span className="text-[10px] font-medium opacity-80">
|
||||||
|
{formatCurrency(price * qty, currency)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setCustomOpen((open) => !open)}
|
||||||
|
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{customOpen && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoFocus
|
||||||
|
value={customValue}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
placeholder={`Amount in ${currency}`}
|
||||||
|
className="flex-1 min-h-[48px] px-4 bg-gray-800 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={disabled || !customValue || Number(customValue) < 0}
|
||||||
|
onClick={() => onPay(tender.method, Number(customValue))}
|
||||||
|
className={clsx(
|
||||||
|
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||||
|
tender.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Take
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{TENDERS.map((tender) => (
|
||||||
|
<button
|
||||||
|
key={tender.method}
|
||||||
|
disabled={disabled}
|
||||||
|
onPointerDown={() => startPress(tender.method)}
|
||||||
|
onPointerUp={() => endPress(tender.method)}
|
||||||
|
onPointerLeave={cancelPress}
|
||||||
|
onPointerCancel={cancelPress}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
className={clsx(
|
||||||
|
'relative min-h-[64px] rounded-2xl text-white font-bold flex flex-col items-center justify-center gap-1 select-none disabled:opacity-50 active:scale-[0.97] transition-transform',
|
||||||
|
tender.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tender.icon className="w-6 h-6" />
|
||||||
|
<span className="text-xs">{tender.label}</span>
|
||||||
|
{tender.method !== 'guest' && (
|
||||||
|
// Visible affordance for the same thing long-press does: staff who
|
||||||
|
// never discover the hold still find the multiples.
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label={`${tender.label} quick amounts`}
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); cancelPress(); }}
|
||||||
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => { e.stopPropagation(); openAmounts(tender.method); }}
|
||||||
|
className="absolute top-0.5 right-0.5 w-7 h-7 flex items-center justify-center rounded-full text-white/70"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { QrCodeIcon, XMarkIcon, VideoCameraIcon } from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
// The camera is a fullscreen overlay opened from the search row, not a tab. It
|
||||||
|
// only exists while a scan is happening, so it never holds the camera (or the
|
||||||
|
// screen) while staff are typing a name.
|
||||||
|
|
||||||
|
/** Release any camera stream html5-qrcode left attached to a <video> element. */
|
||||||
|
function stopAllTracks() {
|
||||||
|
try {
|
||||||
|
document.querySelectorAll('video').forEach((video) => {
|
||||||
|
const stream = video.srcObject as MediaStream | null;
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
video.srcObject = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QRScannerOverlay({
|
||||||
|
onScan,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onScan: (code: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scannerRef = useRef<any>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const elementId = useRef(`qr-scanner-${Date.now()}`);
|
||||||
|
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const destroyScanner = useCallback(async () => {
|
||||||
|
if (scannerRef.current) {
|
||||||
|
try { await scannerRef.current.stop(); } catch {}
|
||||||
|
try { scannerRef.current.clear(); } catch {}
|
||||||
|
scannerRef.current = null;
|
||||||
|
}
|
||||||
|
stopAllTracks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const id = elementId.current;
|
||||||
|
container.innerHTML = '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.id = id;
|
||||||
|
div.style.width = '100%';
|
||||||
|
div.style.height = '100%';
|
||||||
|
container.appendChild(div);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { Html5Qrcode } = await import('html5-qrcode');
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const scanner = new Html5Qrcode(id);
|
||||||
|
scannerRef.current = scanner;
|
||||||
|
|
||||||
|
await scanner.start(
|
||||||
|
{ facingMode },
|
||||||
|
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
|
||||||
|
(decodedText: string) => {
|
||||||
|
if (mountedRef.current) onScan(decodedText);
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
await destroyScanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force a layout pass: some browsers leave the video mis-sized until reflow.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (container) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
container.offsetHeight;
|
||||||
|
container.style.display = '';
|
||||||
|
}
|
||||||
|
if (mountedRef.current) setReady(true);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scanner error:', error);
|
||||||
|
if (!cancelled && mountedRef.current) {
|
||||||
|
toast.error('Failed to start camera. Check permissions.');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
mountedRef.current = false;
|
||||||
|
destroyScanner();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [facingMode]);
|
||||||
|
|
||||||
|
// Backgrounding the browser suspends the camera; drop it and rebuild on return.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
destroyScanner();
|
||||||
|
} else if (document.visibilityState === 'visible' && mountedRef.current) {
|
||||||
|
setFacingMode((prev) => {
|
||||||
|
const temp = prev === 'environment' ? 'user' : 'environment';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mountedRef.current) setFacingMode(prev);
|
||||||
|
}, 100);
|
||||||
|
return temp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
}, [destroyScanner]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||||
|
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 safe-area-top">
|
||||||
|
<p className="text-white font-semibold">Scan ticket</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ready && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'))}
|
||||||
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
|
||||||
|
aria-label="Switch camera"
|
||||||
|
>
|
||||||
|
<VideoCameraIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
|
||||||
|
aria-label="Close scanner"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
|
||||||
|
/>
|
||||||
|
{!ready && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
|
||||||
|
<p className="text-sm opacity-60">Starting camera...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 px-6 py-5 pb-safe">
|
||||||
|
<p className="text-center text-gray-400 text-sm">
|
||||||
|
Point at the ticket QR — it checks in and closes automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
XMarkIcon,
|
||||||
|
ClockIcon,
|
||||||
|
QrCodeIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
UserPlusIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import type { DoorPaymentMethod, DoorSummary } from '@/lib/api';
|
||||||
|
import { DOOR_PAYMENT_METHODS } from '@/lib/api';
|
||||||
|
import { formatCurrency } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SessionEntry {
|
||||||
|
idempotencyKey: string;
|
||||||
|
ticketId: string;
|
||||||
|
name: string;
|
||||||
|
at: string;
|
||||||
|
entry: 'scan' | 'search' | 'walkin';
|
||||||
|
method: DoorPaymentMethod | null;
|
||||||
|
amount: number;
|
||||||
|
undone: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTRY_ICONS = {
|
||||||
|
scan: QrCodeIcon,
|
||||||
|
search: MagnifyingGlassIcon,
|
||||||
|
walkin: UserPlusIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTRY_LABELS = {
|
||||||
|
scan: 'Scanned',
|
||||||
|
search: 'Search',
|
||||||
|
walkin: 'Walk-in',
|
||||||
|
};
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
|
||||||
|
cash: 'Cash',
|
||||||
|
bitcoin: 'Bitcoin',
|
||||||
|
transfer: 'Transfer',
|
||||||
|
guest: 'Guest',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Totals for the current shift, computed from this session's own entries. */
|
||||||
|
function sessionTotals(entries: SessionEntry[]) {
|
||||||
|
const totals: Record<DoorPaymentMethod, { count: number; total: number }> = {
|
||||||
|
cash: { count: 0, total: 0 },
|
||||||
|
bitcoin: { count: 0, total: 0 },
|
||||||
|
transfer: { count: 0, total: 0 },
|
||||||
|
guest: { count: 0, total: 0 },
|
||||||
|
};
|
||||||
|
let grand = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.undone || entry.failed || !entry.method) continue;
|
||||||
|
totals[entry.method].count += 1;
|
||||||
|
totals[entry.method].total += entry.amount;
|
||||||
|
grand += entry.amount;
|
||||||
|
}
|
||||||
|
return { totals, grand };
|
||||||
|
}
|
||||||
|
|
||||||
|
function CashUpGrid({
|
||||||
|
totals,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
totals: Record<DoorPaymentMethod, { count: number; total: number }>;
|
||||||
|
currency: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{DOOR_PAYMENT_METHODS.map((method) => (
|
||||||
|
<div key={method} className="bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">{METHOD_LABELS[method]}</p>
|
||||||
|
<p className="font-bold text-white text-base leading-tight">
|
||||||
|
{method === 'guest' ? `${totals[method].count} free` : formatCurrency(totals[method].total, currency)}
|
||||||
|
</p>
|
||||||
|
{method !== 'guest' && (
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
{totals[method].count} {totals[method].count === 1 ? 'payment' : 'payments'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end-of-night view: what this shift took, what the whole event day took,
|
||||||
|
* and the feed of who came in and how.
|
||||||
|
*/
|
||||||
|
export function SessionSheet({
|
||||||
|
entries,
|
||||||
|
summary,
|
||||||
|
summaryLoading,
|
||||||
|
currency,
|
||||||
|
onRefresh,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
entries: SessionEntry[];
|
||||||
|
summary: DoorSummary | null;
|
||||||
|
summaryLoading: boolean;
|
||||||
|
currency: string;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { totals, grand } = sessionTotals(entries);
|
||||||
|
const liveEntries = entries.filter((e) => !e.undone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-gray-950 flex flex-col" style={{ height: '100dvh' }}>
|
||||||
|
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white text-lg">Session</p>
|
||||||
|
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
||||||
|
aria-label="Refresh totals"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
||||||
|
aria-label="Close session view"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5 pb-safe">
|
||||||
|
{/* This shift */}
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">This session</h2>
|
||||||
|
<p className="text-primary-yellow font-bold">{formatCurrency(grand, currency)}</p>
|
||||||
|
</div>
|
||||||
|
<CashUpGrid totals={totals} currency={currency} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Whole event, from the server — the number to reconcile the cash box against */}
|
||||||
|
<section className="space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
|
||||||
|
<p className="text-primary-yellow font-bold">
|
||||||
|
{summary ? formatCurrency(summary.door.total, summary.currency) : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{summary ? (
|
||||||
|
<>
|
||||||
|
<CashUpGrid totals={summary.door.byMethod} currency={summary.currency} />
|
||||||
|
<div className="flex items-center justify-between bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">Pre-sale</p>
|
||||||
|
<p className="font-bold text-white">
|
||||||
|
{formatCurrency(summary.presale.total, summary.currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-gray-500">Event total</p>
|
||||||
|
<p className="font-bold text-white">{formatCurrency(summary.total, summary.currency)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{summaryLoading ? 'Loading totals…' : 'Totals unavailable — pull to refresh.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Feed */}
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Recent check-ins</h2>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 py-10">
|
||||||
|
<ClockIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">No check-ins yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const Icon = ENTRY_ICONS[entry.entry];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.idempotencyKey}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-xl border px-3 py-2.5 flex items-center gap-3',
|
||||||
|
entry.failed
|
||||||
|
? 'bg-red-950/40 border-red-900'
|
||||||
|
: entry.undone
|
||||||
|
? 'bg-gray-900 border-gray-800 opacity-50'
|
||||||
|
: 'bg-gray-800 border-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'font-medium truncate',
|
||||||
|
entry.undone ? 'text-gray-500 line-through' : 'text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{ENTRY_LABELS[entry.entry]}
|
||||||
|
{entry.method ? ` · ${METHOD_LABELS[entry.method]}` : ''}
|
||||||
|
{entry.method && entry.method !== 'guest'
|
||||||
|
? ` ${formatCurrency(entry.amount, currency)}`
|
||||||
|
: ''}
|
||||||
|
{entry.failed ? ' · failed' : entry.undone ? ' · undone' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400 flex-shrink-0">{entry.at}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import type { DoorPaymentMethod } from '@/lib/api';
|
||||||
|
import { PaymentButtons } from './PaymentButtons';
|
||||||
|
|
||||||
|
export interface WalkInDraft {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
ruc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
|
||||||
|
firstName,
|
||||||
|
lastName: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
ruc: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first
|
||||||
|
* name and four tenders. Email, phone and RUC live behind "Add details" so the
|
||||||
|
* rare person who wants a receipt never slows down the queue behind them.
|
||||||
|
*/
|
||||||
|
export function WalkInRow({
|
||||||
|
typedText,
|
||||||
|
expanded,
|
||||||
|
draft,
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
busy,
|
||||||
|
onExpand,
|
||||||
|
onChange,
|
||||||
|
onPay,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
typedText: string;
|
||||||
|
expanded: boolean;
|
||||||
|
draft: WalkInDraft;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
busy: boolean;
|
||||||
|
onExpand: () => void;
|
||||||
|
onChange: (draft: WalkInDraft) => void;
|
||||||
|
onPay: (method: DoorPaymentMethod, amount: number) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
const firstNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) firstNameRef.current?.focus();
|
||||||
|
}, [expanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded) setDetailsOpen(false);
|
||||||
|
}, [expanded]);
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onExpand}
|
||||||
|
className="w-full min-h-[64px] px-4 py-3 rounded-2xl border-2 border-dashed border-primary-yellow/50 bg-primary-yellow/5 text-left flex items-center gap-3 active:scale-[0.99] transition-transform"
|
||||||
|
>
|
||||||
|
<UserPlusIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
|
||||||
|
<span className="font-bold text-primary-yellow truncate">
|
||||||
|
{typedText ? `Add "${typedText}" as walk-in` : 'Add a walk-in'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = (key: keyof WalkInDraft, value: string) => onChange({ ...draft, [key]: value });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border-2 border-primary-yellow/50 bg-gray-800 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<p className="font-bold text-primary-yellow">New walk-in</p>
|
||||||
|
<button onClick={onCancel} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
ref={firstNameRef}
|
||||||
|
value={draft.firstName}
|
||||||
|
onChange={(e) => field('firstName', e.target.value)}
|
||||||
|
placeholder="First name"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.lastName}
|
||||||
|
onChange={(e) => field('lastName', e.target.value)}
|
||||||
|
placeholder="Last name (optional)"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={draft.phone}
|
||||||
|
onChange={(e) => field('phone', e.target.value)}
|
||||||
|
placeholder="Phone (optional)"
|
||||||
|
inputMode="tel"
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailsOpen((open) => !open)}
|
||||||
|
className="w-full min-h-[48px] flex items-center justify-between px-2 text-sm text-gray-400 active:text-white"
|
||||||
|
>
|
||||||
|
Add details (email, RUC)
|
||||||
|
<ChevronDownIcon className={clsx('w-4 h-4 transition-transform', detailsOpen && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{detailsOpen && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
value={draft.email}
|
||||||
|
onChange={(e) => field('email', e.target.value)}
|
||||||
|
placeholder="Email — sends the usual confirmation"
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.ruc}
|
||||||
|
onChange={(e) => field('ruc', e.target.value)}
|
||||||
|
placeholder="RUC (for factura)"
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaymentButtons
|
||||||
|
price={price}
|
||||||
|
currency={currency}
|
||||||
|
onPay={onPay}
|
||||||
|
disabled={busy || !draft.firstName.trim()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Firing door actions without ever blocking the queue of people at the door.
|
||||||
|
//
|
||||||
|
// The UI flashes green and clears the input the moment staff taps; the write
|
||||||
|
// happens here, in the background, with retries. Venue wifi drops constantly, so
|
||||||
|
// every action carries an idempotency key: a retry that actually succeeded the
|
||||||
|
// first time returns the original result instead of double-charging anyone.
|
||||||
|
|
||||||
|
import { doorApi, type DoorCheckinRequest, type DoorCheckinResponse } from '@/lib/api';
|
||||||
|
|
||||||
|
/** UUID per action. crypto.randomUUID needs a secure context; fall back when absent. */
|
||||||
|
export function newIdempotencyKey(): string {
|
||||||
|
const cryptoRef = typeof crypto !== 'undefined' ? crypto : undefined;
|
||||||
|
if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
|
||||||
|
return `door-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roughly 5 seconds of retrying in total — long enough to ride out a wifi blip,
|
||||||
|
// short enough that staff learn about a real failure while the person is still
|
||||||
|
// in front of them.
|
||||||
|
const RETRY_DELAYS_MS = [400, 1200, 3000];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errors worth retrying are the ones a retry can fix: network failures, gateway
|
||||||
|
* errors, rate limits. A 400 "ticket belongs to a different event" will fail
|
||||||
|
* identically forever, so it surfaces immediately.
|
||||||
|
*/
|
||||||
|
function isRetryable(error: any): boolean {
|
||||||
|
const status = error?.status;
|
||||||
|
if (typeof status === 'number') return status >= 500 || status === 408 || status === 429;
|
||||||
|
// No status at all means the request never reached the server (fetch rejects
|
||||||
|
// with a TypeError when the connection drops) — exactly the case to retry.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export async function submitDoorAction(
|
||||||
|
eventId: string,
|
||||||
|
body: DoorCheckinRequest,
|
||||||
|
): Promise<DoorCheckinResponse> {
|
||||||
|
let lastError: any;
|
||||||
|
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||||
|
try {
|
||||||
|
return await doorApi.checkin(eventId, body);
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt === RETRY_DELAYS_MS.length || !isRetryable(error)) break;
|
||||||
|
await sleep(RETRY_DELAYS_MS[attempt]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function undoDoorAction(eventId: string, idempotencyKey: string): Promise<void> {
|
||||||
|
await doorApi.undo(eventId, idempotencyKey);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user