24 Commits
Author SHA1 Message Date
Michilis 107cd9753e Merge pull request 'Redesign user dashboard with overview tab and i18n payment copy.' (#24) from dashboard-update into main
Reviewed-on: #24
2026-06-29 17:00:15 +00:00
Michilis 194604004e Merge pull request 'security-update' (#23) from security-update into main
Reviewed-on: #23
2026-06-29 06:47:19 +00:00
Michilis bb920ce6f0 Merge pull request 'Add per-quantity TPago payment links for multi-ticket checkout.' (#22) from tpago-update into main
Reviewed-on: #22
2026-06-19 00:24:00 +00:00
Michilis c0315a705d Merge pull request 'Add human-readable event URL slugs with legacy redirect support.' (#21) from new-slugs into main
Reviewed-on: #21
2026-06-05 04:16:13 +00:00
Michilis fbc437a670 Merge pull request 'Fix booking flow scroll position on mobile step changes.' (#20) from dev into main
Reviewed-on: #20
2026-06-05 04:06:39 +00:00
Michilis e0f0700398 Merge pull request 'dev' (#19) from dev into main
Reviewed-on: #19
2026-06-04 23:37:15 +00:00
Michilis defd9685e0 Merge pull request 'dev' (#18) from dev into main
Reviewed-on: #18
2026-04-27 20:42:36 +00:00
Michilis 1ed62b0d3f Merge pull request 'Fix db:export ENOBUFS by streaming pg_dump output to file' (#17) from dev into main
Reviewed-on: #17
2026-03-12 19:18:57 +00:00
Michilis 91de6df04d Merge pull request 'feat(emails): add re-send for all emails, failed tab, and resend indicators' (#16) from dev into main
Reviewed-on: #16
2026-03-12 19:14:36 +00:00
Michilis a5d97d65e1 Merge pull request 'Admin: stats privacy toggle, clickable event rows, fix payment method display' (#15) from dev into main
Reviewed-on: #15
2026-03-10 01:14:36 +00:00
Michilis f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
Michilis b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
Michilis 15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
Michilis 194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
Michilis d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
Michilis dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
Michilis b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
Michilis d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
Michilis a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
Michilis 833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
Michilis ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
Michilis 3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
Michilis 8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
234 changed files with 4602 additions and 23155 deletions
-5
View File
@@ -7,7 +7,6 @@ package-lock.json
# Build outputs # Build outputs
dist/ dist/
.next/ .next/
.next-build/
out/ out/
build/ build/
@@ -57,10 +56,6 @@ yarn-error.log*
# Testing # Testing
coverage/ coverage/
# Go photo-api service
photo-api/bin/
photo-api/data/
# Misc # Misc
*.pem *.pem
+18 -70
View File
@@ -4,24 +4,17 @@ A full-stack web app for organizing and managing language exchange events (Asunc
## Features ## Features
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES) - **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
- Stable `/next` and `/featured` URLs that redirect to the current event - **User dashboard**: profile, tickets, payments, sessions/security
- Human-readable event URL slugs (with legacy-ID redirect support) - **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
- **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
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET` - **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
- **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 (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages - **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
## Local development ## Local development
@@ -29,8 +22,6 @@ 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
@@ -38,14 +29,12 @@ 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 # backend (Drizzle) tables npm run db:migrate
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
``` ```
### Run ### Run
@@ -54,13 +43,10 @@ npm run migrate:photos # photo-api owns only the photos_* tables via its own mi
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
@@ -72,37 +58,16 @@ 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 # backend + frontend + photo-api npm run dev
npm run build # build all workspaces npm run build
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
@@ -117,34 +82,20 @@ 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**: `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**: `JWT_SECRET` (change in production)
- **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)**: 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. - **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
- **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). 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. - Leave empty to use same-origin `/api` (recommended when running behind nginx)
- Inlined at build time: changing it requires a rebuild, not just a restart. - In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend
- 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
@@ -192,13 +143,12 @@ 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`, `deploy/spanglish-photos.service` - **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files) - Backend runs on **3018**, frontend on **3019** 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/3020 - `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
- `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/front-end_nginx.conf` proxies `/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:
@@ -206,9 +156,7 @@ 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.
+4 -53
View File
@@ -22,18 +22,10 @@ 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, login lockout, # Redis connection URL. When set, the cache, rate limiter, pub/sub (real-time
# pub/sub (real-time payment events), distributed locks, and the email hourly # payment events), distributed locks, and the email hourly cap are shared across
# cap are shared across all instances. When unset, each instance uses in-memory # all instances. When unset, each instance uses in-memory equivalents.
# 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
@@ -50,41 +42,13 @@ 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
# Better Auth session secret. REQUIRED in production, 32+ characters. # JWT Secret (change in production!)
# 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
@@ -150,16 +114,3 @@ PENDING_BOOKING_TTL_MINUTES=30
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min) # How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000 PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000
# Auto-Hold Sweep
# Bookings awaiting admin payment approval are put on hold (seat released) after
# this many hours with no confirmation (default: 72)
HOLD_THRESHOLD_HOURS=72
# How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min)
HOLD_SWEEP_INTERVAL_MS=900000
# Ended-Event Payment Sweep
# Once an event is over, any unconfirmed payment (pending / pending_approval /
# on_hold) is auto-rejected and its ticket cancelled. No email is sent.
# How often this sweep runs, in milliseconds (default: 900000 = 15 min)
EVENT_END_SWEEP_INTERVAL_MS=900000
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

+4 -9
View File
@@ -5,7 +5,6 @@
"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",
@@ -20,14 +19,12 @@
"@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-auth": "1.6.25", "better-sqlite3": "^11.0.0",
"better-sqlite3": "^12.11.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.31.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",
"moment-timezone": "^0.6.2",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nodemailer": "^7.0.13", "nodemailer": "^7.0.13",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
@@ -43,10 +40,8 @@
"@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.31.10", "drizzle-kit": "^0.22.8",
"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"
} }
} }
-200
View File
@@ -1,200 +0,0 @@
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;
-100
View File
@@ -1,100 +0,0 @@
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);
});
+5 -375
View File
@@ -1,6 +1,6 @@
import 'dotenv/config'; import 'dotenv/config';
import { db, dbAll, dbGet, events, users } from './index.js'; import { db, dbAll, events } from './index.js';
import { sql, eq, ne } from 'drizzle-orm'; import { sql, eq } 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,25 +133,17 @@ 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_first_name TEXT NOT NULL, attendee_name TEXT NOT NULL,
attendee_last_name TEXT, attendee_email TEXT NOT NULL,
attendee_email TEXT, attendee_phone TEXT NOT NULL,
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
) )
`); `);
@@ -208,18 +200,6 @@ 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
@@ -258,34 +238,6 @@ async function migrate() {
try { try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`); await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
scope TEXT NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TEXT,
created_at TEXT NOT NULL
)
`);
// Invoices table // Invoices table
await (db as any).run(sql` await (db as any).run(sql`
@@ -571,81 +523,6 @@ 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`
@@ -816,18 +693,6 @@ 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,
@@ -854,34 +719,6 @@ async function migrate() {
try { try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`); await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
} catch (e) { /* column may already exist */ } } catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key VARCHAR(128) PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
)
`);
// Invoices table // Invoices table
await (db as any).execute(sql` await (db as any).execute(sql`
@@ -1165,80 +1002,6 @@ 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)
@@ -1249,15 +1012,8 @@ 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 {
@@ -1269,132 +1025,6 @@ 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 }>(
+3 -64
View File
@@ -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, boolean as pgBoolean } from 'drizzle-orm/pg-core'; import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } 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,12 +20,6 @@ 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,14 +104,12 @@ export const sqliteTickets = sqliteTable('tickets', {
attendeePhone: text('attendee_phone'), attendeePhone: text('attendee_phone'),
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
preferredLanguage: text('preferred_language'), preferredLanguage: text('preferred_language'),
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'), status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
checkinAt: text('checkin_at'), checkinAt: text('checkin_at'),
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
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(),
}); });
@@ -127,43 +119,18 @@ export const sqlitePayments = sqliteTable('payments', {
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(), provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
amount: real('amount').notNull(), amount: real('amount').notNull(),
currency: text('currency').notNull().default('PYG'), currency: text('currency').notNull().default('PYG'),
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'), status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
reference: text('reference'), reference: text('reference'),
lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires
lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid" userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
payerName: text('payer_name'), // Name of payer if different from attendee payerName: text('payer_name'), // Name of payer if different from attendee
paidAt: text('paid_at'), paidAt: text('paid_at'),
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(),
@@ -408,12 +375,6 @@ 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(),
}); });
@@ -504,8 +465,6 @@ 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(),
}); });
@@ -517,35 +476,16 @@ export const pgPayments = pgTable('payments', {
currency: varchar('currency', { length: 10 }).notNull().default('PYG'), currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
status: varchar('status', { length: 20 }).notNull().default('pending'), status: varchar('status', { length: 20 }).notNull().default('pending'),
reference: varchar('reference', { length: 255 }), reference: varchar('reference', { length: 255 }),
lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires
lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
userMarkedPaidAt: timestamp('user_marked_paid_at'), userMarkedPaidAt: timestamp('user_marked_paid_at'),
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
paidAt: timestamp('paid_at'), paidAt: timestamp('paid_at'),
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(),
@@ -772,7 +712,6 @@ 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;
+127 -297
View File
@@ -7,12 +7,9 @@ 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 { auth } from './lib/betterAuth.js'; import authRoutes from './routes/auth.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';
@@ -27,11 +24,8 @@ 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, stopQueue } from './lib/emailQueue.js'; import { initEmailQueue } from './lib/emailQueue.js';
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js'; import { startBookingCleanup } from './lib/bookingCleanup.js';
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
import { closeRedis } from './lib/redis.js';
import { getLock } from './lib/stores/lock.js'; import { getLock } from './lib/stores/lock.js';
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js'; import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
@@ -60,7 +54,7 @@ app.use(
if (!origin) return frontendUrl; if (!origin) return frontendUrl;
return allowedOrigins.has(origin) ? origin : null; return allowedOrigins.has(origin) ? origin : null;
}, },
// Session cookies must be allowed on cross-origin API calls (api.* vhost). // We use bearer tokens, but keeping credentials=true matches nginx config.
credentials: true, credentials: true,
}) })
); );
@@ -113,15 +107,11 @@ const openApiSpec = {
], ],
paths: { paths: {
// ==================== Auth Endpoints ==================== // ==================== Auth Endpoints ====================
// Authentication is handled by Better Auth, mounted at /api/auth/*. '/api/auth/register': {
// 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 (Better Auth)', summary: 'Register a new user',
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.', description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -131,8 +121,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 }, password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
name: { type: 'string' }, name: { type: 'string', minLength: 2 },
phone: { type: 'string' }, phone: { type: 'string' },
languagePreference: { type: 'string', enum: ['en', 'es'] }, languagePreference: { type: 'string', enum: ['en', 'es'] },
}, },
@@ -141,17 +131,16 @@ const openApiSpec = {
}, },
}, },
responses: { responses: {
200: { description: 'User created; session cookie set' }, 201: { description: 'User created successfully' },
422: { description: 'Email already registered' }, 400: { description: 'Email already registered or validation error' },
400: { description: 'Validation error' },
}, },
}, },
}, },
'/api/auth/sign-in/email': { '/api/auth/login': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Login with email and password (Better Auth)', summary: 'Login with email and password',
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.', description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -168,46 +157,42 @@ const openApiSpec = {
}, },
}, },
responses: { responses: {
200: { description: 'Login successful; session cookie set' }, 200: { description: 'Login successful, returns JWT token' },
401: { description: 'Invalid credentials' }, 401: { description: 'Invalid credentials' },
429: { description: 'Too many attempts' }, 429: { description: 'Too many login attempts' },
}, },
}, },
}, },
'/api/auth/sign-in/social': { '/api/auth/google': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Login or register with Google (Better Auth)', summary: 'Login or register with Google',
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.', description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
'application/json': { 'application/json': {
schema: { schema: {
type: 'object', type: 'object',
required: ['provider'], required: ['credential'],
properties: { properties: {
provider: { type: 'string', enum: ['google'] }, credential: { type: 'string', description: 'Google ID token' },
idToken: {
type: 'object',
properties: { token: { type: 'string', description: 'Google ID token' } },
},
}, },
}, },
}, },
}, },
}, },
responses: { responses: {
200: { description: 'Login successful; session cookie set' }, 200: { description: 'Login successful' },
401: { description: 'Invalid Google token' }, 400: { description: 'Invalid Google token' },
}, },
}, },
}, },
'/api/auth/sign-in/magic-link': { '/api/auth/magic-link/request': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Request magic link login (Better Auth)', summary: 'Request magic link login',
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.', description: 'Send a one-time login link to email. Link expires in 10 minutes.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -217,33 +202,46 @@ const openApiSpec = {
required: ['email'], required: ['email'],
properties: { properties: {
email: { type: 'string', format: 'email' }, email: { type: 'string', format: 'email' },
callbackURL: { type: 'string' },
}, },
}, },
}, },
}, },
}, },
responses: { 200: { description: 'Magic link sent (if account exists)' } }, responses: {
200: { description: 'Magic link sent (if account exists)' },
},
}, },
}, },
'/api/auth/magic-link/verify': { '/api/auth/magic-link/verify': {
get: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Verify magic link token (Better Auth)', summary: 'Verify magic link token',
parameters: [ description: 'Verify the magic link token and login user.',
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } }, requestBody: {
], required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
},
},
},
},
},
responses: { responses: {
200: { description: 'Login successful; session cookie set' }, 200: { description: 'Login successful' },
400: { description: 'Invalid or expired token' }, 400: { description: 'Invalid or expired token' },
}, },
}, },
}, },
'/api/auth/request-password-reset': { '/api/auth/password-reset/request': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Request password reset (Better Auth)', summary: 'Request password reset',
description: 'Emails a reset link. Token expires in 30 minutes.', description: 'Send a password reset link to email. Link expires in 30 minutes.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@@ -253,30 +251,31 @@ const openApiSpec = {
required: ['email'], required: ['email'],
properties: { properties: {
email: { type: 'string', format: 'email' }, email: { type: 'string', format: 'email' },
redirectTo: { type: 'string' },
}, },
}, },
}, },
}, },
}, },
responses: { 200: { description: 'Reset link sent (if account exists)' } }, responses: {
200: { description: 'Reset link sent (if account exists)' },
},
}, },
}, },
'/api/auth/reset-password': { '/api/auth/password-reset/confirm': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Reset password with token (Better Auth)', summary: 'Confirm password reset',
description: 'Sets a new password and revokes all existing sessions.', description: 'Reset password using the token from email.',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
'application/json': { 'application/json': {
schema: { schema: {
type: 'object', type: 'object',
required: ['newPassword', 'token'], required: ['token', 'password'],
properties: { properties: {
newPassword: { type: 'string', minLength: 10 },
token: { type: 'string' }, token: { type: 'string' },
password: { type: 'string', minLength: 10 },
}, },
}, },
}, },
@@ -288,10 +287,62 @@ 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 (Better Auth)', summary: 'Change password',
description: 'Change password for authenticated user.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
requestBody: { requestBody: {
required: true, required: true,
@@ -303,7 +354,6 @@ const openApiSpec = {
properties: { properties: {
currentPassword: { type: 'string' }, currentPassword: { type: 'string' },
newPassword: { type: 'string', minLength: 10 }, newPassword: { type: 'string', minLength: 10 },
revokeOtherSessions: { type: 'boolean' },
}, },
}, },
}, },
@@ -316,68 +366,28 @@ const openApiSpec = {
}, },
}, },
}, },
'/api/auth/get-session': { '/api/auth/me': {
get: { get: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Get current session and user (Better Auth)', summary: 'Get current user',
description: 'Get the currently authenticated user profile.',
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
responses: { responses: {
200: { description: '{ session, user } or null when not authenticated' }, 200: { description: 'Current user data' },
401: { description: 'Unauthorized' },
}, },
}, },
}, },
'/api/auth/sign-out': { '/api/auth/logout': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
summary: 'Logout (Better Auth)', summary: 'Logout',
description: 'Revokes the current session and clears the session cookie.', description: 'Logout current user (client-side token removal).',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Logged out' } },
},
},
'/api/auth/list-sessions': {
get: {
tags: ['Auth'],
summary: 'List active sessions (Better Auth)',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Active sessions for the current user' } },
},
},
'/api/auth-ext/claim-account': {
post: {
tags: ['Auth'],
summary: 'Claim a guest-created account',
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string', minLength: 10 } },
},
},
},
},
responses: { responses: {
200: { description: 'Account claimed successfully' }, 200: { description: 'Logged out' },
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 ====================
'/api/dashboard/summary': { '/api/dashboard/summary': {
@@ -768,116 +778,6 @@ 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'],
@@ -1859,10 +1759,10 @@ const openApiSpec = {
components: { components: {
securitySchemes: { securitySchemes: {
bearerAuth: { bearerAuth: {
type: 'apiKey', type: 'http',
in: 'cookie', scheme: 'bearer',
name: 'spanglish.session_token', bearerFormat: 'JWT',
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)', description: 'JWT token obtained from login endpoint',
}, },
}, },
schemas: { schemas: {
@@ -1996,32 +1896,7 @@ app.get('/health', (c) => {
}); });
// API Routes // API Routes
// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link, app.route('/api/auth', authRoutes);
// 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);
@@ -2057,20 +1932,11 @@ initEmailQueue(emailService);
// Periodically expire abandoned pending bookings so they stop holding seats. // Periodically expire abandoned pending bookings so they stop holding seats.
startBookingCleanup(); startBookingCleanup();
// Periodically put stale pending-approval payments on hold, releasing their seats.
startHoldSweep();
// Periodically auto-reject unconfirmed payments once their event is over (no email).
startEventEndSweep();
// Initialize email templates on startup. // 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(), { onUnavailable: 'run' }) .withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
.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)');
@@ -2087,43 +1953,7 @@ 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();
const server = serve({ 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'));
+327 -88
View File
@@ -1,78 +1,334 @@
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 { and, eq } from 'drizzle-orm'; import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { auth } from './betterAuth.js'; import { eq, and, gt, sql, isNull } from 'drizzle-orm';
import { db, dbGet } from '../db/index.js'; import { generateId, getNow, toDbDate } from './utils.js';
import { authAccounts } from '../db/auth-schema.js';
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
// sessions validated against the auth_sessions table on every request, so const rawJwtSecret = process.env.JWT_SECRET;
// revocation (ban/suspend/password reset) applies instantly. This module keeps
// the request-side helpers that the route files use.
// Re-exported for routes that hash/validate passwords outside Better Auth // Never allow the insecure default in production: forgeable tokens = full account takeover.
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js'; if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
export interface AuthUser { }
id: string; if (!rawJwtSecret) {
email: string; console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
name: string;
phone: string | null;
role: string;
languagePreference: string | null;
isClaimed: boolean;
rucNumber: string | null;
accountStatus: string;
emailVerified: boolean;
image: string | null;
createdAt: Date | string;
updatedAt: Date | string;
/** ID of the Better Auth session backing this request. */
sessionId: string;
} }
/** const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
* Resolve the authenticated user for a request from its Better Auth session const JWT_ISSUER = 'spanglish';
* cookie, or null when there is no valid session. Suspended/unclaimed/banned const JWT_AUDIENCE = 'spanglish-app';
* accounts never get API access even with a live session cookie.
*/
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
try {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return null;
}
const user = session.user as any; export interface JWTPayload {
sub: string;
email: string;
role: string;
tokenVersion?: number;
iat: number;
exp: number;
}
// Suspended (banned) or unclaimed accounts must not retain API access // Password hashing with Argon2 (spec requirement)
if (user.banned) { export async function hashPassword(password: string): Promise<string> {
return null; return argon2.hash(password, {
} type: argon2.argon2id,
if (user.accountStatus && user.accountStatus !== 'active') { memoryCost: 65536, // 64 MB
return null; timeCost: 3,
} parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Generate secure random token for magic links
export function generateSecureToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// Create magic link token
export async function createMagicLinkToken(
userId: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
expiresInMinutes: number = 10
): Promise<string> {
const token = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
await (db as any).insert(magicLinkTokens).values({
id: generateId(),
userId,
token,
type,
expiresAt,
createdAt: now,
});
return token;
}
// Verify and consume magic link token
export async function verifyMagicLinkToken(
token: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
): Promise<{ valid: boolean; userId?: string; error?: string }> {
const now = getNow();
const tokenRecord = await dbGet<any>(
(db as any)
.select()
.from(magicLinkTokens)
.where(
and(
eq((magicLinkTokens as any).token, token),
eq((magicLinkTokens as any).type, type)
)
)
);
// 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 { return {
id: user.id, valid: false,
email: user.email, error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
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,
}; };
}
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 {
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
});
return payload as unknown as JWTPayload;
} catch { } catch {
return null; return null;
} }
} }
export async function getAuthUser(c: Context): Promise<any | null> {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload) {
return null;
}
// 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') {
return null;
}
return user;
}
export function requireAuth(roles?: string[]) { export function requireAuth(roles?: string[]) {
return async (c: Context, next: () => Promise<void>) => { return async (c: Context, next: () => Promise<void>) => {
const user = await getAuthUser(c); const user = await getAuthUser(c);
@@ -90,38 +346,21 @@ export function requireAuth(roles?: string[]) {
}; };
} }
/** export async function isFirstUser(): Promise<boolean> {
* Fetch only the credential password hash (never exposed via getAuthUser). const result = await dbAll(
* Returns null when the user has no password set (Google-only or unclaimed). (db as any).select().from(users).limit(1)
*/ );
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: (authAccounts as any).password }) .select({ password: (users as any).password })
.from(authAccounts) .from(users)
.where( .where(eq((users as any).id, userId))
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;
}
+2 -5
View File
@@ -1,12 +1,11 @@
// 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, getRedisHealthDetail } from './redis.js'; import { isRedisEnabled, isRedisHealthy } 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() {
@@ -15,13 +14,12 @@ 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(), ...getRedisHealthDetail() }; return { enabled: isRedisEnabled(), healthy: isRedisHealthy() };
} }
/** 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. */
@@ -34,6 +32,5 @@ 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}`);
} }
@@ -1,519 +0,0 @@
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);
});
-420
View File
@@ -1,420 +0,0 @@
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;
+4 -15
View File
@@ -5,20 +5,11 @@
// abandoned checkout would otherwise hold those seats forever. This job cancels // abandoned checkout would otherwise hold those seats forever. This job cancels
// pending tickets whose payment is still 'pending' (i.e. never paid and not // pending tickets whose payment is still 'pending' (i.e. never paid and not
// awaiting admin approval) after a configurable TTL, freeing the seats. // awaiting admin approval) after a configurable TTL, freeing the seats.
//
// Two exclusions:
// - Manual-verification providers (bank transfer / TPago / cash) are never
// auto-failed; they are settled by an admin and instead follow the 72h on-hold
// sweep. See holdSweep.ts and MANUAL_PAYMENT_PROVIDERS.
// - Staleness is measured from `updatedAt`, not `createdAt`, so an admin action
// (e.g. reopening a payment to 'pending' via /reopen) restarts the TTL rather
// than being immediately re-failed on the next run.
import { and, eq, lt, inArray, notInArray } from 'drizzle-orm'; import { 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 './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);
@@ -29,9 +20,8 @@ function getTtlMs(): number {
* Cancel stale pending bookings. Returns the number of tickets cancelled. * Cancel stale pending bookings. Returns the number of tickets cancelled.
* *
* A booking is considered stale when its payment is still 'pending' (not * A booking is considered stale when its payment is still 'pending' (not
* 'pending_approval', which means an admin is reviewing a manual transfer), * 'pending_approval', which means an admin is reviewing a manual transfer) and
* uses a non-manual provider, and has not been touched (updatedAt) for * older than PENDING_BOOKING_TTL_MINUTES.
* PENDING_BOOKING_TTL_MINUTES.
*/ */
export async function cleanupStalePendingBookings(): Promise<number> { export async function cleanupStalePendingBookings(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getTtlMs())); const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
@@ -45,8 +35,7 @@ export async function cleanupStalePendingBookings(): Promise<number> {
.from(payments) .from(payments)
.where(and( .where(and(
eq((payments as any).status, 'pending'), eq((payments as any).status, 'pending'),
notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]), lt((payments as any).createdAt, cutoff)
lt((payments as any).updatedAt, cutoff)
)) ))
); );
-70
View File
@@ -1,70 +0,0 @@
// 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);
}
-51
View File
@@ -1,51 +0,0 @@
// 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}`;
}
-124
View File
@@ -1,124 +0,0 @@
// Auto-reject unconfirmed payments once their event is over.
//
// After an event ends, any booking whose payment was never confirmed
// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored.
// This job silently fails those payments and cancels their tickets so they stop
// lingering as "pending" forever. It deliberately sends NO email — unlike the
// admin reject route, this is a housekeeping sweep and users are not notified.
//
// An event is considered over when COALESCE(end_datetime, start_datetime) is in
// the past. Updates are guarded by the current status so re-running is a no-op.
import { and, eq, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments, events } from '../db/index.js';
import { getNow } from './utils.js';
import { getLock } from './stores/lock.js';
// Payment statuses that represent an unconfirmed booking.
const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold'];
// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled).
const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold'];
/**
* Fail unconfirmed payments (and cancel their tickets) for events that have
* already ended. Returns the number of payments rejected.
*/
export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise<number> {
// Pull candidate rows first, then decide "ended" in JS so the comparison works
// identically for SQLite (ISO text) and Postgres (timestamp) datetime columns.
const rows = await dbAll<{
paymentId: string;
ticketId: string;
endDatetime: string | Date | null;
startDatetime: string | Date | null;
}>(
(db as any)
.select({
paymentId: (payments as any).id,
ticketId: (tickets as any).id,
endDatetime: (events as any).endDatetime,
startDatetime: (events as any).startDatetime,
})
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.innerJoin(events, eq((tickets as any).eventId, (events as any).id))
.where(and(
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
))
);
const nowMs = Date.now();
const ended = rows.filter((r) => {
const ref = r.endDatetime || r.startDatetime;
if (!ref) return false;
return new Date(ref as any).getTime() < nowMs;
});
if (ended.length === 0) return 0;
const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId)));
const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id)));
const now = getNow();
// Fail the payments. The status guard keeps this idempotent and avoids
// clobbering anything that changed since we read the candidates.
await (db as any)
.update(payments)
.set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now })
.where(and(
inArray((payments as any).id, paymentIds),
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
));
// Cancel the associated tickets, freeing any seats they still hold.
if (ticketIds.length > 0) {
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(and(
inArray((tickets as any).id, ticketIds),
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
));
}
console.log(
`[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` +
`cancelled ${ticketIds.length} ticket(s).`
);
return paymentIds.length;
}
let sweepTimer: ReturnType<typeof setInterval> | null = null;
/**
* Start a periodic sweep that auto-rejects unconfirmed payments for ended
* events. Each run is guarded by a distributed lock so that, across multiple
* replicas, only one instance does the work per interval.
*/
export function startEventEndSweep(): void {
const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
const run = () => {
getLock()
.withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () =>
rejectUnconfirmedPaymentsForEndedEvents()
)
.catch((err) =>
console.error('[EventEndSweep] Run failed:', err?.message || err)
);
};
// Run shortly after startup, then on the interval.
setTimeout(run, 60_000).unref?.();
sweepTimer = setInterval(run, intervalMs);
sweepTimer.unref?.();
console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
}
export function stopEventEndSweep(): void {
if (sweepTimer) {
clearInterval(sweepTimer);
sweepTimer = null;
}
}
-138
View File
@@ -1,138 +0,0 @@
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
//
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
// states — user clicking "I've paid", an admin approving/reactivating a payment —
// must atomically re-check that the event still has room, exactly like the original
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
// (e.g. reopening a failed payment) claims no seat and skips the check.
//
// Callers choose which ticket statuses are eligible to be flipped via
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
// left untouched. Tickets that already hold a seat cost no new capacity.
// `options.skipCapacityCheck` lets an admin knowingly approve over capacity —
// the UI warns first (routes/payments.ts /approve with allowOverCapacity).
import { eq, and, inArray } from 'drizzle-orm';
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
export class HoldCapacityError extends Error {
constructor(public available: number) {
super('EVENT_FULL');
}
}
interface ReserveOptions {
paidByAdminId?: string;
extraPaymentFields?: Record<string, any>;
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
/** Admin override: reserve even when it puts the event over capacity. */
skipCapacityCheck?: boolean;
}
/**
* Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
* bookingId), atomically re-checking capacity before flipping their status.
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
* Capacity is asserted against the number of those tickets that don't already
* hold a seat, so re-reserving tickets that are already seated is a no-op.
* Throws HoldCapacityError if the event no longer has room (unless the target
* state holds no seat, or skipCapacityCheck is set).
*/
export async function reserveOnHoldBooking(
eventId: string,
ticketIds: string[],
targetTicketStatus: 'pending' | 'confirmed',
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
options: ReserveOptions = {}
): Promise<void> {
if (ticketIds.length === 0) return;
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
// Bare 'pending' payments hold no seat, so moving a booking back to 'pending'
// consumes no capacity and needs no check.
const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed';
const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck;
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
throw new Error('Event not found');
}
const now = getNow();
const paymentUpdate: Record<string, any> = {
status: targetPaymentStatus,
updatedAt: now,
...options.extraPaymentFields,
};
if (targetPaymentStatus === 'paid') {
paymentUpdate.paidAt = now;
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
}
// Keep the ticket-level payment flag in sync when the payment settles
const ticketUpdate: Record<string, any> = { status: targetTicketStatus };
if (targetPaymentStatus === 'paid') {
ticketUpdate.paymentStatus = 'paid';
}
// `needed` is how many of these tickets don't currently hold a seat and so must
// be found new capacity; tickets already in a seat-holding state cost nothing.
const assertCapacity = (reserved: number, needed: number) => {
if (needed <= 0) return;
if (isEventSoldOut(event.capacity, reserved)) {
throw new HoldCapacityError(0);
}
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (needed > seatsLeft) {
throw new HoldCapacityError(seatsLeft);
}
};
if (isSqlite()) {
(db as any).transaction((tx: any) => {
if (checkCapacity) {
const countRow = seatHolderCountQuery(tx, eventId).get();
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
}
tx.update(tickets)
.set(ticketUpdate)
.where(and(
inArray((tickets as any).id, ticketIds),
inArray((tickets as any).status, fromTicketStatuses)
))
.run();
tx.update(payments)
.set(paymentUpdate)
.where(inArray((payments as any).ticketId, ticketIds))
.run();
});
} else {
await (db as any).transaction(async (tx: any) => {
if (checkCapacity) {
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
}
await tx.update(tickets)
.set(ticketUpdate)
.where(and(
inArray((tickets as any).id, ticketIds),
inArray((tickets as any).status, fromTicketStatuses)
));
await tx.update(payments)
.set(paymentUpdate)
.where(inArray((payments as any).ticketId, ticketIds));
});
}
}
-107
View File
@@ -1,107 +0,0 @@
// Auto-hold stale unsettled manual-payment bookings.
//
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
// the customer never clicked "I've paid" and no admin settled them. These are exempt
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
// dead checkouts out of the admin's pending queues.
//
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
// claimed payment keeps its seat until an admin approves or rejects it — the admin
// UI surfaces aging claims instead of silently releasing them.
//
// The user receives no notification — they can recover via "I've paid", and an
// admin can approve/reactivate directly; every recovery path re-checks capacity.
import { and, eq, lt, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js';
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
function getThresholdMs(): number {
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
return (Number.isFinite(hours) && hours > 0 ? hours : 72) * 60 * 60 * 1000;
}
/**
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
* is never swept. Returns the number of payments put on hold.
*/
export async function sweepStaleApprovals(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
(db as any)
.select({
ticketId: (payments as any).ticketId,
paymentId: (payments as any).id,
})
.from(payments)
.where(and(
eq((payments as any).status, 'pending'),
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
lt((payments as any).createdAt, cutoff)
))
);
if (stale.length === 0) return 0;
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
const paymentIds = stale.map((s) => s.paymentId);
const now = getNow();
await (db as any)
.update(payments)
.set({ status: 'on_hold', updatedAt: now })
.where(inArray((payments as any).id, paymentIds));
if (ticketIds.length > 0) {
await (db as any)
.update(tickets)
.set({ status: 'on_hold' })
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'pending')
));
}
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
return stale.length;
}
let sweepTimer: ReturnType<typeof setInterval> | null = null;
/**
* Start a periodic sweep of stale pending-approval payments. Each run is guarded by
* a distributed lock so that, across multiple replicas, only one instance does the
* work per interval.
*/
export function startHoldSweep(): void {
const intervalMs = parseInt(process.env.HOLD_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
const run = () => {
getLock()
.withLock('sweep-hold-stale-approvals', Math.min(intervalMs, 60_000), () =>
sweepStaleApprovals()
)
.catch((err) =>
console.error('[HoldSweep] Run failed:', err?.message || err)
);
};
// Run shortly after startup, then on the interval.
setTimeout(run, 45_000).unref?.();
sweepTimer = setInterval(run, intervalMs);
sweepTimer.unref?.();
console.log(`[HoldSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
}
export function stopHoldSweep(): void {
if (sweepTimer) {
clearInterval(sweepTimer);
sweepTimer = null;
}
}
-5
View File
@@ -36,11 +36,6 @@ export interface CreateInvoiceParams {
extra?: Record<string, any>; // Additional metadata extra?: Record<string, any>; // Additional metadata
} }
// How long a booking's Lightning invoice is valid for, in seconds. Shared
// between initial booking creation and invoice regeneration so both produce
// invoices with the same lifetime.
export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes
/** /**
* Check if LNbits is configured * Check if LNbits is configured
*/ */
-62
View File
@@ -1,62 +0,0 @@
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 };
}
-37
View File
@@ -1,37 +0,0 @@
// 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';
}
+199 -356
View File
@@ -1,8 +1,6 @@
// 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;
@@ -17,390 +15,235 @@ 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;
} }
/** /**
* Locate the logo. `../../assets` resolves to backend/assets from both * Generate a QR code as a data URL
* 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: 600, width: 200,
margin: 1, margin: 2,
errorCorrectionLevel: 'M', errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#FFFFFF' },
}); });
} }
/** /**
* Short date + time as shown in the ticket header: * Format date for display using site timezone
* en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30"
*/ */
function formatWhen( function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string {
startStr: string, const date = new Date(dateStr);
endStr: string | undefined, return date.toLocaleDateString('en-US', {
timezone: string, weekday: 'long',
locale: string year: 'numeric',
): string { month: 'long',
const isEs = locale === 'es'; day: 'numeric',
const start = new Date(startStr); timeZone: timezone,
const tag = isEs ? 'es-ES' : 'en-US';
const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone });
const month = start
.toLocaleDateString(tag, { month: 'short', timeZone: timezone })
.replace(/\.$/, '')
.toUpperCase();
const time = (d: Date) =>
d
.toLocaleTimeString(tag, {
hour: isEs ? '2-digit' : 'numeric',
minute: '2-digit',
hour12: !isEs,
timeZone: timezone,
})
.toUpperCase();
const date = isEs ? `${day} ${month}` : `${month} ${day}`;
const end = endStr ? new Date(endStr) : null;
const when = end ? `${time(start)} ${time(end)}` : time(start);
return `${date} · ${when}`;
}
/**
* Events store the venue as a single string; the part before the first comma
* reads as the venue name and the remainder as its address.
*/
function splitLocation(location: string): { name: string; address?: string } {
const idx = location.indexOf(',');
if (idx === -1) return { name: location.trim() };
return {
name: location.slice(0, idx).trim(),
address: location.slice(idx + 1).trim() || undefined,
};
}
// ==================== Drawing helpers ====================
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'; * Format time for display using site timezone
let domain = base; */
try { function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string {
domain = new URL(base).host.replace(/^www\./, ''); const date = new Date(dateStr);
} catch { return date.toLocaleTimeString('en-US', {
domain = base.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); hour: '2-digit',
} minute: '2-digit',
return { base, domain }; hour12: true,
timeZone: timezone,
});
} }
/** /**
* 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 generateCombinedTicketsPDF([ticket]); return new Promise(async (resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margin: 50,
});
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
// Generate QR code with ticket URL
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
const qrBuffer = await generateQRCode(qrUrl);
// ==================== Header ====================
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
// Divider line
doc.moveDown(1);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(1);
// ==================== Event Info ====================
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
// ==================== QR Code ====================
doc.moveDown(2);
// Center the QR code
const qrSize = 180;
const pageWidth = 595; // A4 width in points
const qrX = (pageWidth - qrSize) / 2;
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
doc.y += qrSize + 10;
// ==================== Attendee Info ====================
doc.moveDown(1);
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
if (ticket.attendeeEmail) {
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
}
// ==================== Ticket ID ====================
doc.moveDown(1);
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
// ==================== Footer ====================
doc.moveDown(2);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
doc.end();
} catch (error) {
reject(error);
}
});
} }
/** /**
* Generate a combined PDF with multiple tickets (one page each) * Generate a combined PDF with multiple tickets
*/ */
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> { export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
const doc = createDoc(); return new Promise(async (resolve, reject) => {
const done = collect(doc); try {
const { base, domain } = siteUrl(); const doc = new PDFDocument({
size: 'A4',
margin: 50,
});
try { const chunks: Buffer[] = [];
for (let i = 0; i < tickets.length; i++) { doc.on('data', (chunk: Buffer) => chunks.push(chunk));
const ticket = tickets[i]; doc.on('end', () => resolve(Buffer.concat(chunks)));
if (i > 0) doc.addPage(); doc.on('error', reject);
const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`); const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length);
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
if (i > 0) {
doc.addPage();
}
// Generate QR code
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
const qrBuffer = await generateQRCode(qrUrl);
// ==================== Header ====================
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
// Divider line
doc.moveDown(1);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(1);
// ==================== Event Info ====================
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
doc.moveDown(0.5);
// Date and time (using site timezone)
const tz = ticket.timezone || 'America/Asuncion';
doc.fontSize(14).fillColor('#333');
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
const startTime = formatTime(ticket.event.startDatetime, tz);
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
doc.text(timeRange, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
// ==================== QR Code ====================
doc.moveDown(2);
const qrSize = 180;
const pageWidth = 595;
const qrX = (pageWidth - qrSize) / 2;
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
doc.y += qrSize + 10;
// ==================== Attendee Info ====================
doc.moveDown(1);
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
if (ticket.attendeeEmail) {
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
}
// ==================== Ticket ID ====================
doc.moveDown(1);
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
// Ticket number for multi-ticket bookings
if (tickets.length > 1) {
doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
}
// ==================== Footer ====================
doc.moveDown(2);
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
doc.moveDown(0.3);
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
}
doc.end();
} catch (error) {
reject(error);
} }
doc.end(); });
} catch (error) {
doc.end();
throw error;
}
return done;
} }
export default { export default {
-87
View File
@@ -1,87 +0,0 @@
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');
});
});
+4 -61
View File
@@ -10,68 +10,11 @@ import { getRateLimiter } from './stores/rateLimiter.js';
* (horizontal scaling). See lib/stores/rateLimiter.ts. * (horizontal scaling). See lib/stores/rateLimiter.ts.
*/ */
// Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For: /** Best-effort client IP extraction (honours common reverse-proxy headers). */
// 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); const forwarded = c.req.header('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0].trim();
if (socketAddr && isTrustedProxyIp(socketAddr)) { return c.req.header('x-real-ip') || 'unknown';
const realIp = normalizeIp(c.req.header('x-real-ip'));
if (realIp && looksLikeIp(realIp)) return realIp;
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) {
const chain = forwarded.split(',').map((s) => normalizeIp(s)).filter(Boolean);
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrustedProxyIp(chain[i])) {
return looksLikeIp(chain[i]) ? chain[i] : socketAddr;
}
}
// Every hop is one of ours: a genuinely local/internal client.
}
}
return socketAddr || 'unknown';
} }
/** /**
+10 -80
View File
@@ -4,15 +4,7 @@
// 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, an active PING probe, and a health flag that callers and the health // backoff, and a health flag that callers and the health endpoint can read.
// 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';
@@ -20,14 +12,6 @@ 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 {
@@ -39,41 +23,14 @@ 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. Do not // callers degrade to their in-memory fallback rather than hanging.
// 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);
@@ -85,53 +42,30 @@ function buildClient(label: string): Redis {
console.log(`[redis] (${label}) connecting`); console.log(`[redis] (${label}) connecting`);
}); });
instance.on('ready', () => { instance.on('ready', () => {
setHealthy(true, `${label} ready`); healthy = true;
console.log(`[redis] (${label}) ready`);
}); });
instance.on('error', (err) => { instance.on('error', (err) => {
setHealthy(false, `${label} error`); healthy = false;
logErrorDebounced(label, err); console.error(`[redis] (${label}) error:`, err?.message || err);
}); });
instance.on('reconnecting', () => { instance.on('reconnecting', () => {
setHealthy(false, `${label} reconnecting`); healthy = false;
console.warn(`[redis] (${label}) reconnecting`);
}); });
instance.on('end', () => { instance.on('end', () => {
setHealthy(false, `${label} connection closed`); healthy = false;
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. */
@@ -148,10 +82,6 @@ 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));
-98
View File
@@ -1,98 +0,0 @@
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();
});
});
+10 -44
View File
@@ -5,45 +5,22 @@
// //
// 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>, opts?: WithLockOptions): Promise<T | null>; withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null>;
} }
// ==================== Memory implementation ==================== // ==================== Memory implementation ====================
export class MemoryLock implements Lock { 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 }>();
@@ -81,23 +58,23 @@ export 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';
export class RedisLock implements Lock { 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) {
throw new LockUnavailableError('redis client not initialized'); // Redis configured but unavailable: do not block critical sections.
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) {
// Do NOT fabricate a token here: during an outage every replica would console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err);
// "acquire" every lock and run the guarded sections concurrently. Let the // Fail open so a Redis outage does not deadlock startup or jobs.
// caller choose between skipping the run and running unlocked. return randomUUID();
throw new LockUnavailableError(err?.message || String(err));
} }
} }
@@ -111,19 +88,8 @@ export class RedisLock implements Lock {
} }
} }
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null> { async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
let token: string | null; const token = await this.acquire(key, ttlMs);
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();
-125
View File
@@ -1,125 +0,0 @@
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();
});
});
-164
View File
@@ -1,164 +0,0 @@
// 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;
}
@@ -1,84 +0,0 @@
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);
});
});
+10 -35
View File
@@ -1,13 +1,10 @@
// 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, implemented as a single // - redis: shared fixed window across all instances (INCR + PEXPIRE)
// 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 {
@@ -27,7 +24,7 @@ interface Bucket {
resetAt: number; resetAt: number;
} }
export class MemoryRateLimiter implements RateLimiter { 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>();
@@ -61,44 +58,22 @@ export class MemoryRateLimiter implements RateLimiter {
// ==================== Redis implementation ==================== // ==================== Redis implementation ====================
// Atomically increments the window counter, sets the expiry on the first hit, class RedisRateLimiter implements RateLimiter {
// 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, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs); const count = await redis.incr(redisKey);
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 };
} }
-42
View File
@@ -1,42 +0,0 @@
// 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);
}
});
}
+11 -18
View File
@@ -1,6 +1,5 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import moment from 'moment-timezone';
/** /**
* Get database type (reads env var each time to handle module loading order) * Get database type (reads env var each time to handle module loading order)
@@ -22,19 +21,6 @@ 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
@@ -73,10 +59,17 @@ export function parseEventDatetime(
return new Date(datetime); return new Date(datetime);
} }
// Interpret the wall-clock digits as local time in `timezone` using // Treat the digits as UTC so we have a stable reference instant.
// moment-timezone's bundled IANA data. This keeps the conversion correct const fakeUTC = new Date(datetime + 'Z');
// regardless of the host Node runtime's (possibly stale) tz database.
return moment.tz(datetime, timezone).toDate(); // Ask Intl what that UTC instant looks like in both UTC and the target tz.
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
// The gap between the two tells us the tz offset at this point in time.
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
return new Date(fakeUTC.getTime() + offsetMs);
} }
/** /**
+4 -46
View File
@@ -3,7 +3,6 @@ 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();
@@ -21,9 +20,8 @@ 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 with seat counts (paid + claimed, per lib/capacity.ts) // Get upcoming events
// so the dashboard's capacity alerts reflect real availability. const upcomingEvents = await dbAll(
const upcomingEventsRaw = await dbAll<any>(
(db as any) (db as any)
.select() .select()
.from(events) .from(events)
@@ -37,24 +35,6 @@ 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)
@@ -90,8 +70,6 @@ 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(*)` })
@@ -99,20 +77,6 @@ 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>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'on_hold'))
);
const revenueRow = await dbGet<any>( const revenueRow = await dbGet<any>(
(db as any) (db as any)
.select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }) .select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` })
@@ -144,8 +108,6 @@ 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,
totalRevenue, totalRevenue,
newContacts: newContacts?.count || 0, newContacts: newContacts?.count || 0,
totalSubscribers: totalSubscribers?.count || 0, totalSubscribers: totalSubscribers?.count || 0,
@@ -251,7 +213,6 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
userName: user?.name, userName: user?.name,
userEmail: user?.email, userEmail: user?.email,
userPhone: user?.phone, userPhone: user?.phone,
attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null,
eventTitle: event?.title, eventTitle: event?.title,
eventDate: event?.startDatetime, eventDate: event?.startDatetime,
paymentStatus: payment?.status, paymentStatus: payment?.status,
@@ -330,7 +291,6 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
'Full Name': fullName, 'Full Name': fullName,
'Email': ticket.attendeeEmail || '', 'Email': ticket.attendeeEmail || '',
'Phone': ticket.attendeePhone || '', 'Phone': ticket.attendeePhone || '',
'RUC': ticket.attendeeRuc || '',
'Status': ticket.status, 'Status': ticket.status,
'Checked In': isCheckedIn ? 'true' : 'false', 'Checked In': isCheckedIn ? 'true' : 'false',
'Check-in Time': ticket.checkinAt || '', 'Check-in Time': ticket.checkinAt || '',
@@ -342,7 +302,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
); );
const columns = [ const columns = [
'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC', 'Ticket ID', 'Full Name', 'Email', 'Phone',
'Status', 'Checked In', 'Check-in Time', 'Payment Status', 'Status', 'Checked In', 'Check-in Time', 'Payment Status',
'Booked At', 'Notes', 'Booked At', 'Notes',
]; ];
@@ -420,13 +380,12 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
}); });
} }
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'RUC', 'Status', 'Check-in Time', 'Booked At']; const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
const rows = ticketList.map((ticket: any) => ({ const rows = ticketList.map((ticket: any) => ({
'Ticket ID': ticket.id, 'Ticket ID': ticket.id,
'Booking ID': ticket.bookingId || '', 'Booking ID': ticket.bookingId || '',
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '), 'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
'RUC': ticket.attendeeRuc || '',
'Status': ticket.status, 'Status': ticket.status,
'Check-in Time': ticket.checkinAt || '', 'Check-in Time': ticket.checkinAt || '',
'Booked At': ticket.createdAt || '', 'Booked At': ticket.createdAt || '',
@@ -499,7 +458,6 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
attendeeFirstName: ticket.attendeeFirstName, attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName, attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
attendeeRuc: ticket.attendeeRuc || null,
eventId: event?.id, eventId: event?.id,
eventTitle: event?.title, eventTitle: event?.title,
eventDate: event?.startDatetime, eventDate: event?.startDatetime,
+719
View File
@@ -0,0 +1,719 @@
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;
-97
View File
@@ -1,97 +0,0 @@
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;
+45 -71
View File
@@ -1,12 +1,18 @@
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 } from '../db/index.js'; import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } 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, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js'; import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
import { auth } from '../lib/betterAuth.js'; import { generateId, getNow } from '../lib/utils.js';
import { authSessions, authAccounts } from '../db/auth-schema.js';
import { getNow } from '../lib/utils.js'; // User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const dashboard = new Hono(); const dashboard = new Hono();
@@ -44,7 +50,7 @@ dashboard.get('/profile', async (c) => {
isClaimed: user.isClaimed, isClaimed: user.isClaimed,
accountStatus: user.accountStatus, accountStatus: user.accountStatus,
hasPassword, hasPassword,
hasGoogleLinked: await hasGoogleAccount(user.id), hasGoogleLinked: !!user.googleId,
memberSince: user.createdAt, memberSince: user.createdAt,
membershipDays, membershipDays,
createdAt: user.createdAt, createdAt: user.createdAt,
@@ -417,77 +423,49 @@ dashboard.get('/invoices', async (c) => {
// ==================== Security Routes ==================== // ==================== Security Routes ====================
// Get active sessions (Better Auth session table; validated per-request so // Get active sessions
// 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 dbAll<any>( const sessions = await getUserSessions(user.id);
(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.updatedAt, lastActiveAt: s.lastActiveAt,
createdAt: s.createdAt, createdAt: s.createdAt,
expiresAt: s.expiresAt,
current: s.id === user.sessionId,
})), })),
}); });
}); });
// Revoke a specific session. Deleting the row is immediately effective: // Revoke a specific session
// 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 (db as any) await invalidateSession(sessionId, user.id);
.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 other sessions (logout everywhere else); the current session // Revoke all sessions (logout everywhere). Bumping the token version invalidates
// stays valid so this device remains signed in. // every previously issued JWT for this user, which is the actual enforcement
// 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 (db as any) await invalidateAllUserSessions(user.id);
.delete(authSessions) await bumpTokenVersion(user.id);
.where(
and(
eq((authSessions as any).userId, user.id),
sql`${(authSessions as any).id} != ${user.sessionId}`
)
);
return c.json({ message: 'All other sessions revoked.' }); // Issue a fresh token so the current device stays signed in
const refreshed = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
return c.json({ message: 'All other sessions revoked.', token });
}); });
// Set password (for users without one) // Set password (for users without one)
@@ -504,21 +482,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);
} }
try { const hashedPassword = await hashPassword(password);
await auth.api.setPassword({ const now = getNow();
body: { newPassword: password },
headers: c.req.raw.headers, await (db as any)
}); .update(users)
} catch (err: any) { .set({
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400); password: hashedPassword,
} updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Password set successfully' }); return c.json({ message: 'Password set successfully' });
}); });
@@ -527,7 +505,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 (!(await hasGoogleAccount(user.id))) { if (!user.googleId) {
return c.json({ error: 'Google account not linked' }, 400); return c.json({ error: 'Google account not linked' }, 400);
} }
@@ -535,18 +513,14 @@ 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);
} }
await (db as any) const now = getNow();
.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({ updatedAt: getNow() }) .set({
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' });
-443
View File
@@ -1,443 +0,0 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Env must be pinned before the db singleton is imported (dotenv never overrides).
const dir = mkdtempSync(join(tmpdir(), 'door-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
// Who the next request is from. Session auth itself is Better Auth's concern and
// has its own integration suite; this mock keeps the *role* check real so the
// tests can prove which endpoints door staff may reach.
let currentUser: { id: string; name: string; role: string } = STAFF;
vi.mock('../lib/auth.js', () => ({
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
if (roles && !roles.includes(currentUser.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', currentUser);
await next();
},
getAuthUser: async () => currentUser,
}));
/** Run one request as a given role, always restoring the default afterwards. */
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
const previous = currentUser;
currentUser = user;
try {
return await fn();
} finally {
currentUser = previous;
}
}
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
vi.mock('../lib/email.js', () => ({
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
}));
let app: any;
let sqlite: any;
const EVENT_ID = 'evt-door-1';
const PRICE = 60000;
/** POST helper that mirrors how the door screen calls the API. */
async function post(path: string, body: unknown) {
const res = await app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
async function get(path: string) {
const res = await app.request(path);
return { status: res.status, body: await res.json() };
}
function seedTicket(row: {
id: string;
first: string;
last?: string | null;
status: string;
paymentStatus: string;
phone?: string | null;
bookingId?: string | null;
qr?: string;
}) {
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 access', () => {
it('is hidden from door staff — whole-event takings are not door information', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
expect(status).toBe(403);
// The numbers must not leak in the body either: hiding the section in the UI
// alone would still expose them to anyone reading the network response.
expect(body).not.toHaveProperty('door');
expect(body).not.toHaveProperty('presale');
});
it('is available to admin and organizer', async () => {
for (const role of [ADMIN, ORGANIZER]) {
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status, `${role.role} should see door takings`).toBe(200);
}
});
it('still lets door staff do their job — list, check in and undo', async () => {
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
// Comp, so this ticket stays out of the revenue totals asserted below and
// the two tests cannot drift into each other through the shared database.
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-role',
idempotencyKey: 'key-role-check',
});
expect(checkin.status).toBe(201);
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-role-check',
});
expect(undo.status).toBe(200);
});
});
describe('door-summary', () => {
it('totals door takings by tender and splits them from pre-sale', async () => {
const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
expect(status).toBe(200);
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
// cancelled and no longer count).
expect(body.door.byMethod.cash.count).toBe(3);
expect(body.door.byMethod.cash.total).toBe(PRICE * 3);
expect(body.door.byMethod.transfer).toEqual({ count: 1, total: PRICE * 2 });
expect(body.door.byMethod.bitcoin).toEqual({ count: 1, total: PRICE });
expect(body.door.byMethod.guest).toEqual({ count: 1, total: 0 });
expect(body.door.total).toBe(PRICE * 6);
// Settled tickets with no door payment against them: tkt-paid, plus tkt-undo,
// whose door check-in was undone and which is a pre-paid ticket again.
expect(body.presale.count).toBe(2);
expect(body.presale.total).toBe(PRICE * 2);
expect(body.total).toBe(PRICE * 8);
expect(body.door.lines.length).toBe(body.door.count);
expect(body.door.lines[0]).toHaveProperty('name');
});
});
-663
View File
@@ -1,663 +0,0 @@
// Door check-in screen (admin/scanner) API.
//
// At the door, check-in and ticket creation are the same action, so everything
// here is written for one-tap speed on a phone with unreliable venue wifi:
//
// GET /:eventId/door-attendees full attendee list, fetched once and searched
// client-side so typing never hits the network
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
// payment, or creates a walk-in, atomically
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
// split (admin/organizer only)
//
// Every write carries a client-generated idempotencyKey. The key is inserted in
// the same transaction as the writes, so a double tap or a retry after a timeout
// can never produce a second ticket, a second payment or a double check-in — the
// replay returns the original response instead.
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq, and, inArray, sql } from 'drizzle-orm';
import {
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
} 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;
// Whole-event money is management information, not door information: door staff
// reconcile their own shift from the session feed the client keeps locally, and
// never see what the event took overall. Matches the existing convention for
// revenue aggregates (admin/export/financial, admin/analytics).
const REVENUE_ROLES = ['admin', 'organizer'] as const;
const IDEMPOTENCY_SCOPE = 'door-checkin';
// ==================== Shared helpers ====================
const num = (v: any): number => {
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
return Number.isFinite(n) ? n : 0;
};
const iso = (v: any): string | null => {
if (!v) return null;
return v instanceof Date ? v.toISOString() : String(v);
};
function fullName(ticket: any): string {
return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
}
/**
* The row shape the door screen renders. Returned both by the preload list and
* by every write, so the client can splice an updated attendee straight back
* into its in-memory list without a refetch.
*/
function toDoorAttendee(
ticket: any,
opts: { price: number; groupBookingIds: Set<string>; adminNames: Map<string, string>; doorMethod?: string | null } ,
) {
return {
ticketId: ticket.id,
firstName: ticket.attendeeFirstName,
lastName: ticket.attendeeLastName || null,
fullName: fullName(ticket),
email: ticket.attendeeEmail || null,
phone: ticket.attendeePhone || null,
status: ticket.status,
paymentStatus: ticket.paymentStatus,
isGuest: !!ticket.isGuest,
checkedIn: ticket.status === 'checked_in',
checkinAt: iso(ticket.checkinAt),
checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null,
bookingId: ticket.bookingId || null,
isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)),
amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0,
doorMethod: opts.doorMethod ?? null,
qrCode: ticket.qrCode || null,
createdAt: iso(ticket.createdAt),
};
}
async function loadEvent(eventId: string | undefined) {
if (!eventId) return null;
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) return null;
return {
...event,
price: num(event.price),
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([...REVENUE_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;
+55 -61
View File
@@ -7,7 +7,6 @@ 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;
@@ -172,13 +171,6 @@ 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
@@ -202,53 +194,38 @@ 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 (whereClause) query = query.where(whereClause); if (conditions.length > 0) {
query = query.orderBy(desc((events as any).startDatetime)); query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
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); const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
// Single grouped query for seat counts across all events (avoids N+1: previously // Single grouped query for booked counts across all events (avoids N+1: previously
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in); // this ran one COUNT query per event).
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats, const countRows = await dbAll<any>(
// so availableSeats subtracts them together — the same formula the booking-creation (db as any)
// capacity check enforces (lib/capacity.ts). .select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db)); .from(tickets)
const countByEvent = new Map<string, { paid: number; claimed: number }>(); .where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
.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, { countByEvent.set(row.eventId, Number(row.count) || 0);
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 counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 }; const bookedCount = countByEvent.get(event.id) || 0;
return { return {
...normalized, ...normalized,
bookedCount: counts.paid, bookedCount,
claimedCount: counts.claimed, availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
}; };
}); });
return paginated return c.json({ events: eventsWithCounts });
? 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
@@ -269,14 +246,27 @@ 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 counts = await getEventSeatCounts(event.id); const bookedCount = ticketCount?.count || 0;
return c.json({ return c.json({
event: { event: {
...normalized, ...normalized,
bookedCount: counts.paid, bookedCount,
claimedCount: counts.claimed, availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
}, },
}); });
}); });
@@ -288,14 +278,20 @@ async function getSiteTimezone(): Promise<string> {
return settings?.timezone || 'America/Asuncion'; return settings?.timezone || 'America/Asuncion';
} }
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat // Helper function to get ticket count for an event
// counts for one event — see lib/capacity.ts for the seat-holding rule. async function getEventTicketCount(eventId: string): Promise<number> {
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> { const ticketCount = await dbGet<any>(
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId)); (db as any)
return { .select({ count: sql<number>`count(*)` })
paid: Number(row?.paidCount) || 0, .from(tickets)
claimed: Number(row?.claimedCount) || 0, .where(
}; 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)
@@ -319,13 +315,12 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
return null; return null;
} }
const counts = await getEventSeatCounts(event.id); const bookedCount = await getEventTicketCount(event.id);
const normalized = normalizeEvent(event); const normalized = normalizeEvent(event);
return { return {
...normalized, ...normalized,
bookedCount: counts.paid, bookedCount,
claimedCount: counts.claimed, availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
}; };
} }
@@ -388,14 +383,13 @@ 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 counts = await getEventSeatCounts(featuredEvent.id); const bookedCount = await getEventTicketCount(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent); const normalized = normalizeEvent(featuredEvent);
return c.json({ return c.json({
event: { event: {
...normalized, ...normalized,
bookedCount: counts.paid, bookedCount,
claimedCount: counts.claimed, availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
isFeatured: true, isFeatured: true,
}, },
}); });
+16 -201
View File
@@ -1,18 +1,12 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming'; import { streamSSE } from 'hono/streaming';
import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js'; import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
import { eq, and, inArray } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { getNow, toDbDate } from '../lib/utils.js'; import { getNow } from '../lib/utils.js';
import { import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
verifyWebhookPayment,
getPaymentStatus,
createInvoice,
isLNbitsConfigured,
LNBITS_INVOICE_EXPIRY_SECONDS,
} 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, LockUnavailableError } from '../lib/stores/lock.js'; import { getLock } from '../lib/stores/lock.js';
const lnbitsRouter = new Hono(); const lnbitsRouter = new Hono();
@@ -106,26 +100,12 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
const expiryMs = expirySeconds * 1000; const expiryMs = expirySeconds * 1000;
let lockToken: string | null = null; const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
let lockUnavailable = false; if (!lockToken) {
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;
@@ -284,13 +264,11 @@ 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) {
const result: any = await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed', paymentStatus: 'paid' }) .set({ status: 'confirmed' })
.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)
@@ -305,14 +283,6 @@ 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)
@@ -396,7 +366,8 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
} }
}, 15000); }, 15000);
const cleanup = () => { // Clean up on disconnect
stream.onAbort(() => {
clearInterval(heartbeat); clearInterval(heartbeat);
const connections = activeConnections.get(ticketId); const connections = activeConnections.get(ticketId);
if (connections) { if (connections) {
@@ -411,171 +382,15 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
} }
} }
} }
}; });
// Clean up on disconnect // Keep the stream open
stream.onAbort(cleanup); while (true) {
await stream.sleep(30000);
// Keep the stream open, and every 15s fall back to reading the ticket
// status from the DB. Pub/sub is best-effort: a message published while the
// subscriber connection was reconnecting is lost, and without this check
// the client would wait forever on a payment that already confirmed.
try {
while (true) {
await stream.sleep(15000);
const current = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
);
if (current?.status === 'confirmed') {
await sendEvent({ type: 'paid', ticketId });
return;
}
}
} finally {
cleanup();
} }
}); });
}); });
/**
* Get a Lightning invoice for a ticket to pay or re-pay.
*
* Reuses the stored invoice if it still has more than 5 minutes of validity
* left; otherwise generates a fresh one from LNbits. This is what lets a user
* come back to an unpaid Lightning booking later (e.g. from "Pay now" on the
* dashboard) instead of hitting a dead end.
*/
lnbitsRouter.post('/invoice/:ticketId', async (c) => {
const ticketId = c.req.param('ticketId');
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
);
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (payment.provider !== 'lightning') {
return c.json({ error: 'This booking is not a Lightning payment' }, 400);
}
if (ticket.status === 'confirmed' || payment.status === 'paid') {
return c.json({ alreadyPaid: true });
}
if (ticket.status !== 'pending' || payment.status !== 'pending') {
return c.json({
error: 'This booking is no longer active. Please make a new booking.',
}, 400);
}
// Gather every ticket/payment in the booking group - a multi-ticket booking
// shares a single Lightning invoice for the combined total.
let groupTickets: any[] = [ticket];
if (ticket.bookingId) {
groupTickets = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
const groupPayments = await dbAll<any>(
(db as any).select().from(payments).where(inArray((payments as any).ticketId, groupTickets.map((t: any) => t.id)))
);
const invoiceHolder = groupPayments.find((p: any) => p.reference) || payment;
const totalAmount = groupPayments.reduce((sum: number, p: any) => sum + Number(p.amount), 0);
const currency = invoiceHolder.currency;
const now = Date.now();
const FIVE_MIN_MS = 5 * 60 * 1000;
const expiresAtMs = invoiceHolder.lnbitsExpiresAt ? new Date(invoiceHolder.lnbitsExpiresAt).getTime() : 0;
if (invoiceHolder.reference && invoiceHolder.lnbitsInvoice && expiresAtMs - now > FIVE_MIN_MS) {
return c.json({
invoice: {
paymentHash: invoiceHolder.reference,
paymentRequest: invoiceHolder.lnbitsInvoice,
amount: invoiceHolder.lnbitsAmountSats || 0,
fiatAmount: totalAmount,
fiatCurrency: currency,
expiresAt: invoiceHolder.lnbitsExpiresAt,
},
reused: true,
});
}
// No usable stored invoice (never created, expired, or expiring soon) - get a fresh one.
if (!isLNbitsConfigured()) {
return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400);
}
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
);
const apiUrl = process.env.API_URL || 'http://localhost:3001';
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
const webhookUrl = webhookSecret
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
: `${apiUrl}/api/lnbits/webhook`;
const attendeeName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
try {
const lnbitsInvoice = await createInvoice({
amount: totalAmount,
unit: currency,
memo: `Spanglish: ${event?.title || 'Event'} - ${attendeeName}${groupTickets.length > 1 ? ` (${groupTickets.length} tickets)` : ''}`,
webhookUrl,
expiry: LNBITS_INVOICE_EXPIRY_SECONDS,
extra: {
ticketId: invoiceHolder.ticketId,
bookingId: ticket.bookingId || null,
ticketIds: groupTickets.map((t: any) => t.id),
eventId: ticket.eventId,
eventTitle: event?.title,
attendeeName,
attendeeEmail: ticket.attendeeEmail,
ticketCount: groupTickets.length,
},
});
const lnbitsExpiresAt = toDbDate(new Date(now + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
await (db as any)
.update(payments)
.set({
reference: lnbitsInvoice.paymentHash,
lnbitsInvoice: lnbitsInvoice.paymentRequest,
lnbitsExpiresAt,
lnbitsAmountSats: lnbitsInvoice.amount,
updatedAt: getNow(),
})
.where(eq((payments as any).id, invoiceHolder.id));
return c.json({
invoice: {
paymentHash: lnbitsInvoice.paymentHash,
paymentRequest: lnbitsInvoice.paymentRequest,
amount: lnbitsInvoice.amount,
fiatAmount: lnbitsInvoice.fiatAmount ?? totalAmount,
fiatCurrency: lnbitsInvoice.fiatCurrency ?? currency,
expiresAt: lnbitsExpiresAt,
},
reused: false,
});
} catch (error: any) {
console.error('Failed to create Lightning invoice:', error);
return c.json({
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`,
}, 500);
}
});
/** /**
* Get payment status for a ticket (fallback polling endpoint) * Get payment status for a ticket (fallback polling endpoint)
*/ */
+28 -210
View File
@@ -2,16 +2,15 @@ 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, payments, tickets, events } from '../db/index.js'; import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
import { eq, desc, and, or, sql, inArray } from 'drizzle-orm'; import { eq, desc, and, or, sql } 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 emailService from '../lib/email.js'; import emailService from '../lib/email.js';
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
const paymentsRouter = new Hono(); const paymentsRouter = new Hono();
const updatePaymentSchema = z.object({ const updatePaymentSchema = z.object({
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']), status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
reference: z.string().optional(), reference: z.string().optional(),
adminNote: z.string().optional(), adminNote: z.string().optional(),
}); });
@@ -19,9 +18,6 @@ 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({
@@ -29,10 +25,6 @@ const rejectPaymentSchema = z.object({
sendEmail: z.boolean().optional().default(true), sendEmail: z.boolean().optional().default(true),
}); });
const reopenPaymentSchema = z.object({
adminNote: z.string().optional(),
});
// Get all payments (admin) - with ticket and event details // Get all payments (admin) - with ticket and event details
paymentsRouter.get('/', requireAuth(['admin']), async (c) => { paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status'); const status = c.req.query('status');
@@ -93,7 +85,6 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
attendeeLastName: ticket.attendeeLastName, attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail, attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone, attendeePhone: ticket.attendeePhone,
attendeeRuc: ticket.attendeeRuc,
status: ticket.status, status: ticket.status,
} : null, } : null,
event: event ? { event: event ? {
@@ -173,13 +164,12 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id // Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, onHoldRow, revenueRow] = await Promise.all([ const [totalRow, pendingRow, paidRow, refundedRow, failedRow, revenueRow] = await Promise.all([
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))), dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))),
dbGet<any>((db as any).select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))), dbGet<any>((db as any).select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))),
]); ]);
@@ -190,7 +180,6 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
paid: Number(paidRow?.count || 0), paid: Number(paidRow?.count || 0),
refunded: Number(refundedRow?.count || 0), refunded: Number(refundedRow?.count || 0),
failed: Number(failedRow?.count || 0), failed: Number(failedRow?.count || 0),
onHold: Number(onHoldRow?.count || 0),
totalRevenue: Number(revenueRow?.total || 0), totalRevenue: Number(revenueRow?.total || 0),
}, },
}); });
@@ -239,12 +228,6 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
} }
// Confirming a failed payment must go through /approve, which re-checks event
// capacity before re-reserving the (previously released) seat. Block the raw path.
if (data.status === 'paid' && existing.status === 'failed') {
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
}
const now = getNow(); const now = getNow();
const updateData: any = { ...data, updatedAt: now }; const updateData: any = { ...data, updatedAt: now };
@@ -288,7 +271,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
await (db as any) await (db as any)
.update(tickets) .update(tickets)
.set({ status: 'confirmed', paymentStatus: 'paid' }) .set({ status: 'confirmed' })
.where(eq((tickets as any).id, (t as any).id)); .where(eq((tickets as any).id, (t as any).id));
} }
@@ -320,7 +303,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, allowOverCapacity } = c.req.valid('json'); const { adminNote, sendEmail } = 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,14 +317,13 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
} }
// Can approve pending, pending_approval, on_hold, or failed payments. // Can approve pending or pending_approval payments
// 'failed' covers an admin confirming a payment that was auto-failed or rejected if (!['pending', 'pending_approval'].includes(payment.status)) {
// in error; its tickets are cancelled, so recovery re-checks capacity below.
// Bare 'pending' covers customers who paid but never clicked "I've paid".
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400); 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)
@@ -364,54 +346,24 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`); console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
} }
// For a failed payment, only recover tickets whose own payment is also failed. // Update all payments in the booking to paid
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being for (const t of ticketsToConfirm) {
// resurrected or having its payment flipped to paid. await (db as any)
if (payment.status === 'failed') { .update(payments)
const bookingPayments = await dbAll<any>( .set({
(db as any) status: 'paid',
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status }) paidAt: now,
.from(payments)
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
);
const failedTicketIds = new Set(
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
);
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
if (ticketsToConfirm.length === 0) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
}
}
// Confirm the booking through the shared capacity-checked reservation.
// Tickets that already hold a seat ('pending_approval' claims) cost no new
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
// When the event is full, the admin gets a structured over-capacity error and
// may retry with allowOverCapacity to knowingly overbook.
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToConfirm.map((t: any) => t.id),
'confirmed',
'paid',
{
paidByAdminId: user.id, paidByAdminId: user.id,
fromTicketStatuses: adminNote: adminNote || payment.adminNote,
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'], updatedAt: now,
skipCapacityCheck: allowOverCapacity, })
...(adminNote ? { extraPaymentFields: { adminNote } } : {}), .where(eq((payments as any).ticketId, (t as any).id));
}
); // Update ticket status to confirmed
} catch (err) { await (db as any)
if (err instanceof HoldCapacityError) { .update(tickets)
return c.json({ .set({ status: 'confirmed' })
error: 'Approving this payment puts the event over capacity.', .where(eq((tickets as any).id, (t as any).id));
code: 'EVENT_OVER_CAPACITY',
availableSeats: err.available,
requestedSeats: ticketsToConfirm.length,
}, 409);
}
throw err;
} }
// Send confirmation emails asynchronously (if sendEmail is true, which is the default) // Send confirmation emails asynchronously (if sendEmail is true, which is the default)
@@ -453,7 +405,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
return c.json({ error: 'Payment not found' }, 404); return c.json({ error: 'Payment not found' }, 404);
} }
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) { if (!['pending', 'pending_approval'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400); return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
} }
@@ -512,140 +464,6 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' }); return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
}); });
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (payment.status !== 'on_hold') {
return c.json({ error: 'Only on-hold payments can be reactivated' }, 400);
}
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
let ticketsToReactivate: any[] = [ticket];
if (ticket.bookingId) {
ticketsToReactivate = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToReactivate.map((t: any) => t.id),
'pending',
'pending_approval'
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
const updated = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
});
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
// For when a payment was failed in error (auto-fail or rejection) and should
// return to the normal pending flow (reminders, "I've paid", approve/reject).
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote } = c.req.valid('json');
const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (payment.status !== 'failed') {
return c.json({ error: 'Only failed payments can be reopened' }, 400);
}
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
let ticketsToReopen: any[] = [ticket];
if (ticket.bookingId) {
ticketsToReopen = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
const bookingPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
.from(payments)
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
);
const failedTicketIds = new Set(
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
);
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
if (ticketsToReopen.length === 0) {
return c.json({ error: 'Only failed payments can be reopened' }, 400);
}
const reopenIds = ticketsToReopen.map((t: any) => t.id);
try {
await reserveOnHoldBooking(
ticket.eventId,
reopenIds,
'pending',
'pending',
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
if (adminNote) {
await (db as any)
.update(payments)
.set({ adminNote })
.where(inArray((payments as any).ticketId, reopenIds));
}
const updated = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
});
// Send payment reminder email // Send payment reminder email
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => { paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
@@ -667,7 +485,7 @@ paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), a
} }
try { try {
const result = await emailService.sendPaymentReminder(payment.id); const result = await emailService.sendPaymentReminder(id);
if (result.success) { if (result.success) {
const now = getNow(); const now = getNow();
+260 -278
View File
@@ -4,13 +4,11 @@ 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, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { createInvoice, isLNbitsConfigured } 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 { seatHolderCountQuery } from '../lib/capacity.js';
const ticketsRouter = new Hono(); const ticketsRouter = new Hono();
@@ -32,19 +30,11 @@ const createTicketSchema = z.object({
preferredLanguage: z.enum(['en', 'es']).optional(), preferredLanguage: z.enum(['en', 'es']).optional(),
// 'bancard' intentionally excluded: no checkout integration exists for it // 'bancard' intentionally excluded: no checkout integration exists for it
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'), paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
// Base + optional "-" + check digit; digits-only kept for older clients, normalized to dashed form on save ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
ruc: z.string().regex(/^(\d{6,10}|\d{5,8}-\d)$/, 'Invalid RUC format').optional().or(z.literal('')),
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING) // Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(), attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
}); });
// Canonical stored RUC form is "base-checkdigit" (e.g. 1234567-9); older clients send digits only
function normalizeRuc(ruc: string | undefined): string | null {
if (!ruc) return null;
if (ruc.includes('-')) return ruc;
return `${ruc.slice(0, -1)}-${ruc.slice(-1)}`;
}
// Maps a payment provider to the merged payment-option flag that enables it // Maps a payment provider to the merged payment-option flag that enables it
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean { function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
const truthy = (v: any) => v === true || v === 1; const truthy = (v: any) => v === true || v === 1;
@@ -63,7 +53,7 @@ function isPaymentMethodEnabled(method: string, merged: Record<string, any>): bo
} }
const updateTicketSchema = z.object({ const updateTicketSchema = z.object({
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold']).optional(), status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
adminNote: z.string().optional(), adminNote: z.string().optional(),
}); });
@@ -85,7 +75,6 @@ const adminCreateTicketSchema = z.object({
// Book a ticket (public) - supports single or multi-ticket bookings // Book a ticket (public) - supports single or multi-ticket bookings
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const data = c.req.valid('json'); const data = c.req.valid('json');
const rucNumber = normalizeRuc(data.ruc);
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields) // Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
const attendeesList = data.attendees && data.attendees.length > 0 const attendeesList = data.attendees && data.attendees.length > 0
@@ -129,10 +118,19 @@ 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 against held seats (paid/checked-in tickets plus claimed // Check capacity - count pending, confirmed AND checked_in tickets.
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat. // Pending reservations must hold seats to prevent overselling via unpaid bookings
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
const existingTicketCount = await dbGet<any>( const existingTicketCount = await dbGet<any>(
seatHolderCountQuery(db, data.eventId) (db as any)
.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;
@@ -148,12 +146,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}, 400); }, 400);
} }
// Find or create user. The account row is keyed on the normalized address so // Find or create user
// 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, accountEmail)) (db as any).select().from(users).where(eq((users as any).email, data.email))
); );
const now = getNow(); const now = getNow();
@@ -166,28 +161,16 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
const userId = generateId(); const userId = generateId();
user = { user = {
id: userId, id: userId,
email: accountEmail, email: data.email,
password: null, // No password for guest bookings; set on claim (Better Auth credential account) password: '', // No password for guest bookings
name: fullName, name: fullName,
phone: data.phone || null, phone: data.phone || null,
role: 'user', role: 'user',
languagePreference: null, languagePreference: null,
rucNumber,
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);
} else if (rucNumber) {
// Keep the user's saved RUC up to date for future bookings, but never blank
// out an existing value if this booking didn't include one.
await (db as any)
.update(users)
.set({ rucNumber, updatedAt: now })
.where(eq((users as any).id, user.id));
user.rucNumber = rucNumber;
} }
// Check for duplicate booking (unless allowDuplicateBookings is enabled) // Check for duplicate booking (unless allowDuplicateBookings is enabled)
@@ -228,7 +211,16 @@ 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 = seatHolderCountQuery(tx, data.eventId).get(); const countRow = 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')`
)
)
.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');
@@ -251,7 +243,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null, attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email, attendeeEmail: data.email,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: rucNumber, attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null, preferredLanguage: data.preferredLanguage || null,
status: 'pending', status: 'pending',
qrCode, qrCode,
@@ -279,7 +271,17 @@ 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>(seatHolderCountQuery(tx, data.eventId)); const countRow = await dbGet<any>(
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');
@@ -302,7 +304,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null, attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email, attendeeEmail: data.email,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: rucNumber, attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null, preferredLanguage: data.preferredLanguage || null,
status: 'pending', status: 'pending',
qrCode, qrCode,
@@ -367,7 +369,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', paymentStatus: 'paid' }) .set({ status: 'confirmed' })
.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)
@@ -406,7 +408,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc. unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`, memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
webhookUrl, webhookUrl,
expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 15 minutes expiry for faster UX expiry: 900, // 15 minutes expiry for faster UX
extra: { extra: {
ticketId: primaryTicket.id, ticketId: primaryTicket.id,
bookingId: ticketCount > 1 ? bookingId : null, bookingId: ticketCount > 1 ? bookingId : null,
@@ -419,19 +421,10 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}, },
}); });
const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000)); // Update primary payment with LNbits payment hash reference
// Update primary payment with the LNbits invoice - the BOLT11 string and
// expiry are persisted so the "Pay now" page can redisplay this same
// invoice later instead of erroring out on an unpaid Lightning booking.
await (db as any) await (db as any)
.update(payments) .update(payments)
.set({ .set({ reference: lnbitsInvoice.paymentHash })
reference: lnbitsInvoice.paymentHash,
lnbitsInvoice: lnbitsInvoice.paymentRequest,
lnbitsExpiresAt,
lnbitsAmountSats: lnbitsInvoice.amount,
})
.where(eq((payments as any).id, primaryPayment.id)); .where(eq((payments as any).id, primaryPayment.id));
(primaryPayment as any).reference = lnbitsInvoice.paymentHash; (primaryPayment as any).reference = lnbitsInvoice.paymentHash;
@@ -551,24 +544,20 @@ 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'; id: ticket.id,
return { qrCode: ticket.qrCode,
id: ticket.id, attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
qrCode: ticket.qrCode, attendeeEmail: ticket.attendeeEmail,
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), event: {
attendeeEmail: ticket.attendeeEmail, title: event.title,
event: { startDatetime: event.startDatetime,
title: locale === 'es' && event.titleEs ? event.titleEs : event.title, endDatetime: event.endDatetime,
startDatetime: event.startDatetime, location: event.location,
endDatetime: event.endDatetime, locationUrl: event.locationUrl,
location: event.location, },
locationUrl: event.locationUrl, timezone,
}, }));
timezone,
locale,
};
});
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData); const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
@@ -632,22 +621,19 @@ 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: locale === 'es' && event.titleEs ? event.titleEs : event.title, title: 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
@@ -1015,9 +1001,6 @@ 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,
}, },
@@ -1098,9 +1081,7 @@ 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);
} }
// Confirmed/checked-in tickets can still be marked paid when they carry an if (ticket.status === 'confirmed') {
// 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);
} }
@@ -1123,45 +1104,24 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
); );
} }
if (ticket.status === 'on_hold') { // Confirm all tickets in the booking
// The seat was released when this booking went on hold - re-check capacity for (const t of ticketsToConfirm) {
// before confirming it directly. // Update ticket status
try { await (db as any)
await reserveOnHoldBooking( .update(tickets)
ticket.eventId, .set({ status: 'confirmed' })
ticketsToConfirm.map((t: any) => t.id), .where(eq((tickets as any).id, t.id));
'confirmed',
'paid',
{ paidByAdminId: user.id }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
} else {
// Confirm all tickets in the booking (checked-in tickets keep their status)
for (const t of ticketsToConfirm) {
// Update ticket status
await (db as any)
.update(tickets)
.set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' })
.where(eq((tickets as any).id, t.id));
// Update payment status // Update payment status
await (db as any) await (db as any)
.update(payments) .update(payments)
.set({ .set({
status: 'paid', status: 'paid',
paidAt: now, paidAt: now,
paidByAdminId: user.id, paidByAdminId: user.id,
updatedAt: now, updatedAt: now,
}) })
.where(eq((payments as any).ticketId, t.id)); .where(eq((payments as any).ticketId, t.id));
}
} }
// Get payment for sending receipt // Get payment for sending receipt
@@ -1174,7 +1134,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
// Send confirmation emails asynchronously (don't block the response) // Send confirmation emails asynchronously (don't block the response)
Promise.all([ Promise.all([
emailService.sendBookingConfirmation(ticket.id), emailService.sendBookingConfirmation(id),
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(), payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
]).catch(err => { ]).catch(err => {
console.error('[Email] Failed to send confirmation emails:', err); console.error('[Email] Failed to send confirmation emails:', err);
@@ -1241,43 +1201,6 @@ ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, wind
}); });
} }
// A booking that was auto-released after the hold threshold: recover it by
// re-reserving the seat(s) and moving back into the admin approval queue.
if (payment.status === 'on_hold') {
let ticketsToRecover: any[] = [ticket];
if (ticket.bookingId) {
ticketsToRecover = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToRecover.map((t: any) => t.id),
'pending',
'pending_approval',
{ extraPaymentFields: { userMarkedPaidAt: getNow(), payerName: payerName?.trim() || null } }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
const recoveredPayment = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).id, payment.id))
);
return c.json({
payment: recoveredPayment,
message: 'Payment marked as sent. Waiting for admin approval.',
});
}
// Only allow if currently pending // Only allow if currently pending
if (payment.status !== 'pending') { if (payment.status !== 'pending') {
return c.json({ error: 'Payment has already been processed' }, 400); return c.json({ error: 'Payment has already been processed' }, 400);
@@ -1428,10 +1351,9 @@ 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 (see the note on `accountEmail` in the booking route) // Find or create user
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, accountEmail)) (db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
); );
const adminFullName = data.lastName && data.lastName.trim() const adminFullName = data.lastName && data.lastName.trim()
@@ -1442,15 +1364,12 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
const userId = generateId(); const userId = generateId();
user = { user = {
id: userId, id: userId,
email: accountEmail, email: attendeeEmail,
password: null, password: '',
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,
}; };
@@ -1493,7 +1412,6 @@ 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,
@@ -1537,33 +1455,148 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
}, 201); }, 201);
}); });
// Unified admin add-attendee endpoint backing the single Add Ticket modal. // Admin create manual ticket (sends confirmation email + ticket to attendee)
// type drives payment handling: ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
// 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(),
type: z.enum(['paid', 'door', 'unpaid', 'guest']), firstName: z.string().min(2),
// Door walk-ins can be logged with nothing filled in, so firstName is only lastName: z.string().optional().or(z.literal('')),
// required for the other types email: z.string().email('Valid email is required for manual tickets'),
firstName: z.string().optional().or(z.literal('')), phone: 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');
@@ -1574,48 +1607,40 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
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`;
// Nameless door walk-ins still need a display name on the ticket // Find or create user (use placeholder email if none provided)
const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in'; const attendeeEmail = data.email && data.email.trim()
? data.email.trim()
: `guest-${generateId()}@guestinvite.local`;
const fullName = data.lastName && data.lastName.trim() const fullName = data.lastName && data.lastName.trim()
? `${firstName} ${data.lastName.trim()}` ? `${data.firstName} ${data.lastName}`.trim()
: firstName; : data.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, accountEmail)) (db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
); );
if (!user) { if (!user) {
const userId = generateId(); const userId = generateId();
user = { user = {
id: userId, id: userId,
email: accountEmail, email: attendeeEmail,
password: null, password: '',
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 when a real email was provided) // Check for existing active ticket (only for real emails, not placeholder)
if (hasEmail) { if (data.email && data.email.trim()) {
const existingTicket = await dbGet<any>( const existingTicket = await dbGet<any>(
(db as any) (db as any)
.select() .select()
@@ -1634,100 +1659,57 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
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: firstName, attendeeFirstName: data.firstName,
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
attendeeEmail: hasEmail ? data.email!.trim() : null, attendeeEmail: data.email && data.email.trim() ? 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: data.checkinNow ? 'checked_in' : 'confirmed', status: 'confirmed',
isGuest: data.type === 'guest' ? 1 : 0, isGuest: 1,
paymentStatus,
qrCode, qrCode,
checkinAt: data.checkinNow ? now : null, checkinAt: 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);
// Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid // Create a $0 payment record to track the invite
const paymentId = generateId(); const paymentId = generateId();
const newPayment = data.type === 'unpaid' const newPayment = {
? { id: paymentId,
id: paymentId, ticketId,
ticketId, provider: 'cash',
provider: 'tpago', amount: 0,
amount: event.price, currency: event.currency,
currency: event.currency, status: 'paid',
status: 'pending', reference: 'Guest invite',
reference: 'Unpaid ticket — collect at door', paidAt: now,
paidAt: null, paidByAdminId: adminUser?.id || null,
paidByAdminId: null, createdAt: now,
createdAt: now, updatedAt: now,
updatedAt: now, };
}
: {
id: paymentId,
ticketId,
provider: 'cash',
amount: data.type === 'guest' ? 0 : event.price,
currency: event.currency,
status: 'paid',
reference: data.type === 'guest'
? 'Guest invite'
: data.type === 'door'
? 'Paid at door'
: 'Manual ticket',
paidAt: now,
paidByAdminId: adminUser?.id || null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment); await (db as any).insert(payments).values(newPayment);
// Emails (asynchronous): paid always confirms; door/guest confirm only when an // Send booking confirmation email if a real email was provided
// email exists; unpaid sends the TPago (Bancard) pay-link instructions instead if (data.email && data.email.trim()) {
if (data.type === 'unpaid') {
if (hasEmail) {
emailService.sendPaymentInstructions(ticketId).then(result => {
if (!result.success) {
console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error);
}
}).catch(err => {
console.error('[Email] Exception sending pay link for unpaid ticket:', err);
});
}
} else if (data.type === 'paid' || hasEmail) {
emailService.sendBookingConfirmation(ticketId).then(result => { emailService.sendBookingConfirmation(ticketId).then(result => {
if (!result.success) { if (result.success) {
console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error); console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
} else {
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
} }
}).catch(err => { }).catch(err => {
console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err); console.error('[Email] Exception sending booking confirmation for guest 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: {
...newTicket, ...newTicket,
@@ -1738,7 +1720,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
}, },
}, },
payment: newPayment, payment: newPayment,
message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type], message: 'Guest ticket created successfully',
}, 201); }, 201);
}); });
+7 -63
View File
@@ -2,10 +2,9 @@ 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, 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 } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js'; import { requireAuth } from '../lib/auth.js';
import { authSessions } from '../db/auth-schema.js'; import { getNow } from '../lib/utils.js';
import { getNow, toDbDate } from '../lib/utils.js';
interface UserContext { interface UserContext {
id: string; id: string;
@@ -28,42 +27,6 @@ const updateUserSchema = z.object({
// Get all users (admin only) // Get all users (admin only)
usersRouter.get('/', requireAuth(['admin']), async (c) => { usersRouter.get('/', requireAuth(['admin']), async (c) => {
const role = c.req.query('role'); const role = c.req.query('role');
const search = c.req.query('search');
const accountStatus = c.req.query('accountStatus');
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
const registeredAfter = c.req.query('registeredAfter');
const registeredBefore = c.req.query('registeredBefore');
const eventId = c.req.query('eventId');
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
const conditions: any[] = [];
if (role) conditions.push(eq((users as any).role, role));
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter)));
if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore)));
if (search) {
const like = `%${search.toLowerCase()}%`;
conditions.push(sql`(
LOWER(${(users as any).name}) LIKE ${like}
OR LOWER(${(users as any).email}) LIKE ${like}
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
)`);
}
if (hasBookings === 'yes') {
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
} else if (hasBookings === 'no') {
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
}
if (eventId) {
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const totalQuery = whereClause
? (db as any).select({ count: sql<number>`count(*)` }).from(users).where(whereClause)
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
const totalRow = await dbGet<any>(totalQuery);
let query = (db as any).select({ let query = (db as any).select({
id: (users as any).id, id: (users as any).id,
@@ -78,15 +41,13 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
createdAt: (users as any).createdAt, createdAt: (users as any).createdAt,
}).from(users); }).from(users);
if (whereClause) { if (role) {
query = query.where(whereClause); query = query.where(eq((users as any).role, role));
} }
const result = await dbAll( const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize)
);
return c.json({ users: result, total: Number(totalRow?.count || 0), page, pageSize }); return c.json({ users: result });
}); });
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id // Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
@@ -176,28 +137,11 @@ 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, ...statusMirror, updatedAt: getNow() }) .set({ ...data, 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({
+2 -2
View File
@@ -8,9 +8,9 @@
"skipLibCheck": true, "skipLibCheck": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"declaration": false, "declaration": true,
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"] "exclude": ["node_modules", "dist"]
} }
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
-23
View File
@@ -63,29 +63,6 @@ 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;
+2 -15
View File
@@ -28,20 +28,7 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# noeviction is deliberate: rate-limit, lock, and lockout keys must never command: ["redis-server", "--appendonly", "yes"]
# 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:
@@ -59,7 +46,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_PASSWORD:-change-me-redis-password}@redis:6379" REDIS_URL: redis://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.
+1 -36
View File
@@ -18,28 +18,11 @@ server {
} }
} }
# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a
# 301 so only one host is served and indexed.
server { server {
listen 443 ssl; listen 443 ssl;
http2 on; http2 on;
server_name www.spanglishcommunity.com; server_name spanglishcommunity.com www.spanglishcommunity.com;
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
return 301 https://spanglishcommunity.com$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name spanglishcommunity.com;
# Upload size limit (covers same-origin /api uploads via this vhost) # Upload size limit (covers same-origin /api uploads via this vhost)
client_max_body_size 20m; client_max_body_size 20m;
@@ -79,24 +62,6 @@ 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;
-109
View File
@@ -1,109 +0,0 @@
# ============================================================
# 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;
}
}
-146
View File
@@ -1,146 +0,0 @@
# ============================================================
# 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;
}
}
-115
View File
@@ -1,115 +0,0 @@
# ============================================================
# 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;
}
}
-108
View File
@@ -1,108 +0,0 @@
# ============================================================
# 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;
}
}
@@ -1,12 +0,0 @@
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;
-30
View File
@@ -1,30 +0,0 @@
[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
-4
View File
@@ -5,7 +5,3 @@ 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;
}
-5
View File
@@ -7,11 +7,6 @@ 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)
-11
View File
@@ -4,10 +4,6 @@
// 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(',')
@@ -24,9 +20,6 @@ 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).
@@ -46,10 +39,6 @@ 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*`,
-1
View File
@@ -14,7 +14,6 @@
"@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 { useEffect, useState, Suspense } from 'react'; import { useState, Suspense } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, 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';
@@ -9,48 +9,29 @@ 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 { refreshUser } = useAuth(); const { setAuthData } = 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: '',
}); });
useEffect(() => { const token = searchParams.get('token');
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;
@@ -68,8 +49,8 @@ function ClaimAccountContent() {
setLoading(true); setLoading(true);
try { try {
await authApi.confirmClaimAccount(formData.password); const result = await authApi.confirmClaimAccount(token, { password: formData.password });
await refreshUser(); setAuthData({ user: result.user, token: result.token });
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) {
@@ -79,21 +60,7 @@ function ClaimAccountContent() {
} }
}; };
if (checking) { if (!token) {
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">
@@ -109,8 +76,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. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.' ? 'Este enlace de activación no es válido o ha expirado.'
: 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'} : 'This activation link is invalid or has expired.'}
</p> </p>
<Link href="/login"> <Link href="/login">
<Button> <Button>
@@ -6,8 +6,6 @@ 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() {
@@ -20,7 +18,6 @@ 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)
@@ -37,17 +34,11 @@ function MagicLinkContent() {
const verifyToken = async () => { const verifyToken = async () => {
try { try {
const user = await loginWithMagicLink(token!); 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(() => {
redirectAfterAuth(destination); router.push('/dashboard');
}, 1500); }, 1500);
} catch (err: any) { } catch (err: any) {
setStatus('error'); setStatus('error');
@@ -1,21 +1,22 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ticketsApi } from '@/lib/api'; import { ticketsApi } from '@/lib/api';
import type { BookingStep } from '../_types';
/** /**
* Watch for Lightning payment confirmation while an invoice is on screen. * Watch for Lightning payment confirmation while on the paying step.
* SSE gives instant updates; a 3s poll runs in parallel as a safety net so a * SSE gives instant updates; a 3s poll runs in parallel as a safety net so a
* buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI. * buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI.
*/ */
export function useLightningWatcher( export function useLightningWatcher(
active: boolean, step: BookingStep,
ticketId: string | undefined, ticketId: string | undefined,
locale: string, locale: string,
onPaid: () => void, setPaymentPending: (value: boolean) => void,
onExpired: () => void setStep: (value: BookingStep) => void
) { ) {
useEffect(() => { useEffect(() => {
if (!active || !ticketId) return; if (step !== 'paying' || !ticketId) return;
let settled = false; let settled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null; let pollTimer: ReturnType<typeof setTimeout> | null = null;
@@ -24,14 +25,15 @@ export function useLightningWatcher(
if (settled) return; if (settled) return;
settled = true; settled = true;
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
onPaid(); setPaymentPending(false);
setStep('success');
}; };
const expire = () => { const expire = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired'); toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
onExpired(); setPaymentPending(false);
}; };
// Always same-origin so the streaming proxy route handler is used (it // Always same-origin so the streaming proxy route handler is used (it
@@ -77,5 +79,5 @@ export function useLightningWatcher(
eventSource.close(); eventSource.close();
if (pollTimer) clearTimeout(pollTimer); if (pollTimer) clearTimeout(pollTimer);
}; };
}, [active, ticketId, locale]); }, [step, ticketId, locale]);
} }
@@ -8,17 +8,11 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import type { PaymentMethod, BookingResult } from '../_types'; import type { PaymentMethod, BookingResult } from '../_types';
// Paraguayan RUC: 5-8 digit base + "-" + 1 check digit (DV), e.g. 1234567-9 or 80012345-0 export const rucPattern = /^\d{6,10}$/;
export const rucPattern = /^\d{5,8}-\d$/;
/** Sanitize RUC input: digits and a single user-typed dash, max 10 chars. No dash is auto-inserted. */ /** Format RUC input: digits only, max 10. */
export function formatRuc(value: string): string { export function formatRuc(value: string): string {
const cleaned = value.replace(/[^\d-]/g, ''); return value.replace(/\D/g, '').slice(0, 10);
const firstDash = cleaned.indexOf('-');
const oneDash = firstDash === -1
? cleaned
: cleaned.slice(0, firstDash + 1) + cleaned.slice(firstDash + 1).replace(/-/g, '');
return oneDash.slice(0, 10);
} }
/** Truncate a long invoice string for display. */ /** Truncate a long invoice string for display. */
@@ -226,10 +226,25 @@ export function BookingFormStep({
onBlur={handleRucBlur} onBlur={handleRucBlur}
placeholder={t('booking.form.rucPlaceholder')} placeholder={t('booking.form.rucPlaceholder')}
error={errors.ruc} error={errors.ruc}
inputMode="numeric"
maxLength={10} maxLength={10}
aria-label={t('booking.form.ruc')} aria-label={t('booking.form.ruc')}
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('booking.form.preferredLanguage')}
</label>
<select
value={formData.preferredLanguage}
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div> </div>
</Card> </Card>
@@ -320,7 +335,7 @@ export function BookingFormStep({
<button <button
key={method.id} key={method.id}
type="button" type="button"
onClick={() => setFormData((prev) => ({ ...prev, paymentMethod: method.id }))} onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${ className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
formData.paymentMethod === method.id formData.paymentMethod === method.id
? 'border-primary-yellow bg-primary-yellow/10' ? 'border-primary-yellow bg-primary-yellow/10'
@@ -362,9 +377,6 @@ export function BookingFormStep({
</> </>
)} )}
</div> </div>
{errors.paymentMethod && (
<p className="mt-3 text-sm text-red-600">{errors.paymentMethod}</p>
)}
</Card> </Card>
{/* Terms & Privacy agreement */} {/* Terms & Privacy agreement */}
@@ -418,7 +430,7 @@ export function BookingFormStep({
size="lg" size="lg"
className="w-full" className="w-full"
isLoading={submitting} isLoading={submitting}
disabled={paymentMethods.length === 0 || !formData.paymentMethod || !agreedToTerms} disabled={paymentMethods.length === 0 || !agreedToTerms}
> >
{formData.paymentMethod === 'cash' {formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot') ? t('booking.form.reserveSpot')
@@ -12,8 +12,8 @@ export interface BookingFormData {
lastName: string; lastName: string;
email: string; email: string;
phone: string; phone: string;
// Empty until the user explicitly picks a method (no default selection). preferredLanguage: 'en' | 'es';
paymentMethod: PaymentMethod | ''; paymentMethod: PaymentMethod;
ruc: string; ruc: string;
} }
@@ -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, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import { formatDateLong, formatTime } 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 {
@@ -16,7 +16,7 @@ import type {
PaymentMethod, PaymentMethod,
} from './_types'; } from './_types';
import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking'; import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking';
import { useLightningWatcher } from '@/hooks/useLightningWatcher'; import { useLightningWatcher } from './_hooks/useLightningWatcher';
import { PayingStep } from './_steps/PayingStep'; import { PayingStep } from './_steps/PayingStep';
import { ManualPaymentStep } from './_steps/ManualPaymentStep'; import { ManualPaymentStep } from './_steps/ManualPaymentStep';
import { PendingApprovalStep } from './_steps/PendingApprovalStep'; import { PendingApprovalStep } from './_steps/PendingApprovalStep';
@@ -35,6 +35,7 @@ export default function BookingPage() {
const [step, setStep] = useState<BookingStep>('form'); const [step, setStep] = useState<BookingStep>('form');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [bookingResult, setBookingResult] = useState<BookingResult | null>(null); const [bookingResult, setBookingResult] = useState<BookingResult | null>(null);
const [, setPaymentPending] = useState(false);
const [markingPaid, setMarkingPaid] = useState(false); const [markingPaid, setMarkingPaid] = useState(false);
// State for payer name (when paid under different name) // State for payer name (when paid under different name)
@@ -56,7 +57,8 @@ export default function BookingPage() {
lastName: '', lastName: '',
email: '', email: '',
phone: '', phone: '',
paymentMethod: '', preferredLanguage: locale as 'en' | 'es',
paymentMethod: 'cash',
ruc: '', ruc: '',
}); });
@@ -77,10 +79,11 @@ export default function BookingPage() {
} }
}; };
// Validate RUC on blur (optional field: base + "-" + check digit) // Validate RUC on blur (optional field: 610 digits)
const handleRucBlur = () => { const handleRucBlur = () => {
if (!formData.ruc) return; if (!formData.ruc) return;
if (!rucPattern.test(formData.ruc)) { const digits = formData.ruc.replace(/\D/g, '');
if (digits.length > 0 && !rucPattern.test(digits)) {
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') }); setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
} }
}; };
@@ -108,15 +111,16 @@ export default function BookingPage() {
return; return;
} }
// Server-authoritative availability — same formula the booking API const bookedCount = eventRes.event.bookedCount ?? 0;
// enforces, so a sold-out event is caught here, not at submit time. const capacity = eventRes.event.capacity ?? 0;
if (isEventSoldOut(eventRes.event)) { const soldOut = bookedCount >= capacity;
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 = eventSpotsLeft(eventRes.event); const spotsLeft = Math.max(0, capacity - bookedCount);
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)));
@@ -127,7 +131,18 @@ export default function BookingPage() {
return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' }); return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' });
}); });
setPaymentConfig(paymentRes.paymentOptions); setPaymentConfig(paymentRes.paymentOptions);
// No payment method is pre-selected; the user must choose one.
// Set default payment method based on what's enabled
const config = paymentRes.paymentOptions;
if (config.lightningEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'lightning' }));
} else if (config.cashEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'cash' }));
} else if (config.bankTransferEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'bank_transfer' }));
} else if (config.tpagoEnabled) {
setFormData(prev => ({ ...prev, paymentMethod: 'tpago' }));
}
}) })
.catch(() => router.push('/events')) .catch(() => router.push('/events'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -149,7 +164,8 @@ export default function BookingPage() {
lastName: prev.lastName || lastName, lastName: prev.lastName || lastName,
email: prev.email || user.email || '', email: prev.email || user.email || '',
phone: prev.phone || user.phone || '', phone: prev.phone || user.phone || '',
ruc: prev.ruc || formatRucDisplay(user.rucNumber), preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
ruc: prev.ruc || user.rucNumber || '',
}; };
}); });
} }
@@ -193,18 +209,12 @@ export default function BookingPage() {
newErrors.phone = t('booking.form.errors.phoneTooShort'); newErrors.phone = t('booking.form.errors.phoneTooShort');
} }
// RUC validation (optional field - base + "-" + check digit if filled) // RUC validation (optional field - 610 digits if filled)
if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) { if (formData.ruc.trim()) {
newErrors.ruc = t('booking.form.errors.rucInvalidFormat'); const digits = formData.ruc.replace(/\D/g, '');
} if (!/^\d{6,10}$/.test(digits)) {
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
// Payment method must be explicitly chosen and currently enabled }
const availableMethods = buildPaymentMethods(paymentConfig, locale);
if (
!formData.paymentMethod ||
!availableMethods.some((m) => m.id === formData.paymentMethod)
) {
newErrors.paymentMethod = t('booking.form.errors.paymentMethodRequired');
} }
// Validate additional attendees (if multi-ticket) // Validate additional attendees (if multi-ticket)
@@ -235,13 +245,7 @@ export default function BookingPage() {
}; };
// Watch for Lightning payment confirmation while on the paying step. // Watch for Lightning payment confirmation while on the paying step.
useLightningWatcher( useLightningWatcher(step, bookingResult?.ticketId, locale, setPaymentPending, setStep);
step === 'paying',
bookingResult?.ticketId,
locale,
() => setStep('success'),
() => {}
);
// Handle "I Have Paid" button click // Handle "I Have Paid" button click
const handleMarkPaymentSent = async () => { const handleMarkPaymentSent = async () => {
@@ -294,9 +298,9 @@ export default function BookingPage() {
lastName: formData.lastName, lastName: formData.lastName,
email: formData.email, email: formData.email,
phone: formData.phone, phone: formData.phone,
preferredLanguage: locale as 'en' | 'es', preferredLanguage: formData.preferredLanguage,
paymentMethod: formData.paymentMethod as PaymentMethod, paymentMethod: formData.paymentMethod,
...(formData.ruc.trim() && { ruc: formData.ruc.trim() }), ...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
// Include attendees array for multi-ticket bookings // Include attendees array for multi-ticket bookings
...(allAttendees.length > 1 && { attendees: allAttendees }), ...(allAttendees.length > 1 && { attendees: allAttendees }),
}); });
@@ -326,6 +330,7 @@ export default function BookingPage() {
}; };
setBookingResult(result); setBookingResult(result);
setStep('paying'); setStep('paying');
setPaymentPending(true);
// Payment confirmation is handled by the paying-step watcher effect. // Payment confirmation is handled by the paying-step watcher effect.
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
// Manual payment methods - show payment details // Manual payment methods - show payment details
@@ -357,7 +362,7 @@ export default function BookingPage() {
bookingId, bookingId,
qrCode: primaryTicket.qrCode, qrCode: primaryTicket.qrCode,
qrCodes: ticketsList?.map((t: any) => t.qrCode), qrCodes: ticketsList?.map((t: any) => t.qrCode),
paymentMethod: formData.paymentMethod as PaymentMethod, paymentMethod: formData.paymentMethod,
ticketCount, ticketCount,
}); });
setStep('success'); setStep('success');
@@ -365,23 +370,6 @@ 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);
} }
@@ -404,8 +392,8 @@ export default function BookingPage() {
return null; return null;
} }
const spotsLeft = eventSpotsLeft(event); const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
const isSoldOut = isEventSoldOut(event); const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
// 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) {
@@ -1,13 +1,11 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'next/navigation'; import { useParams, 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 { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig, LightningInvoice } from '@/lib/api'; import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils'; import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
import { useLightningWatcher } from '@/hooks/useLightningWatcher';
import { PayingStep } from '@/app/(public)/book/[eventId]/_steps/PayingStep';
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 { import {
@@ -24,7 +22,7 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
type PaymentStep = 'loading' | 'manual_payment' | 'lightning_payment' | 'pending_approval' | 'confirmed' | 'error'; type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error';
export default function BookingPaymentPage() { export default function BookingPaymentPage() {
const params = useParams(); const params = useParams();
@@ -35,36 +33,10 @@ export default function BookingPaymentPage() {
const [step, setStep] = useState<PaymentStep>('loading'); const [step, setStep] = useState<PaymentStep>('loading');
const [markingPaid, setMarkingPaid] = useState(false); const [markingPaid, setMarkingPaid] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lightningInvoice, setLightningInvoice] = useState<LightningInvoice | null>(null);
const [invoiceExpired, setInvoiceExpired] = useState(false);
const [fetchingInvoice, setFetchingInvoice] = useState(false);
const ticketId = params.ticketId as string; const ticketId = params.ticketId as string;
const requestedStep = searchParams.get('step'); const requestedStep = searchParams.get('step');
// Get (or refresh) the Lightning invoice for this ticket - reuses the
// stored invoice if still valid, otherwise the backend generates a new one.
const fetchLightningInvoice = useCallback(async () => {
setFetchingInvoice(true);
try {
const result = await ticketsApi.getLightningInvoice(ticketId);
if (result.alreadyPaid) {
setStep('confirmed');
return;
}
if (result.invoice) {
setLightningInvoice(result.invoice);
setInvoiceExpired(false);
setStep('lightning_payment');
}
} catch (err: any) {
setError(err.message || 'Failed to load Lightning invoice');
setStep('error');
} finally {
setFetchingInvoice(false);
}
}, [ticketId]);
// Fetch ticket and payment config // Fetch ticket and payment config
useEffect(() => { useEffect(() => {
if (!ticketId) return; if (!ticketId) return;
@@ -82,24 +54,10 @@ export default function BookingPaymentPage() {
setTicket(ticketData); setTicket(ticketData);
// Only proceed for manual payment methods
const paymentMethod = ticketData.payment?.provider; const paymentMethod = ticketData.payment?.provider;
if (paymentMethod === 'lightning') {
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
setStep('confirmed');
} else if (ticketData.status === 'cancelled') {
setError(locale === 'es'
? 'Esta reserva ya no está activa. Por favor realiza una nueva reserva.'
: 'This booking is no longer active. Please make a new booking.');
setStep('error');
} else {
await fetchLightningInvoice();
}
return;
}
if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) { if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) {
// Not a manual or Lightning payment method, show appropriate state // Not a manual payment method, redirect to success page or show appropriate state
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') { if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
setStep('confirmed'); setStep('confirmed');
} else { } else {
@@ -138,15 +96,6 @@ export default function BookingPaymentPage() {
loadBookingData(); loadBookingData();
}, [ticketId]); }, [ticketId]);
// Watch for Lightning payment confirmation while the invoice is on screen.
useLightningWatcher(
step === 'lightning_payment' && !invoiceExpired,
ticketId,
locale,
() => setStep('confirmed'),
() => setInvoiceExpired(true)
);
// Handle "I Have Paid" button click // Handle "I Have Paid" button click
const handleMarkPaymentSent = async () => { const handleMarkPaymentSent = async () => {
if (!ticket) return; if (!ticket) return;
@@ -352,49 +301,6 @@ export default function BookingPaymentPage() {
); );
} }
// Lightning payment step - show the (reused or freshly generated) invoice
if (step === 'lightning_payment' && ticket) {
if (invoiceExpired) {
return (
<div className="section-padding">
<div className="container-page max-w-xl">
<Card className="p-8 text-center">
<div className="w-16 h-16 rounded-full bg-orange-100 flex items-center justify-center mx-auto mb-6">
<ClockIcon className="w-10 h-10 text-orange-600" />
</div>
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{locale === 'es' ? 'La factura expiró' : 'Invoice expired'}
</h1>
<p className="text-gray-600 mb-6">
{locale === 'es'
? 'Genera una nueva factura Lightning para continuar con el pago.'
: 'Generate a new Lightning invoice to continue with payment.'}
</p>
<Button onClick={fetchLightningInvoice} isLoading={fetchingInvoice} size="lg">
{locale === 'es' ? 'Generar Nueva Factura' : 'Generate New Invoice'}
</Button>
</Card>
</div>
</div>
);
}
if (!lightningInvoice) {
return (
<div className="section-padding">
<div className="container-page max-w-xl text-center">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
<p className="mt-4 text-gray-600">
{locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'}
</p>
</div>
</div>
);
}
return <PayingStep invoice={lightningInvoice} qrCode={ticket.qrCode} locale={locale} />;
}
// Manual payment step - show payment details and "I have paid" button // Manual payment step - show payment details and "I have paid" button
if (step === 'manual_payment' && ticket && paymentConfig) { if (step === 'manual_payment' && ticket && paymentConfig) {
const isBankTransfer = ticket.payment?.provider === 'bank_transfer'; const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
@@ -3,9 +3,6 @@ import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Join Our Language Exchange Community', title: 'Join Our Language Exchange Community',
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.', description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
alternates: {
canonical: '/community',
},
openGraph: { openGraph: {
title: 'Join Our Language Exchange Community Spanglish', title: 'Join Our Language Exchange Community Spanglish',
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.', description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
@@ -3,7 +3,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItem } from '@/lib/api'; import { faqApi, FaqItem } from '@/lib/api';
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from '@heroicons/react/24/outline';
import Link from 'next/link'; import Link from 'next/link';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -24,20 +23,7 @@ export default function HomepageFaqSection() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
if (loading) { if (loading || faqs.length === 0) {
return (
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
<div className="container-page">
<div className="max-w-2xl mx-auto">
<Skeleton className="h-9 w-72 max-w-full mx-auto mb-8" />
<FaqListSkeleton count={4} compact />
</div>
</div>
</section>
);
}
if (faqs.length === 0) {
return null; return null;
} }
@@ -5,7 +5,6 @@ import Link from 'next/link';
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 } from '@/lib/utils';
import { FeaturedEventSkeleton } from '@/components/ui/Skeleton';
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline'; import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
interface NextEventSectionProps { interface NextEventSectionProps {
@@ -52,7 +51,11 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
: ''; : '';
if (loading) { if (loading) {
return <FeaturedEventSkeleton />; return (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
);
} }
if (!nextEvent) { if (!nextEvent) {
@@ -3,9 +3,6 @@ import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Contact Us', title: 'Contact Us',
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
alternates: {
canonical: '/contact',
},
openGraph: { openGraph: {
title: 'Contact Us Spanglish', title: 'Contact Us Spanglish',
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.', description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
@@ -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 } = useAuth(); const { user, updateUser, logout } = 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,17 +187,15 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
if ( if (
!confirm( !confirm(
locale === 'es' locale === 'es'
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.' ? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all other sessions? This session stays signed in.' : 'Log out of all sessions? You will be logged out.'
) )
) )
return; return;
try { try {
await dashboardApi.revokeAllSessions(); await dashboardApi.revokeAllSessions();
toast.success( toast.success(locale === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked' logout();
);
loadData();
} catch (error) { } catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed'); toast.error(locale === 'es' ? 'Error' : 'Failed');
} }
@@ -479,7 +477,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{sessions.map((session) => ( {sessions.map((session, index) => (
<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"
@@ -496,7 +494,7 @@ export default function AccountTab({ onUpdate }: AccountTabProps) {
{session.ipAddress && `${session.ipAddress}`} {session.ipAddress && `${session.ipAddress}`}
</p> </p>
</div> </div>
{session.current ? ( {index === 0 ? (
<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>
@@ -22,8 +22,6 @@ import {
groupByBooking, groupByBooking,
isUnpaid, isUnpaid,
isAwaitingApproval, isAwaitingApproval,
isOnHold,
isActionableAttention,
ticketAmount, ticketAmount,
shareTicket, shareTicket,
isToday, isToday,
@@ -59,14 +57,11 @@ export default function OverviewTab({
// awaiting approval), ordered by soonest event. // awaiting approval), ordered by soonest event.
const attentionTicket = useMemo(() => { const attentionTicket = useMemo(() => {
const candidates = activeTickets.filter( const candidates = activeTickets.filter(
(t) => (t) => isUnpaid(t) || isAwaitingApproval(t)
isActionableAttention(t) &&
(isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t))
); );
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
candidates.sort((a, b) => { candidates.sort((a, b) => {
const aUnpaid = priority(a); const aUnpaid = isUnpaid(a) ? 0 : 1;
const bUnpaid = priority(b); const bUnpaid = isUnpaid(b) ? 0 : 1;
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid; if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
const aStart = a.event?.startDatetime const aStart = a.event?.startDatetime
? parseDate(a.event.startDatetime).getTime() ? parseDate(a.event.startDatetime).getTime()
@@ -195,19 +190,7 @@ export default function OverviewTab({
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StatusPill status={status} locale={locale} /> <StatusPill status={status} locale={locale} />
{isOnHold(t) ? ( {isUnpaid(t) ? (
<PayActions
ticketId={t.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
onHold
/>
) : isUnpaid(t) ? (
<PayActions <PayActions
ticketId={t.id} ticketId={t.id}
amount={amount} amount={amount}
@@ -363,19 +346,7 @@ function HeroCard({
)} )}
{/* Smart primary action. */} {/* Smart primary action. */}
{isOnHold(ticket) ? ( {isUnpaid(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="md"
onHold
/>
) : isUnpaid(ticket) ? (
<PayActions <PayActions
ticketId={ticket.id} ticketId={ticket.id}
amount={amount} amount={amount}
@@ -106,7 +106,6 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
: payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event'); : payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event');
const canMarkPaid = const canMarkPaid =
payment.status === 'pending' && isManualProvider(payment.provider); payment.status === 'pending' && isManualProvider(payment.provider);
const canRebook = payment.status === 'on_hold';
return ( return (
<Card key={payment.id} className="p-4"> <Card key={payment.id} className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -132,7 +131,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
</div> </div>
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end"> <div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
{canRebook ? ( {canMarkPaid && (
<PayActions <PayActions
ticketId={payment.ticketId} ticketId={payment.ticketId}
amount={Number(payment.amount)} amount={Number(payment.amount)}
@@ -142,21 +141,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
onPaid={onChange} onPaid={onChange}
layout="inline" layout="inline"
size="sm" size="sm"
onHold
/> />
) : (
canMarkPaid && (
<PayActions
ticketId={payment.ticketId}
amount={Number(payment.amount)}
currency={payment.currency}
destination={eventTitle}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
/>
)
)} )}
{payment.invoice && ( {payment.invoice && (
<a <a
@@ -16,11 +16,9 @@ import { StatusPill, deriveTicketStatus } from './_shared/status';
import { import {
groupByBooking, groupByBooking,
isUnpaid, isUnpaid,
isOnHold,
ticketAmount, ticketAmount,
ticketPdfUrl, ticketPdfUrl,
pyg, pyg,
HOLD_THRESHOLD_HOURS,
type BookingGroup, type BookingGroup,
} from './_shared/helpers'; } from './_shared/helpers';
import PayActions from './_shared/PayActions'; import PayActions from './_shared/PayActions';
@@ -178,31 +176,12 @@ function BookingCard({
<p className="text-gray-500"> <p className="text-gray-500">
{pyg(amount, currency)} {pyg(amount, currency)}
</p> </p>
{isOnHold(ticket) && (
<p className="text-slate-600">
{locale === 'es'
? `Tu lugar fue liberado porque el pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
: `Your spot has been released because payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
</p>
)}
</div> </div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex flex-col gap-2 sm:w-44"> <div className="flex flex-col gap-2 sm:w-44">
{isOnHold(ticket) ? ( {isUnpaid(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="stack"
size="sm"
onHold
/>
) : isUnpaid(ticket) ? (
<PayActions <PayActions
ticketId={ticket.id} ticketId={ticket.id}
amount={amount} amount={amount}
@@ -13,8 +13,6 @@ import {
ticketAmount, ticketAmount,
pyg, pyg,
isAwaitingApproval, isAwaitingApproval,
isOnHold,
HOLD_THRESHOLD_HOURS,
} from './helpers'; } from './helpers';
/** /**
@@ -40,41 +38,6 @@ export default function AttentionBanner({
: ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event'); : ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event');
const { amount, currency } = ticketAmount(ticket); const { amount, currency } = ticketAmount(ticket);
if (isOnHold(ticket)) {
return (
<div className="rounded-card border border-slate-200 bg-slate-50 p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-slate-500" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-slate-800">
{locale === 'es'
? `Tu lugar para ${eventTitle} fue liberado`
: `Your spot for ${eventTitle} was released`}
</p>
<p className="mt-0.5 text-sm text-slate-600">
{locale === 'es'
? `El pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
: `Payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
</p>
</div>
</div>
<div className="mt-3 sm:pl-9">
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={eventTitle}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
onHold
/>
</div>
</div>
);
}
if (isAwaitingApproval(ticket)) { if (isAwaitingApproval(ticket)) {
return ( return (
<div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4"> <div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4">
@@ -23,11 +23,6 @@ interface PayActionsProps {
/** Stack the two buttons full-width (cards) vs inline (rows). */ /** Stack the two buttons full-width (cards) vs inline (rows). */
layout?: 'stack' | 'inline'; layout?: 'stack' | 'inline';
className?: string; className?: string;
/**
* The booking's spot was released after the hold threshold passed. Hides the
* "Pay now" link (money was already sent) and labels the retry "Rebook".
*/
onHold?: boolean;
} }
/** /**
@@ -46,7 +41,6 @@ export default function PayActions({
size = 'sm', size = 'sm',
layout = 'stack', layout = 'stack',
className, className,
onHold = false,
}: PayActionsProps) { }: PayActionsProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
@@ -82,22 +76,18 @@ export default function PayActions({
return ( return (
<> <>
<div className={`${containerClass} ${className || ''}`}> <div className={`${containerClass} ${className || ''}`}>
{!onHold && ( <Link href={`/booking/${ticketId}`} className={btnWidth}>
<Link href={`/booking/${ticketId}`} className={btnWidth}> <Button size={size} className={btnWidth}>
<Button size={size} className={btnWidth}> {locale === 'es' ? 'Pagar ahora' : 'Pay now'}
{locale === 'es' ? 'Pagar ahora' : 'Pay now'} </Button>
</Button> </Link>
</Link>
)}
<Button <Button
variant={onHold ? 'primary' : 'outline'} variant="outline"
size={size} size={size}
className={btnWidth} className={btnWidth}
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
> >
{onHold {locale === 'es' ? 'Ya pagué' : "I've paid"}
? (locale === 'es' ? 'Reservar de nuevo' : 'Rebook')
: (locale === 'es' ? 'Ya pagué' : "I've paid")}
</Button> </Button>
</div> </div>
@@ -118,24 +108,16 @@ export default function PayActions({
</div> </div>
</div> </div>
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark"> <h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
{onHold {locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'}
? (locale === 'es' ? '¿Reservar de nuevo?' : 'Rebook your spot?')
: (locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?')}
</h3> </h3>
<p className="mb-6 text-center text-sm text-gray-600"> <p className="mb-6 text-center text-sm text-gray-600">
{onHold {locale === 'es'
? (locale === 'es' ? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
? `Intentaremos reservar tu lugar de nuevo para ${destination}.` : `Did you already send the ${pyg(amount, currency)} for ${destination}?`}
: `We'll try to re-reserve your spot for ${destination}.`)
: (locale === 'es'
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`)}
</p> </p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button isLoading={marking} className="w-full" onClick={handleConfirm}> <Button isLoading={marking} className="w-full" onClick={handleConfirm}>
{onHold {locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid"}
? (locale === 'es' ? 'Sí, reservar de nuevo' : 'Yes, rebook')
: (locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -6,16 +6,6 @@ import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
// ticket was created, capped at the event start time. // ticket was created, capped at the event start time.
export const PAYMENT_HOLD_HOURS = 24; export const PAYMENT_HOLD_HOURS = 24;
// Hours a pending-approval booking (payment already marked as sent) can wait
// for admin review before the auto-hold sweep releases the spot. Mirrors the
// backend's HOLD_THRESHOLD_HOURS env default - keep these in sync.
export const HOLD_THRESHOLD_HOURS = 72;
/** True once a booking has been auto-released after the approval hold window. */
export function isOnHold(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
return ticket.status === 'on_hold' || ticket.payment?.status === 'on_hold';
}
/** Currency is Guarani with no decimals, e.g. "21 PYG". */ /** Currency is Guarani with no decimals, e.g. "21 PYG". */
export function pyg(amount: number, currency: string = 'PYG'): string { export function pyg(amount: number, currency: string = 'PYG'): string {
return formatPrice(Number(amount) || 0, currency || 'PYG'); return formatPrice(Number(amount) || 0, currency || 'PYG');
@@ -35,22 +25,6 @@ export function isAwaitingApproval(ticket: { payment?: { status?: string } | nul
return ticket.payment?.status === 'pending_approval'; return ticket.payment?.status === 'pending_approval';
} }
/**
* Whether an attention banner (e.g. "your spot was released", "payment pending")
* is still worth showing to the user. It only makes sense to nudge the user when
* the event still exists, is still bookable (published/unlisted), and has not
* already ended. This suppresses stale banners for deleted, unpublished,
* cancelled/completed/archived, or past events.
*/
export function isActionableAttention(ticket: UserTicket): boolean {
const event = ticket.event;
if (!event) return false;
if (event.status !== 'published' && event.status !== 'unlisted') return false;
const refDate = event.endDatetime || event.startDatetime;
if (!refDate) return false;
return parseDate(refDate).getTime() > Date.now();
}
/** /**
* The moment the seat hold expires. null when paid/awaiting/cancelled or when * The moment the seat hold expires. null when paid/awaiting/cancelled or when
* the data needed to compute it is missing. * the data needed to compute it is missing.
@@ -9,14 +9,12 @@ import type { UserTicket, Payment } from '@/lib/api';
// unpaid -> pale red // unpaid -> pale red
// attended -> pale blue (replaces the raw "checked_in" value) // attended -> pale blue (replaces the raw "checked_in" value)
// cancelled -> pale gray // cancelled -> pale gray
// onHold -> pale slate (spot released after the payment deadline passed)
export type DashStatus = export type DashStatus =
| 'confirmed' | 'confirmed'
| 'awaiting' | 'awaiting'
| 'unpaid' | 'unpaid'
| 'attended' | 'attended'
| 'cancelled' | 'cancelled';
| 'onHold';
/** /**
* Collapse a ticket status + payment status into a single user-facing status. * Collapse a ticket status + payment status into a single user-facing status.
@@ -28,7 +26,6 @@ export function deriveTicketStatus(
): DashStatus { ): DashStatus {
if (ticketStatus === 'checked_in') return 'attended'; if (ticketStatus === 'checked_in') return 'attended';
if (ticketStatus === 'cancelled') return 'cancelled'; if (ticketStatus === 'cancelled') return 'cancelled';
if (ticketStatus === 'on_hold' || paymentStatus === 'on_hold') return 'onHold';
if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed'; if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting'; if (paymentStatus === 'pending_approval') return 'awaiting';
return 'unpaid'; return 'unpaid';
@@ -38,7 +35,6 @@ export function deriveTicketStatus(
export function derivePaymentStatus(paymentStatus?: string): DashStatus { export function derivePaymentStatus(paymentStatus?: string): DashStatus {
if (paymentStatus === 'paid') return 'confirmed'; if (paymentStatus === 'paid') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting'; if (paymentStatus === 'pending_approval') return 'awaiting';
if (paymentStatus === 'on_hold') return 'onHold';
if (paymentStatus === 'refunded') return 'cancelled'; if (paymentStatus === 'refunded') return 'cancelled';
return 'unpaid'; return 'unpaid';
} }
@@ -50,7 +46,6 @@ export function statusLabel(status: DashStatus, locale: string): string {
unpaid: { en: 'Unpaid', es: 'No pagado' }, unpaid: { en: 'Unpaid', es: 'No pagado' },
attended: { en: 'Attended', es: 'Asistió' }, attended: { en: 'Attended', es: 'Asistió' },
cancelled: { en: 'Cancelled', es: 'Cancelado' }, cancelled: { en: 'Cancelled', es: 'Cancelado' },
onHold: { en: 'On Hold', es: 'En Espera' },
}; };
return locale === 'es' ? labels[status].es : labels[status].en; return locale === 'es' ? labels[status].es : labels[status].en;
} }
@@ -61,7 +56,6 @@ const PILL_STYLES: Record<DashStatus, string> = {
unpaid: 'bg-red-100 text-red-700', unpaid: 'bg-red-100 text-red-700',
attended: 'bg-blue-100 text-blue-800', attended: 'bg-blue-100 text-blue-800',
cancelled: 'bg-gray-100 text-gray-600', cancelled: 'bg-gray-100 text-gray-600',
onHold: 'bg-slate-100 text-slate-600',
}; };
export function StatusPill({ export function StatusPill({
+6 -7
View File
@@ -11,7 +11,6 @@ import {
UserPayment, UserPayment,
} from '@/lib/api'; } from '@/lib/api';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { CardListSkeleton } from '@/components/ui/Skeleton';
import OverviewTab from './components/OverviewTab'; import OverviewTab from './components/OverviewTab';
import TicketsTab from './components/TicketsTab'; import TicketsTab from './components/TicketsTab';
@@ -23,7 +22,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 } = useAuth(); const { user, isLoading: authLoading, token } = 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,13 +35,11 @@ export default function DashboardPage() {
router.push('/login'); router.push('/login');
return; return;
} }
// Auth rides on the httpOnly session cookie; once the user has resolved if (user && token) {
// 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]); }, [user, authLoading, token]);
const loadDashboardData = async () => { const loadDashboardData = async () => {
setLoading(true); setLoading(true);
@@ -107,7 +104,9 @@ export default function DashboardPage() {
{/* Tab content */} {/* Tab content */}
{loading ? ( {loading ? (
<CardListSkeleton count={3} /> <div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
</div>
) : ( ) : (
<> <>
{activeTab === 'overview' && ( {activeTab === 'overview' && (
@@ -1,147 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
// Receives the event list already fetched on the server so event titles, dates,
// and locations are present in the initial HTML. The upcoming/past filter below
// is client-side interactivity layered on top of the server-rendered data.
export default function EventsClient({ initialEvents }: { initialEvents: Event[] }) {
const { t, locale } = useLanguage();
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
const now = new Date();
const upcomingEvents = initialEvents.filter(e =>
e.status === 'published' && new Date(e.startDatetime) >= now
);
const pastEvents = initialEvents.filter(e =>
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
);
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const getStatusBadge = (event: Event) => {
if (event.status === 'cancelled') {
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
return null;
};
return (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title">{t('events.title')}</h1>
{/* Filter tabs */}
<div className="mt-8 flex gap-2">
<button
onClick={() => setFilter('upcoming')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'upcoming'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.upcoming')} ({upcomingEvents.length})
</button>
<button
onClick={() => setFilter('past')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'past'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.past')} ({pastEvents.length})
</button>
</div>
{/* Events grid */}
<div className="mt-8">
{displayedEvents.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg">{t('events.noEvents')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.slug}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
className="h-40 w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h3>
{getStatusBadge(event)}
</div>
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" />
<span className="truncate">{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<span className="font-bold text-xl text-primary-dark">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
<Button size="sm">
{t('common.moreInfo')}
</Button>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -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, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import { formatPrice, formatDateLong, formatTime } 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,10 +43,9 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
.catch(console.error); .catch(console.error);
}, [eventId]); }, [eventId]);
// Server-authoritative availability (paid + claimed seats count; abandoned // Spots left: never negative; sold out when confirmed >= capacity
// pending bookings don't) — matches the booking API's sold-out check exactly. 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);
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(() => {
@@ -1,6 +0,0 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the event gallery.
export default function Loading() {
return <GallerySkeleton />;
}
@@ -1,50 +0,0 @@
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>
);
}
@@ -1,28 +0,0 @@
import { Skeleton, ArticleSkeleton } from '@/components/ui/Skeleton';
// Route-level skeleton for the event detail page: back link, banner, article
// body on the left and the booking card in the sidebar.
export default function EventDetailLoading() {
return (
<div className="section-padding">
<div className="container-page">
<Skeleton className="h-5 w-36" />
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<ArticleSkeleton withBanner />
</div>
<div>
<div className="bg-white rounded-card shadow-card p-6" aria-hidden="true">
<div className="text-center">
<Skeleton className="h-4 w-16 mx-auto" />
<Skeleton className="mt-3 h-10 w-32 mx-auto" />
</div>
<Skeleton className="mt-6 h-12 w-full rounded-btn" />
<Skeleton className="mt-3 h-4 w-3/4 mx-auto" />
</div>
</div>
</div>
</div>
</div>
);
}
@@ -117,18 +117,13 @@ function generateEventJsonLd(event: Event) {
'@type': 'Offer', '@type': 'Offer',
price: event.price, price: event.price,
priceCurrency: event.currency, priceCurrency: event.currency,
availability: availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
(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}`,
validFrom: new Date().toISOString(), validFrom: new Date().toISOString(),
}, },
image: event.bannerUrl image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
: `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.slug}`, url: `${siteUrl}/events/${event.slug}`,
}; };
} }
+1 -6
View File
@@ -1,13 +1,8 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
// Note: the page title for the listing lives on events/page.tsx, not here. A
// plain-string title in this layout would reset the root title template for the
// child /events/[id] route, stripping the brand suffix from event detail titles.
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Upcoming Language Exchange Events in Asunción',
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.', description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
alternates: {
canonical: '/events',
},
openGraph: { openGraph: {
title: 'Upcoming Language Exchange Events in Asunción Spanglish', title: 'Upcoming Language Exchange Events in Asunción Spanglish',
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.', description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
@@ -1,20 +0,0 @@
import { Skeleton, EventGridSkeleton } from '@/components/ui/Skeleton';
// Route-level skeleton shown during navigation while the server fetches the
// event list. Mirrors the EventsClient shell: title, filter tabs, card grid.
export default function EventsLoading() {
return (
<div className="section-padding">
<div className="container-page">
<Skeleton className="h-10 w-64" />
<div className="mt-8 flex gap-2">
<Skeleton className="h-10 w-32 rounded-btn" />
<Skeleton className="h-10 w-28 rounded-btn" />
</div>
<div className="mt-8">
<EventGridSkeleton count={6} />
</div>
</div>
</div>
);
}
+153 -25
View File
@@ -1,29 +1,157 @@
import type { Metadata } from 'next'; 'use client';
import { Event } from '@/lib/api';
import EventsClient from './EventsClient';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
// Listing title lives here (not in the layout) so the root title template still export default function EventsPage() {
// applies to the sibling /events/[id] detail route. Picks up "%s Spanglish". const { t, locale } = useLanguage();
export const metadata: Metadata = { const [events, setEvents] = useState<Event[]>([]);
title: 'Upcoming Language Exchange Events in Asunción', const [loading, setLoading] = useState(true);
}; const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
// Fetch the public (published) event list on the server so event titles, dates, useEffect(() => {
// and locations appear in the initial HTML rather than only after JS runs. eventsApi.getAll()
async function getEvents(): Promise<Event[]> { .then(({ events }) => setEvents(events))
try { .catch(console.error)
const res = await fetch(`${apiUrl}/api/events`, { next: { revalidate: 60 } }); .finally(() => setLoading(false));
if (!res.ok) return []; }, []);
const data = await res.json();
return data.events || []; const now = new Date();
} catch { const upcomingEvents = events.filter(e =>
return []; e.status === 'published' && new Date(e.startDatetime) >= now
} );
} const pastEvents = events.filter(e =>
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
export default async function EventsPage() { );
const events = await getEvents();
return <EventsClient initialEvents={events} />; const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const getStatusBadge = (event: Event) => {
if (event.status === 'cancelled') {
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
return null;
};
return (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title">{t('events.title')}</h1>
{/* Filter tabs */}
<div className="mt-8 flex gap-2">
<button
onClick={() => setFilter('upcoming')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'upcoming'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.upcoming')} ({upcomingEvents.length})
</button>
<button
onClick={() => setFilter('past')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'past'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.past')} ({pastEvents.length})
</button>
</div>
{/* Events grid */}
<div className="mt-8">
{loading ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
) : displayedEvents.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg">{t('events.noEvents')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.slug}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
className="h-40 w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h3>
{getStatusBadge(event)}
</div>
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" />
<span className="truncate">{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<span className="font-bold text-xl text-primary-dark">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
<Button size="sm">
{t('common.moreInfo')}
</Button>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
} }
@@ -1,95 +0,0 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { FaqItem } from '@/lib/api';
import Card from '@/components/ui/Card';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
// Receives the FAQ list already fetched on the server so the questions and
// answers are present in the initial HTML for crawlers. The accordion below is
// purely a visual toggle; the answer text stays in the DOM either way.
export default function FaqClient({ initialFaqs }: { initialFaqs: FaqItem[] }) {
const { locale } = useLanguage();
const [openIndex, setOpenIndex] = useState<number | null>(null);
const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="section-padding">
<div className="container-page max-w-3xl">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-primary-dark mb-4">
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
: 'Find answers to the most common questions about Spanglish'}
</p>
</div>
{initialFaqs.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{locale === 'es'
? 'No hay preguntas frecuentes publicadas en este momento.'
: 'No FAQ questions are published at the moment.'}
</p>
</Card>
) : (
<div className="space-y-4">
{initialFaqs.map((faq, index) => (
<Card key={faq.id} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div>
</div>
</Card>
))}
</div>
)}
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2">
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
</h2>
<p className="text-gray-600 mb-4">
{locale === 'es'
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
: "Don't hesitate to reach out. We're here to help!"}
</p>
<a
href="/contact"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
</a>
</Card>
</div>
</div>
);
}
-3
View File
@@ -20,9 +20,6 @@ async function getFaqForSchema(): Promise<{ question: string; answer: string }[]
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Frequently Asked Questions', title: 'Frequently Asked Questions',
description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.', description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.',
alternates: {
canonical: '/faq',
},
openGraph: { openGraph: {
title: 'Frequently Asked Questions Spanglish', title: 'Frequently Asked Questions Spanglish',
description: 'Find answers to common questions about Spanglish language exchange events in Asunción.', description: 'Find answers to common questions about Spanglish language exchange events in Asunción.',
-16
View File
@@ -1,16 +0,0 @@
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
// Route-level skeleton for the FAQ page: centered header + accordion rows.
export default function FaqLoading() {
return (
<div className="section-padding">
<div className="container-page max-w-3xl">
<div className="text-center mb-12">
<Skeleton className="h-10 w-80 max-w-full mx-auto" />
<Skeleton className="mt-4 h-4 w-96 max-w-full mx-auto" />
</div>
<FaqListSkeleton count={6} />
</div>
</div>
);
}
+111 -17
View File
@@ -1,22 +1,116 @@
import { FaqItem } from '@/lib/api'; 'use client';
import FaqClient from './FaqClient';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItem } from '@/lib/api';
import Card from '@/components/ui/Card';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
// Fetch the published FAQ list on the server so the questions and answers are export default function FAQPage() {
// rendered into the initial HTML (crawlers see the content without running JS). const { locale } = useLanguage();
async function getFaqs(): Promise<FaqItem[]> { const [faqs, setFaqs] = useState<FaqItem[]>([]);
try { const [loading, setLoading] = useState(true);
const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } }); const [openIndex, setOpenIndex] = useState<number | null>(null);
if (!res.ok) return [];
const data = await res.json(); useEffect(() => {
return data.faqs || []; let cancelled = false;
} catch { faqApi.getList().then((res) => {
return []; if (!cancelled) {
setFaqs(res.faqs);
}
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
if (loading) {
return (
<div className="section-padding">
<div className="container-page max-w-3xl flex justify-center py-20">
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
</div>
);
} }
}
export default async function FAQPage() { return (
const faqs = await getFaqs(); <div className="section-padding">
return <FaqClient initialFaqs={faqs} />; <div className="container-page max-w-3xl">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-primary-dark mb-4">
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
: 'Find answers to the most common questions about Spanglish'}
</p>
</div>
{faqs.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{locale === 'es'
? 'No hay preguntas frecuentes publicadas en este momento.'
: 'No FAQ questions are published at the moment.'}
</p>
</Card>
) : (
<div className="space-y-4">
{faqs.map((faq, index) => (
<Card key={faq.id} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div>
</div>
</Card>
))}
</div>
)}
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2">
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
</h2>
<p className="text-gray-600 mb-4">
{locale === 'es'
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
: "Don't hesitate to reach out. We're here to help!"}
</p>
<a
href="/contact"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
</a>
</Card>
</div>
</div>
);
} }
@@ -1,25 +0,0 @@
import { Skeleton, SkeletonGroup, SkeletonText } from '@/components/ui/Skeleton';
// Route-level skeleton for legal pages: back link, bordered header, prose body.
export default function LegalPageLoading() {
return (
<div className="section-padding">
<div className="container-page max-w-4xl">
<SkeletonGroup>
<Skeleton className="h-5 w-32 mb-8" />
<div className="mb-8 pb-6 border-b border-gray-200">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="mt-3 h-4 w-44" />
</div>
<div className="space-y-6">
<SkeletonText lines={4} />
<Skeleton className="h-6 w-1/3" />
<SkeletonText lines={5} />
<Skeleton className="h-6 w-2/5" />
<SkeletonText lines={4} />
</div>
</SkeletonGroup>
</div>
</div>
);
}
@@ -40,8 +40,7 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
} }
return { return {
// The root layout's title template appends " Spanglish"; do not repeat it here. title: `${legalPage.title} Spanglish`,
title: legalPage.title,
description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`, description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`,
robots: { robots: {
index: true, index: true,
+7 -84
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation'; import { useRouter, 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,20 +11,14 @@ 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, user, isLoading: authLoading } = useAuth(); const { login } = 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({
@@ -35,27 +29,6 @@ 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);
@@ -63,12 +36,10 @@ 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!');
// Deliberately leaves `loading` set: the button must stay disabled until the router.push(redirectTo);
// 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);
} }
}; };
@@ -96,38 +67,6 @@ 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">
@@ -212,25 +151,9 @@ function LoginContent() {
</Link> </Link>
</div> </div>
<Button <Button type="submit" className="w-full" size="lg" isLoading={loading}>
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">
+3 -9
View File
@@ -59,9 +59,7 @@ export async function generateMetadata(): Promise<Metadata> {
if (!event) { if (!event) {
return { return {
// Title already carries the brand, so bypass the "%s Spanglish" title: 'Spanglish Language Exchange Events in Asunción',
// template to avoid doubling it.
title: { absolute: 'Spanglish Language Exchange Events in Asunción' },
description: description:
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.', 'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
}; };
@@ -78,9 +76,7 @@ export async function generateMetadata(): Promise<Metadata> {
const description = `Next event: ${eventDate} ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`; const description = `Next event: ${eventDate} ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
return { return {
// Title already carries the brand, so bypass the "%s Spanglish" title: 'Spanglish Language Exchange Events in Asunción',
// template to avoid doubling it.
title: { absolute: 'Spanglish Language Exchange Events in Asunción' },
description, description,
openGraph: { openGraph: {
title: 'Spanglish Language Exchange Events in Asunción', title: 'Spanglish Language Exchange Events in Asunción',
@@ -146,9 +142,7 @@ function generateNextEventJsonLd(event: NextEvent) {
: 'https://schema.org/SoldOut', : 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.slug}`, url: `${siteUrl}/events/${event.slug}`,
}, },
image: event.bannerUrl image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
: `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.slug}`, url: `${siteUrl}/events/${event.slug}`,
}; };
} }
@@ -1,72 +0,0 @@
'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>
);
}

Some files were not shown because too many files have changed in this diff Show More