24 Commits
Author SHA1 Message Date
MichilisandClaude Opus 4.6 dafa3711f8 Fix post-login redirect when the session cookie is off-origin
Signing in showed the "Welcome back!" toast but never left /login. The
session cookie was host-only on the API subdomain, so the Next middleware
guard on the site origin saw no cookie and bounced /dashboard straight
back to /login?redirect=/dashboard.

- Add AUTH_COOKIE_DOMAIN, wiring Better Auth's crossSubDomainCookies so
  the cookie also reaches the site origin. Unset in dev, where localhost
  is single-host and must stay host-only.
- Navigate after authentication with a full page load, via a shared
  authRedirect helper: only a top-level request carries the httpOnly
  cookie. Used by the login, register, magic-link and Google flows.
- Show "Redirecting..." on the login and register pages and keep the
  submit button disabled until the browser replaces the page, instead of
  re-enabling it mid-navigation.
- Guard against a redirect loop with a sessionStorage marker. A React ref
  cannot do this: the full page load resets component state. If the
  destination bounces back, explain it rather than navigating again.
- Middleware: accept any *.session_token cookie so a cookiePrefix change
  cannot lock everyone out, and preserve the destination's query string.
- Trust any loopback port in dev, so reaching the dev server through a
  forwarded port does not fail Better Auth's CSRF origin check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-29 20:45:05 +00:00
MichilisandClaude Opus 5 733d2459df Migrate authentication to Better Auth
Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:07:04 +00:00
MichilisandClaude Opus 5 4afa5d6fa0 Show when the booking was made in the verify-payment modal
The modal only had "User marked as paid", which is absent for manual
payments the customer never confirmed — those opened with no timestamp
at all. Show the payment's createdAt unconditionally as "Booking made",
and give both lines a relative age suffix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:06:39 +00:00
MichilisandClaude Opus 4.6 617c884012 Add view tokens for non-public gallery images and per-mode gate pages.
<img> tags cannot send Authorization headers, so non-public gallery photos
were invisible even to authorized viewers. The server now mints short-lived
HMAC view tokens (gallery-scoped, hour-bucketed) and embeds them in every
file URL for non-public galleries. Access denials return distinct 403
messages per visibility mode, and the frontend renders a matching gate page
(private, link-only, ticket-holders, login prompt) with an inline login
modal so visitors never leave the gallery page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-26 06:48:05 +00:00
MichilisandClaude Opus 4.8 93476ac72a Document photo galleries, payment providers, and updated deploy topology in README
Cover features added since the last README update: the standalone photo-api
Go service, the central payment provider registry (Lightning + manual
TPago/bank/card/cash), stable /next and /featured URLs, dashboard overview,
and the three-service dev/deploy setup (photo-api on 3003 dev / 3020 prod).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:57:30 +00:00
MichilisandClaude Fable 5 c4094630d9 Add a thumbnail filmstrip to the photo lightbox.
- Render a horizontally scrollable strip of thumbnails below the image
  that highlights and auto-centres the active photo and lets you jump
  straight to any shot.
- Pass thumbUrl through from the public and admin galleries, falling
  back to previewUrl when no thumbnail exists.
- Restructure the overlay into a flex column (image area + strip) and
  require a clearly horizontal gesture before treating a touch as a
  prev/next swipe, so vertical scrolls no longer trigger navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:53:37 +00:00
MichilisandClaude Fable 5 9b2668f498 Unify admin ticket creation into one modal with first-class payment status.
- Replace the three Attendees-tab modals (Manual Ticket / Add at Door /
  Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented
  control, shared fields, "Check in now" for all types, and a live
  "what happens" preview, backed by one POST /api/tickets/admin/add.
- Add tickets.payment_status (paid | unpaid | comp) with a backfill
  migration; keep it in sync on every payment-settlement path (mark-paid,
  admin approval, Lightning, free bookings, hold recovery).
- Show Paid/Unpaid/Comp badges in the attendee list, count only paid
  tickets toward revenue, let unpaid tickets be resolved via Mark Paid,
  and flag unpaid tickets with their balance due in the door scanner.
- Replace the per-page useStatsPrivacy hook with an admin-wide
  PrivacyContext + SensitiveValue mask, toggled from the admin layout.
- Add server-side pagination with page-size options to the users page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:25:00 +00:00
MichilisandClaude Fable 5 e38d14970d Harden the Redis layer for production.
- Locks no longer fail open: an unreachable backend throws
  LockUnavailableError and withLock skips the run (or opts into running
  unlocked, as template seeding does). The lnbits payment poller keeps
  polling through an outage, made safe by only sending confirmation
  emails when a row actually transitioned.
- Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing
  counters stranded without a TTL.
- Per-email login lockout moves to a Redis-backed store shared across
  replicas (in-memory fallback preserved), case-insensitive on email.
- Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the
  server, force-close lingering SSE sockets, close Redis.
- Active PING probe backs the health flag; /health reports last ping.
  SSE payment streams also poll the DB as a pub/sub-gap fallback.
- Scale compose gains requirepass, maxmemory 256mb with noeviction, and
  an authenticated healthcheck; .env.example documents passwords, TLS
  (rediss://), and DB-index selection.
- First tests in the repo: vitest + ioredis-mock covering the lock,
  rate limiter, and login lockout stores (27 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:52 +00:00
MichilisandClaude Fable 5 71c277045b Centralize seat capacity accounting and the payment provider registry.
Replace manualProviders.ts with a paymentProviders.ts registry
(automatic vs manual settlement) and move all seat counting into
capacity.ts as the single source of truth: only paid/checked-in tickets
and pending_approval payments hold a seat, so abandoned checkouts never
block sales. Admins can now knowingly approve a payment over capacity
(allowOverCapacity), with the booking and admin UIs updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:40 +00:00
MichilisandCursor c9a600b6d6 Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:32:23 +00:00
MichilisandCursor 4772b85f3d Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.
Exclude manual providers from booking cleanup, hold them via the 72h sweep instead, and let admins reopen failed payments to pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 03:53:51 +00:00
MichilisandCursor 0d47156071 Normalize RUC formatting to base-checkdigit across booking and admin views.
Accept dashed or digits-only RUC input on the backend, store a canonical dashed form, and display it consistently in booking and admin UIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 03:47:35 +00:00
MichilisandCursor 75e317d73e Replace spinners with skeleton loading states for faster perceived UX.
Add reusable Skeleton components and route-level loading files across public pages and admin lists so layouts stay stable while data loads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 23:14:11 +00:00
MichilisandCursor d8c207c995 Add Next.js as a root dependency for the monorepo workspace.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 22:05:22 +00:00
MichilisandCursor cb422332c8 Fix booking defaults, dashboard alerts, admin edit flow, and ended-event payments.
Require an explicit payment method on booking, hide stale release banners for past or inactive events, open event editing in place on the detail page, and auto-reject unconfirmed payments after events end without sending email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 22:04:22 +00:00
MichilisandCursor 8d5fbf18b4 Fix event time save off-by-one using bundled tz data.
Use moment-timezone in parseEventDatetime so naive America/Asuncion wall-clock times convert correctly regardless of the host Node tz database. Also normalize user registration date filters with toDbDate for Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 01:51:18 +00:00
MichilisandCursor 78ffd0ae52 Allow Lightning invoice reuse and re-payment from the dashboard.
Store LNbits invoice data on payments, add an invoice endpoint that reuses valid invoices or regenerates expired ones, and wire the booking payment page to fetch and display invoices via a shared watcher hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:48:28 +00:00
MichilisandClaude Sonnet 5 09bfb17f03 Add custom error and global-error pages matching site design
Adds app/error.tsx (client error boundary, reuses Header/Footer/Button)
and app/global-error.tsx (root layout crash fallback with self-contained
html/body and manually replicated styling), both bilingual (ES/EN) and
matching the brand's colors, fonts, and tone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 06:06:22 +00:00
Michilis cacc52ec24 Security recovery: hold sweep, dashboard updates, and admin fixes. 2026-07-01 05:51:38 +00:00
MichilisandCursor 38526f17b5 Redesign user dashboard with overview tab and i18n payment copy.
Consolidate profile and security into AccountTab, add shared dashboard components, and move awaiting-approval payment messages to translations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 07:39:52 +00:00
MichilisandCursor f5fe87613f Add stable /next and /featured URLs that redirect to the current event.
These short links always resolve to the latest upcoming or featured event so they can be shared without updating when events rotate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 05:35:52 +00:00
MichilisandCursor 613bd7be1d Refactor monolithic modules and harden booking, email, and auth infrastructure.
Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 07:12:59 +00:00
MichilisandCursor f0e2de2834 Fix Lightning payment UI not updating after LNbits invoice is paid.
Ensure the booking page receives payment confirmation via a streaming SSE proxy, parallel status polling, and more reliable backend event delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 04:15:02 +00:00
MichilisandCursor a6840ea953 Harden auth, payments, and frontend against review findings.
Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 19:59:02 +00:00
242 changed files with 25551 additions and 9910 deletions
+5
View File
@@ -7,6 +7,7 @@ package-lock.json
# Build outputs
dist/
.next/
.next-build/
out/
build/
@@ -56,6 +57,10 @@ yarn-error.log*
# Testing
coverage/
# Go photo-api service
photo-api/bin/
photo-api/data/
# Misc
*.pem
+135 -18
View File
@@ -4,17 +4,24 @@ A full-stack web app for organizing and managing language exchange events (Asunc
## Features
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
- **User dashboard**: profile, tickets, payments, sessions/security
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
- Stable `/next` and `/featured` URLs that redirect to the current event
- Human-readable event URL slugs (with legacy-ID redirect support)
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
- Lightning invoice reuse and re-payment straight from the dashboard
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
- One unified ticket-creation modal with first-class payment status
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
## Tech stack
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
- **Email**: `nodemailer` (SMTP) with optional provider config
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, SWR, Heroicons
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
## Local development
@@ -22,6 +29,8 @@ A full-stack web app for organizing and managing language exchange events (Asunc
- Node.js 18+
- npm
- Go 1.26+ (for the `photo-api` service)
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
### Setup
@@ -29,12 +38,14 @@ A full-stack web app for organizing and managing language exchange events (Asunc
npm install
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
```
### Initialize database (SQLite by default)
```bash
npm run db:migrate
npm run db:migrate # backend (Drizzle) tables
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
```
### Run
@@ -43,10 +54,13 @@ npm run db:migrate
npm run dev
```
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
Default URLs:
- Frontend: `http://localhost:3002`
- Backend API: `http://localhost:3001`
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
- API docs: `http://localhost:3001/api-docs`
### First user becomes admin
@@ -58,14 +72,20 @@ The first user to register becomes the **admin**. Register at `/register`.
Run these from the repo root:
```bash
npm run dev
npm run build
npm run dev # backend + frontend + photo-api
npm run build # build all workspaces
npm run start
npm run db:generate
npm run db:migrate
npm run db:studio
npm run db:export # Backup database
npm run db:import # Restore from backup
npm run db:export # Backup database
npm run db:import # Restore from backup
# Photo service (Go)
npm run dev:photos # go run ./cmd/photo-api
npm run build:photos # go build -o bin/photo-api
npm run migrate:photos # apply photos_* migrations
npm run test:photos # go test ./...
```
You can also run per workspace:
@@ -82,10 +102,22 @@ npm run dev --workspace=frontend
Key settings (see `backend/.env.example` for the full list):
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
- **Auth**: `JWT_SECRET` (change in production)
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development.
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
### Photo service (`photo-api/.env`)
Key settings (see `photo-api/.env.example`):
- **Port**: `PORT=3003` (dev)
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
### Frontend (`frontend/.env`)
@@ -93,8 +125,10 @@ Key settings (see `frontend/.env.example`):
- **Server port**: `PORT=3002`
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
- Leave empty to use same-origin `/api` (recommended when running behind nginx)
- In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend
- Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port.
- Inlined at build time: changing it requires a rebuild, not just a restart.
- Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well.
- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`).
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
## Database
@@ -142,12 +176,13 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
This repo includes example configs in `deploy/`:
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
- Backend needs write access to `backend/data` and `backend/uploads`
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
- **nginx**:
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
- `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
Typical production flow:
@@ -155,11 +190,93 @@ Typical production flow:
```bash
npm ci
npm run build
npm run build:photos
npm run db:migrate
npm run migrate:photos
```
Then install/enable the systemd services and nginx configs for your server.
## Horizontal scaling
The backend can run as a single instance with zero extra configuration (the
default), or as multiple replicas behind a load balancer. Scaling support is
fully optional and backward compatible: if you set none of the variables below,
the app behaves exactly as before with in-memory state and local-disk uploads.
### Requirements for multiple instances
- **Use PostgreSQL.** Set `DB_TYPE=postgres`. SQLite is a single local file and
cannot be shared safely across instances.
- **Set `REDIS_URL`.** This makes the following subsystems shared across
instances instead of per process:
- distributed cache
- rate limiting (shared sliding/fixed window)
- pub/sub for real-time payment events, so an SSE client connected to one
instance still receives an event when the LNbits webhook lands on another
- distributed locks (so only one instance seeds email templates per boot and
only one instance polls LNbits per pending ticket)
- the email hourly cap (`MAX_EMAILS_PER_HOUR`) becomes a global cap
- **Tune the DB pool.** `DB_POOL_MAX` is the max Postgres connections per
instance (default 10). Keep `DB_POOL_MAX * replicas` below the Postgres
`max_connections` setting (default 100). For example, 5 replicas at
`DB_POOL_MAX=15` uses up to 75 connections.
If Redis is configured but becomes unreachable at runtime, each subsystem
degrades gracefully (rate limiter fails open, cache misses fall through to the
DB, locks proceed) and the API keeps serving rather than crashing.
### Uploads across instances
Media uploads default to local disk (`./uploads`). With more than one instance
you must use shared storage so a file uploaded on one instance is readable on
the others. Two options:
- **S3-compatible storage (recommended):** set `S3_ENDPOINT`, `S3_BUCKET`,
`S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (and optionally `S3_PUBLIC_URL`,
`S3_REGION`, `S3_FORCE_PATH_STYLE`). Works with Garage, MinIO, or AWS S3.
- **Shared volume:** mount the same `./uploads` directory (e.g. NFS) into every
instance.
### Real-time payment SSE behind a load balancer
The payment status stream (`/api/lnbits/stream/:ticketId`) is a long-lived SSE
connection. With Redis pub/sub enabled, any instance can deliver the payment
event regardless of which instance holds the socket, so sticky sessions are not
strictly required. Enabling sticky sessions (IP hash) for the SSE path is still
a reasonable optimization.
### Health and observability
`GET /health` always returns 200 and reports Redis connectivity and which
backend each subsystem selected, for example:
```json
{
"status": "ok",
"redis": { "enabled": true, "healthy": true },
"backends": {
"cache": "redis",
"rateLimiter": "redis",
"pubsub": "redis",
"lock": "redis",
"storage": "s3"
}
}
```
The same selection is logged once at startup.
### docker-compose example (N replicas + Redis)
A ready-to-edit snippet lives at `deploy/docker-compose.scale.yml`. It runs
Postgres, Redis, and the API scaled to multiple replicas behind nginx. Bring it
up with:
```bash
docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
```
## Documentation
- **Specs / notes**: `about/`
+91 -1
View File
@@ -8,13 +8,83 @@ DATABASE_URL=./data/spanglish.db
# For PostgreSQL
# DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
# JWT Secret (change in production!)
# Max PostgreSQL connections per instance (default 10). When running multiple
# replicas, keep DB_POOL_MAX * replicas below the Postgres max_connections limit.
# DB_POOL_MAX=10
# ---------------------------------------------------------------------------
# Horizontal scaling (all optional)
# ---------------------------------------------------------------------------
# Leave everything below UNSET to run as a single instance with in-memory
# backends and local-disk uploads (zero-config, identical to the original
# behavior). Set them to run multiple API replicas behind a load balancer.
#
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
# single local file and cannot be shared safely across instances.
# Redis connection URL. When set, the cache, rate limiter, login lockout,
# pub/sub (real-time payment events), distributed locks, and the email hourly
# cap are shared across all instances. When unset, each instance uses in-memory
# equivalents.
#
# In production always set a password (requirepass on the server) and put it in
# the URL. Use the rediss:// scheme for TLS (handled natively by the client),
# and an optional /N path to select a DB index when sharing a Redis instance
# with other applications.
# REDIS_URL=redis://localhost:6379
# REDIS_URL=redis://:your-redis-password@redis.internal:6379
# REDIS_URL=rediss://:your-redis-password@redis.example.com:6380/1
# Optional S3-compatible object storage for media uploads (e.g. Garage, MinIO,
# AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and
# are shared across instances. When unset, uploads are written to ./uploads on
# local disk (the default).
# S3_ENDPOINT=https://garage.example.com
# S3_REGION=garage
# S3_BUCKET=spanglish-media
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# Public base URL used to build fileUrl for stored objects (CDN or web endpoint).
# If unset, a path-style URL against S3_ENDPOINT/S3_BUCKET is used.
# S3_PUBLIC_URL=https://media.example.com
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
# S3_FORCE_PATH_STYLE=true
# Better Auth session secret. REQUIRED in production, 32+ characters.
# Generate one with: openssl rand -base64 48
# Rotating it signs everyone out (cookie signatures invalidate).
BETTER_AUTH_SECRET=
# Public site origin Better Auth builds its URLs against (falls back to
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
BETTER_AUTH_URL=
# Session cookie domain, shared across subdomains. Set this when the API is served
# from a different host than the site (e.g. api.spanglishcommunity.com vs
# spanglishcommunity.com): without it the cookie is host-only and the frontend's
# Next middleware cannot see it, so /dashboard and /admin bounce back to /login.
# Must start with a dot. Leave EMPTY in development (localhost is single-host).
# E.g. .spanglishcommunity.com
AUTH_COOKIE_DOMAIN=
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
# trusted; anything else is treated as a client and rate-limited by its
# actual socket address.
# TRUSTED_PROXIES=
# DEPRECATED: no longer used for API auth (Better Auth replaced the JWTs).
# Still read by photo-api as the fallback secret for gallery view tokens
# until PHOTO_VIEW_SECRET is set there; safe to remove after that.
JWT_SECRET=your-super-secret-key-change-in-production
# Google OAuth (optional - for Google Sign-In)
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
# Note: The same Client ID should be used in frontend/.env
GOOGLE_CLIENT_ID=
# Only needed for the redirect OAuth flow; the Google Identity Services
# button (ID token sign-in) works with the Client ID alone.
GOOGLE_CLIENT_SECRET=
# Server Configuration
PORT=3001
@@ -73,3 +143,23 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
# If the limit is reached, queued emails will pause and resume automatically
MAX_EMAILS_PER_HOUR=30
# Pending Booking Cleanup
# Pending bookings whose payment is still unpaid (not awaiting admin approval)
# are cancelled after this many minutes, freeing the seats (default: 30)
PENDING_BOOKING_TTL_MINUTES=30
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000
# Auto-Hold Sweep
# Bookings awaiting admin payment approval are put on hold (seat released) after
# this many hours with no confirmation (default: 72)
HOLD_THRESHOLD_HOURS=72
# How often the hold sweep job runs, in milliseconds (default: 900000 = 15 min)
HOLD_SWEEP_INTERVAL_MS=900000
# Ended-Event Payment Sweep
# Once an event is over, any unconfirmed payment (pending / pending_approval /
# on_hold) is auto-rejected and its ticket cancelled. No email is sent.
# How often this sweep runs, in milliseconds (default: 900000 = 15 min)
EVENT_END_SWEEP_INTERVAL_MS=900000
+11 -4
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"test": "vitest run",
"start": "NODE_ENV=production node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
@@ -13,16 +14,20 @@
"db:import": "tsx src/db/import.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@hono/node-server": "^1.11.4",
"@hono/swagger-ui": "^0.4.0",
"@hono/zod-openapi": "^0.14.4",
"argon2": "^0.44.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.0.0",
"better-auth": "1.6.25",
"better-sqlite3": "^12.11.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.31.2",
"drizzle-orm": "^0.45.2",
"hono": "^4.4.7",
"ioredis": "^5.11.1",
"jose": "^5.4.0",
"moment-timezone": "^0.6.2",
"nanoid": "^5.0.7",
"nodemailer": "^7.0.13",
"pdfkit": "^0.17.2",
@@ -38,8 +43,10 @@
"@types/pdfkit": "^0.17.4",
"@types/pg": "^8.11.6",
"@types/qrcode": "^1.5.6",
"drizzle-kit": "^0.22.8",
"drizzle-kit": "^0.31.10",
"ioredis-mock": "^8.13.1",
"tsx": "^4.15.7",
"typescript": "^5.5.2"
"typescript": "^5.5.2",
"vitest": "^4.1.10"
}
}
+200
View File
@@ -0,0 +1,200 @@
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
import {
pgTable,
uuid,
varchar,
text as pgText,
timestamp,
boolean as pgBoolean,
bigint,
customType as pgCustomType,
} from 'drizzle-orm/pg-core';
// Better Auth table definitions for both dialects.
//
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
// foreign key that references them) survive the auth migration untouched. Only
// the columns Better Auth reads/writes are declared here; legacy columns
// (password, google_id, token_version) stay physically present but invisible
// to Better Auth. The full application-facing definition lives in schema.ts —
// two Drizzle table objects can safely describe the same SQL table.
const dbType = process.env.DB_TYPE || 'sqlite';
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
dataType() {
return 'text';
},
toDriver(value: Date): string {
return (value instanceof Date ? value : new Date(value)).toISOString();
},
fromDriver(value: string): Date {
return new Date(value);
},
});
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
dataType() {
return 'integer';
},
toDriver(value: boolean): number {
return value ? 1 : 0;
},
fromDriver(value: number | boolean): boolean {
return Boolean(value);
},
});
// ==================== SQLite ====================
export const sqliteAuthUsers = sqliteTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
createdAt: isoText('created_at').notNull(),
updatedAt: isoText('updated_at').notNull(),
// admin plugin fields
role: text('role').notNull().default('user'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
// application additionalFields
phone: text('phone'),
languagePreference: text('language_preference'),
rucNumber: text('ruc_number'),
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
accountStatus: text('account_status').notNull().default('active'),
});
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
// admin plugin (impersonation)
impersonatedBy: text('impersonated_by'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
id: text('id').primaryKey(),
key: text('key'),
count: integer('count'),
lastRequest: integer('last_request'),
});
// ==================== PostgreSQL ====================
export const pgAuthUsers = pgTable('users', {
id: uuid('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
// admin plugin fields
role: varchar('role', { length: 20 }).notNull().default('user'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
// application additionalFields
phone: varchar('phone', { length: 50 }),
languagePreference: varchar('language_preference', { length: 10 }),
rucNumber: varchar('ruc_number', { length: 15 }),
isClaimed: pgIntBool('is_claimed').notNull(),
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
});
export const pgAuthSessions = pgTable('auth_sessions', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
token: varchar('token', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at').notNull(),
ipAddress: varchar('ip_address', { length: 45 }),
userAgent: pgText('user_agent'),
// admin plugin (impersonation)
impersonatedBy: uuid('impersonated_by'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthAccounts = pgTable('auth_accounts', {
id: uuid('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
accountId: varchar('account_id', { length: 255 }).notNull(),
providerId: varchar('provider_id', { length: 100 }).notNull(),
accessToken: pgText('access_token'),
refreshToken: pgText('refresh_token'),
idToken: pgText('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: pgText('scope'),
password: pgText('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthVerifications = pgTable('auth_verifications', {
id: uuid('id').primaryKey(),
identifier: varchar('identifier', { length: 255 }).notNull(),
value: pgText('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
id: varchar('id', { length: 64 }).primaryKey(),
key: varchar('key', { length: 255 }),
count: bigint('count', { mode: 'number' }),
lastRequest: bigint('last_request', { mode: 'number' }),
});
// ==================== Runtime-switched exports ====================
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
+3
View File
@@ -12,8 +12,11 @@ const dbType = process.env.DB_TYPE || 'sqlite';
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
if (dbType === 'postgres') {
// Cap connections per instance so that, when running multiple replicas,
// DB_POOL_MAX * replicas stays below the Postgres max_connections limit.
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
max: Number(process.env.DB_POOL_MAX || 10),
});
db = drizzlePg(pool, { schema });
} else {
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import Database from 'better-sqlite3';
// Migration idempotency for the Better Auth backfill: seed legacy-shaped user
// rows, run migrate repeatedly, and assert the backfill is correct and never
// duplicates.
const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-'));
const dbPath = join(dir, 'migrate.db');
function runMigrate() {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
env: {
...process.env,
DB_TYPE: 'sqlite',
DATABASE_URL: dbPath,
REDIS_URL: '',
},
stdio: 'pipe',
});
}
let db: Database.Database;
beforeAll(() => {
// First run creates the schema
runMigrate();
db = new Database(dbPath);
// Seed legacy-shaped users (pre-Better-Auth): password lives on users,
// google_id links Google, '' marks guest accounts, suspended via status.
const now = new Date().toISOString();
const insert = db.prepare(
`INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
);
insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now);
insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now);
insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now);
insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now);
insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now);
insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now);
// Second run performs the backfill against the seeded rows
runMigrate();
}, 240_000);
describe('Better Auth migration backfill', () => {
it('creates credential accounts for users with real passwords only', () => {
const rows = db
.prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id")
.all() as any[];
const byUser = new Map(rows.map((r) => [r.user_id, r.password]));
expect(byUser.get('legacy-argon')).toContain('$argon2id$');
expect(byUser.get('legacy-bcrypt')).toContain('$2a$');
expect(byUser.get('legacy-both')).toContain('$argon2id$');
expect(byUser.get('legacy-suspended')).toBeTruthy();
// Guests ('' password) and Google-only users get no credential account
expect(byUser.has('legacy-guest')).toBe(false);
expect(byUser.has('legacy-google')).toBe(false);
});
it('creates google accounts from google_id', () => {
const rows = db
.prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id")
.all() as any[];
expect(rows).toEqual([
{ user_id: 'legacy-both', account_id: 'google-sub-456' },
{ user_id: 'legacy-google', account_id: 'google-sub-123' },
]);
});
it('marks claimed legacy users email-verified, guests not', () => {
const verified = (email: string) =>
(db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified;
expect(verified('argon@old.py')).toBe(1);
expect(verified('google@old.py')).toBe(1);
expect(verified('guest@old.py')).toBe(0);
});
it('mirrors suspended accounts to banned', () => {
const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any;
expect(row.banned).toBe(1);
expect(row.ban_reason).toContain('suspended');
const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any;
expect(active.banned).toBe(0);
});
it('is idempotent: a third run adds nothing', () => {
const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n;
const before = count();
runMigrate();
expect(count()).toBe(before);
}, 60_000);
});
+324 -2
View File
@@ -5,7 +5,7 @@ import { uniqueSlug } from '../lib/slugify.js';
const dbType = process.env.DB_TYPE || 'sqlite';
console.log(`Database type: ${dbType}`);
console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`);
// Do not log DATABASE_URL: it may contain credentials.
async function migrate() {
console.log('Running migrations...');
@@ -43,6 +43,9 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Magic link tokens table
await (db as any).run(sql`
@@ -196,7 +199,19 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
// backfilled from is_guest and the payments table on first run
try {
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`);
await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
await (db as any).run(sql`
UPDATE tickets SET payment_status = 'paid'
WHERE is_guest = 0
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
`);
} catch (e) { /* column may already exist */ }
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
@@ -235,6 +250,15 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Invoices table
await (db as any).run(sql`
@@ -429,6 +453,18 @@ async function migrate() {
)
`);
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS email_queue (
id TEXT PRIMARY KEY,
params TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TEXT NOT NULL,
processed_at TEXT
)
`);
// Site settings table
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS site_settings (
@@ -508,6 +544,81 @@ async function migrate() {
updated_by TEXT REFERENCES users(id)
)
`);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table).
// Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode).
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
ip_address TEXT,
user_agent TEXT,
impersonated_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id TEXT PRIMARY KEY,
key TEXT,
count INTEGER,
last_request INTEGER
)
`);
} else {
// PostgreSQL migrations
await (db as any).execute(sql`
@@ -541,6 +652,9 @@ async function migrate() {
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Magic link tokens table
await (db as any).execute(sql`
@@ -675,6 +789,18 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
// backfilled from is_guest and the payments table on first run
try {
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`);
await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
await (db as any).execute(sql`
UPDATE tickets SET payment_status = 'paid'
WHERE is_guest = 0
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS payments (
id UUID PRIMARY KEY,
@@ -701,6 +827,15 @@ async function migrate() {
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Invoices table
await (db as any).execute(sql`
@@ -893,6 +1028,18 @@ async function migrate() {
)
`);
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS email_queue (
id UUID PRIMARY KEY,
params TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP
)
`);
// Site settings table
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS site_settings (
@@ -972,6 +1119,181 @@ async function migrate() {
updated_by UUID REFERENCES users(id)
)
`);
// ==================== Better Auth ====================
// Better Auth core + admin plugin columns on the existing users table
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`);
} catch (e) { /* column may already exist */ }
// Better Auth sessions (replaces the legacy user_sessions table)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
ip_address VARCHAR(45),
user_agent TEXT,
impersonated_by UUID,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth accounts: credential (password hash) and OAuth provider links
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id VARCHAR(255) NOT NULL,
provider_id VARCHAR(100) NOT NULL,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
password TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth verification values (magic links, password reset tokens)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_verifications (
id UUID PRIMARY KEY,
identifier VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
`);
// Better Auth rate limiting (used when Redis is not configured)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS auth_rate_limits (
id VARCHAR(64) PRIMARY KEY,
key VARCHAR(255),
count BIGINT,
last_request BIGINT
)
`);
}
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
const indexStatements = [
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
`CREATE INDEX IF NOT EXISTS tickets_user_id_idx ON tickets(user_id)`,
`CREATE INDEX IF NOT EXISTS tickets_booking_id_idx ON tickets(booking_id)`,
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS auth_accounts_user_id_idx ON auth_accounts(user_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
];
for (const stmt of indexStatements) {
try {
if (dbType === 'sqlite') {
await (db as any).run(sql.raw(stmt));
} else {
await (db as any).execute(sql.raw(stmt));
}
} catch (e) { /* index may already exist */ }
}
// ==================== Better Auth data backfill ====================
// Idempotent: every statement is guarded so re-running migrate is safe, and
// legacy users are distinguished from Better-Auth-created users by having
// users.password / users.google_id set (Better Auth never writes either).
if (dbType === 'sqlite') {
// Legacy password hashes -> credential accounts (argon2 and bcrypt hashes
// both stay valid via the custom password verifier in lib/betterAuth.ts)
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.id, 'credential', u.password,
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
// Legacy Google links -> google provider accounts
await (db as any).run(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT lower(hex(randomblob(16))), u.id, u.google_id, 'google',
CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
// Claimed legacy accounts proved their email (register/claim link/Google)
await (db as any).run(sql`
UPDATE users SET email_verified = 1
WHERE email_verified = 0 AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
// Suspended -> banned (admin plugin field); users.ts keeps them in sync
await (db as any).run(sql`
UPDATE users SET banned = 1, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = 0
`);
} else {
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.id::text, 'credential', u.password, NOW(), NOW()
FROM users u
WHERE u.password IS NOT NULL AND u.password != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'credential'
)
`);
await (db as any).execute(sql`
INSERT INTO auth_accounts (id, user_id, account_id, provider_id, created_at, updated_at)
SELECT gen_random_uuid(), u.id, u.google_id, 'google', NOW(), NOW()
FROM users u
WHERE u.google_id IS NOT NULL AND u.google_id != ''
AND NOT EXISTS (
SELECT 1 FROM auth_accounts a WHERE a.user_id = u.id AND a.provider_id = 'google'
)
`);
await (db as any).execute(sql`
UPDATE users SET email_verified = TRUE
WHERE email_verified = FALSE AND is_claimed = 1
AND ((password IS NOT NULL AND password != '') OR google_id IS NOT NULL)
`);
await (db as any).execute(sql`
UPDATE users SET banned = TRUE, ban_reason = 'migrated: account suspended'
WHERE account_status = 'suspended' AND banned = FALSE
`);
}
// Backfill slugs for any events that don't have one yet (shared across DB types).
+54 -3
View File
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core';
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
// Type to determine which schema to use
const dbType = process.env.DB_TYPE || 'sqlite';
@@ -18,6 +18,14 @@ export const sqliteUsers = sqliteTable('users', {
googleId: text('google_id'),
rucNumber: text('ruc_number'),
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: integer('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
image: text('image'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -102,12 +110,14 @@ export const sqliteTickets = sqliteTable('tickets', {
attendeePhone: text('attendee_phone'),
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
preferredLanguage: text('preferred_language'),
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'),
checkinAt: text('checkin_at'),
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
qrCode: text('qr_code'),
adminNote: text('admin_note'),
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
createdAt: text('created_at').notNull(),
});
@@ -117,8 +127,11 @@ export const sqlitePayments = sqliteTable('payments', {
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
amount: real('amount').notNull(),
currency: text('currency').notNull().default('PYG'),
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
reference: text('reference'),
lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires
lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
payerName: text('payer_name'), // Name of payer if different from attendee
paidAt: text('paid_at'),
@@ -271,6 +284,18 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
updatedAt: text('updated_at').notNull(),
});
// Durable email queue. Jobs survive process restarts; a startup recovery step
// resets any 'processing' rows back to 'pending'.
export const sqliteEmailQueue = sqliteTable('email_queue', {
id: text('id').primaryKey(),
params: text('params').notNull(), // JSON-encoded TemplateEmailJobParams
status: text('status', { enum: ['pending', 'processing', 'sent', 'failed'] }).notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
lastError: text('last_error'),
createdAt: text('created_at').notNull(),
processedAt: text('processed_at'),
});
// Legal Pages table for admin-editable legal content
export const sqliteLegalPages = sqliteTable('legal_pages', {
id: text('id').primaryKey(),
@@ -359,6 +384,14 @@ export const pgUsers = pgTable('users', {
googleId: varchar('google_id', { length: 255 }),
rucNumber: varchar('ruc_number', { length: 15 }),
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: pgInteger('token_version').notNull().default(0),
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
emailVerified: pgBoolean('email_verified').notNull().default(false),
image: pgText('image'),
banned: pgBoolean('banned').notNull().default(false),
banReason: pgText('ban_reason'),
banExpires: timestamp('ban_expires'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
@@ -449,6 +482,8 @@ export const pgTickets = pgTable('tickets', {
qrCode: varchar('qr_code', { length: 255 }),
adminNote: pgText('admin_note'),
isGuest: pgInteger('is_guest').notNull().default(0),
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
createdAt: timestamp('created_at').notNull(),
});
@@ -460,6 +495,9 @@ export const pgPayments = pgTable('payments', {
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
status: varchar('status', { length: 20 }).notNull().default('pending'),
reference: varchar('reference', { length: 255 }),
lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires
lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
userMarkedPaidAt: timestamp('user_marked_paid_at'),
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
paidAt: timestamp('paid_at'),
@@ -604,6 +642,18 @@ export const pgEmailSettings = pgTable('email_settings', {
updatedAt: timestamp('updated_at').notNull(),
});
// Durable email queue. Jobs survive process restarts; a startup recovery step
// resets any 'processing' rows back to 'pending'.
export const pgEmailQueue = pgTable('email_queue', {
id: uuid('id').primaryKey(),
params: pgText('params').notNull(), // JSON-encoded TemplateEmailJobParams
status: varchar('status', { length: 20 }).notNull().default('pending'),
attempts: pgInteger('attempts').notNull().default(0),
lastError: pgText('last_error'),
createdAt: timestamp('created_at').notNull(),
processedAt: timestamp('processed_at'),
});
// Legal Pages table for admin-editable legal content
export const pgLegalPages = pgTable('legal_pages', {
id: uuid('id').primaryKey(),
@@ -691,6 +741,7 @@ export const auditLogs = dbType === 'postgres' ? pgAuditLogs : sqliteAuditLogs;
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
export const emailQueue = dbType === 'postgres' ? pgEmailQueue : sqliteEmailQueue;
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
+264 -141
View File
@@ -7,7 +7,9 @@ import { logger } from 'hono/logger';
import { swaggerUI } from '@hono/swagger-ui';
import { serveStatic } from '@hono/node-server/serve-static';
import authRoutes from './routes/auth.js';
import { auth } from './lib/betterAuth.js';
import authExtRoutes from './routes/authExt.js';
import { getClientIp } from './lib/rateLimit.js';
import eventsRoutes from './routes/events.js';
import ticketsRoutes from './routes/tickets.js';
import usersRoutes from './routes/users.js';
@@ -24,7 +26,13 @@ import legalPagesRoutes from './routes/legal-pages.js';
import legalSettingsRoutes from './routes/legal-settings.js';
import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js';
import { initEmailQueue } from './lib/emailQueue.js';
import { initEmailQueue, stopQueue } from './lib/emailQueue.js';
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
import { closeRedis } from './lib/redis.js';
import { getLock } from './lib/stores/lock.js';
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
const app = new Hono();
@@ -51,11 +59,24 @@ app.use(
if (!origin) return frontendUrl;
return allowedOrigins.has(origin) ? origin : null;
},
// We use bearer tokens, but keeping credentials=true matches nginx config.
// Session cookies must be allowed on cross-origin API calls (api.* vhost).
credentials: true,
})
);
// Baseline security headers on every response.
const isProduction = process.env.NODE_ENV === 'production';
app.use('*', async (c, next) => {
await next();
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('X-XSS-Protection', '0');
if (isProduction) {
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});
// OpenAPI specification
const openApiSpec = {
openapi: '3.0.0',
@@ -91,11 +112,15 @@ const openApiSpec = {
],
paths: {
// ==================== Auth Endpoints ====================
'/api/auth/register': {
// Authentication is handled by Better Auth, mounted at /api/auth/*.
// Sessions are httpOnly cookies (spanglish.session_token); the endpoints
// below are the subset the frontend uses. See https://better-auth.com/docs
// for the full endpoint reference.
'/api/auth/sign-up/email': {
post: {
tags: ['Auth'],
summary: 'Register a new user',
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
summary: 'Register a new user (Better Auth)',
description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.',
requestBody: {
required: true,
content: {
@@ -105,8 +130,8 @@ const openApiSpec = {
required: ['email', 'password', 'name'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
name: { type: 'string', minLength: 2 },
password: { type: 'string', minLength: 10 },
name: { type: 'string' },
phone: { type: 'string' },
languagePreference: { type: 'string', enum: ['en', 'es'] },
},
@@ -115,16 +140,17 @@ const openApiSpec = {
},
},
responses: {
201: { description: 'User created successfully' },
400: { description: 'Email already registered or validation error' },
200: { description: 'User created; session cookie set' },
422: { description: 'Email already registered' },
400: { description: 'Validation error' },
},
},
},
'/api/auth/login': {
'/api/auth/sign-in/email': {
post: {
tags: ['Auth'],
summary: 'Login with email and password',
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
summary: 'Login with email and password (Better Auth)',
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
requestBody: {
required: true,
content: {
@@ -141,42 +167,46 @@ const openApiSpec = {
},
},
responses: {
200: { description: 'Login successful, returns JWT token' },
200: { description: 'Login successful; session cookie set' },
401: { description: 'Invalid credentials' },
429: { description: 'Too many login attempts' },
429: { description: 'Too many attempts' },
},
},
},
'/api/auth/google': {
'/api/auth/sign-in/social': {
post: {
tags: ['Auth'],
summary: 'Login or register with Google',
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
summary: 'Login or register with Google (Better Auth)',
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['credential'],
required: ['provider'],
properties: {
credential: { type: 'string', description: 'Google ID token' },
provider: { type: 'string', enum: ['google'] },
idToken: {
type: 'object',
properties: { token: { type: 'string', description: 'Google ID token' } },
},
},
},
},
},
},
responses: {
200: { description: 'Login successful' },
400: { description: 'Invalid Google token' },
200: { description: 'Login successful; session cookie set' },
401: { description: 'Invalid Google token' },
},
},
},
'/api/auth/magic-link/request': {
'/api/auth/sign-in/magic-link': {
post: {
tags: ['Auth'],
summary: 'Request magic link login',
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
summary: 'Request magic link login (Better Auth)',
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
requestBody: {
required: true,
content: {
@@ -186,46 +216,33 @@ const openApiSpec = {
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
callbackURL: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Magic link sent (if account exists)' },
},
responses: { 200: { description: 'Magic link sent (if account exists)' } },
},
},
'/api/auth/magic-link/verify': {
post: {
get: {
tags: ['Auth'],
summary: 'Verify magic link token',
description: 'Verify the magic link token and login user.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
},
},
},
},
},
summary: 'Verify magic link token (Better Auth)',
parameters: [
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Login successful' },
200: { description: 'Login successful; session cookie set' },
400: { description: 'Invalid or expired token' },
},
},
},
'/api/auth/password-reset/request': {
'/api/auth/request-password-reset': {
post: {
tags: ['Auth'],
summary: 'Request password reset',
description: 'Send a password reset link to email. Link expires in 30 minutes.',
summary: 'Request password reset (Better Auth)',
description: 'Emails a reset link. Token expires in 30 minutes.',
requestBody: {
required: true,
content: {
@@ -235,31 +252,30 @@ const openApiSpec = {
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
redirectTo: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Reset link sent (if account exists)' },
},
responses: { 200: { description: 'Reset link sent (if account exists)' } },
},
},
'/api/auth/password-reset/confirm': {
'/api/auth/reset-password': {
post: {
tags: ['Auth'],
summary: 'Confirm password reset',
description: 'Reset password using the token from email.',
summary: 'Reset password with token (Better Auth)',
description: 'Sets a new password and revokes all existing sessions.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token', 'password'],
required: ['newPassword', 'token'],
properties: {
newPassword: { type: 'string', minLength: 10 },
token: { type: 'string' },
password: { type: 'string', minLength: 10 },
},
},
},
@@ -271,62 +287,10 @@ const openApiSpec = {
},
},
},
'/api/auth/claim-account/request': {
post: {
tags: ['Auth'],
summary: 'Request account claim link',
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
},
},
},
responses: {
200: { description: 'Claim link sent (if unclaimed account exists)' },
},
},
},
'/api/auth/claim-account/confirm': {
post: {
tags: ['Auth'],
summary: 'Confirm account claim',
description: 'Claim an unclaimed account by setting password or linking Google.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
},
},
},
},
},
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Invalid token or missing credentials' },
},
},
},
'/api/auth/change-password': {
post: {
tags: ['Auth'],
summary: 'Change password',
description: 'Change password for authenticated user.',
summary: 'Change password (Better Auth)',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
@@ -338,6 +302,7 @@ const openApiSpec = {
properties: {
currentPassword: { type: 'string' },
newPassword: { type: 'string', minLength: 10 },
revokeOtherSessions: { type: 'boolean' },
},
},
},
@@ -350,26 +315,66 @@ const openApiSpec = {
},
},
},
'/api/auth/me': {
'/api/auth/get-session': {
get: {
tags: ['Auth'],
summary: 'Get current user',
description: 'Get the currently authenticated user profile.',
summary: 'Get current session and user (Better Auth)',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Current user data' },
401: { description: 'Unauthorized' },
200: { description: '{ session, user } or null when not authenticated' },
},
},
},
'/api/auth/logout': {
'/api/auth/sign-out': {
post: {
tags: ['Auth'],
summary: 'Logout',
description: 'Logout current user (client-side token removal).',
responses: {
200: { description: 'Logged out' },
summary: 'Logout (Better Auth)',
description: 'Revokes the current session and clears the session cookie.',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Logged out' } },
},
},
'/api/auth/list-sessions': {
get: {
tags: ['Auth'],
summary: 'List active sessions (Better Auth)',
security: [{ bearerAuth: [] }],
responses: { 200: { description: 'Active sessions for the current user' } },
},
},
'/api/auth-ext/claim-account': {
post: {
tags: ['Auth'],
summary: 'Claim a guest-created account',
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: { password: { type: 'string', minLength: 10 } },
},
},
},
},
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Already claimed or validation error' },
401: { description: 'No session (claim link required)' },
},
},
},
'/api/auth-ext/claim-eligibility': {
get: {
tags: ['Auth'],
summary: 'Check whether an email has an unclaimed account',
parameters: [
{ name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } },
],
responses: { 200: { description: '{ canClaim: boolean }' } },
},
},
@@ -657,11 +662,21 @@ const openApiSpec = {
},
},
},
'/api/events/next': {
get: {
tags: ['Events'],
summary: 'Get next upcoming event (chronological)',
description: 'Get the single earliest upcoming published event, ignoring featured promotion.',
responses: {
200: { description: 'Next event or null' },
},
},
},
'/api/events/next/upcoming': {
get: {
tags: ['Events'],
summary: 'Get next upcoming event',
description: 'Get the single next upcoming published event.',
description: 'Get the featured event if valid, otherwise the single next upcoming published event.',
responses: {
200: { description: 'Next event or null' },
},
@@ -1733,10 +1748,10 @@ const openApiSpec = {
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT token obtained from login endpoint',
type: 'apiKey',
in: 'cookie',
name: 'spanglish.session_token',
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
},
},
schemas: {
@@ -1827,24 +1842,72 @@ const openApiSpec = {
},
};
// OpenAPI JSON endpoint
app.get('/openapi.json', (c) => {
return c.json(openApiSpec);
// API documentation is disabled in production to avoid exposing the full API
// surface (and schema) to anonymous users. Enable locally / in non-prod only.
if (!isProduction) {
// OpenAPI JSON endpoint
app.get('/openapi.json', (c) => {
return c.json(openApiSpec);
});
// Swagger UI
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
} else {
app.get('/openapi.json', (c) => c.json({ error: 'Not Found' }, 404));
app.get('/api-docs', (c) => c.json({ error: 'Not Found' }, 404));
}
// Static file serving for uploads.
// Uploads are validated as images at write time, but as defense-in-depth we force
// any non-image path to download as an opaque attachment so a stray/legacy
// .html/.svg can never be rendered (and therefore never execute script) in-origin.
app.use('/uploads/*', async (c, next) => {
await next();
const path = c.req.path.toLowerCase();
const isInlineImage = /\.(jpg|jpeg|png|gif|webp|avif)$/.test(path);
if (!isInlineImage) {
c.header('Content-Disposition', 'attachment');
c.header('Content-Type', 'application/octet-stream');
}
});
// Swagger UI
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
// Static file serving for uploads
app.use('/uploads/*', serveStatic({ root: './' }));
// Health check
// Health check.
// Always returns 200 so a transient Redis blip does not cause the load balancer
// to pull a node; Redis/subsystem status is reported in the body for monitoring.
app.get('/health', (c) => {
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
redis: describeRedis(),
backends: describeBackends(),
});
});
// API Routes
app.route('/api/auth', authRoutes);
// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link,
// password reset, Google, session management). CORS above runs first.
//
// Better Auth only sees the Request (no TCP peer address), so its per-IP rate
// limiting is fed the socket-anchored client IP resolved by getClientIp via a
// private header. The inbound value is always discarded — a client cannot
// choose its own rate-limit bucket.
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
const headers = new Headers(c.req.raw.headers);
headers.delete('x-client-ip');
const clientIp = getClientIp(c);
if (clientIp && clientIp !== 'unknown') {
headers.set('x-client-ip', clientIp);
}
return auth.handler(
new Request(c.req.raw, {
headers,
// Node's fetch requires duplex for requests carrying a body stream
...(c.req.raw.body ? { duplex: 'half' as const } : {}),
} as RequestInit)
);
});
app.route('/api/auth-ext', authExtRoutes);
app.route('/api/events', eventsRoutes);
app.route('/api/tickets', ticketsRoutes);
app.route('/api/users', usersRoutes);
@@ -1877,16 +1940,76 @@ const port = parseInt(process.env.PORT || '3001');
// Initialize email queue with the email service reference
initEmailQueue(emailService);
// Initialize email templates on startup
emailService.seedDefaultTemplates().catch(err => {
console.error('[Email] Failed to seed templates:', err);
});
// Periodically expire abandoned pending bookings so they stop holding seats.
startBookingCleanup();
// Periodically put stale pending-approval payments on hold, releasing their seats.
startHoldSweep();
// Periodically auto-reject unconfirmed payments once their event is over (no email).
startEventEndSweep();
// Initialize email templates on startup.
// Guarded by a distributed lock so that, when running multiple replicas, only
// one instance seeds/updates templates per boot instead of all of them racing.
// onUnavailable 'run': at boot the Redis connection may not be ready yet, and
// seeding is upsert-idempotent, so racing replicas are safe — never skipping
// beats never seeding on a first boot during a Redis blip.
getLock()
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' })
.then((result) => {
if (result === null) {
console.log('[Email] Template seeding skipped (another instance holds the lock)');
}
})
.catch(err => {
console.error('[Email] Failed to seed templates:', err);
});
console.log(`🚀 Spanglish API server starting on port ${port}`);
console.log(`📚 API docs available at http://localhost:${port}/api-docs`);
console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
serve({
// Log which backend (memory/redis, local/s3) each subsystem selected.
logSelectedBackends();
const server = serve({
fetch: app.fetch,
port,
});
// Graceful shutdown: stop the periodic jobs, stop accepting connections, then
// close Redis and exit. Open SSE payment streams hold sockets forever, so
// server.close() alone never completes — force-close remaining connections
// after a short grace period, with a hard exit as the final backstop.
let shuttingDown = false;
function shutdown(signal: string): void {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] ${signal} received, draining...`);
stopBookingCleanup();
stopHoldSweep();
stopEventEndSweep();
stopQueue();
server.close(() => {
console.log('[shutdown] server closed, closing redis');
closeRedis().finally(() => process.exit(0));
});
const forceClose = setTimeout(() => {
console.warn('[shutdown] force-closing remaining connections (SSE streams)');
(server as any).closeAllConnections?.();
}, 5_000);
forceClose.unref();
const forceExit = setTimeout(() => {
console.warn('[shutdown] drain timed out, forcing exit');
process.exit(1);
}, 10_000);
forceExit.unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+98 -225
View File
@@ -1,254 +1,127 @@
import * as jose from 'jose';
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { Context } from 'hono';
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt } from 'drizzle-orm';
import { generateId, getNow, toDbDate } from './utils.js';
import { and, eq } from 'drizzle-orm';
import { auth } from './betterAuth.js';
import { db, dbGet } from '../db/index.js';
import { authAccounts } from '../db/auth-schema.js';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
const JWT_ISSUER = 'spanglish';
const JWT_AUDIENCE = 'spanglish-app';
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
// sessions validated against the auth_sessions table on every request, so
// revocation (ban/suspend/password reset) applies instantly. This module keeps
// the request-side helpers that the route files use.
export interface JWTPayload {
sub: string;
// Re-exported for routes that hash/validate passwords outside Better Auth
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
export interface AuthUser {
id: string;
email: string;
name: string;
phone: string | null;
role: string;
iat: number;
exp: number;
languagePreference: string | null;
isClaimed: boolean;
rucNumber: string | null;
accountStatus: string;
emailVerified: boolean;
image: string | null;
createdAt: Date | string;
updatedAt: Date | string;
/** ID of the Better Auth session backing this request. */
sessionId: string;
}
// Password hashing with Argon2 (spec requirement)
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Generate secure random token for magic links
export function generateSecureToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// Create magic link token
export async function createMagicLinkToken(
userId: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
expiresInMinutes: number = 10
): Promise<string> {
const token = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
await (db as any).insert(magicLinkTokens).values({
id: generateId(),
userId,
token,
type,
expiresAt,
createdAt: now,
});
return token;
}
// Verify and consume magic link token
export async function verifyMagicLinkToken(
token: string,
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
): Promise<{ valid: boolean; userId?: string; error?: string }> {
const now = getNow();
const tokenRecord = await dbGet<any>(
(db as any)
.select()
.from(magicLinkTokens)
.where(
and(
eq((magicLinkTokens as any).token, token),
eq((magicLinkTokens as any).type, type)
)
)
);
if (!tokenRecord) {
return { valid: false, error: 'Invalid token' };
}
if (tokenRecord.usedAt) {
return { valid: false, error: 'Token already used' };
}
if (new Date(tokenRecord.expiresAt) < new Date()) {
return { valid: false, error: 'Token expired' };
}
// Mark token as used
await (db as any)
.update(magicLinkTokens)
.set({ usedAt: now })
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
return { valid: true, userId: tokenRecord.userId };
}
// Create user session
export async function createUserSession(
userId: string,
userAgent?: string,
ipAddress?: string
): Promise<string> {
const sessionToken = generateSecureToken();
const now = getNow();
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
await (db as any).insert(userSessions).values({
id: generateId(),
userId,
token: sessionToken,
userAgent: userAgent || null,
ipAddress: ipAddress || null,
lastActiveAt: now,
expiresAt,
createdAt: now,
});
return sessionToken;
}
// Get user's active sessions
export async function getUserSessions(userId: string) {
const now = getNow();
return dbAll(
(db as any)
.select()
.from(userSessions)
.where(
and(
eq((userSessions as any).userId, userId),
gt((userSessions as any).expiresAt, now)
)
)
);
}
// Invalidate a specific session
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
const result = await (db as any)
.delete(userSessions)
.where(
and(
eq((userSessions as any).id, sessionId),
eq((userSessions as any).userId, userId)
)
);
return true;
}
// Invalidate all user sessions (logout everywhere)
export async function invalidateAllUserSessions(userId: string): Promise<void> {
await (db as any)
.delete(userSessions)
.where(eq((userSessions as any).userId, userId));
}
// Password validation (min 10 characters per spec)
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
return { valid: true };
}
export async function createToken(userId: string, email: string, role: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime('7d')
.sign(JWT_SECRET);
return token;
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setExpirationTime('30d')
.sign(JWT_SECRET);
return token;
}
export async function verifyToken(token: string): Promise<JWTPayload | null> {
/**
* Resolve the authenticated user for a request from its Better Auth session
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
* accounts never get API access even with a live session cookie.
*/
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
try {
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
});
return payload as unknown as JWTPayload;
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session?.user) {
return null;
}
const user = session.user as any;
// Suspended (banned) or unclaimed accounts must not retain API access
if (user.banned) {
return null;
}
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
phone: user.phone ?? null,
role: user.role ?? 'user',
languagePreference: user.languagePreference ?? null,
isClaimed: Boolean(user.isClaimed),
rucNumber: user.rucNumber ?? null,
accountStatus: user.accountStatus ?? 'active',
emailVerified: Boolean(user.emailVerified),
image: user.image ?? null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
sessionId: session.session.id,
};
} catch {
return null;
}
}
export async function getAuthUser(c: Context): Promise<any | null> {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload) {
return null;
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
);
return user || null;
}
export function requireAuth(roles?: string[]) {
return async (c: Context, next: () => Promise<void>) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
if (roles && !roles.includes(user.role)) {
return c.json({ error: 'Forbidden' }, 403);
}
c.set('user', user);
await next();
};
}
export async function isFirstUser(): Promise<boolean> {
const result = await dbAll(
(db as any).select().from(users).limit(1)
/**
* Fetch only the credential password hash (never exposed via getAuthUser).
* Returns null when the user has no password set (Google-only or unclaimed).
*/
export async function getUserPasswordHash(userId: string): Promise<string | null> {
const row = await dbGet<any>(
(db as any)
.select({ password: (authAccounts as any).password })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'credential')
)
)
);
return !result || result.length === 0;
const hash = row?.password;
return hash && String(hash).length > 0 ? String(hash) : null;
}
/** Whether the user has a linked Google account. */
export async function hasGoogleAccount(userId: string): Promise<boolean> {
const row = await dbGet<any>(
(db as any)
.select({ id: (authAccounts as any).id })
.from(authAccounts)
.where(
and(
eq((authAccounts as any).userId, userId),
eq((authAccounts as any).providerId, 'google')
)
)
);
return !!row;
}
+39
View File
@@ -0,0 +1,39 @@
// Reports which backend each scalable subsystem is using, for the health
// endpoint and startup logging.
import { isRedisEnabled, isRedisHealthy, getRedisHealthDetail } from './redis.js';
import { getRateLimiter } from './stores/rateLimiter.js';
import { getPubSub } from './stores/pubsub.js';
import { getCache } from './stores/cache.js';
import { getLock } from './stores/lock.js';
import { getLoginLockout } from './stores/loginLockout.js';
import { getStorage } from './storage.js';
export function describeBackends() {
return {
cache: getCache().backend,
rateLimiter: getRateLimiter().backend,
pubsub: getPubSub().backend,
lock: getLock().backend,
loginLockout: getLoginLockout().backend,
storage: getStorage().backend,
};
}
export function describeRedis() {
return { enabled: isRedisEnabled(), healthy: isRedisHealthy(), ...getRedisHealthDetail() };
}
/** Log one line per subsystem at startup so the active backend is obvious. */
export function logSelectedBackends(): void {
const b = describeBackends();
const r = describeRedis();
console.log('[startup] Subsystem backends:');
console.log(` redis: ${r.enabled ? 'enabled' : 'disabled (in-memory fallback)'}`);
console.log(` cache: ${b.cache}`);
console.log(` rate limiter: ${b.rateLimiter}`);
console.log(` pub/sub: ${b.pubsub}`);
console.log(` lock: ${b.lock}`);
console.log(` login lockout:${b.loginLockout}`);
console.log(` storage: ${b.storage}`);
}
@@ -0,0 +1,293 @@
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';
// 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
delete process.env.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]);
}
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);
})();
}, 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);
});
});
+362
View File
@@ -0,0 +1,362 @@
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'],
},
},
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' } };
}
},
},
},
},
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;
+115
View File
@@ -0,0 +1,115 @@
// Expire stale pending bookings.
//
// When a booking is started, its tickets are created with status 'pending' and
// a 'pending' payment. Pending tickets count toward an event's capacity, so an
// abandoned checkout would otherwise hold those seats forever. This job cancels
// pending tickets whose payment is still 'pending' (i.e. never paid and not
// awaiting admin approval) after a configurable TTL, freeing the seats.
//
// Two exclusions:
// - Manual-verification providers (bank transfer / TPago / cash) are never
// auto-failed; they are settled by an admin and instead follow the 72h on-hold
// sweep. See holdSweep.ts and MANUAL_PAYMENT_PROVIDERS.
// - Staleness is measured from `updatedAt`, not `createdAt`, so an admin action
// (e.g. reopening a payment to 'pending' via /reopen) restarts the TTL rather
// than being immediately re-failed on the next run.
import { and, eq, lt, inArray, notInArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js';
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
function getTtlMs(): number {
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
return (Number.isFinite(minutes) && minutes > 0 ? minutes : 30) * 60 * 1000;
}
/**
* Cancel stale pending bookings. Returns the number of tickets cancelled.
*
* A booking is considered stale when its payment is still 'pending' (not
* 'pending_approval', which means an admin is reviewing a manual transfer),
* uses a non-manual provider, and has not been touched (updatedAt) for
* PENDING_BOOKING_TTL_MINUTES.
*/
export async function cleanupStalePendingBookings(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
(db as any)
.select({
ticketId: (payments as any).ticketId,
paymentId: (payments as any).id,
})
.from(payments)
.where(and(
eq((payments as any).status, 'pending'),
notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
lt((payments as any).updatedAt, cutoff)
))
);
if (stale.length === 0) return 0;
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
const paymentIds = stale.map((s) => s.paymentId);
const now = getNow();
let cancelledTickets = 0;
if (ticketIds.length > 0) {
const result: any = await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'pending')
));
cancelledTickets = result?.changes ?? result?.rowCount ?? ticketIds.length;
}
await (db as any)
.update(payments)
.set({ status: 'failed', updatedAt: now })
.where(inArray((payments as any).id, paymentIds));
console.log(
`[BookingCleanup] Expired ${stale.length} stale pending payment(s); ` +
`cancelled ${cancelledTickets} ticket(s).`
);
return cancelledTickets;
}
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
/**
* Start a periodic cleanup of stale pending bookings. Each run is guarded by a
* distributed lock so that, across multiple replicas, only one instance does
* the work per interval.
*/
export function startBookingCleanup(): void {
const intervalMs = parseInt(process.env.PENDING_BOOKING_CLEANUP_INTERVAL_MS || '300000', 10); // 5 min
const run = () => {
getLock()
.withLock('cleanup-pending-bookings', Math.min(intervalMs, 60_000), () =>
cleanupStalePendingBookings()
)
.catch((err) =>
console.error('[BookingCleanup] Run failed:', err?.message || err)
);
};
// Run shortly after startup, then on the interval.
setTimeout(run, 30_000).unref?.();
cleanupTimer = setInterval(run, intervalMs);
cleanupTimer.unref?.();
console.log(`[BookingCleanup] Scheduled every ${Math.round(intervalMs / 1000)}s`);
}
export function stopBookingCleanup(): void {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
}
+70
View File
@@ -0,0 +1,70 @@
// Single source of truth for event seat accounting.
//
// A seat is held by a booking the moment the money is real or claimed to be:
// - ticket 'confirmed' or 'checked_in' (paid), or
// - ticket 'pending' whose payment is 'pending_approval' (customer clicked
// "I've paid" and is waiting for admin verification).
//
// A bare 'pending' payment — an opened checkout that was never paid nor claimed,
// on any provider — holds NO seat, so abandoned bookings can never block sales.
// The accepted trade-off is a small oversell window when several people book the
// last seats and all later pay/claim; admin approval is the backstop and may
// knowingly approve over capacity (see routes/payments.ts).
//
// 'pending_approval' is a payment status (the ticket row stays 'pending'), so
// every capacity count joins tickets to payments. There is exactly one payment
// row per ticket (created together in routes/tickets.ts).
//
// Every place that counts seats — booking creation, public availability,
// hold recovery, admin dashboards — must go through these builders so the
// formula cannot diverge between surfaces.
import { sql, and, eq, inArray } from 'drizzle-orm';
import { tickets, payments } from '../db/index.js';
// COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no
// payment row must count as "not holding" — NULL would poison NOT ...).
export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`;
/**
* Query: number of seats currently held for an event.
* `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`;
* async pg tx / plain db: await via dbGet).
*/
export function seatHolderCountQuery(executor: any, eventId: string) {
return executor
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
.where(and(eq((tickets as any).eventId, eventId), seatHoldingSql));
}
/**
* Query: how many of the given tickets do NOT currently hold a seat (and so
* would need fresh capacity if promoted to a seat-holding state).
*/
export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) {
return executor
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
.where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`));
}
/**
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
* Pass `eventId` to restrict to one event (still returns a grouped row).
*/
export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
const query = executor
.select({
eventId: (tickets as any).eventId,
paidCount: sql<number>`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`,
claimedCount: sql<number>`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`,
})
.from(tickets)
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query)
.groupBy((tickets as any).eventId);
}
+56 -1404
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
// High-level booking confirmation email sender.
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { sendTemplateEmail } from './templateService.js';
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
/**
* Send booking confirmation email
* Supports multi-ticket bookings - includes all tickets in the booking
*/
export async function sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket with event info
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Get all tickets in this booking (if multi-ticket)
let allTickets: any[] = [ticket];
if (ticket.bookingId) {
allTickets = await dbAll(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
const ticketCount = allTickets.length;
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
// Generate ticket PDF URL (primary ticket, or use combined endpoint for multi)
const apiUrl = process.env.API_URL || 'http://localhost:3001';
const ticketPdfUrl = ticketCount > 1 && ticket.bookingId
? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf`
: `${apiUrl}/api/tickets/${ticket.id}/pdf`;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Build attendee list for multi-ticket emails
const attendeeNames = allTickets.map(t =>
`${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim()
).join(', ');
// Calculate total price for multi-ticket bookings
const totalPrice = event.price * ticketCount;
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
return sendTemplateEmail({
templateSlug: 'booking-confirmation',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
bookingId: ticket.bookingId || ticket.id,
qrCode: ticket.qrCode || '',
ticketPdfUrl,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
eventPrice: formatCurrency(event.price, event.currency),
// Multi-ticket specific variables
ticketCount: ticketCount.toString(),
totalPrice: formatCurrency(totalPrice, event.currency),
attendeeNames,
isMultiTicket: ticketCount > 1 ? 'true' : 'false',
},
});
}
+101
View File
@@ -0,0 +1,101 @@
// Event-wide bulk email sending via the background queue.
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
import { eq, and } from 'drizzle-orm';
import { enqueueBulkEmails, type TemplateEmailJobParams } from '../emailQueue.js';
import { getTemplate } from './templateService.js';
import { formatDate, formatTime, getSiteTimezone } from './formatting.js';
/**
* Queue emails for event attendees (non-blocking).
* Adds all matching recipients to the background email queue and returns immediately.
* Rate limiting and actual sending is handled by the email queue.
*/
export async function queueEventEmails(params: {
eventId: string;
templateSlug: string;
customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
sentBy: string;
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
// Validate event exists
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
);
if (!event) {
return { success: false, queuedCount: 0, error: 'Event not found' };
}
// Validate template exists
const template = await getTemplate(templateSlug);
if (!template) {
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
}
// Get tickets based on filter
let ticketQuery = (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, eventId));
if (recipientFilter !== 'all') {
ticketQuery = ticketQuery.where(
and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).status, recipientFilter)
)
);
}
const eventTickets = await dbAll<any>(ticketQuery);
if (eventTickets.length === 0) {
return { success: true, queuedCount: 0, error: 'No recipients found' };
}
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
// Build individual email jobs for the queue
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
return {
templateSlug,
to: ticket.attendeeEmail,
toName: fullName,
locale,
eventId: event.id,
sentBy,
variables: {
attendeeName: fullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
...customVariables,
},
};
});
// Enqueue all emails for background processing
enqueueBulkEmails(jobs);
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
return {
success: true,
queuedCount: jobs.length,
};
}
+69
View File
@@ -0,0 +1,69 @@
// Shared formatting helpers and common template variables for emails.
import { db, dbGet, siteSettings } from '../../db/index.js';
import { getCache } from '../stores/cache.js';
/**
* Get common variables for all emails
*/
export function getCommonVariables(): Record<string, string> {
return {
siteName: 'Spanglish',
siteUrl: process.env.FRONTEND_URL || 'https://spanglish.com',
currentYear: new Date().getFullYear().toString(),
supportEmail: process.env.EMAIL_FROM || 'hello@spanglish.com',
};
}
/**
* Get the site timezone from settings (cached for performance).
* Cached for a short TTL via the cache abstraction (in-memory or Redis).
*/
export async function getSiteTimezone(): Promise<string> {
const cached = await getCache().get<string>('site:timezone');
if (cached) return cached;
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
const timezone = settings?.timezone || 'America/Asuncion';
await getCache().set('site:timezone', timezone, 60);
return timezone;
}
/**
* Format date for emails using site timezone
*/
export function formatDate(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: timezone,
});
}
/**
* Format time for emails using site timezone
*/
export function formatTime(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
timeZone: timezone,
});
}
/**
* Format currency for emails. Kept distinct from lib/utils.ts formatCurrency
* because the email output format ("12.345 PYG" / "$10.00 USD") must not change.
*/
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
}
+474
View File
@@ -0,0 +1,474 @@
// High-level payment-related email senders and payment config resolution.
import { db, dbGet, dbAll, events, tickets, payments, paymentOptions, eventPaymentOverrides } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { sendTemplateEmail } from './templateService.js';
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
/**
* Send payment receipt email
*/
export async function sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment with ticket and event info
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Calculate total amount for multi-ticket bookings
let totalAmount = payment.amount;
let ticketCount = 1;
if (ticket.bookingId) {
// Get all payments for this booking
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
// Sum up all payment amounts for the booking
const bookingPayments = await Promise.all(
bookingTickets.map((t: any) =>
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
)
);
totalAmount = bookingPayments
.filter((p: any) => p)
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const paymentMethodNames: Record<string, Record<string, string>> = {
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
};
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
: formatCurrency(totalAmount, payment.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
return sendTemplateEmail({
templateSlug: 'payment-receipt',
to: ticket.attendeeEmail,
toName: receiptFullName,
locale,
eventId: event.id,
variables: {
attendeeName: receiptFullName,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
paymentAmount: amountDisplay,
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
paymentReference: payment.reference || payment.id,
paymentDate: formatDate(payment.paidAt || payment.createdAt, locale, timezone),
},
});
}
/**
* Get merged payment configuration for an event (global + overrides)
*/
export async function getPaymentConfig(eventId: string): Promise<Record<string, any>> {
// Get global options
const globalOptions = await dbGet<any>(
(db as any)
.select()
.from(paymentOptions)
);
// Get event overrides
const overrides = await dbGet<any>(
(db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
);
// Defaults
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
};
const global = globalOptions || defaults;
// Merge: override values take precedence if they're not null/undefined
return {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
bankName: overrides?.bankName ?? global.bankName,
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
bankAlias: overrides?.bankAlias ?? global.bankAlias,
bankPhone: overrides?.bankPhone ?? global.bankPhone,
bankNotes: overrides?.bankNotes ?? global.bankNotes,
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
};
}
/**
* Send payment instructions email (for TPago or Bank Transfer)
* This email is sent immediately after user clicks "Continue to Payment"
*/
export async function sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Only send for manual payment methods
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
}
// Get merged payment config for this event
const paymentConfig = await getPaymentConfig(event.id);
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Calculate total price for multi-ticket bookings
let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
// Count all tickets in this booking
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
totalPrice = event.price * ticketCount;
}
// Generate a payment reference using booking ID or ticket ID
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
// Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
// Determine which template to use
const templateSlug = payment.provider === 'tpago'
? 'payment-instructions-tpago'
: 'payment-instructions-bank-transfer';
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
// Build variables based on payment method
const variables: Record<string, any> = {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
paymentAmount: amountDisplay,
paymentReference,
bookingUrl,
};
// Add payment-method specific variables
if (payment.provider === 'tpago') {
// Select the TPago link matching the number of tickets (1-5), falling back to the base link
const tpagoLinkKey = ticketCount <= 1 ? 'tpagoLink' : `tpagoLink${Math.min(ticketCount, 5)}`;
variables.tpagoLink = paymentConfig[tpagoLinkKey] || paymentConfig.tpagoLink || '';
} else {
// Bank transfer
variables.bankName = paymentConfig.bankName || '';
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
variables.bankAlias = paymentConfig.bankAlias || '';
variables.bankPhone = paymentConfig.bankPhone || '';
}
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug,
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables,
});
}
/**
* Send payment rejection email
* This email is sent when admin rejects a TPago or Bank Transfer payment
*/
export async function sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Generate a new booking URL for the event
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const newBookingUrl = `${frontendUrl}/book/${event.id}`;
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug: 'payment-rejected',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
newBookingUrl,
},
});
}
/**
* Send payment reminder email
* This email is sent when admin wants to remind attendee about pending payment
*/
export async function sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Only send for pending/pending_approval payments
if (!['pending', 'pending_approval'].includes(payment.status)) {
return { success: false, error: 'Payment reminder can only be sent for pending payments' };
}
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Calculate total price for multi-ticket bookings
let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
totalPrice = event.price * ticketCount;
}
// Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug: 'payment-reminder',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
paymentAmount: amountDisplay,
bookingUrl,
},
});
}
+307
View File
@@ -0,0 +1,307 @@
// Template DB access, seeding, and the core template/custom send + logging logic.
import { db, dbGet, emailTemplates, emailLogs } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { getNow, generateId } from '../utils.js';
import { replaceTemplateVariables, wrapInBaseTemplate, defaultTemplates } from '../emailTemplates.js';
import { sendEmail } from './transport.js';
import { getCommonVariables } from './formatting.js';
/**
* Get a template by slug
*/
export async function getTemplate(slug: string): Promise<any | null> {
const template = await dbGet(
(db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).slug, slug))
);
return template || null;
}
/**
* Seed default templates if they don't exist, and update system templates with latest content
*/
export async function seedDefaultTemplates(): Promise<void> {
console.log('[Email] Checking for default templates...');
for (const template of defaultTemplates) {
const existing = await getTemplate(template.slug);
const now = getNow();
if (!existing) {
console.log(`[Email] Creating template: ${template.name}`);
await (db as any).insert(emailTemplates).values({
id: generateId(),
name: template.name,
slug: template.slug,
subject: template.subject,
subjectEs: template.subjectEs,
bodyHtml: template.bodyHtml,
bodyHtmlEs: template.bodyHtmlEs,
bodyText: template.bodyText,
bodyTextEs: template.bodyTextEs,
description: template.description,
variables: JSON.stringify(template.variables),
isSystem: template.isSystem ? 1 : 0,
isActive: 1,
createdAt: now,
updatedAt: now,
});
} else if (existing.isSystem) {
// Update system templates with latest content from defaults
console.log(`[Email] Updating system template: ${template.name}`);
await (db as any)
.update(emailTemplates)
.set({
subject: template.subject,
subjectEs: template.subjectEs,
bodyHtml: template.bodyHtml,
bodyHtmlEs: template.bodyHtmlEs,
bodyText: template.bodyText,
bodyTextEs: template.bodyTextEs,
description: template.description,
variables: JSON.stringify(template.variables),
updatedAt: now,
})
.where(eq((emailTemplates as any).slug, template.slug));
}
}
console.log('[Email] Default templates check complete');
}
/**
* Send an email using a template
*/
export async function sendTemplateEmail(params: {
templateSlug: string;
to: string;
toName?: string;
variables: Record<string, any>;
locale?: string;
eventId?: string;
sentBy?: string;
}): Promise<{ success: boolean; logId?: string; error?: string }> {
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
// Get template
const template = await getTemplate(templateSlug);
if (!template) {
return { success: false, error: `Template "${templateSlug}" not found` };
}
// Build variables
const allVariables = {
...getCommonVariables(),
lang: locale,
...variables,
};
// Get localized content
const subject = locale === 'es' && template.subjectEs
? template.subjectEs
: template.subject;
const bodyHtml = locale === 'es' && template.bodyHtmlEs
? template.bodyHtmlEs
: template.bodyHtml;
const bodyText = locale === 'es' && template.bodyTextEs
? template.bodyTextEs
: template.bodyText;
// Replace variables
const finalSubject = replaceTemplateVariables(subject, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
// Create log entry
const logId = generateId();
const now = getNow();
await (db as any).insert(emailLogs).values({
id: logId,
templateId: template.id,
eventId: eventId || null,
recipientEmail: to,
recipientName: toName || null,
subject: finalSubject,
bodyHtml: finalBodyHtml,
status: 'pending',
sentBy: sentBy || null,
createdAt: now,
});
// Send email
const result = await sendEmail({
to,
subject: finalSubject,
html: finalBodyHtml,
text: finalBodyText,
});
// Update log with result
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: getNow(),
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
logId,
error: result.error
};
}
/**
* Send a custom email (not from template)
*/
export async function sendCustomEmail(params: {
to: string;
toName?: string;
subject: string;
bodyHtml: string;
bodyText?: string;
replyTo?: string;
eventId?: string;
sentBy?: string | null;
}): Promise<{ success: boolean; logId?: string; error?: string }> {
const { to: rawTo, toName, subject: rawSubject, bodyHtml, bodyText, replyTo: rawReplyTo, eventId, sentBy = null } = params;
// Strip CR/LF from header-bound values to prevent email header injection
// (e.g. an attacker-supplied subject/replyTo smuggling extra headers/recipients).
const stripHeader = (v?: string) => (v ? v.replace(/[\r\n]+/g, ' ').trim() : v);
const to = stripHeader(rawTo) as string;
const subject = stripHeader(rawSubject) as string;
const replyTo = stripHeader(rawReplyTo);
const allVariables = {
...getCommonVariables(),
subject,
};
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
// Create log entry
const logId = generateId();
const now = getNow();
await (db as any).insert(emailLogs).values({
id: logId,
templateId: null,
eventId: eventId || null,
recipientEmail: to,
recipientName: toName || null,
subject,
bodyHtml: finalBodyHtml,
status: 'pending',
sentBy: sentBy || null,
createdAt: now,
});
// Send email
const result = await sendEmail({
to,
subject,
html: finalBodyHtml,
text: bodyText,
replyTo,
});
// Update log
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: getNow(),
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
logId,
error: result.error
};
}
/**
* Resend an email from an existing log entry
*/
export async function resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
const log = await dbGet<any>(
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
);
if (!log) {
return { success: false, error: 'Email log not found' };
}
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
return { success: false, error: 'Email log missing required data to resend' };
}
const result = await sendEmail({
to: log.recipientEmail,
subject: log.subject,
html: log.bodyHtml,
text: undefined,
});
const now = getNow();
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: now,
errorMessage: null,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
error: result.error,
};
}
+308
View File
@@ -0,0 +1,308 @@
// Email transport layer: provider configuration, SMTP setup, and the low-level
// sendEmail router. No template rendering or DB logging happens here.
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
// ==================== Types ====================
export interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
text?: string;
replyTo?: string;
}
export interface SendEmailResult {
success: boolean;
messageId?: string;
error?: string;
}
export type EmailProvider = 'resend' | 'smtp' | 'console';
// ==================== Provider Configuration ====================
function getEmailProvider(): EmailProvider {
const provider = (process.env.EMAIL_PROVIDER || 'console').toLowerCase();
if (provider === 'resend' || provider === 'smtp' || provider === 'console') {
return provider;
}
console.warn(`[Email] Unknown provider "${provider}", falling back to console`);
return 'console';
}
function getFromEmail(): string {
return process.env.EMAIL_FROM || 'noreply@spanglish.com';
}
function getFromName(): string {
return process.env.EMAIL_FROM_NAME || 'Spanglish';
}
/** Provider info for diagnostics endpoints. */
export function getProviderInfo(): { provider: EmailProvider; configured: boolean } {
const provider = getEmailProvider();
let configured = false;
switch (provider) {
case 'resend':
configured = !!(process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY);
break;
case 'smtp':
configured = !!process.env.SMTP_HOST;
break;
case 'console':
configured = true;
break;
}
return { provider, configured };
}
// ==================== SMTP Configuration ====================
interface SMTPConfig {
host: string;
port: number;
secure: boolean;
auth?: {
user: string;
pass: string;
};
}
function getSMTPConfig(): SMTPConfig | null {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || '587');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
if (!host) {
return null;
}
const config: SMTPConfig = {
host,
port,
secure,
};
if (user && pass) {
config.auth = { user, pass };
}
return config;
}
// Cached SMTP transporter
let smtpTransporter: Transporter | null = null;
function getSMTPTransporter(): Transporter | null {
if (smtpTransporter) {
return smtpTransporter;
}
const config = getSMTPConfig();
if (!config) {
console.error('[Email] SMTP configuration missing');
return null;
}
smtpTransporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
// Additional options for better deliverability
pool: true,
maxConnections: 5,
maxMessages: 100,
// TLS options
tls: {
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
},
});
// Verify connection configuration
smtpTransporter.verify((error, success) => {
if (error) {
console.error('[Email] SMTP connection verification failed:', error.message);
} else {
console.log('[Email] SMTP server is ready to send emails');
}
});
return smtpTransporter;
}
// ==================== Email Providers ====================
/**
* Send email using Resend API
*/
async function sendWithResend(options: SendEmailOptions): Promise<SendEmailResult> {
const apiKey = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY;
const fromEmail = getFromEmail();
const fromName = getFromName();
if (!apiKey) {
console.error('[Email] Resend API key not configured');
return { success: false, error: 'Resend API key not configured' };
}
try {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: `${fromName} <${fromEmail}>`,
to: Array.isArray(options.to) ? options.to : [options.to],
subject: options.subject,
html: options.html,
text: options.text,
reply_to: options.replyTo,
}),
});
const data = await response.json();
if (!response.ok) {
console.error('[Email] Resend API error:', data);
return {
success: false,
error: data.message || data.error || 'Failed to send email'
};
}
console.log('[Email] Email sent via Resend:', data.id);
return {
success: true,
messageId: data.id
};
} catch (error: any) {
console.error('[Email] Resend error:', error);
return {
success: false,
error: error.message || 'Failed to send email via Resend'
};
}
}
/**
* Send email using SMTP (Nodemailer)
*/
async function sendWithSMTP(options: SendEmailOptions): Promise<SendEmailResult> {
const transporter = getSMTPTransporter();
if (!transporter) {
return { success: false, error: 'SMTP not configured' };
}
const fromEmail = getFromEmail();
const fromName = getFromName();
try {
const info = await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
replyTo: options.replyTo,
subject: options.subject,
html: options.html,
text: options.text,
});
console.log('[Email] Email sent via SMTP:', info.messageId);
return {
success: true,
messageId: info.messageId
};
} catch (error: any) {
console.error('[Email] SMTP error:', error);
return {
success: false,
error: error.message || 'Failed to send email via SMTP'
};
}
}
/**
* Console logger for development/testing (no actual email sent)
*/
async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResult> {
const to = Array.isArray(options.to) ? options.to.join(', ') : options.to;
console.log('\n========================================');
console.log('[Email] Console Mode - Email Preview');
console.log('========================================');
console.log(`To: ${to}`);
console.log(`Subject: ${options.subject}`);
console.log(`Reply-To: ${options.replyTo || 'N/A'}`);
console.log('----------------------------------------');
console.log('HTML Body (truncated):');
console.log(options.html?.substring(0, 500) + '...');
console.log('========================================\n');
return {
success: true,
messageId: `console-${Date.now()}`
};
}
// Mask an email address for logs: keep first char + domain (e.g. j***@example.com).
function maskEmail(email: string): string {
const [local, domain] = String(email).split('@');
if (!domain) return '***';
const head = local.slice(0, 1);
return `${head}***@${domain}`;
}
/**
* Main send function that routes to the appropriate provider
*/
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const provider = getEmailProvider();
const recipientCount = Array.isArray(options.to) ? options.to.length : 1;
const sample = Array.isArray(options.to) ? options.to[0] : options.to;
console.log(`[Email] Sending email via ${provider} to ${maskEmail(sample)}${recipientCount > 1 ? ` (+${recipientCount - 1} more)` : ''}`);
switch (provider) {
case 'resend':
return sendWithResend(options);
case 'smtp':
return sendWithSMTP(options);
case 'console':
default:
return sendWithConsole(options);
}
}
/**
* Test email configuration by sending a test email
*/
export async function testConnection(to: string): Promise<SendEmailResult> {
const { provider, configured } = getProviderInfo();
if (!configured) {
return { success: false, error: `Email provider "${provider}" is not configured` };
}
return sendEmail({
to,
subject: 'Spanglish - Email Test',
html: `
<h2>Email Configuration Test</h2>
<p>This is a test email from your Spanglish platform.</p>
<p><strong>Provider:</strong> ${provider}</p>
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
<p>If you received this email, your email configuration is working correctly!</p>
`,
text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`,
});
}
+161 -50
View File
@@ -1,17 +1,16 @@
// In-memory email queue with rate limiting
// Processes emails asynchronously in the background without blocking the request thread
// Durable email queue with rate limiting.
// Jobs are persisted in the `email_queue` DB table so they survive process
// restarts. Emails are processed asynchronously in the background without
// blocking the request thread.
import { generateId } from './utils.js';
import { eq, and, asc, sql } from 'drizzle-orm';
import { db, dbGet, emailQueue } from '../db/index.js';
import { generateId, getNow } from './utils.js';
import { isRedisEnabled } from './redis.js';
import { getRateLimiter } from './stores/rateLimiter.js';
// ==================== Types ====================
export interface EmailJob {
id: string;
type: 'template';
params: TemplateEmailJobParams;
addedAt: number;
}
export interface TemplateEmailJobParams {
templateSlug: string;
to: string;
@@ -22,6 +21,11 @@ export interface TemplateEmailJobParams {
sentBy?: string;
}
interface ClaimedJob {
id: string;
params: TemplateEmailJobParams;
}
export interface QueueStatus {
queued: number;
processing: boolean;
@@ -31,7 +35,8 @@ export interface QueueStatus {
// ==================== Queue State ====================
const queue: EmailJob[] = [];
// Tracks send timestamps for the per-process (non-Redis) sliding-window rate
// limit. The job backlog itself lives in the database, not in memory.
const sentTimestamps: number[] = [];
let processing = false;
let processTimer: ReturnType<typeof setTimeout> | null = null;
@@ -41,7 +46,6 @@ let _emailService: any = null;
function getEmailService() {
if (!_emailService) {
// Dynamic import to avoid circular dependency
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
}
return _emailService;
@@ -50,12 +54,31 @@ function getEmailService() {
/**
* Initialize the email queue with a reference to the email service.
* Must be called once at startup.
*
* Also performs crash recovery: any jobs left in the 'processing' state by a
* previous (crashed) process are reset to 'pending' so they get retried.
*/
export function initEmailQueue(emailService: any): void {
_emailService = emailService;
recoverProcessingJobs()
.then((recovered) => {
if (recovered > 0) {
console.log(`[EmailQueue] Recovered ${recovered} in-flight job(s) after restart`);
}
scheduleProcessing();
})
.catch((err) => console.error('[EmailQueue] Recovery failed:', err?.message || err));
console.log('[EmailQueue] Initialized');
}
async function recoverProcessingJobs(): Promise<number> {
const result: any = await (db as any)
.update(emailQueue)
.set({ status: 'pending' })
.where(eq((emailQueue as any).status, 'processing'));
return result?.changes ?? result?.rowCount ?? 0;
}
// ==================== Rate Limiting ====================
function getMaxPerHour(): number {
@@ -74,19 +97,26 @@ function cleanOldTimestamps(): void {
// ==================== Queue Operations ====================
async function insertJob(id: string, params: TemplateEmailJobParams): Promise<void> {
await (db as any).insert(emailQueue).values({
id,
params: JSON.stringify(params),
status: 'pending',
attempts: 0,
createdAt: getNow(),
});
}
/**
* Add a single email job to the queue.
* Returns the job ID.
* Returns the job ID. Persistence happens asynchronously so the caller is not
* blocked; processing is scheduled once the row is written.
*/
export function enqueueEmail(params: TemplateEmailJobParams): string {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
scheduleProcessing();
insertJob(id, params)
.then(() => scheduleProcessing())
.catch((err) => console.error('[EmailQueue] Failed to enqueue email:', err?.message || err));
return id;
}
@@ -95,31 +125,32 @@ export function enqueueEmail(params: TemplateEmailJobParams): string {
* Returns array of job IDs.
*/
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
const ids: string[] = [];
for (const params of paramsList) {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
ids.push(id);
}
if (ids.length > 0) {
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
scheduleProcessing();
}
const ids = paramsList.map(() => generateId());
if (ids.length === 0) return ids;
Promise.all(paramsList.map((params, i) => insertJob(ids[i], params)))
.then(() => {
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
scheduleProcessing();
})
.catch((err) => console.error('[EmailQueue] Failed to enqueue bulk emails:', err?.message || err));
return ids;
}
/**
* Get current queue status
*/
export function getQueueStatus(): QueueStatus {
export async function getQueueStatus(): Promise<QueueStatus> {
cleanOldTimestamps();
const row = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(emailQueue)
.where(eq((emailQueue as any).status, 'pending'))
);
return {
queued: queue.length,
queued: Number(row?.count || 0),
processing,
sentInLastHour: sentTimestamps.length,
maxPerHour: getMaxPerHour(),
@@ -135,46 +166,125 @@ function scheduleProcessing(): void {
setImmediate(() => processNext());
}
/**
* Atomically claim the oldest pending job by flipping its status to
* 'processing'. Returns null if there is nothing to do. The conditional update
* (WHERE status='pending') guards against two workers claiming the same row.
*/
async function claimNextJob(): Promise<ClaimedJob | null> {
for (let attempt = 0; attempt < 5; attempt++) {
const row = await dbGet<any>(
(db as any)
.select({ id: (emailQueue as any).id, params: (emailQueue as any).params })
.from(emailQueue)
.where(eq((emailQueue as any).status, 'pending'))
.orderBy(asc((emailQueue as any).createdAt))
.limit(1)
);
if (!row) return null;
const result: any = await (db as any)
.update(emailQueue)
.set({ status: 'processing' })
.where(and(
eq((emailQueue as any).id, row.id),
eq((emailQueue as any).status, 'pending')
));
const affected = result?.changes ?? result?.rowCount ?? 0;
if (affected > 0) {
try {
return { id: row.id, params: JSON.parse(row.params) };
} catch {
// Corrupt params: mark failed and move on rather than crash-looping.
await markJob(row.id, 'failed', 'Invalid job params (JSON parse failed)');
continue;
}
}
// Lost the race for this row; try the next pending one.
}
return null;
}
async function markJob(id: string, status: 'sent' | 'failed', error?: string | null): Promise<void> {
const update: any = { status, processedAt: getNow() };
if (status === 'failed') {
update.attempts = sql`${(emailQueue as any).attempts} + 1`;
if (error) update.lastError = error.slice(0, 1000);
}
await (db as any).update(emailQueue).set(update).where(eq((emailQueue as any).id, id));
}
async function releaseJob(id: string): Promise<void> {
await (db as any)
.update(emailQueue)
.set({ status: 'pending' })
.where(eq((emailQueue as any).id, id));
}
async function processNext(): Promise<void> {
if (queue.length === 0) {
let job: ClaimedJob | null;
try {
job = await claimNextJob();
} catch (error: any) {
// Database error while claiming: back off and retry rather than stop.
console.error('[EmailQueue] Failed to claim next job:', error?.message || error);
processTimer = setTimeout(() => processNext(), 5_000);
return;
}
if (!job) {
processing = false;
console.log('[EmailQueue] Queue empty. Processing stopped.');
return;
}
// Rate limit check
// Rate limit check.
// - Without Redis: per-process sliding window.
// - With Redis: a shared hourly counter so the cap applies across all
// instances rather than once per replica.
cleanOldTimestamps();
const maxPerHour = getMaxPerHour();
let waitMs = 0;
if (sentTimestamps.length >= maxPerHour) {
if (isRedisEnabled()) {
const result = await getRateLimiter().consume('email:hourly', maxPerHour, 3_600_000);
if (!result.allowed) {
waitMs = (result.retryAfter ?? 60) * 1000 + 500; // 500ms buffer
}
} else if (sentTimestamps.length >= maxPerHour) {
// Calculate when the oldest timestamp in the window expires
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
}
if (waitMs > 0) {
// Put the claimed job back so it is retried after the cooldown.
await releaseJob(job.id);
console.log(
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
`Pausing for ${Math.ceil(waitMs / 1000)}s.`
);
processTimer = setTimeout(() => processNext(), waitMs);
return;
}
// Dequeue and process
const job = queue.shift()!;
try {
const emailService = getEmailService();
await emailService.sendTemplateEmail(job.params);
sentTimestamps.push(Date.now());
await markJob(job.id, 'sent');
console.log(
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
`Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
);
} catch (error: any) {
await markJob(job.id, 'failed', error?.message || String(error));
console.error(
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
error?.message || error
);
// The sendTemplateEmail method already logs the failure in the email_logs table,
// so we don't need to retry here. The error is logged and we move on.
// The sendTemplateEmail method already logs the failure in the email_logs
// table, so we just record it on the queue row and move on.
}
// Small delay between sends to be gentle on the email server
@@ -182,7 +292,8 @@ async function processNext(): Promise<void> {
}
/**
* Stop processing (for graceful shutdown)
* Stop processing (for graceful shutdown). In-flight and pending jobs remain
* persisted in the database and resume on the next startup.
*/
export function stopQueue(): void {
if (processTimer) {
@@ -190,5 +301,5 @@ export function stopQueue(): void {
processTimer = null;
}
processing = false;
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
console.log('[EmailQueue] Stopped. Pending jobs remain persisted in the database.');
}
+28 -6
View File
@@ -1216,8 +1216,26 @@ Spanglish`,
},
];
// Helper function to replace template variables
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
// Escape HTML-significant characters so substituted variable values can't inject markup.
function escapeHtmlValue(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Helper function to replace template variables.
// When `escapeHtml` is true (HTML rendering contexts), substituted *values* are
// HTML-escaped to prevent stored-XSS via attacker-influenced variables (e.g. event
// location, attendee name). The template markup itself is never escaped. Subjects and
// plain-text bodies pass `escapeHtml=false` so they don't show literal entities.
export function replaceTemplateVariables(
template: string,
variables: Record<string, any>,
escapeHtml: boolean = false
): string {
let result = template;
// Handle conditional blocks {{#if variable}}...{{/if}}
@@ -1229,16 +1247,20 @@ export function replaceTemplateVariables(template: string, variables: Record<str
// Replace simple variables {{variable}}
const variableRegex = /\{\{(\w+)\}\}/g;
result = result.replace(variableRegex, (match, varName) => {
return variables[varName] !== undefined ? String(variables[varName]) : match;
if (variables[varName] === undefined) return match;
const raw = String(variables[varName]);
return escapeHtml ? escapeHtmlValue(raw) : raw;
});
return result;
}
// Helper to wrap content in the base template
// Helper to wrap content in the base template.
// `content` is treated as trusted HTML (it is the already-rendered body). The wrapper's
// own variables (subject, year, etc.) are HTML-escaped on substitution.
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
return replaceTemplateVariables(wrappedContent, variables);
const wrappedContent = baseEmailWrapper.replace('{{content}}', () => content);
return replaceTemplateVariables(wrappedContent, variables, true);
}
// Get all available variables for a template by slug
+124
View File
@@ -0,0 +1,124 @@
// Auto-reject unconfirmed payments once their event is over.
//
// After an event ends, any booking whose payment was never confirmed
// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored.
// This job silently fails those payments and cancels their tickets so they stop
// lingering as "pending" forever. It deliberately sends NO email — unlike the
// admin reject route, this is a housekeeping sweep and users are not notified.
//
// An event is considered over when COALESCE(end_datetime, start_datetime) is in
// the past. Updates are guarded by the current status so re-running is a no-op.
import { and, eq, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments, events } from '../db/index.js';
import { getNow } from './utils.js';
import { getLock } from './stores/lock.js';
// Payment statuses that represent an unconfirmed booking.
const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold'];
// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled).
const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold'];
/**
* Fail unconfirmed payments (and cancel their tickets) for events that have
* already ended. Returns the number of payments rejected.
*/
export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise<number> {
// Pull candidate rows first, then decide "ended" in JS so the comparison works
// identically for SQLite (ISO text) and Postgres (timestamp) datetime columns.
const rows = await dbAll<{
paymentId: string;
ticketId: string;
endDatetime: string | Date | null;
startDatetime: string | Date | null;
}>(
(db as any)
.select({
paymentId: (payments as any).id,
ticketId: (tickets as any).id,
endDatetime: (events as any).endDatetime,
startDatetime: (events as any).startDatetime,
})
.from(payments)
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
.innerJoin(events, eq((tickets as any).eventId, (events as any).id))
.where(and(
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
))
);
const nowMs = Date.now();
const ended = rows.filter((r) => {
const ref = r.endDatetime || r.startDatetime;
if (!ref) return false;
return new Date(ref as any).getTime() < nowMs;
});
if (ended.length === 0) return 0;
const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId)));
const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id)));
const now = getNow();
// Fail the payments. The status guard keeps this idempotent and avoids
// clobbering anything that changed since we read the candidates.
await (db as any)
.update(payments)
.set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now })
.where(and(
inArray((payments as any).id, paymentIds),
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
));
// Cancel the associated tickets, freeing any seats they still hold.
if (ticketIds.length > 0) {
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(and(
inArray((tickets as any).id, ticketIds),
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
));
}
console.log(
`[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` +
`cancelled ${ticketIds.length} ticket(s).`
);
return paymentIds.length;
}
let sweepTimer: ReturnType<typeof setInterval> | null = null;
/**
* Start a periodic sweep that auto-rejects unconfirmed payments for ended
* events. Each run is guarded by a distributed lock so that, across multiple
* replicas, only one instance does the work per interval.
*/
export function startEventEndSweep(): void {
const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
const run = () => {
getLock()
.withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () =>
rejectUnconfirmedPaymentsForEndedEvents()
)
.catch((err) =>
console.error('[EventEndSweep] Run failed:', err?.message || err)
);
};
// Run shortly after startup, then on the interval.
setTimeout(run, 60_000).unref?.();
sweepTimer = setInterval(run, intervalMs);
sweepTimer.unref?.();
console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
}
export function stopEventEndSweep(): void {
if (sweepTimer) {
clearInterval(sweepTimer);
sweepTimer = null;
}
}
+138
View File
@@ -0,0 +1,138 @@
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
//
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
// states — user clicking "I've paid", an admin approving/reactivating a payment —
// must atomically re-check that the event still has room, exactly like the original
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
// (e.g. reopening a failed payment) claims no seat and skips the check.
//
// Callers choose which ticket statuses are eligible to be flipped via
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
// left untouched. Tickets that already hold a seat cost no new capacity.
// `options.skipCapacityCheck` lets an admin knowingly approve over capacity —
// the UI warns first (routes/payments.ts /approve with allowOverCapacity).
import { eq, and, inArray } from 'drizzle-orm';
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
export class HoldCapacityError extends Error {
constructor(public available: number) {
super('EVENT_FULL');
}
}
interface ReserveOptions {
paidByAdminId?: string;
extraPaymentFields?: Record<string, any>;
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
/** Admin override: reserve even when it puts the event over capacity. */
skipCapacityCheck?: boolean;
}
/**
* Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
* bookingId), atomically re-checking capacity before flipping their status.
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
* Capacity is asserted against the number of those tickets that don't already
* hold a seat, so re-reserving tickets that are already seated is a no-op.
* Throws HoldCapacityError if the event no longer has room (unless the target
* state holds no seat, or skipCapacityCheck is set).
*/
export async function reserveOnHoldBooking(
eventId: string,
ticketIds: string[],
targetTicketStatus: 'pending' | 'confirmed',
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
options: ReserveOptions = {}
): Promise<void> {
if (ticketIds.length === 0) return;
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
// Bare 'pending' payments hold no seat, so moving a booking back to 'pending'
// consumes no capacity and needs no check.
const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed';
const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck;
const event = await dbGet<any>(
(db as any).select().from(events).where(eq((events as any).id, eventId))
);
if (!event) {
throw new Error('Event not found');
}
const now = getNow();
const paymentUpdate: Record<string, any> = {
status: targetPaymentStatus,
updatedAt: now,
...options.extraPaymentFields,
};
if (targetPaymentStatus === 'paid') {
paymentUpdate.paidAt = now;
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
}
// Keep the ticket-level payment flag in sync when the payment settles
const ticketUpdate: Record<string, any> = { status: targetTicketStatus };
if (targetPaymentStatus === 'paid') {
ticketUpdate.paymentStatus = 'paid';
}
// `needed` is how many of these tickets don't currently hold a seat and so must
// be found new capacity; tickets already in a seat-holding state cost nothing.
const assertCapacity = (reserved: number, needed: number) => {
if (needed <= 0) return;
if (isEventSoldOut(event.capacity, reserved)) {
throw new HoldCapacityError(0);
}
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (needed > seatsLeft) {
throw new HoldCapacityError(seatsLeft);
}
};
if (isSqlite()) {
(db as any).transaction((tx: any) => {
if (checkCapacity) {
const countRow = seatHolderCountQuery(tx, eventId).get();
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
}
tx.update(tickets)
.set(ticketUpdate)
.where(and(
inArray((tickets as any).id, ticketIds),
inArray((tickets as any).status, fromTicketStatuses)
))
.run();
tx.update(payments)
.set(paymentUpdate)
.where(inArray((payments as any).ticketId, ticketIds))
.run();
});
} else {
await (db as any).transaction(async (tx: any) => {
if (checkCapacity) {
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
}
await tx.update(tickets)
.set(ticketUpdate)
.where(and(
inArray((tickets as any).id, ticketIds),
inArray((tickets as any).status, fromTicketStatuses)
));
await tx.update(payments)
.set(paymentUpdate)
.where(inArray((payments as any).ticketId, ticketIds));
});
}
}
+107
View File
@@ -0,0 +1,107 @@
// Auto-hold stale unsettled manual-payment bookings.
//
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
// the customer never clicked "I've paid" and no admin settled them. These are exempt
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
// dead checkouts out of the admin's pending queues.
//
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
// claimed payment keeps its seat until an admin approves or rejects it — the admin
// UI surfaces aging claims instead of silently releasing them.
//
// The user receives no notification — they can recover via "I've paid", and an
// admin can approve/reactivate directly; every recovery path re-checks capacity.
import { and, eq, lt, inArray } from 'drizzle-orm';
import { db, dbAll, tickets, payments } from '../db/index.js';
import { getNow, toDbDate } from './utils.js';
import { getLock } from './stores/lock.js';
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
function getThresholdMs(): number {
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
return (Number.isFinite(hours) && hours > 0 ? hours : 72) * 60 * 60 * 1000;
}
/**
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
* is never swept. Returns the number of payments put on hold.
*/
export async function sweepStaleApprovals(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
(db as any)
.select({
ticketId: (payments as any).ticketId,
paymentId: (payments as any).id,
})
.from(payments)
.where(and(
eq((payments as any).status, 'pending'),
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
lt((payments as any).createdAt, cutoff)
))
);
if (stale.length === 0) return 0;
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
const paymentIds = stale.map((s) => s.paymentId);
const now = getNow();
await (db as any)
.update(payments)
.set({ status: 'on_hold', updatedAt: now })
.where(inArray((payments as any).id, paymentIds));
if (ticketIds.length > 0) {
await (db as any)
.update(tickets)
.set({ status: 'on_hold' })
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'pending')
));
}
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
return stale.length;
}
let sweepTimer: ReturnType<typeof setInterval> | null = null;
/**
* Start a periodic sweep of stale pending-approval payments. Each run is guarded by
* a distributed lock so that, across multiple replicas, only one instance does the
* work per interval.
*/
export function startHoldSweep(): void {
const intervalMs = parseInt(process.env.HOLD_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
const run = () => {
getLock()
.withLock('sweep-hold-stale-approvals', Math.min(intervalMs, 60_000), () =>
sweepStaleApprovals()
)
.catch((err) =>
console.error('[HoldSweep] Run failed:', err?.message || err)
);
};
// Run shortly after startup, then on the interval.
setTimeout(run, 45_000).unref?.();
sweepTimer = setInterval(run, intervalMs);
sweepTimer.unref?.();
console.log(`[HoldSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
}
export function stopHoldSweep(): void {
if (sweepTimer) {
clearInterval(sweepTimer);
sweepTimer = null;
}
}
+5 -3
View File
@@ -36,6 +36,11 @@ export interface CreateInvoiceParams {
extra?: Record<string, any>; // Additional metadata
}
// How long a booking's Lightning invoice is valid for, in seconds. Shared
// between initial booking creation and invoice regeneration so both produce
// invoices with the same lifetime.
export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes
/**
* Check if LNbits is configured
*/
@@ -80,11 +85,8 @@ export async function createInvoice(params: CreateInvoiceParams): Promise<LNbits
}
console.log('Creating LNbits invoice:', {
url: `${config.url}${apiEndpoint}`,
amount: params.amount,
unit: payload.unit,
memo: params.memo,
webhook: params.webhookUrl,
});
const response = await fetch(`${config.url}${apiEndpoint}`, {
+62
View File
@@ -0,0 +1,62 @@
import * as argon2 from 'argon2';
import bcrypt from 'bcryptjs';
// Password hashing with Argon2 (spec requirement)
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
// Support both bcrypt (legacy) and argon2 hashes for migration
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
// Legacy bcrypt support
return bcrypt.compare(password, hash);
}
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
+37
View File
@@ -0,0 +1,37 @@
// Payment provider registry.
//
// Every provider is either:
// - 'automatic': the gateway itself confirms the payment (webhook/invoice
// settlement) and the booking is auto-approved on success. No admin involved.
// Currently Lightning; future online gateways (e.g. Stripe) go here.
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
// card handled offline, cash at the door). These are never auto-confirmed
// and never auto-failed; an admin settles them by hand. Bank transfer and
// TPago additionally expose an online "I've paid" step that moves the
// payment to 'pending_approval'.
//
// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and
// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either
// kind — holds no seat, so an abandoned checkout can never block sales.
export type PaymentProviderKind = 'automatic' | 'manual';
export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> = {
lightning: { kind: 'automatic' },
tpago: { kind: 'manual' },
bank_transfer: { kind: 'manual' },
card: { kind: 'manual' },
cash: { kind: 'manual' },
};
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
(p) => PAYMENT_PROVIDERS[p].kind === 'manual'
);
export function isManualProvider(provider: string): boolean {
return PAYMENT_PROVIDERS[provider]?.kind === 'manual';
}
export function isAutomaticProvider(provider: string): boolean {
return PAYMENT_PROVIDERS[provider]?.kind === 'automatic';
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
// Minimal Hono-Context stand-in: headers + the node-server env with the
// socket peer address.
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
const headers = new Map(
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
);
return {
req: { header: (name: string) => headers.get(name.toLowerCase()) },
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
} as any;
}
describe('isTrustedProxyIp', () => {
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
expect(isTrustedProxyIp('::1')).toBe(true);
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
});
it('does not trust public addresses or near-miss ranges', () => {
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
expect(isTrustedProxyIp('')).toBe(false);
});
});
describe('getClientIp', () => {
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
});
expect(getClientIp(c)).toBe('203.0.113.7');
});
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
// spoofed prefix, then the real client appended by nginx, then the Next
// proxy hop — the rightmost untrusted entry wins
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
});
expect(getClientIp(c)).toBe('203.0.113.7');
});
it('ignores forwarded headers entirely when the peer is untrusted', () => {
// A client hitting the API directly cannot pick its own bucket
const c = fakeContext({
peer: '198.51.100.4',
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
});
expect(getClientIp(c)).toBe('198.51.100.4');
});
it('falls back to the socket address for local traffic with no headers', () => {
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
});
it('falls back to the socket address when every forwarded hop is internal', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': '127.0.0.1' },
});
expect(getClientIp(c)).toBe('127.0.0.1');
});
it('rejects junk header values instead of using them as bucket keys', () => {
const c = fakeContext({
peer: '127.0.0.1',
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
});
expect(getClientIp(c)).toBe('127.0.0.1');
});
it('returns "unknown" without a socket address or trusted headers', () => {
expect(getClientIp(fakeContext({}))).toBe('unknown');
});
});
+105
View File
@@ -0,0 +1,105 @@
import { Context } from 'hono';
import { getRateLimiter } from './stores/rateLimiter.js';
/**
* Rate limiting helpers.
*
* The actual counting is delegated to a pluggable rate limiter (in-memory by
* default, Redis-backed when REDIS_URL is set) so limits are enforced either
* per instance (single-instance deployments) or across all instances
* (horizontal scaling). See lib/stores/rateLimiter.ts.
*/
// Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For:
// loopback (nginx on the same host, the Next.js proxy) and RFC1918 ranges
// (the docker-compose scale deployment, where nginx is another container).
// Extend with TRUSTED_PROXIES (comma-separated IP prefixes, e.g. "172.20.").
const DEFAULT_TRUSTED_PROXY_PREFIXES = ['127.', '10.', '192.168.', '::1'];
function trustedProxyPrefixes(): string[] {
const extra = (process.env.TRUSTED_PROXIES || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return [...DEFAULT_TRUSTED_PROXY_PREFIXES, ...extra];
}
/** Strip the IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.". */
function normalizeIp(ip: string | undefined | null): string {
const trimmed = (ip || '').trim();
return trimmed.toLowerCase().startsWith('::ffff:') ? trimmed.slice(7) : trimmed;
}
export function isTrustedProxyIp(ip: string): boolean {
const normalized = normalizeIp(ip);
if (!normalized) return false;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalized)) return true; // 172.16.0.0/12
return trustedProxyPrefixes().some((prefix) => normalized === prefix || normalized.startsWith(prefix));
}
// Rough shape check so a junk header value can't become a rate-limit key.
function looksLikeIp(value: string): boolean {
return value.length > 0 && value.length <= 45 && /^[0-9a-fA-F.:]+$/.test(value);
}
/**
* Spoof-resistant client IP resolution.
*
* The TCP peer address (via @hono/node-server's env.incoming) anchors the
* trust decision: forwarded headers are only honoured when the direct peer is
* one of our own proxies. X-Real-IP is preferred because nginx overwrites it
* at the edge (deploy/*.conf); X-Forwarded-For is append-only, so it is
* walked from the right past our proxy hops — the leftmost entries are
* client-controlled and never trusted on their own.
*/
export function getClientIp(c: Context): string {
const socketAddr = normalizeIp((c.env as any)?.incoming?.socket?.remoteAddress);
if (socketAddr && isTrustedProxyIp(socketAddr)) {
const realIp = normalizeIp(c.req.header('x-real-ip'));
if (realIp && looksLikeIp(realIp)) return realIp;
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) {
const chain = forwarded.split(',').map((s) => normalizeIp(s)).filter(Boolean);
for (let i = chain.length - 1; i >= 0; i--) {
if (!isTrustedProxyIp(chain[i])) {
return looksLikeIp(chain[i]) ? chain[i] : socketAddr;
}
}
// Every hop is one of ours: a genuinely local/internal client.
}
}
return socketAddr || 'unknown';
}
/**
* Consume one unit against a key. Returns whether the request is allowed and,
* when blocked, how many seconds until the window resets.
*/
export function consumeRateLimit(
key: string,
max: number,
windowMs: number
): Promise<{ allowed: boolean; retryAfter?: number }> {
return getRateLimiter().consume(key, max, windowMs);
}
/**
* Hono middleware factory that rate-limits by client IP.
* Use a distinct `prefix` per endpoint group so unrelated routes don't share a bucket.
*/
export function rateLimitMiddleware(opts: { max: number; windowMs: number; prefix: string }) {
return async (c: Context, next: () => Promise<void>) => {
const ip = getClientIp(c);
const result = await consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs);
if (!result.allowed) {
return c.json(
{ error: 'Too many requests. Please try again later.', retryAfter: result.retryAfter },
429
);
}
await next();
};
}
+163
View File
@@ -0,0 +1,163 @@
// Optional Redis connection manager.
//
// Redis is entirely optional. When REDIS_URL is unset the app runs exactly as
// before with in-memory backends. When set, this module owns a single shared
// command connection plus a dedicated subscriber connection (a connection in
// subscribe mode cannot run normal commands), with auto-reconnect, capped
// backoff, an active PING probe, and a health flag that callers and the health
// endpoint can read.
//
// TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path
// selects a DB index (e.g. redis://host:6379/1) when sharing an instance.
// We deliberately do not use ioredis's keyPrefix option: all keys are already
// namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a
// pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not)
// that would silently break the payment SSE channel.
import Redis from 'ioredis';
let client: Redis | null = null;
let subscriber: Redis | null = null;
let healthy = false;
let initialized = false;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let lastPingOkAt: string | null = null;
let lastPingMs: number | null = null;
// Debounce repeated error logs during a sustained outage: transitions are
// always logged, repeated per-retry errors at most once per LOG_EVERY_MS.
const ERROR_LOG_EVERY_MS = 30_000;
let lastErrorLogAt = 0;
/** Whether Redis is configured via REDIS_URL. */
export function isRedisEnabled(): boolean {
return !!process.env.REDIS_URL;
}
/** Whether the Redis connection is currently usable. */
export function isRedisHealthy(): boolean {
return isRedisEnabled() && healthy;
}
/** Detail for the health endpoint: when the last successful PING happened. */
export function getRedisHealthDetail(): { lastPingOkAt: string | null; lastPingMs: number | null } {
return { lastPingOkAt, lastPingMs };
}
function setHealthy(next: boolean, context: string): void {
if (next !== healthy) {
if (next) {
console.log(`[redis] healthy (${context})`);
} else {
console.warn(`[redis] unhealthy (${context})`);
}
}
healthy = next;
}
function logErrorDebounced(label: string, err: unknown): void {
const now = Date.now();
if (now - lastErrorLogAt >= ERROR_LOG_EVERY_MS) {
lastErrorLogAt = now;
console.error(`[redis] (${label}) error:`, (err as any)?.message || err);
}
}
function buildClient(label: string): Redis {
const url = process.env.REDIS_URL as string;
const instance = new Redis(url, {
// Keep the process responsive: fail fast on a per-command basis and let the
// callers degrade to their in-memory fallback rather than hanging. Do not
// queue commands while disconnected — with fail-open callers everywhere a
// growing offline queue would only add latency and memory pressure.
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
lazyConnect: false,
connectTimeout: 5000,
retryStrategy(times) {
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
const delay = Math.min(times * 200, 5000);
return delay;
},
});
instance.on('connect', () => {
console.log(`[redis] (${label}) connecting`);
});
instance.on('ready', () => {
setHealthy(true, `${label} ready`);
});
instance.on('error', (err) => {
setHealthy(false, `${label} error`);
logErrorDebounced(label, err);
});
instance.on('reconnecting', () => {
setHealthy(false, `${label} reconnecting`);
});
instance.on('end', () => {
setHealthy(false, `${label} connection closed`);
});
return instance;
}
// Actively probe the command connection. The event-driven flag alone misses a
// silently hung connection; a periodic PING with a hard timeout catches it.
async function pingOnce(): Promise<void> {
if (!client) return;
const started = Date.now();
try {
await Promise.race([
client.ping(),
new Promise((_, reject) => {
const t = setTimeout(() => reject(new Error('ping timeout')), 2000);
(t as any).unref?.();
}),
]);
lastPingMs = Date.now() - started;
lastPingOkAt = new Date().toISOString();
setHealthy(true, 'ping ok');
} catch (err) {
setHealthy(false, 'ping failed');
logErrorDebounced('ping', err);
}
}
function ensureInit(): void {
if (initialized || !isRedisEnabled()) return;
initialized = true;
client = buildClient('commands');
subscriber = buildClient('subscriber');
pingTimer = setInterval(() => {
void pingOnce();
}, 10_000);
(pingTimer as any).unref?.();
}
/** Shared command connection, or null when Redis is not configured. */
export function getRedis(): Redis | null {
ensureInit();
return client;
}
/** Dedicated subscriber connection, or null when Redis is not configured. */
export function getSubscriber(): Redis | null {
ensureInit();
return subscriber;
}
/** Close connections (used for graceful shutdown). */
export async function closeRedis(): Promise<void> {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
const tasks: Promise<unknown>[] = [];
if (client) tasks.push(client.quit().catch(() => undefined));
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
await Promise.all(tasks);
client = null;
subscriber = null;
initialized = false;
healthy = false;
}
+136
View File
@@ -0,0 +1,136 @@
// Media storage abstraction with two implementations:
// - local: writes to the ./uploads directory and serves via /uploads/* (the
// original behavior, and the zero-config default)
// - s3: stores objects in an S3-compatible bucket (e.g. Garage), so uploads are
// shared across instances instead of living on one container's local disk
//
// S3 is enabled only when S3_ENDPOINT and S3_BUCKET are set. With it unset the
// app behaves exactly as before. A "key" is the object name (e.g. "abc123.jpg").
import { writeFile, mkdir, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = './uploads';
export interface Storage {
readonly backend: 'local' | 's3';
put(key: string, buffer: Buffer, contentType: string): Promise<void>;
delete(key: string): Promise<void>;
// Public URL to persist as the media record's fileUrl.
publicUrl(key: string): string;
}
/** Whether S3-compatible storage is configured. */
export function isS3Enabled(): boolean {
return !!(process.env.S3_ENDPOINT && process.env.S3_BUCKET);
}
/** Extract the storage key (object name) from a stored fileUrl. */
export function keyFromUrl(fileUrl: string): string {
return fileUrl.split('/').pop() || fileUrl;
}
// ==================== Local implementation ====================
class LocalStorage implements Storage {
readonly backend = 'local' as const;
private async ensureDir(): Promise<void> {
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
}
async put(key: string, buffer: Buffer): Promise<void> {
await this.ensureDir();
await writeFile(join(UPLOAD_DIR, key), buffer);
}
async delete(key: string): Promise<void> {
const filepath = join(UPLOAD_DIR, key);
if (existsSync(filepath)) {
await unlink(filepath);
}
}
publicUrl(key: string): string {
return `/uploads/${key}`;
}
}
// ==================== S3 implementation ====================
// Imported lazily so the AWS SDK is only loaded when S3 is actually configured.
type S3ClientType = import('@aws-sdk/client-s3').S3Client;
class S3Storage implements Storage {
readonly backend = 's3' as const;
private client: S3ClientType | null = null;
private bucket = process.env.S3_BUCKET as string;
private async getClient(): Promise<S3ClientType> {
if (this.client) return this.client;
const { S3Client } = await import('@aws-sdk/client-s3');
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE || 'true') !== 'false';
this.client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
forcePathStyle,
credentials:
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
? {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
}
: undefined,
});
return this.client;
}
async put(key: string, buffer: Buffer, contentType: string): Promise<void> {
const client = await this.getClient();
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
await client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
}
async delete(key: string): Promise<void> {
const client = await this.getClient();
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
await client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
})
);
}
publicUrl(key: string): string {
// Prefer an explicit public base URL (e.g. a CDN or Garage web endpoint).
const base = process.env.S3_PUBLIC_URL;
if (base) {
return `${base.replace(/\/$/, '')}/${key}`;
}
// Fall back to a path-style URL against the configured endpoint.
const endpoint = (process.env.S3_ENDPOINT || '').replace(/\/$/, '');
return `${endpoint}/${this.bucket}/${key}`;
}
}
// ==================== Selection ====================
let instance: Storage | null = null;
export function getStorage(): Storage {
if (!instance) {
instance = isS3Enabled() ? new S3Storage() : new LocalStorage();
}
return instance;
}
+105
View File
@@ -0,0 +1,105 @@
// Cache abstraction with two implementations:
// - memory: per-process Map with TTL expiry (single instance)
// - redis: shared GET / SETEX / DEL with JSON values (all instances)
//
// Values are JSON-serialized. Selection happens once based on REDIS_URL. On any
// Redis error the cache behaves as a miss so callers fall back to their source.
import { getRedis, isRedisEnabled } from '../redis.js';
export interface Cache {
readonly backend: 'memory' | 'redis';
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
del(key: string): Promise<void>;
}
// ==================== Memory implementation ====================
interface Entry {
value: unknown;
expiresAt: number;
}
class MemoryCache implements Cache {
readonly backend = 'memory' as const;
private store = new Map<string, Entry>();
constructor() {
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.store) {
if (now > entry.expiresAt) this.store.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async get<T>(key: string): Promise<T | null> {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value as T;
}
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
}
async del(key: string): Promise<void> {
this.store.delete(key);
}
}
// ==================== Redis implementation ====================
class RedisCache implements Cache {
readonly backend = 'redis' as const;
async get<T>(key: string): Promise<T | null> {
const redis = getRedis();
if (!redis) return null;
try {
const raw = await redis.get(`cache:${key}`);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch (err: any) {
console.error('[cache] redis get error:', err?.message || err);
return null;
}
}
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.set(`cache:${key}`, JSON.stringify(value), 'EX', ttlSeconds);
} catch (err: any) {
console.error('[cache] redis set error:', err?.message || err);
}
}
async del(key: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.del(`cache:${key}`);
} catch (err: any) {
console.error('[cache] redis del error:', err?.message || err);
}
}
}
// ==================== Selection ====================
let instance: Cache | null = null;
export function getCache(): Cache {
if (!instance) {
instance = isRedisEnabled() ? new RedisCache() : new MemoryCache();
}
return instance;
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js';
describe('MemoryLock', () => {
it('grants the lock, blocks contenders, and frees on release', async () => {
const lock = new MemoryLock();
const token = await lock.acquire('a', 60_000);
expect(token).toBeTruthy();
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
it('ignores release with the wrong token', async () => {
const lock = new MemoryLock();
const token = await lock.acquire('a', 60_000);
await lock.release('a', 'not-the-token');
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
});
it('withLock runs fn while held and returns null under contention', async () => {
const lock = new MemoryLock();
const held = await lock.acquire('a', 60_000);
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull();
await lock.release('a', held!);
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran');
// Released after withLock completes.
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
});
describe('RedisLock', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('grants the lock and blocks contenders', async () => {
const lock = new RedisLock();
const token = await lock.acquire('a', 60_000);
expect(token).toBeTruthy();
expect(await lock.acquire('a', 60_000)).toBeNull();
});
it('release is compare-and-delete: wrong token does not free the lock', async () => {
const lock = new RedisLock();
const token = await lock.acquire('a', 60_000);
await lock.release('a', 'not-the-token');
expect(await lock.acquire('a', 60_000)).toBeNull();
await lock.release('a', token!);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
it('throws LockUnavailableError when the client is not initialized', async () => {
mocks.redis = null;
const lock = new RedisLock();
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
});
it('throws LockUnavailableError when the backend errors', async () => {
mocks.redis = { set: () => Promise.reject(new Error('connection refused')) };
const lock = new RedisLock();
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
});
it('withLock skips (returns null, fn not called) when unavailable by default', async () => {
mocks.redis = null;
const lock = new RedisLock();
const fn = vi.fn(async () => 'ran');
expect(await lock.withLock('a', 60_000, fn)).toBeNull();
expect(fn).not.toHaveBeenCalled();
});
it('withLock runs fn without the lock when onUnavailable is "run"', async () => {
mocks.redis = null;
const lock = new RedisLock();
const fn = vi.fn(async () => 'ran');
expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran');
expect(fn).toHaveBeenCalledOnce();
});
it('withLock returns fn result and releases the lock afterwards', async () => {
const lock = new RedisLock();
expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42);
expect(await lock.acquire('a', 60_000)).toBeTruthy();
});
});
+145
View File
@@ -0,0 +1,145 @@
// Distributed lock abstraction with two implementations:
// - memory: per-process key set with TTL (only meaningful within one instance)
// - redis: SET key token NX PX ttl, released with a compare-and-delete Lua
// script so only the holder can release it
//
// Use acquire/release for long-lived ownership (e.g. a background poller) and
// withLock for a one-shot critical section. Selection is based on REDIS_URL.
//
// There is deliberately no auto-renewal/watchdog: every guarded section (sweep
// jobs, template seeding) is a handful of status-conditional bulk UPDATEs that
// finish far below the lock TTL, and a rare TTL overrun only risks one
// idempotent overlapping run. Keep TTLs generous instead of adding renewal.
import { randomUUID } from 'crypto';
import { getRedis, isRedisEnabled } from '../redis.js';
// Thrown when Redis is configured but the lock backend cannot be reached.
// This is distinct from contention (acquire resolves null): the caller must
// decide whether its critical section is safe to run without mutual exclusion.
export class LockUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'LockUnavailableError';
}
}
export interface WithLockOptions {
// What withLock should do when the lock backend is unavailable (not mere
// contention): 'skip' (default) returns null as if the lock were held
// elsewhere; 'run' executes fn without mutual exclusion.
onUnavailable?: 'skip' | 'run';
}
export interface Lock {
readonly backend: 'memory' | 'redis';
// Returns a token when the lock was acquired, or null when already held.
// Throws LockUnavailableError when the backend is configured but erroring.
acquire(key: string, ttlMs: number): Promise<string | null>;
release(key: string, token: string): Promise<void>;
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null>;
}
// ==================== Memory implementation ====================
export class MemoryLock implements Lock {
readonly backend = 'memory' as const;
private held = new Map<string, { token: string; expiresAt: number }>();
async acquire(key: string, ttlMs: number): Promise<string | null> {
const existing = this.held.get(key);
const now = Date.now();
if (existing && existing.expiresAt > now) {
return null;
}
const token = randomUUID();
this.held.set(key, { token, expiresAt: now + ttlMs });
return token;
}
async release(key: string, token: string): Promise<void> {
const existing = this.held.get(key);
if (existing && existing.token === token) {
this.held.delete(key);
}
}
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
const token = await this.acquire(key, ttlMs);
if (!token) return null;
try {
return await fn();
} finally {
await this.release(key, token);
}
}
}
// ==================== Redis implementation ====================
const RELEASE_SCRIPT =
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
export class RedisLock implements Lock {
readonly backend = 'redis' as const;
async acquire(key: string, ttlMs: number): Promise<string | null> {
const redis = getRedis();
if (!redis) {
throw new LockUnavailableError('redis client not initialized');
}
const token = randomUUID();
try {
const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX');
return result === 'OK' ? token : null;
} catch (err: any) {
// Do NOT fabricate a token here: during an outage every replica would
// "acquire" every lock and run the guarded sections concurrently. Let the
// caller choose between skipping the run and running unlocked.
throw new LockUnavailableError(err?.message || String(err));
}
}
async release(key: string, token: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.eval(RELEASE_SCRIPT, 1, `lock:${key}`, token);
} catch (err: any) {
console.error('[lock] redis release error:', err?.message || err);
}
}
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null> {
let token: string | null;
try {
token = await this.acquire(key, ttlMs);
} catch (err) {
if (!(err instanceof LockUnavailableError)) throw err;
if (opts?.onUnavailable === 'run') {
console.warn(`[lock] backend unavailable, running "${key}" WITHOUT mutual exclusion:`, err.message);
return fn();
}
console.warn(`[lock] backend unavailable, skipping "${key}" this run:`, err.message);
return null;
}
if (!token) return null;
try {
return await fn();
} finally {
await this.release(key, token);
}
}
}
// ==================== Selection ====================
let instance: Lock | null = null;
export function getLock(): Lock {
if (!instance) {
instance = isRedisEnabled() ? new RedisLock() : new MemoryLock();
}
return instance;
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import {
MemoryLoginLockout,
RedisLoginLockout,
MAX_LOGIN_ATTEMPTS,
} from './loginLockout.js';
describe('MemoryLoginLockout', () => {
it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
await lockout.recordFailure('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
}
await lockout.recordFailure('user@example.com');
const status = await lockout.isLocked('user@example.com');
expect(status.locked).toBe(true);
expect(status.retryAfter).toBeGreaterThan(0);
});
it('clear removes the lockout', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
await lockout.clear('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('treats emails case-insensitively', async () => {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('User@Example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
});
it('expires after the lockout window', async () => {
vi.useFakeTimers();
try {
const lockout = new MemoryLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
vi.advanceTimersByTime(16 * 60 * 1000);
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
describe('RedisLoginLockout', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
await lockout.recordFailure('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
}
await lockout.recordFailure('user@example.com');
const status = await lockout.isLocked('user@example.com');
expect(status.locked).toBe(true);
expect(status.retryAfter).toBeGreaterThan(0);
});
it('sets the lockout window TTL on the first failure', async () => {
const lockout = new RedisLoginLockout();
await lockout.recordFailure('user@example.com');
const ttl = await mocks.redis.pttl('lockout:user@example.com');
expect(ttl).toBeGreaterThan(0);
});
it('clear removes the lockout', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('user@example.com');
}
await lockout.clear('user@example.com');
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('treats emails case-insensitively', async () => {
const lockout = new RedisLoginLockout();
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
await lockout.recordFailure('User@Example.com');
}
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
});
it('fails open when the client is not initialized', async () => {
mocks.redis = null;
const lockout = new RedisLoginLockout();
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
});
it('fails open when the backend errors', async () => {
mocks.redis = {
get: () => Promise.reject(new Error('connection refused')),
pttl: () => Promise.reject(new Error('connection refused')),
del: () => Promise.reject(new Error('connection refused')),
lockoutRecordFailure: () => Promise.reject(new Error('connection refused')),
};
const lockout = new RedisLoginLockout();
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
await expect(lockout.clear('user@example.com')).resolves.toBeUndefined();
});
});
+164
View File
@@ -0,0 +1,164 @@
// Per-email login lockout abstraction with two implementations:
// - memory: per-process Map (the original routes/auth.ts behavior)
// - redis: shared counter across all instances so the lockout cannot be
// bypassed by round-robining replicas
//
// Semantics: recordFailure starts a window on the first failure; once the
// failure count reaches the max the email is locked until the window expires;
// clear removes the counter on successful login. Selection is based on
// REDIS_URL. The redis implementation fails open (never locked, failures not
// recorded) — the per-IP auth rate limit remains as a backstop during a
// Redis outage.
import type Redis from 'ioredis';
import { getRedis, isRedisEnabled } from '../redis.js';
export const MAX_LOGIN_ATTEMPTS = 5;
export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
export interface LockoutStatus {
locked: boolean;
// Seconds until the lockout lifts; only set when locked.
retryAfter?: number;
}
export interface LoginLockout {
readonly backend: 'memory' | 'redis';
isLocked(email: string): Promise<LockoutStatus>;
recordFailure(email: string): Promise<void>;
clear(email: string): Promise<void>;
}
// Emails are compared case-insensitively so "User@x.com" and "user@x.com"
// share one counter.
function normalize(email: string): string {
return email.trim().toLowerCase();
}
// ==================== Memory implementation ====================
export class MemoryLoginLockout implements LoginLockout {
readonly backend = 'memory' as const;
private attempts = new Map<string, { count: number; resetAt: number }>();
constructor() {
// Periodically drop expired entries so the Map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.attempts) {
if (now > entry.resetAt) this.attempts.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async isLocked(email: string): Promise<LockoutStatus> {
const entry = this.attempts.get(normalize(email));
const now = Date.now();
if (!entry || now > entry.resetAt) return { locked: false };
if (entry.count >= MAX_LOGIN_ATTEMPTS) {
return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
}
return { locked: false };
}
async recordFailure(email: string): Promise<void> {
const key = normalize(email);
const now = Date.now();
const entry = this.attempts.get(key);
if (!entry || now > entry.resetAt) {
this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS });
return;
}
entry.count++;
}
async clear(email: string): Promise<void> {
this.attempts.delete(normalize(email));
}
}
// ==================== Redis implementation ====================
// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the
// window starts at the first failure and any TTL-less counter is repaired.
const RECORD_FAILURE_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if redis.call('PTTL', KEYS[1]) < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return count
`;
type RedisWithLockout = Redis & {
lockoutRecordFailure(key: string, windowMs: number): Promise<number>;
};
function withLockoutCommand(redis: Redis): RedisWithLockout {
if (typeof (redis as any).lockoutRecordFailure !== 'function') {
redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT });
}
return redis as RedisWithLockout;
}
export class RedisLoginLockout implements LoginLockout {
readonly backend = 'redis' as const;
private key(email: string): string {
return `lockout:${normalize(email)}`;
}
async isLocked(email: string): Promise<LockoutStatus> {
const redis = getRedis();
if (!redis) return { locked: false };
try {
const [count, ttl] = await Promise.all([
redis.get(this.key(email)),
redis.pttl(this.key(email)),
]);
if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) {
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000);
return { locked: true, retryAfter };
}
return { locked: false };
} catch (err: any) {
// Fail open: the per-IP auth rate limit still applies.
console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err);
return { locked: false };
}
}
async recordFailure(email: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS);
} catch (err: any) {
console.error('[loginLockout] redis error recording failure:', err?.message || err);
}
}
async clear(email: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.del(this.key(email));
} catch (err: any) {
console.error('[loginLockout] redis error clearing failures:', err?.message || err);
}
}
}
// ==================== Selection ====================
let instance: LoginLockout | null = null;
export function getLoginLockout(): LoginLockout {
if (!instance) {
instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout();
}
return instance;
}
+121
View File
@@ -0,0 +1,121 @@
// Pub/Sub abstraction with two implementations:
// - memory: in-process EventEmitter (single instance only)
// - redis: PUBLISH / SUBSCRIBE so a message published on one instance reaches
// subscribers on every instance
//
// Messages are JSON-serialized. Selection happens once based on REDIS_URL.
import { EventEmitter } from 'events';
import { getRedis, getSubscriber, isRedisEnabled } from '../redis.js';
export type PubSubHandler = (message: any) => void;
export interface PubSub {
readonly backend: 'memory' | 'redis';
publish(channel: string, message: any): Promise<void>;
// Returns an unsubscribe function for this specific handler.
subscribe(channel: string, handler: PubSubHandler): Promise<() => void>;
}
// ==================== Memory implementation ====================
class MemoryPubSub implements PubSub {
readonly backend = 'memory' as const;
private emitter = new EventEmitter();
constructor() {
// SSE fan-out can attach many listeners to the same channel; lift the cap.
this.emitter.setMaxListeners(0);
}
async publish(channel: string, message: any): Promise<void> {
this.emitter.emit(channel, message);
}
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
this.emitter.on(channel, handler);
return () => this.emitter.off(channel, handler);
}
}
// ==================== Redis implementation ====================
class RedisPubSub implements PubSub {
readonly backend = 'redis' as const;
// Per-channel handler sets so a single Redis subscription fans out locally.
private handlers = new Map<string, Set<PubSubHandler>>();
private wired = false;
private ensureWired(): void {
if (this.wired) return;
const sub = getSubscriber();
if (!sub) return;
this.wired = true;
sub.on('message', (channel: string, payload: string) => {
const set = this.handlers.get(channel);
if (!set || set.size === 0) return;
let parsed: any = payload;
try {
parsed = JSON.parse(payload);
} catch {
// Leave as raw string if it was not JSON.
}
for (const handler of set) {
try {
handler(parsed);
} catch (err: any) {
console.error('[pubsub] handler error:', err?.message || err);
}
}
});
}
async publish(channel: string, message: any): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.publish(channel, JSON.stringify(message));
} catch (err: any) {
console.error('[pubsub] publish error:', err?.message || err);
}
}
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
this.ensureWired();
const sub = getSubscriber();
if (!sub) return () => undefined;
let set = this.handlers.get(channel);
if (!set) {
set = new Set();
this.handlers.set(channel, set);
try {
await sub.subscribe(channel);
} catch (err: any) {
console.error('[pubsub] subscribe error:', err?.message || err);
}
}
set.add(handler);
return () => {
const current = this.handlers.get(channel);
if (!current) return;
current.delete(handler);
if (current.size === 0) {
this.handlers.delete(channel);
sub.unsubscribe(channel).catch(() => undefined);
}
};
}
}
// ==================== Selection ====================
let instance: PubSub | null = null;
export function getPubSub(): PubSub {
if (!instance) {
instance = isRedisEnabled() ? new RedisPubSub() : new MemoryPubSub();
}
return instance;
}
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';
const mocks = vi.hoisted(() => ({ redis: null as any }));
vi.mock('../redis.js', () => ({
isRedisEnabled: () => true,
getRedis: () => mocks.redis,
}));
import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js';
describe('MemoryRateLimiter', () => {
it('allows up to max within the window, then blocks with retryAfter', async () => {
const limiter = new MemoryRateLimiter();
for (let i = 0; i < 3; i++) {
expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true);
}
const blocked = await limiter.consume('k', 3, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfter).toBeGreaterThan(0);
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
});
it('resets after the window elapses', async () => {
vi.useFakeTimers();
try {
const limiter = new MemoryRateLimiter();
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false);
vi.advanceTimersByTime(1_500);
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
describe('RedisRateLimiter', () => {
beforeEach(async () => {
mocks.redis = new RedisMock();
// ioredis-mock shares data between instances by connection string.
await mocks.redis.flushall();
});
it('sets the window TTL atomically on the first hit', async () => {
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true);
const ttl = await mocks.redis.pttl('rl:k');
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(60_000);
});
it('blocks over the limit with a sane retryAfter', async () => {
const limiter = new RedisRateLimiter();
for (let i = 0; i < 2; i++) {
expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true);
}
const blocked = await limiter.consume('k', 2, 60_000);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfter).toBeGreaterThan(0);
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
});
it('self-heals a counter stranded without a TTL', async () => {
await mocks.redis.set('rl:k', '3');
expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0);
const limiter = new RedisRateLimiter();
await limiter.consume('k', 10, 60_000);
expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0);
});
it('fails open when the backend errors', async () => {
mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) };
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
});
it('fails open when the client is not initialized', async () => {
mocks.redis = null;
const limiter = new RedisRateLimiter();
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
});
});
+123
View File
@@ -0,0 +1,123 @@
// Rate limiter abstraction with two implementations:
// - memory: per-process fixed window (the original behavior)
// - redis: shared fixed window across all instances, implemented as a single
// Lua script so INCR and PEXPIRE are atomic (a crash between separate calls
// would otherwise strand a counter with no expiry)
//
// Selection happens once based on REDIS_URL. On any Redis error the limiter
// fails open (allows the request) so a Redis blip never takes the API down.
import type Redis from 'ioredis';
import { getRedis, isRedisEnabled } from '../redis.js';
export interface RateLimitResult {
allowed: boolean;
retryAfter?: number;
}
export interface RateLimiter {
readonly backend: 'memory' | 'redis';
consume(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
}
// ==================== Memory implementation ====================
interface Bucket {
count: number;
resetAt: number;
}
export class MemoryRateLimiter implements RateLimiter {
readonly backend = 'memory' as const;
private buckets = new Map<string, Bucket>();
constructor() {
// Periodically drop expired buckets so the Map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, bucket] of this.buckets) {
if (now > bucket.resetAt) this.buckets.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
const now = Date.now();
const bucket = this.buckets.get(key);
if (!bucket || now > bucket.resetAt) {
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true };
}
bucket.count++;
if (bucket.count > max) {
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
}
return { allowed: true };
}
}
// ==================== Redis implementation ====================
// Atomically increments the window counter, sets the expiry on the first hit,
// and repairs any counter left without a TTL (self-heals keys stranded by the
// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}.
export const CONSUME_SCRIPT = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('PTTL', KEYS[1])
if ttl < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
ttl = tonumber(ARGV[1])
end
return {count, ttl}
`;
type RedisWithConsume = Redis & {
rlConsume(key: string, windowMs: number): Promise<[number, number]>;
};
function withConsumeCommand(redis: Redis): RedisWithConsume {
if (typeof (redis as any).rlConsume !== 'function') {
// ioredis caches the script SHA and transparently handles NOSCRIPT.
redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT });
}
return redis as RedisWithConsume;
}
export class RedisRateLimiter implements RateLimiter {
readonly backend = 'redis' as const;
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
const redis = getRedis();
if (!redis) return { allowed: true };
try {
const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
if (count > max) {
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
return { allowed: false, retryAfter };
}
return { allowed: true };
} catch (err: any) {
// Fail open: never block traffic because Redis is unavailable.
console.error('[rateLimiter] redis error, allowing request:', err?.message || err);
return { allowed: true };
}
}
}
// ==================== Selection ====================
let instance: RateLimiter | null = null;
export function getRateLimiter(): RateLimiter {
if (!instance) {
instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter();
}
return instance;
}
+5 -11
View File
@@ -1,5 +1,6 @@
import { nanoid } from 'nanoid';
import { randomUUID } from 'crypto';
import moment from 'moment-timezone';
/**
* Get database type (reads env var each time to handle module loading order)
@@ -59,17 +60,10 @@ export function parseEventDatetime(
return new Date(datetime);
}
// Treat the digits as UTC so we have a stable reference instant.
const fakeUTC = new Date(datetime + 'Z');
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
// The gap between the two tells us the tz offset at this point in time.
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
return new Date(fakeUTC.getTime() + offsetMs);
// Interpret the wall-clock digits as local time in `timezone` using
// moment-timezone's bundled IANA data. This keeps the conversion correct
// regardless of the host Node runtime's (possibly stale) tz database.
return moment.tz(datetime, timezone).toDate();
}
/**
+123 -100
View File
@@ -3,15 +3,27 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
const adminRouter = new Hono();
// Escape a value for inclusion in a CSV cell (RFC 4180 quoting).
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
// Dashboard overview stats (admin)
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
// Get upcoming events
const upcomingEvents = await dbAll(
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
// so the dashboard's capacity alerts reflect real availability.
const upcomingEventsRaw = await dbAll<any>(
(db as any)
.select()
.from(events)
@@ -24,6 +36,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.orderBy((events as any).startDatetime)
.limit(5)
);
const seatRows = await dbAll<any>(eventSeatBreakdownQuery(db));
const seatsByEvent = new Map<string, { paid: number; claimed: number }>();
for (const row of seatRows) {
seatsByEvent.set(row.eventId, {
paid: Number(row.paidCount) || 0,
claimed: Number(row.claimedCount) || 0,
});
}
const upcomingEvents = upcomingEventsRaw.map((event: any) => {
const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...event,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed),
};
});
// Get recent tickets
const recentTickets = await dbAll(
@@ -60,21 +90,37 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.where(eq((tickets as any).status, 'confirmed'))
);
// 'pending' = checkout opened, nothing paid or claimed (informational);
// 'pending_approval' = customer says they paid, needs admin verification (actionable).
const pendingPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending'))
);
const paidPayments = await dbAll<any>(
const awaitingApprovalPayments = await dbGet<any>(
(db as any)
.select()
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending_approval'))
);
const onHoldPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'on_hold'))
);
const revenueRow = await dbGet<any>(
(db as any)
.select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` })
.from(payments)
.where(eq((payments as any).status, 'paid'))
);
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
const totalRevenue = Number(revenueRow?.total || 0);
const newContacts = await dbGet<any>(
(db as any)
@@ -98,6 +144,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
totalTickets: totalTickets?.count || 0,
confirmedTickets: confirmedTickets?.count || 0,
pendingPayments: pendingPayments?.count || 0,
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
onHoldPayments: onHoldPayments?.count || 0,
totalRevenue,
newContacts: newContacts?.count || 0,
totalSubscribers: totalSubscribers?.count || 0,
@@ -110,56 +158,52 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
// Get analytics data (admin)
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
// Get events with ticket counts
// Get events with ticket counts using grouped aggregates (avoids 3 queries per event).
const allEvents = await dbAll<any>((db as any).select().from(events));
const eventStats = await Promise.all(
allEvents.map(async (event: any) => {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(eq((tickets as any).eventId, event.id))
);
const confirmedCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed'),
ne((tickets as any).isGuest, 1)
)
)
);
const checkedInCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'checked_in'),
ne((tickets as any).isGuest, 1)
)
)
);
return {
id: event.id,
title: event.title,
date: event.startDatetime,
capacity: event.capacity,
totalBookings: ticketCount?.count || 0,
confirmedBookings: confirmedCount?.count || 0,
checkedIn: checkedInCount?.count || 0,
revenue: (confirmedCount?.count || 0) * event.price,
};
})
const totalRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.groupBy((tickets as any).eventId)
);
const confirmedRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.where(and(eq((tickets as any).status, 'confirmed'), ne((tickets as any).isGuest, 1)))
.groupBy((tickets as any).eventId)
);
const checkedInRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.where(and(eq((tickets as any).status, 'checked_in'), ne((tickets as any).isGuest, 1)))
.groupBy((tickets as any).eventId)
);
const toMap = (rows: any[]) => {
const m = new Map<string, number>();
for (const r of rows) m.set(r.eventId, Number(r.count) || 0);
return m;
};
const totalMap = toMap(totalRows);
const confirmedMap = toMap(confirmedRows);
const checkedInMap = toMap(checkedInRows);
const eventStats = allEvents.map((event: any) => {
const confirmedBookings = confirmedMap.get(event.id) || 0;
return {
id: event.id,
title: event.title,
date: event.startDatetime,
capacity: event.capacity,
totalBookings: totalMap.get(event.id) || 0,
confirmedBookings,
checkedIn: checkedInMap.get(event.id) || 0,
revenue: confirmedBookings * event.price,
};
});
return c.json({
analytics: {
@@ -179,30 +223,25 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
}
const ticketList = await dbAll<any>(query);
const userIds = [...new Set(ticketList.map((t: any) => t.userId).filter(Boolean))];
const eventIds = [...new Set(ticketList.map((t: any) => t.eventId).filter(Boolean))];
const ticketIds = ticketList.map((t: any) => t.id);
const [userRows, eventRows, paymentRows] = await Promise.all([
userIds.length ? dbAll<any>((db as any).select().from(users).where(inArray((users as any).id, userIds))) : Promise.resolve([]),
eventIds.length ? dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds))) : Promise.resolve([]),
ticketIds.length ? dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))) : Promise.resolve([]),
]);
const usersById = new Map(userRows.map((u: any) => [u.id, u]));
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
// Get user and event details for each ticket
const enrichedTickets = await Promise.all(
ticketList.map(async (ticket: any) => {
const user = await dbGet<any>(
(db as any)
.select()
.from(users)
.where(eq((users as any).id, ticket.userId))
);
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
const enrichedTickets = ticketList.map((ticket: any) => {
const user = usersById.get(ticket.userId);
const event = eventsById.get(ticket.eventId);
const payment = paymentsByTicketId.get(ticket.id);
return {
ticketId: ticket.id,
@@ -212,14 +251,14 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
userName: user?.name,
userEmail: user?.email,
userPhone: user?.phone,
attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null,
eventTitle: event?.title,
eventDate: event?.startDatetime,
paymentStatus: payment?.status,
paymentAmount: payment?.amount,
createdAt: ticket.createdAt,
};
})
);
});
return c.json({ tickets: enrichedTickets });
});
@@ -291,6 +330,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
'Full Name': fullName,
'Email': ticket.attendeeEmail || '',
'Phone': ticket.attendeePhone || '',
'RUC': ticket.attendeeRuc || '',
'Status': ticket.status,
'Checked In': isCheckedIn ? 'true' : 'false',
'Check-in Time': ticket.checkinAt || '',
@@ -301,18 +341,8 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
})
);
// Generate CSV
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = [
'Ticket ID', 'Full Name', 'Email', 'Phone',
'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC',
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
'Booked At', 'Notes',
];
@@ -390,21 +420,13 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
});
}
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'RUC', 'Status', 'Check-in Time', 'Booked At'];
const rows = ticketList.map((ticket: any) => ({
'Ticket ID': ticket.id,
'Booking ID': ticket.bookingId || '',
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
'RUC': ticket.attendeeRuc || '',
'Status': ticket.status,
'Check-in Time': ticket.checkinAt || '',
'Booked At': ticket.createdAt || '',
@@ -477,6 +499,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
attendeeFirstName: ticket.attendeeFirstName,
attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail,
attendeeRuc: ticket.attendeeRuc || null,
eventId: event?.id,
eventTitle: event?.title,
eventDate: event?.startDatetime,
-672
View File
@@ -1,672 +0,0 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
import { eq } from 'drizzle-orm';
import {
hashPassword,
verifyPassword,
createToken,
createRefreshToken,
isFirstUser,
getAuthUser,
validatePassword,
createMagicLinkToken,
verifyMagicLinkToken,
invalidateAllUserSessions,
requireAuth,
} from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
const auth = new Hono();
// Rate limiting store (in production, use Redis)
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const attempts = loginAttempts.get(email);
if (!attempts) {
return { allowed: true };
}
if (now > attempts.resetAt) {
loginAttempts.delete(email);
return { allowed: true };
}
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
}
return { allowed: true };
}
function recordFailedAttempt(email: string): void {
const now = Date.now();
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
attempts.count++;
loginAttempts.set(email, attempts);
}
function clearFailedAttempts(email: string): void {
loginAttempts.delete(email);
}
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(10, 'Password must be at least 10 characters'),
name: z.string().min(2),
phone: z.string().optional(),
languagePreference: z.enum(['en', 'es']).optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
const magicLinkRequestSchema = z.object({
email: z.string().email(),
});
const magicLinkVerifySchema = z.object({
token: z.string(),
});
const passwordResetRequestSchema = z.object({
email: z.string().email(),
});
const passwordResetSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const claimAccountSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters').optional(),
googleId: z.string().optional(),
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
});
const googleAuthSchema = z.object({
credential: z.string(), // Google ID token
});
// Register
auth.post('/register', zValidator('json', registerSchema), async (c) => {
const data = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(data.password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Check if email exists
const existing = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (existing) {
// If user exists but is unclaimed, allow claiming
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
return c.json({
error: 'Email already registered',
canClaim: true,
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
}, 400);
}
return c.json({ error: 'Email already registered' }, 400);
}
// Check if first user (becomes admin)
const firstUser = await isFirstUser();
const hashedPassword = await hashPassword(data.password);
const now = getNow();
const id = generateId();
const newUser = {
id,
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone || null,
role: firstUser ? 'admin' : 'user',
languagePreference: data.languagePreference || null,
isClaimed: toDbBool(true),
googleId: null,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
const token = await createToken(id, data.email, newUser.role);
const refreshToken = await createRefreshToken(id);
return c.json({
user: {
id,
email: data.email,
name: data.name,
role: newUser.role,
isClaimed: true,
},
token,
refreshToken,
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
}, 201);
});
// Login with email/password
auth.post('/login', zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Check rate limit
const rateLimit = checkRateLimit(data.email);
if (!rateLimit.allowed) {
return c.json({
error: 'Too many login attempts. Please try again later.',
retryAfter: rateLimit.retryAfter
}, 429);
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, data.email))
);
if (!user) {
recordFailedAttempt(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Check if account is suspended
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
// Check if user has a password set
if (!user.password) {
return c.json({
error: 'No password set for this account',
needsClaim: !user.isClaimed,
message: user.isClaimed
? 'Please use Google login or request a password reset.'
: 'Please claim your account first.'
}, 400);
}
const validPassword = await verifyPassword(data.password, user.password);
if (!validPassword) {
recordFailedAttempt(data.email);
return c.json({ error: 'Invalid credentials' }, 401);
}
// Clear failed attempts on successful login
clearFailedAttempts(data.email);
const token = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token,
refreshToken,
});
});
// Request magic link login
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
}
// Create magic link token (expires in 10 minutes)
const token = await createMagicLinkToken(user.id, 'login', 10);
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Your Spanglish Login Link',
html: `
<h2>Login to Spanglish</h2>
<p>Click the link below to log in. This link expires in 10 minutes.</p>
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
<p>Or copy this link: ${magicLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send magic link email:', error);
}
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
});
// Verify magic link and login
auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => {
const { token } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'login');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
if (!user || user.accountStatus === 'suspended') {
return c.json({ error: 'Invalid token' }, 400);
}
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
});
// Request password reset
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Don't reveal if email exists
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
if (user.accountStatus === 'suspended') {
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
}
// Create reset token (expires in 30 minutes)
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Reset Your Spanglish Password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
<p>Or copy this link: ${resetLink}</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
} catch (error) {
console.error('Failed to send password reset email:', error);
}
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
});
// Reset password
auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => {
const { token, password } = c.req.valid('json');
// Validate password strength
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const verification = await verifyMagicLinkToken(token, 'reset_password');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, verification.userId));
// Invalidate all existing sessions for security
await invalidateAllUserSessions(verification.userId!);
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
});
// Claim unclaimed account
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
}
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
return c.json({ error: 'Account is already claimed' }, 400);
}
// Create claim token (expires in 24 hours)
const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60);
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
// Send email
try {
await sendEmail({
to: email,
subject: 'Claim Your Spanglish Account',
html: `
<h2>Claim Your Account</h2>
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${claimLink}</p>
<p>This link expires in 24 hours.</p>
`,
});
} catch (error) {
console.error('Failed to send claim account email:', error);
}
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
});
// Complete account claim
auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => {
const { token, password, googleId } = c.req.valid('json');
if (!password && !googleId) {
return c.json({ error: 'Please provide either a password or link a Google account' }, 400);
}
const verification = await verifyMagicLinkToken(token, 'claim_account');
if (!verification.valid) {
return c.json({ error: verification.error }, 400);
}
const now = getNow();
const updates: Record<string, any> = {
isClaimed: toDbBool(true),
accountStatus: 'active',
updatedAt: now,
};
if (password) {
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
updates.password = await hashPassword(password);
}
if (googleId) {
updates.googleId = googleId;
}
await (db as any)
.update(users)
.set(updates)
.where(eq((users as any).id, verification.userId));
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
message: 'Account claimed successfully!',
});
});
// Google OAuth login/register
auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
const { credential } = c.req.valid('json');
try {
// Verify Google token
// In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
if (!response.ok) {
return c.json({ error: 'Invalid Google token' }, 400);
}
const googleData = await response.json() as {
sub: string;
email: string;
name: string;
email_verified: string;
};
if (googleData.email_verified !== 'true') {
return c.json({ error: 'Google email not verified' }, 400);
}
const { sub: googleId, email, name } = googleData;
// Check if user exists by email or google_id
let user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
if (!user) {
// Check by google_id
user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
);
}
const now = getNow();
if (user) {
// User exists - link Google account if not already linked
if (user.accountStatus === 'suspended') {
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
}
if (!user.googleId) {
await (db as any)
.update(users)
.set({
googleId,
isClaimed: toDbBool(true),
accountStatus: 'active',
updatedAt: now,
})
.where(eq((users as any).id, user.id));
}
// Refresh user data
user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
} else {
// Create new user
const firstUser = await isFirstUser();
const id = generateId();
const newUser = {
id,
email,
password: null,
name,
phone: null,
role: firstUser ? 'admin' : 'user',
languagePreference: null,
isClaimed: toDbBool(true),
googleId,
rucNumber: null,
accountStatus: 'active',
createdAt: now,
updatedAt: now,
};
await (db as any).insert(users).values(newUser);
user = newUser;
}
const authToken = await createToken(user.id, user.email, user.role);
const refreshToken = await createRefreshToken(user.id);
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isClaimed: user.isClaimed,
phone: user.phone,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
},
token: authToken,
refreshToken,
});
} catch (error) {
console.error('Google auth error:', error);
return c.json({ error: 'Failed to authenticate with Google' }, 500);
}
});
// Get current user
auth.get('/me', async (c) => {
const user = await getAuthUser(c);
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
phone: user.phone,
isClaimed: user.isClaimed,
rucNumber: user.rucNumber,
languagePreference: user.languagePreference,
accountStatus: user.accountStatus,
createdAt: user.createdAt,
},
});
});
// Change password (authenticated users)
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { currentPassword, newPassword } = c.req.valid('json');
// Validate new password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
// Verify current password if user has one
if (user.password) {
const validPassword = await verifyPassword(currentPassword, user.password);
if (!validPassword) {
return c.json({ error: 'Current password is incorrect' }, 400);
}
}
const hashedPassword = await hashPassword(newPassword);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
return c.json({ message: 'Password changed successfully' });
});
// Logout (client-side token removal, but we can log the action)
auth.post('/logout', async (c) => {
return c.json({ message: 'Logged out successfully' });
});
export default auth;
+96
View File
@@ -0,0 +1,96 @@
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 } 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 });
}
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).email, email))
);
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
return c.json({ canClaim });
});
export default authExt;
+32 -32
View File
@@ -4,25 +4,17 @@ import { z } from 'zod';
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { generateId, getNow, sanitizeHtml } from '../lib/utils.js';
import { emailService } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
const contactsRouter = new Hono();
// ==================== Sanitization Helpers ====================
// Per-IP rate limit for public, unauthenticated write endpoints (contact form,
// newsletter subscribe/unsubscribe) to prevent spam and email flooding.
const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000, prefix: 'contacts' });
/**
* Sanitize a string to prevent HTML injection
* Escapes HTML special characters
*/
function sanitizeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// ==================== Sanitization Helpers ====================
/**
* Sanitize email header values to prevent email header injection
@@ -33,14 +25,14 @@ function sanitizeHeaderValue(str: string): string {
}
const createContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
name: z.string().min(2).max(200),
email: z.string().email().max(254),
message: z.string().min(10).max(5000),
});
const subscribeSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
email: z.string().email().max(254),
name: z.string().max(200).optional(),
});
const updateContactSchema = z.object({
@@ -48,7 +40,7 @@ const updateContactSchema = z.object({
});
// Submit contact form (public)
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema), async (c) => {
const data = c.req.valid('json');
const now = getNow();
const id = generateId();
@@ -125,7 +117,7 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
});
// Subscribe to newsletter (public)
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeSchema), async (c) => {
const data = c.req.valid('json');
// Check if already subscribed
@@ -166,28 +158,30 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
});
// Unsubscribe from newsletter (public)
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
contactsRouter.post('/unsubscribe', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), async (c) => {
const { email } = c.req.valid('json');
const existing = await dbGet<any>(
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
);
if (!existing) {
return c.json({ error: 'Email not found' }, 404);
// Always return the same response whether or not the address exists, to avoid
// leaking which emails are subscribed (enumeration).
if (existing && existing.status !== 'unsubscribed') {
await (db as any)
.update(emailSubscribers)
.set({ status: 'unsubscribed' })
.where(eq((emailSubscribers as any).id, existing.id));
}
await (db as any)
.update(emailSubscribers)
.set({ status: 'unsubscribed' })
.where(eq((emailSubscribers as any).id, existing.id));
return c.json({ message: 'Successfully unsubscribed' });
return c.json({ message: 'If this email was subscribed, it has been unsubscribed.' });
});
// Get all contacts (admin)
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const status = c.req.query('status');
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 500);
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
let query = (db as any).select().from(contacts);
@@ -195,7 +189,9 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(eq((contacts as any).status, status));
}
const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
const result = await dbAll(
query.orderBy(desc((contacts as any).createdAt)).limit(limit).offset(offset)
);
return c.json({ contacts: result });
});
@@ -255,6 +251,8 @@ contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
// Get all subscribers (admin)
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
const status = c.req.query('status');
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 1000);
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
let query = (db as any).select().from(emailSubscribers);
@@ -262,7 +260,9 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy
query = query.where(eq((emailSubscribers as any).status, status));
}
const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
const result = await dbAll(
query.orderBy(desc((emailSubscribers as any).createdAt)).limit(limit).offset(offset)
);
return c.json({ subscribers: result });
});
+117 -84
View File
@@ -1,18 +1,12 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { eq, desc, and, gt, sql } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
isClaimed: boolean;
googleId: string | null;
rucNumber: string | null;
accountStatus: string;
};
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
import { auth } from '../lib/betterAuth.js';
import { authSessions, authAccounts } from '../db/auth-schema.js';
import { getNow } from '../lib/utils.js';
const dashboard = new Hono();
@@ -37,6 +31,8 @@ dashboard.get('/profile', async (c) => {
const now = new Date();
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
const hasPassword = !!(await getUserPasswordHash(user.id));
return c.json({
profile: {
id: user.id,
@@ -47,8 +43,8 @@ dashboard.get('/profile', async (c) => {
rucNumber: user.rucNumber,
isClaimed: user.isClaimed,
accountStatus: user.accountStatus,
hasPassword: !!user.password,
hasGoogleLinked: !!user.googleId,
hasPassword,
hasGoogleLinked: await hasGoogleAccount(user.id),
memberSince: user.createdAt,
membershipDays,
createdAt: user.createdAt,
@@ -103,34 +99,31 @@ dashboard.get('/tickets', async (c) => {
.where(eq((tickets as any).userId, user.id))
.orderBy(desc((tickets as any).createdAt))
);
// Batch-fetch related events, payments, and invoices (avoids N+1 per ticket).
const eventIds = [...new Set(userTickets.map((t: any) => t.eventId).filter(Boolean))];
const ticketIds = userTickets.map((t: any) => t.id);
const eventRows = eventIds.length
? await dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds)))
: [];
const paymentRows = ticketIds.length
? await dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds)))
: [];
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
const paidPaymentIds = paymentRows.filter((p: any) => p.status === 'paid').map((p: any) => p.id);
const invoiceRows = paidPaymentIds.length
? await dbAll<any>((db as any).select().from(invoices).where(inArray((invoices as any).paymentId, paidPaymentIds)))
: [];
const invoicesByPaymentId = new Map(invoiceRows.map((inv: any) => [inv.paymentId, inv]));
// Get event details for each ticket
const ticketsWithEvents = await Promise.all(
userTickets.map(async (ticket: any) => {
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
// Check for invoice
let invoice: any = null;
if (payment && payment.status === 'paid') {
invoice = await dbGet<any>(
(db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
);
}
const ticketsWithEvents = userTickets.map((ticket: any) => {
const event = eventsById.get(ticket.eventId);
const payment = paymentsByTicketId.get(ticket.id);
const invoice = payment && payment.status === 'paid' ? invoicesByPaymentId.get(payment.id) : null;
return {
...ticket,
@@ -162,8 +155,7 @@ dashboard.get('/tickets', async (c) => {
createdAt: invoice.createdAt,
} : null,
};
})
);
});
return c.json({ tickets: ticketsWithEvents });
});
@@ -425,40 +417,77 @@ dashboard.get('/invoices', async (c) => {
// ==================== Security Routes ====================
// Get active sessions
// Get active sessions (Better Auth session table; validated per-request so
// this list is always live). Session tokens are never exposed to the client.
dashboard.get('/sessions', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessions = await getUserSessions(user.id);
const sessions = await dbAll<any>(
(db as any)
.select({
id: (authSessions as any).id,
userAgent: (authSessions as any).userAgent,
ipAddress: (authSessions as any).ipAddress,
createdAt: (authSessions as any).createdAt,
updatedAt: (authSessions as any).updatedAt,
expiresAt: (authSessions as any).expiresAt,
})
.from(authSessions)
.where(
and(
eq((authSessions as any).userId, user.id),
gt((authSessions as any).expiresAt, new Date())
)
)
.orderBy(desc((authSessions as any).updatedAt))
);
return c.json({
sessions: sessions.map((s: any) => ({
id: s.id,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
lastActiveAt: s.lastActiveAt,
lastActiveAt: s.updatedAt,
createdAt: s.createdAt,
expiresAt: s.expiresAt,
current: s.id === user.sessionId,
})),
});
});
// Revoke a specific session
// Revoke a specific session. Deleting the row is immediately effective:
// sessions are validated against the table on every request (no cookie cache).
dashboard.delete('/sessions/:id', async (c) => {
const user = (c as any).get('user') as AuthUser;
const sessionId = c.req.param('id');
await invalidateSession(sessionId, user.id);
await (db as any)
.delete(authSessions)
.where(
and(
eq((authSessions as any).id, sessionId),
eq((authSessions as any).userId, user.id)
)
);
return c.json({ message: 'Session revoked' });
});
// Revoke all sessions (logout everywhere)
// Revoke all other sessions (logout everywhere else); the current session
// stays valid so this device remains signed in.
dashboard.post('/sessions/revoke-all', async (c) => {
const user = (c as any).get('user') as AuthUser;
await invalidateAllUserSessions(user.id);
return c.json({ message: 'All sessions revoked. Please log in again.' });
await (db as any)
.delete(authSessions)
.where(
and(
eq((authSessions as any).userId, user.id),
sql`${(authSessions as any).id} != ${user.sessionId}`
)
);
return c.json({ message: 'All other sessions revoked.' });
});
// Set password (for users without one)
@@ -469,53 +498,57 @@ const setPasswordSchema = z.object({
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
const user = (c as any).get('user') as AuthUser;
const { password } = c.req.valid('json');
// Check if user already has a password
if (user.password) {
if (await getUserPasswordHash(user.id)) {
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
}
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
// policy hook does not cover it — validate explicitly.
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const hashedPassword = await hashPassword(password);
const now = getNow();
await (db as any)
.update(users)
.set({
password: hashedPassword,
updatedAt: now,
})
.where(eq((users as any).id, user.id));
try {
await auth.api.setPassword({
body: { newPassword: password },
headers: c.req.raw.headers,
});
} catch (err: any) {
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
}
return c.json({ message: 'Password set successfully' });
});
// Unlink Google account (only if password is set)
dashboard.post('/unlink-google', async (c) => {
const user = (c as any).get('user') as AuthUser;
if (!user.googleId) {
if (!(await hasGoogleAccount(user.id))) {
return c.json({ error: 'Google account not linked' }, 400);
}
if (!user.password) {
if (!(await getUserPasswordHash(user.id))) {
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
}
const now = getNow();
await (db as any)
.delete(authAccounts)
.where(
and(
eq((authAccounts as any).userId, user.id),
eq((authAccounts as any).providerId, 'google')
)
);
await (db as any)
.update(users)
.set({
googleId: null,
updatedAt: now,
})
.set({ updatedAt: getNow() })
.where(eq((users as any).id, user.id));
return c.json({ message: 'Google account unlinked' });
});
+75 -31
View File
@@ -1,4 +1,6 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
@@ -9,6 +11,50 @@ import { getQueueStatus } from '../lib/emailQueue.js';
const emailsRouter = new Hono();
const slugPattern = /^[a-z0-9-]+$/;
const createTemplateSchema = z.object({
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(slugPattern),
subject: z.string().min(1).max(500),
subjectEs: z.string().max(500).optional().nullable(),
bodyHtml: z.string().min(1).max(200_000),
bodyHtmlEs: z.string().max(200_000).optional().nullable(),
bodyText: z.string().max(200_000).optional().nullable(),
bodyTextEs: z.string().max(200_000).optional().nullable(),
description: z.string().max(2000).optional().nullable(),
variables: z.array(z.any()).max(100).optional(),
});
const updateTemplateSchema = createTemplateSchema.partial().extend({
isActive: z.boolean().optional(),
});
const sendCustomEmailSchema = z.object({
to: z.string().email().max(254),
toName: z.string().max(200).optional(),
subject: z.string().min(1).max(500),
bodyHtml: z.string().min(1).max(200_000),
bodyText: z.string().max(200_000).optional(),
eventId: z.string().optional(),
});
const emailLogsQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
});
// Safely parse a stored JSON variables column; a corrupt row must not 500 the route.
function safeParseVariables(raw: any): any[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
// ==================== Template Routes ====================
// Get all email templates
@@ -20,7 +66,7 @@ emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) =>
// Parse variables JSON for each template
const parsedTemplates = templates.map((t: any) => ({
...t,
variables: t.variables ? JSON.parse(t.variables) : [],
variables: safeParseVariables(t.variables),
isSystem: Boolean(t.isSystem),
isActive: Boolean(t.isActive),
}));
@@ -46,7 +92,7 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
return c.json({
template: {
...template,
variables: template.variables ? JSON.parse(template.variables) : [],
variables: safeParseVariables(template.variables),
isSystem: Boolean(template.isSystem),
isActive: Boolean(template.isActive),
}
@@ -54,14 +100,10 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
});
// Create new email template
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
emailsRouter.post('/templates', requireAuth(['admin']), zValidator('json', createTemplateSchema), async (c) => {
const body = c.req.valid('json');
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
if (!name || !slug || !subject || !bodyHtml) {
return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400);
}
// Check if slug already exists
const existing = await dbGet<any>(
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
@@ -104,9 +146,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
});
// Update email template
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', updateTemplateSchema), async (c) => {
const { id } = c.req.param();
const body = await c.req.json();
const body = c.req.valid('json');
const existing = await dbGet<any>(
(db as any)
@@ -121,22 +163,22 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
const updateData: any = { updatedAt: getNow() };
// Only allow updating certain fields for system templates
const systemProtectedFields = ['slug', 'isSystem'];
// System templates cannot have their slug or isSystem flag changed; only the
// editable fields below are applied.
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
if (!existing.isSystem) {
allowedFields.push('slug');
}
for (const field of allowedFields) {
if (body[field] !== undefined) {
const value = (body as Record<string, unknown>)[field];
if (value !== undefined) {
if (field === 'variables') {
updateData[field] = JSON.stringify(body[field]);
updateData[field] = JSON.stringify(value);
} else if (field === 'isActive') {
updateData[field] = body[field] ? 1 : 0;
updateData[field] = value ? 1 : 0;
} else {
updateData[field] = body[field];
updateData[field] = value;
}
}
}
@@ -156,7 +198,7 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
return c.json({
template: {
...updated,
variables: updated.variables ? JSON.parse(updated.variables) : [],
variables: safeParseVariables(updated.variables),
isSystem: Boolean(updated.isSystem),
isActive: Boolean(updated.isActive),
},
@@ -203,16 +245,22 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
const body = await c.req.json();
const { templateSlug, customVariables, recipientFilter } = body;
if (!templateSlug) {
if (!templateSlug || typeof templateSlug !== 'string') {
return c.json({ error: 'Template slug is required' }, 400);
}
const allowedFilters = ['confirmed', 'pending', 'all', 'checked_in'];
const filter = recipientFilter || 'confirmed';
if (!allowedFilters.includes(filter)) {
return c.json({ error: `Invalid recipientFilter. Allowed: ${allowedFilters.join(', ')}` }, 400);
}
// Queue emails for background processing instead of sending synchronously
const result = await emailService.queueEventEmails({
eventId,
templateSlug,
customVariables,
recipientFilter: recipientFilter || 'confirmed',
recipientFilter: filter,
sentBy: user?.id,
});
@@ -220,14 +268,9 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
});
// Send custom email to specific recipients
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidator('json', sendCustomEmailSchema), async (c) => {
const user = (c as any).get('user');
const body = await c.req.json();
const { to, toName, subject, bodyHtml, bodyText, eventId } = body;
if (!to || !subject || !bodyHtml) {
return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400);
}
const { to, toName, subject, bodyHtml, bodyText, eventId } = c.req.valid('json');
const result = await emailService.sendCustomEmail({
to,
@@ -272,7 +315,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
: template.bodyHtml;
const finalSubject = replaceTemplateVariables(subject, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
return c.json({
@@ -288,8 +331,9 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
const search = c.req.query('search');
const limit = parseInt(c.req.query('limit') || '50');
const offset = parseInt(c.req.query('offset') || '0');
// Clamp pagination so a NaN / out-of-range value can't produce undefined query behaviour.
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1), 200);
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
let query = (db as any).select().from(emailLogs);
@@ -441,7 +485,7 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
// Get email queue status
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
const status = getQueueStatus();
const status = await getQueueStatus();
return c.json({ status });
});
+122 -97
View File
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
import { slugify, uniqueSlug } from '../lib/slugify.js';
import { revalidateFrontendCache } from '../lib/revalidate.js';
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
interface UserContext {
id: string;
@@ -127,8 +128,13 @@ const baseEventSchema = z.object({
currency: z.string().default('PYG'),
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')),
// Accept relative paths (/uploads/...) or http(s) URLs only — reject schemes like
// javascript:/data: that could be reflected into an href/src on the frontend.
bannerUrl: z.string()
.refine((v) => v === '' || v.startsWith('/') || /^https?:\/\//i.test(v), {
message: 'Banner URL must be a relative path or an http(s) URL',
})
.optional().nullable().or(z.literal('')),
// External booking support - accept boolean or number (0/1 from DB)
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
@@ -166,51 +172,60 @@ const updateEventSchema = baseEventSchema.partial().refine(
eventsRouter.get('/', async (c) => {
const status = c.req.query('status');
const upcoming = c.req.query('upcoming');
let query = (db as any).select().from(events);
if (status) {
query = query.where(eq((events as any).status, status));
}
// Only privileged users may see non-public events (drafts, archived, etc.).
// Anonymous/regular callers are restricted to published events regardless of
// any client-supplied status filter, so drafts cannot leak.
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
const conditions: any[] = [];
if (upcoming === 'true') {
const now = getNow();
query = query.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
);
// Upcoming feed is always published + future-dated, for everyone.
conditions.push(eq((events as any).status, 'published'));
conditions.push(gte((events as any).startDatetime, getNow()));
} else if (isPrivileged) {
// Admins/staff may filter by any status (or list everything when unset).
if (status) {
conditions.push(eq((events as any).status, status));
}
} else {
// Public listing: published events only, regardless of any status param.
conditions.push(eq((events as any).status, 'published'));
}
let query = (db as any).select().from(events);
if (conditions.length > 0) {
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
}
const result = await dbAll(query.orderBy(desc((events as any).startDatetime)));
// Get ticket counts for each event
const eventsWithCounts = await Promise.all(
result.map(async (event: any) => {
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
// This ensures check-in doesn't affect capacity/spots_left
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
const normalized = normalizeEvent(event);
const bookedCount = ticketCount?.count || 0;
return {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
};
})
);
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
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
// so availableSeats subtracts them together — the same formula the booking-creation
// capacity check enforces (lib/capacity.ts).
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
const countByEvent = new Map<string, { paid: number; claimed: number }>();
for (const row of countRows) {
countByEvent.set(row.eventId, {
paid: Number(row.paidCount) || 0,
claimed: Number(row.claimedCount) || 0,
});
}
const eventsWithCounts = result.map((event: any) => {
const normalized = normalizeEvent(event);
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
return {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
});
return c.json({ events: eventsWithCounts });
});
@@ -223,28 +238,24 @@ eventsRouter.get('/:id', async (c) => {
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
// This ensures check-in doesn't affect capacity/spots_left
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
)
)
);
// Draft events are only visible to privileged users (admin preview); hide from public.
if ((event as any).status === 'draft') {
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
if (!isPrivileged) {
return c.json({ error: 'Event not found' }, 404);
}
}
const normalized = normalizeEvent(event);
const bookedCount = ticketCount?.count || 0;
const counts = await getEventSeatCounts(event.id);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
},
});
});
@@ -256,22 +267,56 @@ async function getSiteTimezone(): Promise<string> {
return settings?.timezone || 'America/Asuncion';
}
// Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>(
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
// counts for one event — see lib/capacity.ts for the seat-holding rule.
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
return {
paid: Number(row?.paidCount) || 0,
claimed: Number(row?.claimedCount) || 0,
};
}
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
async function getNextChronologicalUpcoming(): Promise<any | null> {
const now = getNow();
const event = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.select()
.from(events)
.where(
and(
eq((tickets as any).eventId, eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
)
.orderBy((events as any).startDatetime)
.limit(1)
);
return ticketCount?.count || 0;
if (!event) {
return null;
}
const counts = await getEventSeatCounts(event.id);
const normalized = normalizeEvent(event);
return {
...normalized,
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
};
}
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
eventsRouter.get('/next', async (c) => {
const event = await getNextChronologicalUpcoming();
if (!event) {
return c.json({ event: null });
}
return c.json({ event: { ...event, isFeatured: false } });
});
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow();
@@ -322,47 +367,27 @@ eventsRouter.get('/next/upcoming', async (c) => {
// If we have a valid featured event, return it
if (featuredEvent) {
const bookedCount = await getEventTicketCount(featuredEvent.id);
const counts = await getEventSeatCounts(featuredEvent.id);
const normalized = normalizeEvent(featuredEvent);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
bookedCount: counts.paid,
claimedCount: counts.claimed,
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
isFeatured: true,
},
});
}
// Fallback: get the next upcoming published event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
)
.orderBy((events as any).startDatetime)
.limit(1)
);
const event = await getNextChronologicalUpcoming();
if (!event) {
return c.json({ event: null });
}
const bookedCount = await getEventTicketCount(event.id);
const normalized = normalizeEvent(event);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
isFeatured: false,
},
});
return c.json({ event: { ...event, isFeatured: false } });
});
// Create event (admin/organizer only)
+35
View File
@@ -6,6 +6,22 @@ import { getNow, generateId } from '../lib/utils.js';
const faqRouter = new Hono();
// Upper bounds for admin-supplied FAQ content (guards against accidental/abusive huge payloads)
const MAX_QUESTION_LEN = 1000;
const MAX_ANSWER_LEN = 20000;
// Returns an error message if any provided field exceeds its limit, else null.
function faqLengthError(fields: { question?: any; questionEs?: any; answer?: any; answerEs?: any }): string | null {
const check = (v: any, max: number, label: string) =>
typeof v === 'string' && v.length > max ? `${label} must be at most ${max} characters` : null;
return (
check(fields.question, MAX_QUESTION_LEN, 'Question') ||
check(fields.questionEs, MAX_QUESTION_LEN, 'Question (ES)') ||
check(fields.answer, MAX_ANSWER_LEN, 'Answer') ||
check(fields.answerEs, MAX_ANSWER_LEN, 'Answer (ES)')
);
}
// ==================== Public Routes ====================
// Get FAQ list for public (only enabled; optional filter for homepage)
@@ -98,6 +114,11 @@ faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
return c.json({ error: 'Question and answer (EN) are required' }, 400);
}
const lengthError = faqLengthError({ question, questionEs, answer, answerEs });
if (lengthError) {
return c.json({ error: lengthError }, 400);
}
const now = getNow();
const id = generateId();
@@ -157,6 +178,11 @@ faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
return c.json({ error: 'FAQ not found' }, 404);
}
const lengthError = faqLengthError({ question, questionEs, answer, answerEs });
if (lengthError) {
return c.json({ error: lengthError }, 400);
}
const updateData: Record<string, unknown> = {
updatedAt: getNow(),
};
@@ -209,6 +235,15 @@ faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
return c.json({ error: 'ids array is required' }, 400);
}
// Verify every id exists before applying ranks (prevents silent no-ops on bad input).
const existingRows = await dbAll<any>(
(db as any).select({ id: (faqQuestions as any).id }).from(faqQuestions)
);
const existingIds = new Set(existingRows.map((r: any) => r.id));
if (ids.some((id: string) => !existingIds.has(id))) {
return c.json({ error: 'One or more FAQ ids are invalid' }, 400);
}
const now = getNow();
for (let i = 0; i < ids.length; i++) {
await (db as any)
+18 -18
View File
@@ -9,24 +9,6 @@ import path from 'path';
const legalPagesRouter = new Hono();
// Helper: Convert plain text to simple markdown
// Preserves paragraphs and line breaks, nothing fancy
function textToMarkdown(text: string): string {
if (!text) return '';
// Split into paragraphs (double newlines)
const paragraphs = text.split(/\n\s*\n/);
// Process each paragraph
const processed = paragraphs.map(para => {
// Replace single newlines with double spaces + newline for markdown line breaks
return para.trim().replace(/\n/g, ' \n');
});
// Join paragraphs with double newlines
return processed.join('\n\n');
}
// Helper: Convert markdown to plain text for editing
function markdownToText(markdown: string): string {
if (!markdown) return '';
@@ -162,6 +144,13 @@ legalPagesRouter.get('/', async (c) => {
legalPagesRouter.get('/:slug', async (c) => {
const { slug } = c.req.param();
const locale = c.req.query('locale') || 'en';
// Reject anything that isn't a simple slug. The filesystem fallback below builds a
// path from this value, so an unconstrained slug (e.g. "../../etc/passwd") would
// allow path traversal / arbitrary file reads.
if (!/^[a-z0-9-]+$/.test(slug)) {
return c.json({ error: 'Legal page not found' }, 404);
}
// First try to get from database
const page = await dbGet<any>(
@@ -275,6 +264,17 @@ legalPagesRouter.put('/admin/:slug', requireAuth(['admin']), async (c) => {
if (!enContent && !esContent) {
return c.json({ error: 'At least one language content is required' }, 400);
}
// Bound the sizes of admin-supplied content to avoid unbounded payloads.
const MAX_CONTENT_LEN = 200000; // ~200 KB of markdown per language
const MAX_TITLE_LEN = 255;
const tooLong = (v: any, max: number) => typeof v === 'string' && v.length > max;
if (tooLong(enContent, MAX_CONTENT_LEN) || tooLong(esContent, MAX_CONTENT_LEN)) {
return c.json({ error: `Content must be at most ${MAX_CONTENT_LEN} characters` }, 400);
}
if (tooLong(title, MAX_TITLE_LEN) || tooLong(titleEs, MAX_TITLE_LEN)) {
return c.json({ error: `Title must be at most ${MAX_TITLE_LEN} characters` }, 400);
}
const existing = await dbGet(
(db as any)
+335 -62
View File
@@ -1,19 +1,36 @@
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js';
import { eq, and, inArray } from 'drizzle-orm';
import { getNow, toDbDate } from '../lib/utils.js';
import {
verifyWebhookPayment,
getPaymentStatus,
createInvoice,
isLNbitsConfigured,
LNBITS_INVOICE_EXPIRY_SECONDS,
} from '../lib/lnbits.js';
import emailService from '../lib/email.js';
import { getPubSub } from '../lib/stores/pubsub.js';
import { getLock, LockUnavailableError } from '../lib/stores/lock.js';
const lnbitsRouter = new Hono();
// Store for active SSE connections (ticketId -> Set of response writers)
const activeConnections = new Map<string, Set<(data: any) => void>>();
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
// Pub/sub unsubscribe handles per ticket (one local subscription per ticket).
const channelUnsubs = new Map<string, () => void>();
// Store for active background checkers (ticketId -> intervalId)
const activeCheckers = new Map<string, NodeJS.Timeout>();
/** Pub/sub channel that carries payment events for a ticket. */
function paymentChannel(ticketId: string): string {
return `payment:${ticketId}`;
}
/**
* LNbits webhook payload structure
*/
@@ -32,32 +49,85 @@ interface LNbitsWebhookPayload {
}
/**
* Notify all connected clients for a ticket
* Notify every client for a ticket across all instances.
*
* Publishes to the ticket's pub/sub channel. In single-instance / in-memory
* mode this is an in-process broadcast; with Redis it reaches whichever
* instance(s) actually hold the SSE socket(s) for this ticket.
*/
function notifyClients(ticketId: string, data: any) {
async function notifyClients(ticketId: string, data: any) {
await getPubSub().publish(paymentChannel(ticketId), data);
}
/**
* Deliver an event to the SSE sockets held by THIS process for a ticket.
* Invoked by the pub/sub subscription handler.
*/
async function deliverLocal(ticketId: string, data: any) {
const connections = activeConnections.get(ticketId);
if (connections) {
connections.forEach(send => {
try {
send(data);
} catch (e) {
// Connection might be closed
}
});
await Promise.all(
Array.from(connections).map(async (send) => {
try {
await send(data);
} catch (e) {
// Connection might be closed
}
})
);
}
}
// Distributed lock tokens for the per-ticket poller (ticketId -> token).
const checkerLockTokens = new Map<string, string>();
/** Release the per-ticket poller lock if this process holds it. */
function releaseCheckerLock(ticketId: string) {
const token = checkerLockTokens.get(ticketId);
if (token) {
checkerLockTokens.delete(ticketId);
void getLock().release(`checker:${ticketId}`, token);
}
}
/**
* Start background payment checking for a ticket
* Start background payment checking for a ticket.
*
* Only one instance should poll LNbits per ticket, so we take a distributed
* lock for the lifetime of the poll. Other instances skip polling and instead
* receive the result via pub/sub. With no Redis configured the lock is a local
* no-op and behavior matches the original single-instance polling.
*/
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking on this instance
if (activeCheckers.has(ticketId)) {
return;
}
const startTime = Date.now();
const expiryMs = expirySeconds * 1000;
let lockToken: string | null = null;
let lockUnavailable = false;
try {
lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
} catch (err) {
if (!(err instanceof LockUnavailableError)) throw err;
// Fail open: in webhook-less deployments this poller is the only way a
// Lightning payment gets confirmed, so a Redis outage must not stop it.
// Duplicate pollers across replicas are harmless because
// handlePaymentComplete only acts on rows it actually transitions.
console.warn(`[lnbits] lock backend unavailable, polling ticket ${ticketId} without lock:`, err.message);
lockUnavailable = true;
}
if (!lockToken && !lockUnavailable) {
// Another instance is already polling this ticket.
return;
}
if (lockToken) {
checkerLockTokens.set(ticketId, lockToken);
}
const startTime = Date.now();
let checkCount = 0;
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
@@ -71,7 +141,8 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Invoice expired for ticket ${ticketId}`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
notifyClients(ticketId, { type: 'expired', ticketId });
releaseCheckerLock(ticketId);
await notifyClients(ticketId, { type: 'expired', ticketId });
return;
}
@@ -82,9 +153,10 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
await handlePaymentComplete(ticketId, paymentHash);
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
}
} catch (error) {
console.error(`Error checking payment for ticket ${ticketId}:`, error);
@@ -102,6 +174,7 @@ function stopBackgroundChecker(ticketId: string) {
if (interval) {
clearInterval(interval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
}
}
@@ -111,13 +184,23 @@ function stopBackgroundChecker(ticketId: string) {
*/
lnbitsRouter.post('/webhook', async (c) => {
try {
// Optional shared-secret gate: if LNBITS_WEBHOOK_SECRET is configured, the
// webhook URL must carry a matching ?token=... (set when the invoice is created).
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
if (webhookSecret) {
const provided = c.req.query('token') || c.req.header('x-webhook-secret') || '';
if (provided !== webhookSecret) {
console.warn('LNbits webhook rejected: invalid or missing secret');
return c.json({ received: true, processed: false }, 401);
}
}
const payload: LNbitsWebhookPayload = await c.req.json();
// Log identifiers only (no full payload / PII)
console.log('LNbits webhook received:', {
paymentHash: payload.payment_hash,
status: payload.status,
amount: payload.amount,
extra: payload.extra,
});
// Verify the payment is actually complete by checking with LNbits
@@ -135,13 +218,27 @@ lnbitsRouter.post('/webhook', async (c) => {
return c.json({ received: true, processed: false }, 200);
}
// CRITICAL: bind the paid hash to this ticket's own invoice. Without this, a
// valid paid hash from any other invoice could be replayed with an arbitrary
// ticketId to confirm tickets for free.
const ticketPayment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
);
if (!ticketPayment || ticketPayment.reference !== payload.payment_hash) {
console.warn('LNbits webhook rejected: payment hash does not match the ticket invoice', {
ticketId,
paymentHash: payload.payment_hash,
});
return c.json({ received: true, processed: false }, 200);
}
// Stop background checker since webhook confirmed payment
stopBackgroundChecker(ticketId);
await handlePaymentComplete(ticketId, payload.payment_hash);
// Notify connected clients via SSE
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
return c.json({ received: true, processed: true }, 200);
} catch (error) {
@@ -186,28 +283,36 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
}
// Confirm all tickets in the booking
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
let transitioned = 0;
for (const ticket of ticketsToConfirm) {
// Update ticket status to confirmed
await (db as any)
const result: any = await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, ticket.id));
// Update payment status to paid
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
transitioned += result?.changes ?? result?.rowCount ?? 0;
await (db as any)
.update(payments)
.set({
.set({
status: 'paid',
reference: paymentHash,
paidAt: now,
updatedAt: now,
})
.where(eq((payments as any).ticketId, ticket.id));
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
}
// Only the caller that actually flipped rows sends the emails. The webhook
// and the background poller (and pollers on multiple replicas during a Redis
// outage) can all land here; without this guard they would each email.
if (transitioned === 0) {
console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`);
return;
}
// Get primary payment for sending receipt
const payment = await dbGet<any>(
(db as any)
@@ -215,7 +320,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
);
// Send confirmation emails asynchronously
// For multi-ticket bookings, send email with all ticket info
Promise.all([
@@ -242,38 +347,45 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
return c.json({ error: 'Ticket not found' }, 404);
}
// If already paid, return immediately
if (ticket.status === 'confirmed') {
return c.json({ type: 'already_paid', ticketId }, 200);
}
// Get payment to start background checker
const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
);
// Start background checker if not already running
if (payment?.reference && !activeCheckers.has(ticketId)) {
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
// Start background checker if not already running (only while still pending)
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
}
// Prevent proxies/CDNs from buffering the event stream so events flush immediately.
c.header('Cache-Control', 'no-cache, no-transform');
c.header('X-Accel-Buffering', 'no');
return streamSSE(c, async (stream) => {
// Register this connection
if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set());
}
const sendEvent = (data: any) => {
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
const sendEvent = async (data: any) => {
await stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
};
// If already paid, notify over SSE and close (EventSource can parse this).
if (ticket.status === 'confirmed') {
await sendEvent({ type: 'already_paid', ticketId });
return;
}
// Register this connection. The first local connection for a ticket also
// subscribes to the ticket's pub/sub channel so events published by any
// instance (webhook or background checker) are delivered to these sockets.
if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set());
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
void deliverLocal(ticketId, data);
});
channelUnsubs.set(ticketId, unsub);
}
activeConnections.get(ticketId)!.add(sendEvent);
// Send initial status
await stream.writeSSE({
data: JSON.stringify({ type: 'connected', ticketId }),
event: 'payment'
});
await sendEvent({ type: 'connected', ticketId });
// Keep connection alive with heartbeat
const heartbeat = setInterval(async () => {
@@ -284,25 +396,186 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
}
}, 15000);
// Clean up on disconnect
stream.onAbort(() => {
const cleanup = () => {
clearInterval(heartbeat);
const connections = activeConnections.get(ticketId);
if (connections) {
connections.delete(sendEvent);
if (connections.size === 0) {
activeConnections.delete(ticketId);
// Drop the pub/sub subscription once no local sockets remain.
const unsub = channelUnsubs.get(ticketId);
if (unsub) {
unsub();
channelUnsubs.delete(ticketId);
}
}
}
});
// Keep the stream open
while (true) {
await stream.sleep(30000);
};
// Clean up on disconnect
stream.onAbort(cleanup);
// Keep the stream open, and every 15s fall back to reading the ticket
// status from the DB. Pub/sub is best-effort: a message published while the
// subscriber connection was reconnecting is lost, and without this check
// the client would wait forever on a payment that already confirmed.
try {
while (true) {
await stream.sleep(15000);
const current = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
);
if (current?.status === 'confirmed') {
await sendEvent({ type: 'paid', ticketId });
return;
}
}
} finally {
cleanup();
}
});
});
/**
* 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)
*/
+66 -39
View File
@@ -1,24 +1,51 @@
import { Hono } from 'hono';
import { db, dbGet, dbAll, media } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { writeFile, mkdir, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join, extname } from 'path';
import { getStorage, keyFromUrl } from '../lib/storage.js';
const mediaRouter = new Hono();
const UPLOAD_DIR = './uploads';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
const MAX_FILE_SIZE =
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
// Ensure upload directory exists
async function ensureUploadDir() {
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
/**
* Detect a real image type from the file's magic bytes (content sniffing).
* Returns the canonical mime + extension, or null if the content is not an
* allowed image. We deliberately ignore the client-supplied filename and
* Content-Type so an attacker cannot store e.g. an .html/.svg payload.
*/
function detectImageType(buf: Buffer): { mime: string; ext: string } | null {
if (buf.length < 12) return null;
// JPEG: FF D8 FF
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
return { mime: 'image/jpeg', ext: '.jpg' };
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
) {
return { mime: 'image/png', ext: '.png' };
}
// GIF: "GIF87a" / "GIF89a"
if (buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a') {
return { mime: 'image/gif', ext: '.gif' };
}
// WEBP: "RIFF"...."WEBP"
if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
return { mime: 'image/webp', ext: '.webp' };
}
// AVIF / HEIF: "....ftyp" with an avif/heic brand
if (buf.toString('ascii', 4, 8) === 'ftyp') {
const brand = buf.toString('ascii', 8, 12);
if (brand === 'avif' || brand === 'avis') {
return { mime: 'image/avif', ext: '.avif' };
}
}
return null;
}
// Upload image
@@ -31,28 +58,28 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
return c.json({ error: 'No file provided' }, 400);
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
}
// Validate file size
// Validate file size (cheap check before reading the whole buffer)
if (file.size > MAX_FILE_SIZE) {
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
}
await ensureUploadDir();
// Generate unique filename
const id = generateId();
const ext = extname(file.name) || '.jpg';
const filename = `${id}${ext}`;
const filepath = join(UPLOAD_DIR, filename);
// Write file
// Read the bytes and validate the *content* (not the client-provided type/name)
const arrayBuffer = await file.arrayBuffer();
await writeFile(filepath, Buffer.from(arrayBuffer));
const buffer = Buffer.from(arrayBuffer);
const detected = detectImageType(buffer);
if (!detected) {
return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
}
// Generate unique filename using the *detected* extension (ignore client filename)
const id = generateId();
const filename = `${id}${detected.ext}`;
// Persist via the storage backend (local disk or S3-compatible object store).
const storage = getStorage();
await storage.put(filename, buffer, detected.mime);
// Get related info from form data
const relatedId = body['relatedId'] as string | undefined;
@@ -62,7 +89,7 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
const mediaRecord = {
id,
fileUrl: `/uploads/${filename}`,
fileUrl: storage.publicUrl(filename),
type: 'image' as const,
relatedId: relatedId || null,
relatedType: relatedType || null,
@@ -108,12 +135,9 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
return c.json({ error: 'Media not found' }, 404);
}
// Delete file from disk
// Delete the underlying object from the storage backend.
try {
const filepath = join('.', mediaRecord.fileUrl);
if (existsSync(filepath)) {
await unlink(filepath);
}
await getStorage().delete(keyFromUrl(mediaRecord.fileUrl));
} catch (error) {
console.error('Failed to delete file:', error);
}
@@ -128,17 +152,20 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const relatedType = c.req.query('relatedType');
const relatedId = c.req.query('relatedId');
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '200', 10) || 200, 1), 500);
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
// Combine filters into a single where() — chaining .where() replaces the prior condition in Drizzle.
const conditions: any[] = [];
if (relatedType) conditions.push(eq((media as any).relatedType, relatedType));
if (relatedId) conditions.push(eq((media as any).relatedId, relatedId));
let query = (db as any).select().from(media);
if (relatedType) {
query = query.where(eq((media as any).relatedType, relatedType));
}
if (relatedId) {
query = query.where(eq((media as any).relatedId, relatedId));
if (conditions.length > 0) {
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
}
const result = await dbAll(query);
const result = await dbAll(query.limit(limit).offset(offset));
return c.json({ media: result });
});
+37 -3
View File
@@ -1,9 +1,9 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
const paymentOptionsRouter = new Hono();
@@ -40,6 +40,23 @@ const updatePaymentOptionsSchema = z.object({
allowDuplicateBookings: booleanOrNumber.optional(),
});
/** Strip bank account numbers from payment options for anonymous callers. */
function publicPaymentOptions(merged: Record<string, any>) {
return {
...merged,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
};
}
// Schema for event-level overrides
const updateEventOverridesSchema = z.object({
tpagoEnabled: booleanOrNumber.optional().nullable(),
@@ -151,6 +168,7 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
// Get payment options for a specific event (merged with global)
paymentOptionsRouter.get('/event/:eventId', async (c) => {
const eventId = c.req.param('eventId');
const ticketId = c.req.query('ticketId');
// Get the event first to verify it exists
const event = await dbGet(
@@ -229,8 +247,24 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
};
// Full bank/TPago credentials are only returned when the caller proves they hold
// a valid ticket for this event (the ticket UUID is the booking capability token),
// or when an authenticated admin/organizer requests them.
let revealSensitive = false;
const authUser: any = await getAuthUser(c);
if (authUser && ['admin', 'organizer'].includes(authUser.role)) {
revealSensitive = true;
} else if (ticketId) {
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
);
if (ticket && ticket.eventId === eventId && ticket.status !== 'cancelled') {
revealSensitive = true;
}
}
return c.json({
paymentOptions: merged,
paymentOptions: revealSensitive ? merged : publicPaymentOptions(merged),
hasOverrides: !!overrides,
});
});
+307 -102
View File
@@ -2,15 +2,16 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
import { eq, desc, and, or, sql } from 'drizzle-orm';
import { eq, desc, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import emailService from '../lib/email.js';
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
const paymentsRouter = new Hono();
const updatePaymentSchema = z.object({
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']),
reference: z.string().optional(),
adminNote: z.string().optional(),
});
@@ -18,6 +19,9 @@ const updatePaymentSchema = z.object({
const approvePaymentSchema = z.object({
adminNote: z.string().optional(),
sendEmail: z.boolean().optional().default(true),
// Admin override: confirm the booking even when it puts the event over
// capacity. The UI asks for explicit confirmation before sending this.
allowOverCapacity: z.boolean().optional().default(false),
});
const rejectPaymentSchema = z.object({
@@ -25,6 +29,10 @@ const rejectPaymentSchema = z.object({
sendEmail: z.boolean().optional().default(true),
});
const reopenPaymentSchema = z.object({
adminNote: z.string().optional(),
});
// Get all payments (admin) - with ticket and event details
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
const status = c.req.query('status');
@@ -85,6 +93,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
attendeeLastName: ticket.attendeeLastName,
attendeeEmail: ticket.attendeeEmail,
attendeePhone: ticket.attendeePhone,
attendeeRuc: ticket.attendeeRuc,
status: ticket.status,
} : null,
event: event ? {
@@ -95,7 +104,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
};
})
);
// Filter by event(s)
if (eventId) {
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
@@ -162,6 +171,31 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
return c.json({ payments: enrichedPayments });
});
// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, onHoldRow, revenueRow] = await Promise.all([
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'on_hold'))),
dbGet<any>((db as any).select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))),
]);
return c.json({
stats: {
total: Number(totalRow?.count || 0),
pending: Number(pendingRow?.count || 0),
paid: Number(paidRow?.count || 0),
refunded: Number(refundedRow?.count || 0),
failed: Number(failedRow?.count || 0),
onHold: Number(onHoldRow?.count || 0),
totalRevenue: Number(revenueRow?.total || 0),
},
});
});
// Get payment by ID (admin)
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
@@ -205,10 +239,16 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
return c.json({ error: 'Payment not found' }, 404);
}
// Confirming a failed payment must go through /approve, which re-checks event
// capacity before re-reserving the (previously released) seat. Block the raw path.
if (data.status === 'paid' && existing.status === 'failed') {
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
}
const now = getNow();
const updateData: any = { ...data, updatedAt: now };
// If marking as paid, record who approved it and when
if (data.status === 'paid' && existing.status !== 'paid') {
updateData.paidAt = now;
@@ -248,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(eq((tickets as any).id, (t as any).id));
}
@@ -280,27 +320,28 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
// Approve payment (admin) - specifically for pending_approval payments
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote, sendEmail } = c.req.valid('json');
const { adminNote, sendEmail, allowOverCapacity } = c.req.valid('json');
const user = (c as any).get('user');
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, id))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
// Can approve pending or pending_approval payments
if (!['pending', 'pending_approval'].includes(payment.status)) {
// Can approve pending, pending_approval, on_hold, or failed payments.
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
// in error; its tickets are cancelled, so recovery re-checks capacity below.
// Bare 'pending' covers customers who paid but never clicked "I've paid".
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
}
const now = getNow();
// Get the ticket associated with this payment
const ticket = await dbGet<any>(
(db as any)
@@ -308,10 +349,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
// Check if this is part of a multi-ticket booking
let ticketsToConfirm: any[] = [ticket];
if (ticket?.bookingId) {
// Get all tickets in this booking
ticketsToConfirm = await dbAll(
@@ -322,27 +363,57 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
);
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
}
// Update all payments in the booking to paid
for (const t of ticketsToConfirm) {
await (db as any)
.update(payments)
.set({
status: 'paid',
paidAt: now,
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).ticketId, (t as any).id));
// Update ticket status to confirmed
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, (t as any).id));
// For a failed payment, only recover tickets whose own payment is also failed.
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
// resurrected or having its payment flipped to paid.
if (payment.status === 'failed') {
const bookingPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
.from(payments)
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
);
const failedTicketIds = new Set(
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
);
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
if (ticketsToConfirm.length === 0) {
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
}
}
// Confirm the booking through the shared capacity-checked reservation.
// Tickets that already hold a seat ('pending_approval' claims) cost no new
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
// When the event is full, the admin gets a structured over-capacity error and
// may retry with allowOverCapacity to knowingly overbook.
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToConfirm.map((t: any) => t.id),
'confirmed',
'paid',
{
paidByAdminId: user.id,
fromTicketStatuses:
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'],
skipCapacityCheck: allowOverCapacity,
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
}
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'Approving this payment puts the event over capacity.',
code: 'EVENT_OVER_CAPACITY',
availableSeats: err.available,
requestedSeats: ticketsToConfirm.length,
}, 409);
}
throw err;
}
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
if (sendEmail !== false) {
Promise.all([
@@ -382,31 +453,45 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
return c.json({ error: 'Payment not found' }, 404);
}
if (!['pending', 'pending_approval'].includes(payment.status)) {
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
}
const now = getNow();
// Update payment status to failed
await (db as any)
.update(payments)
.set({
status: 'failed',
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).id, id));
// Cancel the ticket - booking is no longer valid after rejection
await (db as any)
.update(tickets)
.set({
status: 'cancelled',
updatedAt: now,
})
.where(eq((tickets as any).id, payment.ticketId));
// Determine all tickets in this booking (multi-ticket bookings must be rejected together)
const rejectTicket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
let ticketsToReject: any[] = rejectTicket ? [rejectTicket] : [];
if (rejectTicket?.bookingId) {
ticketsToReject = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, rejectTicket.bookingId))
);
console.log(`[Payment] Rejecting multi-ticket booking: ${rejectTicket.bookingId}, ${ticketsToReject.length} tickets`);
}
for (const t of ticketsToReject) {
// Fail the payment for each ticket in the booking
await (db as any)
.update(payments)
.set({
status: 'failed',
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).ticketId, (t as any).id));
// Cancel the ticket - booking is no longer valid after rejection
await (db as any)
.update(tickets)
.set({
status: 'cancelled',
updatedAt: now,
})
.where(eq((tickets as any).id, (t as any).id));
}
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
@@ -427,6 +512,140 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
});
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (payment.status !== 'on_hold') {
return c.json({ error: 'Only on-hold payments can be reactivated' }, 400);
}
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
let ticketsToReactivate: any[] = [ticket];
if (ticket.bookingId) {
ticketsToReactivate = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
try {
await reserveOnHoldBooking(
ticket.eventId,
ticketsToReactivate.map((t: any) => t.id),
'pending',
'pending_approval'
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
const updated = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
});
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
// For when a payment was failed in error (auto-fail or rejection) and should
// return to the normal pending flow (reminders, "I've paid", approve/reject).
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
const id = c.req.param('id');
const { adminNote } = c.req.valid('json');
const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
if (!payment) {
return c.json({ error: 'Payment not found' }, 404);
}
if (payment.status !== 'failed') {
return c.json({ error: 'Only failed payments can be reopened' }, 400);
}
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return c.json({ error: 'Ticket not found' }, 404);
}
let ticketsToReopen: any[] = [ticket];
if (ticket.bookingId) {
ticketsToReopen = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
const bookingPayments = await dbAll<any>(
(db as any)
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
.from(payments)
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
);
const failedTicketIds = new Set(
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
);
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
if (ticketsToReopen.length === 0) {
return c.json({ error: 'Only failed payments can be reopened' }, 400);
}
const reopenIds = ticketsToReopen.map((t: any) => t.id);
try {
await reserveOnHoldBooking(
ticket.eventId,
reopenIds,
'pending',
'pending',
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
);
} catch (err) {
if (err instanceof HoldCapacityError) {
return c.json({
error: 'This event is now full. Your spot was released after the payment deadline passed.',
}, 400);
}
throw err;
}
if (adminNote) {
await (db as any)
.update(payments)
.set({ adminNote })
.where(inArray((payments as any).ticketId, reopenIds));
}
const updated = await dbGet(
(db as any).select().from(payments).where(eq((payments as any).id, id))
);
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
});
// Send payment reminder email
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
@@ -529,56 +748,42 @@ paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
}
const now = getNow();
// Update payment status
await (db as any)
.update(payments)
.set({ status: 'refunded', updatedAt: now })
.where(eq((payments as any).id, id));
// Cancel associated ticket
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(eq((tickets as any).id, payment.ticketId));
// Refund all tickets/payments in the booking (multi-ticket bookings refund together)
const refundTicket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
let ticketsToRefund: any[] = refundTicket ? [refundTicket] : [];
if (refundTicket?.bookingId) {
ticketsToRefund = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, refundTicket.bookingId))
);
console.log(`[Payment] Refunding multi-ticket booking: ${refundTicket.bookingId}, ${ticketsToRefund.length} tickets`);
}
for (const t of ticketsToRefund) {
// Only refund payments that were actually paid; leave others untouched
await (db as any)
.update(payments)
.set({ status: 'refunded', updatedAt: now })
.where(and(eq((payments as any).ticketId, (t as any).id), eq((payments as any).status, 'paid')));
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(eq((tickets as any).id, (t as any).id));
}
return c.json({ message: 'Refund processed successfully' });
});
// Payment webhook (for Stripe/MercadoPago)
// Not implemented: there is deliberately NO status mutation here. Until provider
// signature verification is implemented, accepting webhooks would let anyone forge
// a "paid" status. Returns 501 and never updates payments/tickets.
paymentsRouter.post('/webhook', async (c) => {
// This would handle webhook notifications from payment providers
// Implementation depends on which provider is used
const body = await c.req.json();
// Log webhook for debugging
console.log('Payment webhook received:', body);
// TODO: Implement provider-specific webhook handling
// - Verify webhook signature
// - Update payment status
// - Update ticket status
return c.json({ received: true });
});
// Get payment statistics (admin)
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const allPayments = await dbAll<any>((db as any).select().from(payments));
const stats = {
total: allPayments.length,
pending: allPayments.filter((p: any) => p.status === 'pending').length,
paid: allPayments.filter((p: any) => p.status === 'paid').length,
refunded: allPayments.filter((p: any) => p.status === 'refunded').length,
failed: allPayments.filter((p: any) => p.status === 'failed').length,
totalRevenue: allPayments
.filter((p: any) => p.status === 'paid')
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0),
};
return c.json({ stats });
console.warn('Payment webhook received but provider webhooks are not implemented (no signature verification).');
return c.json({ error: 'Webhook handling is not implemented' }, 501);
});
export default paymentsRouter;
+13 -2
View File
@@ -17,9 +17,20 @@ interface UserContext {
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Validation schema for updating site settings
// Validate against the runtime's IANA timezone database (rejects arbitrary strings
// that later get fed into Intl.DateTimeFormat on the frontend).
const isValidTimezone = (tz: string): boolean => {
try {
Intl.DateTimeFormat('en-US', { timeZone: tz });
return true;
} catch {
return false;
}
};
const updateSiteSettingsSchema = z.object({
timezone: z.string().optional(),
siteName: z.string().optional(),
timezone: z.string().refine(isValidTimezone, { message: 'Invalid timezone' }).optional(),
siteName: z.string().max(255).optional(),
siteDescription: z.string().optional().nullable(),
siteDescriptionEs: z.string().optional().nullable(),
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
File diff suppressed because it is too large Load Diff
+89 -33
View File
@@ -2,9 +2,10 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
import { eq, desc, sql } from 'drizzle-orm';
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow } from '../lib/utils.js';
import { authSessions } from '../db/auth-schema.js';
import { getNow, toDbDate } from '../lib/utils.js';
interface UserContext {
id: string;
@@ -27,7 +28,43 @@ const updateUserSchema = z.object({
// Get all users (admin only)
usersRouter.get('/', requireAuth(['admin']), async (c) => {
const role = c.req.query('role');
const search = c.req.query('search');
const accountStatus = c.req.query('accountStatus');
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
const registeredAfter = c.req.query('registeredAfter');
const registeredBefore = c.req.query('registeredBefore');
const eventId = c.req.query('eventId');
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
const conditions: any[] = [];
if (role) conditions.push(eq((users as any).role, role));
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter)));
if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore)));
if (search) {
const like = `%${search.toLowerCase()}%`;
conditions.push(sql`(
LOWER(${(users as any).name}) LIKE ${like}
OR LOWER(${(users as any).email}) LIKE ${like}
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
)`);
}
if (hasBookings === 'yes') {
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
} else if (hasBookings === 'no') {
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
}
if (eventId) {
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const totalQuery = whereClause
? (db as any).select({ count: sql<number>`count(*)` }).from(users).where(whereClause)
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
const totalRow = await dbGet<any>(totalQuery);
let query = (db as any).select({
id: (users as any).id,
email: (users as any).email,
@@ -40,14 +77,39 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
accountStatus: (users as any).accountStatus,
createdAt: (users as any).createdAt,
}).from(users);
if (role) {
query = query.where(eq((users as any).role, role));
if (whereClause) {
query = query.where(whereClause);
}
const result = await dbAll(
query.orderBy(desc((users as any).createdAt)).limit(pageSize).offset((page - 1) * pageSize)
);
return c.json({ users: result, total: Number(totalRow?.count || 0), page, pageSize });
});
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
);
const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
const adminCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.where(eq((users as any).role, 'admin'))
);
return c.json({ users: result });
return c.json({
stats: {
total: totalUsers?.count || 0,
admins: adminCount?.count || 0,
},
});
});
// Get user by ID (admin or self)
@@ -114,11 +176,28 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
return c.json({ error: 'User not found' }, 404);
}
// Keep the Better Auth admin `banned` flag in sync with accountStatus so
// sign-in is blocked at the auth layer too, and kill live sessions on
// suspension so it takes effect immediately (sessions are DB-validated on
// every request by both the backend and the photo API).
const statusMirror: Record<string, any> = {};
if (data.accountStatus === 'suspended') {
statusMirror.banned = true;
statusMirror.banReason = 'Suspended by admin';
} else if (data.accountStatus) {
statusMirror.banned = false;
statusMirror.banReason = null;
}
await (db as any)
.update(users)
.set({ ...data, updatedAt: getNow() })
.set({ ...data, ...statusMirror, updatedAt: getNow() })
.where(eq((users as any).id, id));
if (data.accountStatus === 'suspended') {
await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id));
}
const updated = await dbGet(
(db as any)
.select({
@@ -286,27 +365,4 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
}
});
// Get user statistics (admin)
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
);
const adminCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.where(eq((users as any).role, 'admin'))
);
return c.json({
stats: {
total: totalUsers?.count || 0,
admins: adminCount?.count || 0,
},
});
});
export default usersRouter;
+2 -2
View File
@@ -8,9 +8,9 @@
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declaration": false,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
+23
View File
@@ -63,6 +63,29 @@ server {
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
}
# Photo gallery service (photo-api, port 3020). ^~ wins over the /
# prefix below; bigger body cap and unbuffered uploads for photo batches.
location ^~ /api/photos/ {
limit_req zone=spanglish_api_limit burst=50 nodelay;
proxy_pass http://spanglish_photos;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Strip CORS headers from the service (nginx handles CORS here)
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
location / {
limit_req zone=spanglish_api_limit burst=50 nodelay;
+94
View File
@@ -0,0 +1,94 @@
# Example docker-compose for running the Spanglish API as multiple replicas
# behind nginx, with Redis for shared state and Postgres as the database.
#
# This is a starting point, not a turnkey production setup. It expects a
# Dockerfile at backend/Dockerfile that builds the API and runs it on PORT.
#
# Bring it up with N API replicas:
# docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
#
# No em dashes are used in this file by design.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: spanglish
POSTGRES_PASSWORD: spanglish
POSTGRES_DB: spanglish
# Raise max_connections if DB_POOL_MAX * replicas approaches the default 100.
command: ["postgres", "-c", "max_connections=200"]
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U spanglish"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
# noeviction is deliberate: rate-limit, lock, and lockout keys must never
# be evicted for correctness. The cache workload is a single short-TTL key,
# so memory pressure is negligible; if caching ever grows, revisit this or
# move the cache to its own DB index.
command:
[
"redis-server",
"--appendonly", "yes",
"--requirepass", "${REDIS_PASSWORD:-change-me-redis-password}",
"--maxmemory", "256mb",
"--maxmemory-policy", "noeviction",
]
environment:
REDISCLI_AUTH: "${REDIS_PASSWORD:-change-me-redis-password}"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
api:
build:
context: ../backend
environment:
NODE_ENV: production
PORT: "3001"
DB_TYPE: postgres
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
DB_POOL_MAX: "15"
REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379"
JWT_SECRET: change-me-to-a-strong-secret
FRONTEND_URL: http://localhost:8080
# Optional S3-compatible storage so uploads are shared across replicas.
# If you omit these, mount a shared volume at /app/uploads on every replica.
# S3_ENDPOINT: http://garage:3900
# S3_REGION: garage
# S3_BUCKET: spanglish-media
# S3_ACCESS_KEY_ID: ""
# S3_SECRET_ACCESS_KEY: ""
# S3_PUBLIC_URL: http://localhost:8080/media
expose:
- "3001"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Load balancer across the scaled api replicas. nginx resolves the "api"
# service name via Docker's embedded DNS, which round-robins across replicas.
lb:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx.scale.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
volumes:
pgdata:
redisdata:
+55 -1
View File
@@ -18,11 +18,28 @@ server {
}
}
# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a
# 301 so only one host is served and indexed.
server {
listen 443 ssl;
http2 on;
server_name spanglishcommunity.com www.spanglishcommunity.com;
server_name www.spanglishcommunity.com;
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
return 301 https://spanglishcommunity.com$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name spanglishcommunity.com;
# Upload size limit (covers same-origin /api uploads via this vhost)
client_max_body_size 20m;
@@ -43,6 +60,43 @@ server {
access_log /var/log/nginx/spanglish_frontend_access.log;
error_log /var/log/nginx/spanglish_frontend_error.log;
# LNbits payment SSE stream - must not be buffered or events won't flush in
# real time. Regex location takes precedence over the /api prefix below.
location ~ ^/api/lnbits/stream/ {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
# Disable buffering/caching for Server-Sent Events
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_connect_timeout 300s;
}
# Photo gallery service (photo-api, port 3020). ^~ wins over the /api
# prefix below. Batch photo uploads are large and slow, so the body cap
# is raised and request buffering is off for this location only.
location ^~ /api/photos/ {
proxy_pass http://spanglish_photos;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100m;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
}
# Proxy /api to backend
location /api {
proxy_pass http://spanglish_backend;
+29
View File
@@ -0,0 +1,29 @@
# nginx load balancer for the scaled "api" service in docker-compose.scale.yml.
# Uses Docker's embedded DNS resolver so newly scaled replicas are discovered
# without editing a static upstream list.
server {
listen 80;
# Docker embedded DNS. valid=10s re-resolves so scaling up/down is picked up.
resolver 127.0.0.11 valid=10s;
location / {
# Use a variable so nginx defers resolution to request time (round-robin
# across all replicas of the "api" service).
set $api_upstream http://api:3001;
proxy_pass $api_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Server-Sent Events: do not buffer the payment status stream.
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_set_header Connection "";
proxy_http_version 1.1;
}
}
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=Spanglish Photo Gallery API
Documentation=https://git.azzamo.net/Michilis/Spanglish
After=network.target spanglish-backend.service
[Service]
Type=simple
User=spanglish
Group=spanglish
WorkingDirectory=/home/spanglish/Spanglish/photo-api
EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env
Environment=PORT=3020
# Apply pending photos_* migrations before serving (idempotent, advisory-locked)
ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate
ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=spanglish-photos
# Security hardening (mirrors spanglish-backend.service)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/spanglish/Spanglish/photo-api/data
[Install]
WantedBy=multi-user.target
+4
View File
@@ -4,4 +4,8 @@ upstream spanglish_frontend {
upstream spanglish_backend {
server 127.0.0.1:3018;
}
upstream spanglish_photos {
server 127.0.0.1:3020;
}
+5
View File
@@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com
# API URL (leave empty for same-origin proxy)
NEXT_PUBLIC_API_URL=
# Photo gallery service (photo-api) origin, used by the /api/photos rewrite
# and server-side rendering of /photos pages.
# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020
PHOTO_API_URL=
# Google OAuth (optional - leave empty to hide Google Sign-In button)
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
# 1. Create a new OAuth 2.0 Client ID (Web application)
+46 -12
View File
@@ -1,28 +1,62 @@
/** @type {import('next').NextConfig} */
// Backend origin for API/upload proxying. Configurable per environment instead of
// being hardcoded to localhost.
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
// The standalone photo gallery service (photo-api/). Must be rewritten
// before the generic /api rule below — Next rewrites are order-sensitive.
const PHOTO_API_URL = process.env.PHOTO_API_URL || 'http://localhost:3003';
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
.split(',')
.map((h) => h.trim())
.filter(Boolean)
.map((hostname) => ({ protocol: 'https', hostname }));
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-XSS-Protection', value: '0' },
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
];
const nextConfig = {
// Overridable so a production build can run while `next dev` holds .next
// (e.g. NEXT_DIST_DIR=.next-build npm run build).
distDir: process.env.NEXT_DIST_DIR || '.next',
images: {
domains: ['localhost', 'images.unsplash.com'],
// Restrict remote image sources to a known allowlist instead of allowing any
// https host (which let the Next image optimizer be used as an open proxy).
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
{
protocol: 'http',
hostname: 'localhost',
port: '3001',
},
{ protocol: 'https', hostname: 'images.unsplash.com' },
{ protocol: 'http', hostname: 'localhost', port: '3001' },
...extraImageHosts,
],
},
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
async rewrites() {
return [
{
source: '/api/photos/:path*',
destination: `${PHOTO_API_URL}/api/photos/:path*`,
},
{
source: '/api/:path*',
destination: 'http://localhost:3001/api/:path*',
destination: `${BACKEND_URL}/api/:path*`,
},
{
source: '/uploads/:path*',
destination: 'http://localhost:3001/uploads/:path*',
destination: `${BACKEND_URL}/uploads/:path*`,
},
];
},
+2 -2
View File
@@ -14,6 +14,7 @@
"@tiptap/pm": "^3.18.0",
"@tiptap/react": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"better-auth": "1.6.25",
"clsx": "^2.1.1",
"html5-qrcode": "^2.3.8",
"next": "^14.2.4",
@@ -22,8 +23,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"swr": "^2.2.5"
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/node": "^20.14.9",
@@ -1,7 +1,7 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
@@ -9,29 +9,48 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { authApi } from '@/lib/api';
import { authClient } from '@/lib/auth-client';
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() {
const router = useRouter();
const searchParams = useSearchParams();
const { locale: language } = useLanguage();
const { setAuthData } = useAuth();
const { refreshUser } = useAuth();
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [alreadyClaimed, setAlreadyClaimed] = useState(false);
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
const token = searchParams.get('token');
useEffect(() => {
let cancelled = false;
authClient
.getSession()
.then(({ data }) => {
if (cancelled) return;
const user: any = data?.user;
setHasSession(!!user);
setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active');
})
.finally(() => {
if (!cancelled) setChecking(false);
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) {
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
return;
}
if (formData.password !== formData.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
@@ -49,8 +68,8 @@ function ClaimAccountContent() {
setLoading(true);
try {
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
setAuthData({ user: result.user, token: result.token });
await authApi.confirmClaimAccount(formData.password);
await refreshUser();
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
router.push('/dashboard');
} catch (error: any) {
@@ -60,7 +79,21 @@ function ClaimAccountContent() {
}
};
if (!token) {
if (checking) {
return (
<div className="section-padding min-h-[70vh] flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
</div>
);
}
if (alreadyClaimed) {
// Signed-in and already active: nothing to claim
router.push('/dashboard');
return null;
}
if (!hasSession) {
return (
<div className="section-padding min-h-[70vh] flex items-center">
<div className="container-page">
@@ -76,8 +109,8 @@ function ClaimAccountContent() {
</h2>
<p className="text-gray-600 mb-6">
{language === 'es'
? 'Este enlace de activación no es válido o ha expirado.'
: 'This activation link is invalid or has expired.'}
? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.'
: 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}
</p>
<Link href="/login">
<Button>
@@ -127,7 +160,7 @@ function ClaimAccountContent() {
<p className="text-xs text-gray-500 -mt-4">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmPassword"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
@@ -136,7 +169,7 @@ function ClaimAccountContent() {
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
/>
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
{language === 'es' ? 'Activar Cuenta' : 'Activate Account'}
</Button>
@@ -6,6 +6,8 @@ import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { safeInternalPath } from '@/lib/safeRedirect';
import { redirectAfterAuth } from '@/lib/authRedirect';
import toast from 'react-hot-toast';
function MagicLinkContent() {
@@ -18,11 +20,12 @@ function MagicLinkContent() {
const verificationAttempted = useRef(false);
const token = searchParams.get('token');
const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard');
useEffect(() => {
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
if (verificationAttempted.current) return;
if (token) {
verificationAttempted.current = true;
verifyToken();
@@ -34,11 +37,17 @@ function MagicLinkContent() {
const verifyToken = async () => {
try {
await loginWithMagicLink(token!);
const user = await loginWithMagicLink(token!);
setStatus('success');
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(() => {
router.push('/dashboard');
redirectAfterAuth(destination);
}, 1500);
} catch (err: any) {
setStatus('error');
@@ -0,0 +1,137 @@
import toast from 'react-hot-toast';
import { PaymentOptionsConfig } from '@/lib/api';
import {
CreditCardIcon,
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
} from '@heroicons/react/24/outline';
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{5,8}-\d$/;
/** Sanitize RUC input: digits and a single user-typed dash, max 10 chars. No dash is auto-inserted. */
export function formatRuc(value: string): string {
const cleaned = value.replace(/[^\d-]/g, '');
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. */
export function truncateInvoice(invoice: string, chars: number = 20): string {
if (invoice.length <= chars * 2) return invoice;
return `${invoice.slice(0, chars)}...${invoice.slice(-chars)}`;
}
/** Copy a Lightning invoice to the clipboard with localized feedback. */
export function copyInvoiceToClipboard(invoice: string, locale: string): void {
navigator.clipboard.writeText(invoice).then(() => {
toast.success(locale === 'es' ? '¡Copiado!' : 'Copied!');
}).catch(() => {
toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy');
});
}
export interface PaymentMethodOption {
id: PaymentMethod;
icon: typeof CreditCardIcon;
label: string;
description: string;
badge?: string;
}
/** Build the list of selectable payment methods from the event config. */
export function buildPaymentMethods(
paymentConfig: PaymentOptionsConfig | null,
locale: string
): PaymentMethodOption[] {
const paymentMethods: PaymentMethodOption[] = [];
if (paymentConfig?.lightningEnabled) {
paymentMethods.push({
id: 'lightning',
icon: BoltIcon,
label: 'Bitcoin Lightning',
description: locale === 'es' ? 'Pago instantáneo con Bitcoin' : 'Instant payment with Bitcoin',
badge: locale === 'es' ? 'Instantáneo' : 'Instant',
});
}
if (paymentConfig?.tpagoEnabled) {
paymentMethods.push({
id: 'tpago',
icon: CreditCardIcon,
label: locale === 'es' ? 'TPago / Tarjetas de Crédito' : 'TPago / Credit Cards',
description: locale === 'es' ? 'Pagá con tarjetas de crédito locales o internacionales' : 'Pay with local or international credit cards',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
if (paymentConfig?.bankTransferEnabled) {
paymentMethods.push({
id: 'bank_transfer',
icon: BuildingLibraryIcon,
label: locale === 'es' ? 'Transferencia Bancaria Local' : 'Local Bank Transfer',
description: locale === 'es' ? 'Pago por transferencia bancaria en Paraguay' : 'Pay via Paraguayan bank transfer',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
if (paymentConfig?.cashEnabled) {
paymentMethods.push({
id: 'cash',
icon: BanknotesIcon,
label: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
description: locale === 'es' ? 'Paga cuando llegues al evento' : 'Pay when you arrive at the event',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
return paymentMethods;
}
export interface SuccessContent {
title: string;
description: string;
iconColor: string;
iconTextColor: string;
}
/** Resolve the success-screen copy based on the payment method used. */
export function getSuccessContent(
bookingResult: BookingResult | null,
locale: string,
t: (key: string) => string
): SuccessContent {
if (bookingResult?.paymentMethod === 'cash') {
return {
title: locale === 'es' ? '¡Reserva Recibida!' : 'Reservation Received!',
description: locale === 'es'
? 'Tu lugar está reservado. El pago se realizará en el evento.'
: 'Your spot is reserved. Payment will be collected at the event.',
iconColor: 'bg-yellow-100',
iconTextColor: 'text-yellow-600',
};
}
if (bookingResult?.paymentMethod === 'lightning') {
// For Lightning, if we're on success step, payment was confirmed
return {
title: locale === 'es' ? '¡Pago Confirmado!' : 'Payment Confirmed!',
description: locale === 'es'
? '¡Tu reserva está confirmada! Te esperamos en el evento.'
: 'Your booking is confirmed! See you at the event.',
iconColor: 'bg-green-100',
iconTextColor: 'text-green-600',
};
}
return {
title: t('booking.success.title'),
description: t('booking.success.description'),
iconColor: 'bg-green-100',
iconTextColor: 'text-green-600',
};
}
@@ -0,0 +1,435 @@
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CalendarIcon,
MapPinIcon,
UserGroupIcon,
CurrencyDollarIcon,
ArrowLeftIcon,
CheckCircleIcon,
UserIcon,
} from '@heroicons/react/24/outline';
import { Event } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import type { AttendeeInfo, BookingFormData } from '../_types';
import type { PaymentMethodOption } from '../_logic/booking';
interface BookingFormStepProps {
event: Event;
locale: string;
t: (key: string) => string;
spotsLeft: number;
isSoldOut: boolean;
ticketQuantity: number;
formData: BookingFormData;
setFormData: React.Dispatch<React.SetStateAction<BookingFormData>>;
errors: Partial<Record<keyof BookingFormData, string>>;
attendees: AttendeeInfo[];
setAttendees: React.Dispatch<React.SetStateAction<AttendeeInfo[]>>;
attendeeErrors: { [key: number]: string };
setAttendeeErrors: React.Dispatch<React.SetStateAction<{ [key: number]: string }>>;
handleRucChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleRucBlur: () => void;
paymentMethods: PaymentMethodOption[];
agreedToTerms: boolean;
setAgreedToTerms: (value: boolean) => void;
termsError: string | null;
submitting: boolean;
onSubmit: (e: React.FormEvent) => void;
formatDate: (dateStr: string) => string;
fmtTime: (dateStr: string) => string;
}
export function BookingFormStep({
event,
locale,
t,
spotsLeft,
isSoldOut,
ticketQuantity,
formData,
setFormData,
errors,
attendees,
setAttendees,
attendeeErrors,
setAttendeeErrors,
handleRucChange,
handleRucBlur,
paymentMethods,
agreedToTerms,
setAgreedToTerms,
termsError,
submitting,
onSubmit,
formatDate,
fmtTime,
}: BookingFormStepProps) {
return (
<div className="section-padding bg-secondary-gray min-h-screen">
<div className="container-page max-w-2xl">
<Link
href={`/events/${event.slug}`}
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
{/* Event Summary - Always Visible */}
<Card className="mb-6 overflow-hidden">
<div className="bg-primary-yellow/20 p-4 border-b border-primary-yellow/30">
<h2 className="font-bold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h2>
</div>
<div className="p-4 space-y-2 text-sm">
<div className="flex items-center gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(event.startDatetime)} {fmtTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
<span>{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-3">
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
<span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
</div>
)}
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
<span className="font-bold text-lg">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
{event.price > 0 && (
<span className="text-gray-400 text-sm">
{locale === 'es' ? 'por persona' : 'per person'}
</span>
)}
</div>
{/* Ticket quantity and total */}
{ticketQuantity > 1 && (
<div className="mt-3 pt-3 border-t border-secondary-light-gray">
<div className="flex items-center justify-between">
<span className="text-gray-600">
{locale === 'es' ? 'Tickets' : 'Tickets'}: <span className="font-semibold">{ticketQuantity}</span>
</span>
<span className="font-bold text-lg text-primary-dark">
{locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)}
</span>
</div>
</div>
)}
</div>
</Card>
{isSoldOut ? (
<Card className="p-8 text-center">
<UserGroupIcon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-700">{t('events.details.soldOut')}</h2>
<p className="text-gray-500 mt-2">{t('booking.form.soldOutMessage')}</p>
</Card>
) : (
<form onSubmit={onSubmit}>
{/* User Information Section */}
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
{attendees.length > 0 && (
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
1
</span>
)}
{t('booking.form.personalInfo')}
{attendees.length > 0 && (
<span className="text-sm font-normal text-gray-500">
({locale === 'es' ? 'Asistente principal' : 'Primary attendee'})
</span>
)}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label={t('booking.form.firstName')}
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
placeholder={t('booking.form.firstNamePlaceholder')}
error={errors.firstName}
required
/>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.lastName')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
placeholder={t('booking.form.lastNamePlaceholder')}
error={errors.lastName}
/>
</div>
</div>
<div>
<Input
label={t('booking.form.email')}
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder={t('booking.form.emailPlaceholder')}
error={errors.email}
required
/>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.phone')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder={t('booking.form.phonePlaceholder')}
error={errors.phone}
/>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.ruc')}
</label>
<span className="text-xs text-gray-400">
{t('booking.form.rucOptional')}
</span>
</div>
<Input
value={formData.ruc}
onChange={handleRucChange}
onBlur={handleRucBlur}
placeholder={t('booking.form.rucPlaceholder')}
error={errors.ruc}
maxLength={10}
aria-label={t('booking.form.ruc')}
/>
</div>
</div>
</Card>
{/* Additional Attendees Section (for multi-ticket bookings) */}
{attendees.length > 0 && (
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
<UserIcon className="w-5 h-5 text-primary-yellow" />
{locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{locale === 'es'
? 'Ingresa el nombre de cada asistente adicional. Cada persona recibirá su propio ticket.'
: 'Enter the name for each additional attendee. Each person will receive their own ticket.'}
</p>
<div className="space-y-4">
{attendees.map((attendee, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
{index + 2}
</span>
<span className="font-medium text-gray-700">
{locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label={t('booking.form.firstName')}
value={attendee.firstName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].firstName = e.target.value;
setAttendees(newAttendees);
if (attendeeErrors[index]) {
const newErrors = { ...attendeeErrors };
delete newErrors[index];
setAttendeeErrors(newErrors);
}
}}
placeholder={t('booking.form.firstNamePlaceholder')}
error={attendeeErrors[index]}
required
/>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.lastName')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
value={attendee.lastName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].lastName = e.target.value;
setAttendees(newAttendees);
}}
placeholder={t('booking.form.lastNamePlaceholder')}
/>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Payment Selection Section */}
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark">
{t('booking.form.paymentMethod')}
</h3>
<div className="space-y-3">
{paymentMethods.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{locale === 'es'
? 'No hay métodos de pago disponibles para este evento.'
: 'No payment methods available for this event.'}
</div>
) : (
<>
{paymentMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, paymentMethod: method.id }))}
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
formData.paymentMethod === method.id
? 'border-primary-yellow bg-primary-yellow/10'
: 'border-secondary-light-gray hover:border-gray-300'
}`}
>
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
formData.paymentMethod === method.id
? 'bg-primary-yellow'
: 'bg-gray-100'
}`}>
<method.icon className={`w-5 h-5 ${
formData.paymentMethod === method.id
? 'text-primary-dark'
: 'text-gray-500'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-primary-dark">{method.label}</p>
{method.badge && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
method.badge === 'Instant' || method.badge === 'Instantáneo'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
{method.badge}
</span>
)}
</div>
<p className="text-sm text-gray-500">{method.description}</p>
</div>
{formData.paymentMethod === method.id && (
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" />
)}
</button>
))}
</>
)}
</div>
{errors.paymentMethod && (
<p className="mt-3 text-sm text-red-600">{errors.paymentMethod}</p>
)}
</Card>
{/* Terms & Privacy agreement */}
<Card className="mb-6 p-6">
<div className="flex items-start gap-3">
<input
id="booking-terms-agree"
type="checkbox"
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
aria-required="true"
aria-invalid={termsError ? true : undefined}
aria-describedby={termsError ? 'booking-terms-error' : undefined}
className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer"
/>
<label
htmlFor="booking-terms-agree"
className="text-sm text-gray-500 leading-relaxed cursor-pointer select-none"
>
{t('booking.form.termsAgreePart1')}
<Link
href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.termsOfService')}
</Link>
{t('booking.form.termsAgreePart2')}
<Link
href={`/legal/privacy-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.privacyPolicy')}
</Link>
{t('booking.form.termsAgreePart3')}
</label>
</div>
{termsError && (
<p id="booking-terms-error" className="mt-1.5 text-sm text-red-600">
{termsError}
</p>
)}
</Card>
{/* Submit Button */}
<Button
type="submit"
size="lg"
className="w-full"
isLoading={submitting}
disabled={paymentMethods.length === 0 || !formData.paymentMethod || !agreedToTerms}
>
{formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot')
: formData.paymentMethod === 'lightning'
? t('booking.form.proceedPayment')
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
}
</Button>
</form>
)}
</div>
</div>
);
}
@@ -0,0 +1,249 @@
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CreditCardIcon,
BuildingLibraryIcon,
CheckCircleIcon,
ArrowTopRightOnSquareIcon,
} from '@heroicons/react/24/outline';
import { Event, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, getTpagoLink } from '@/lib/utils';
import type { BookingResult } from '../_types';
interface ManualPaymentStepProps {
bookingResult: BookingResult;
event: Event;
paymentConfig: PaymentOptionsConfig;
locale: string;
paidUnderDifferentName: boolean;
setPaidUnderDifferentName: (value: boolean) => void;
payerName: string;
setPayerName: (value: string) => void;
markingPaid: boolean;
onMarkPaymentSent: () => void;
}
export function ManualPaymentStep({
bookingResult,
event,
paymentConfig,
locale,
paidUnderDifferentName,
setPaidUnderDifferentName,
payerName,
setPayerName,
markingPaid,
onMarkPaymentSent,
}: ManualPaymentStepProps) {
const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer';
const isTpago = bookingResult.paymentMethod === 'tpago';
const ticketCount = bookingResult.ticketCount || 1;
const totalAmount = (event?.price || 0) * ticketCount;
const tpagoLink = getTpagoLink(paymentConfig, ticketCount);
return (
<div className="section-padding">
<div className="container-page max-w-xl">
<Card className="p-6">
<div className="text-center mb-6">
<div className={`w-16 h-16 rounded-full ${isBankTransfer ? 'bg-green-100' : 'bg-blue-100'} flex items-center justify-center mx-auto mb-4`}>
{isBankTransfer ? (
<BuildingLibraryIcon className="w-8 h-8 text-green-600" />
) : (
<CreditCardIcon className="w-8 h-8 text-blue-600" />
)}
</div>
<h1 className="text-xl font-bold text-primary-dark mb-2">
{locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Sigue las instrucciones para completar tu pago'
: 'Follow the instructions to complete your payment'}
</p>
</div>
{/* Amount to pay */}
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-center">
<p className="text-sm text-gray-500 mb-1">
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
</p>
<p className="text-2xl font-bold text-primary-dark">
{event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}
</p>
{ticketCount > 1 && (
<p className="text-sm text-gray-500 mt-1">
{ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')}
</p>
)}
</div>
{/* Bank Transfer Details */}
{isBankTransfer && (
<div className="space-y-4 mb-6">
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Datos Bancarios' : 'Bank Details'}
</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
{paymentConfig.bankName && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Banco' : 'Bank'}:</span>
<span className="font-medium">{paymentConfig.bankName}</span>
</div>
)}
{paymentConfig.bankAccountHolder && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Titular' : 'Account Holder'}:</span>
<span className="font-medium">{paymentConfig.bankAccountHolder}</span>
</div>
)}
{paymentConfig.bankAccountNumber && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}:</span>
<span className="font-medium font-mono">{paymentConfig.bankAccountNumber}</span>
</div>
)}
{paymentConfig.bankAlias && (
<div className="flex justify-between">
<span className="text-gray-600">Alias:</span>
<span className="font-medium">{paymentConfig.bankAlias}</span>
</div>
)}
{paymentConfig.bankPhone && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Teléfono' : 'Phone'}:</span>
<span className="font-medium">{paymentConfig.bankPhone}</span>
</div>
)}
</div>
{(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && (
<p className="text-sm text-gray-600">
{locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes}
</p>
)}
</div>
)}
{/* TPago Link */}
{isTpago && (
<div className="space-y-4 mb-6">
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
</h3>
{tpagoLink && (
<a
href={tpagoLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
>
<ArrowTopRightOnSquareIcon className="w-5 h-5" />
{locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'}
</a>
)}
{(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && (
<p className="text-sm text-gray-600">
{locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions}
</p>
)}
</div>
)}
{/* Reference */}
<div className="bg-gray-100 rounded-lg p-3 mb-6">
<p className="text-xs text-gray-500 mb-1">
{locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'}
</p>
<p className="font-mono font-bold text-lg">{bookingResult.qrCode}</p>
</div>
{/* Manual verification notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">
{locale === 'es' ? 'Verificación manual' : 'Manual verification'}
</p>
<p className="text-blue-700">
{locale === 'es'
? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.'
: 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'}
</p>
</div>
</div>
</div>
{/* Paid under different name option */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={paidUnderDifferentName}
onChange={(e) => {
setPaidUnderDifferentName(e.target.checked);
if (!e.target.checked) setPayerName('');
}}
className="mt-1 w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
/>
<div>
<span className="font-medium text-gray-700">
{locale === 'es'
? 'El pago está a nombre de otra persona'
: 'The payment is under another person\'s name'}
</span>
<p className="text-xs text-gray-500 mt-1">
{locale === 'es'
? 'Marcá esta opción si el pago fue realizado por un familiar o tercero.'
: 'Check this option if the payment was made by a family member or a third party.'}
</p>
</div>
</label>
{paidUnderDifferentName && (
<div className="mt-3 pl-7">
<Input
label={locale === 'es' ? 'Nombre del pagador' : 'Payer name'}
value={payerName}
onChange={(e) => setPayerName(e.target.value)}
placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'}
required
/>
</div>
)}
</div>
{/* Warning before I Have Paid button */}
<p className="text-sm text-center text-amber-700 font-medium mb-3">
{locale === 'es'
? 'Solo haz clic aquí después de haber completado el pago.'
: 'Only click this after you have actually completed the payment.'}
</p>
{/* I Have Paid Button */}
<Button
onClick={onMarkPaymentSent}
isLoading={markingPaid}
size="lg"
className="w-full"
disabled={paidUnderDifferentName && !payerName.trim()}
>
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
</Button>
<p className="text-xs text-center text-gray-500 mt-4">
{locale === 'es'
? 'Tu reserva será confirmada una vez que verifiquemos el pago'
: 'Your booking will be confirmed once we verify the payment'}
</p>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,81 @@
import { QRCodeSVG } from 'qrcode.react';
import Card from '@/components/ui/Card';
import { BoltIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
import { copyInvoiceToClipboard, truncateInvoice } from '../_logic/booking';
import type { LightningInvoice } from '../_types';
interface PayingStepProps {
invoice: LightningInvoice;
qrCode: string;
locale: string;
}
export function PayingStep({ invoice, qrCode, locale }: PayingStepProps) {
return (
<div className="section-padding">
<div className="container-page max-w-md">
<Card className="p-6 text-center">
{/* Amount - prominent at top */}
<div className="mb-4">
{invoice.fiatAmount && invoice.fiatCurrency && (
<p className="text-2xl font-bold text-primary-dark">
{invoice.fiatAmount.toLocaleString()} {invoice.fiatCurrency}
</p>
)}
<p className="text-orange-600 font-medium">
{invoice.amount.toLocaleString()} sats
</p>
</div>
{/* QR Code - clickable to copy */}
<div
className="bg-white p-4 rounded-lg shadow-inner inline-block mb-4 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest, locale)}
title={locale === 'es' ? 'Clic para copiar' : 'Click to copy'}
>
<QRCodeSVG
value={invoice.paymentRequest.toUpperCase()}
size={200}
level="M"
includeMargin={false}
/>
</div>
{/* Invoice string - truncated, clickable */}
<div
className="bg-secondary-gray rounded-lg p-3 mb-4 cursor-pointer hover:bg-gray-200 transition-colors"
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest, locale)}
>
<p className="font-mono text-xs text-gray-600 flex items-center justify-center gap-2">
<ClipboardDocumentIcon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{truncateInvoice(invoice.paymentRequest, 16)}</span>
</p>
<p className="text-xs text-gray-400 mt-1">
{locale === 'es' ? 'Toca para copiar' : 'Tap to copy'}
</p>
</div>
{/* Open in Wallet - primary action */}
<a
href={`lightning:${invoice.paymentRequest}`}
className="inline-flex items-center justify-center gap-2 w-full px-6 py-3 bg-orange-500 text-white rounded-btn hover:bg-orange-600 transition-colors font-medium mb-4"
>
<BoltIcon className="w-5 h-5" />
{locale === 'es' ? 'Abrir en Billetera' : 'Open in Wallet'}
</a>
{/* Status indicator */}
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
<div className="animate-spin w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full" />
<span>{locale === 'es' ? 'Esperando pago...' : 'Waiting for payment...'}</span>
</div>
{/* Ticket reference - small */}
<p className="text-xs text-gray-400 mt-3">
{locale === 'es' ? 'Ref' : 'Ref'}: {qrCode}
</p>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { ClockIcon, TicketIcon } from '@heroicons/react/24/outline';
import { Event } from '@/lib/api';
import type { BookingResult } from '../_types';
interface PendingApprovalStepProps {
bookingResult: BookingResult;
event: Event | null;
locale: string;
t: (key: string) => string;
formatDate: (dateStr: string) => string;
fmtTime: (dateStr: string) => string;
}
export function PendingApprovalStep({
bookingResult,
event,
locale,
t,
formatDate,
fmtTime,
}: PendingApprovalStepProps) {
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-yellow-100 flex items-center justify-center mx-auto mb-6">
<ClockIcon className="w-10 h-10 text-yellow-600" />
</div>
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'}
</h1>
<p className="text-gray-600 mb-6">
{locale === 'es'
? 'Estamos verificando tu pago. Recibirás un email de confirmación una vez aprobado.'
: 'We are verifying your payment. You will receive a confirmation email once approved.'}
</p>
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
<div className="flex items-center justify-center gap-2 mb-4">
<TicketIcon className="w-6 h-6 text-primary-yellow" />
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
</div>
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {event && fmtTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
{locale === 'es'
? 'La verificación del pago puede tomar hasta 24 horas hábiles. Por favor revisa tu email regularmente.'
: 'Payment verification may take up to 24 business hours. Please check your email regularly.'}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/events">
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
</Link>
<Link href="/">
<Button>{t('booking.success.backHome')}</Button>
</Link>
</div>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
CheckCircleIcon,
TicketIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
import { Event } from '@/lib/api';
import { getSuccessContent } from '../_logic/booking';
import type { BookingResult } from '../_types';
interface SuccessStepProps {
bookingResult: BookingResult;
event: Event;
locale: string;
t: (key: string) => string;
formatDate: (dateStr: string) => string;
fmtTime: (dateStr: string) => string;
}
export function SuccessStep({
bookingResult,
event,
locale,
t,
formatDate,
fmtTime,
}: SuccessStepProps) {
const successContent = getSuccessContent(bookingResult, locale, t);
return (
<div className="section-padding">
<div className="container-page max-w-2xl">
<Card className="p-8 text-center">
<div className={`w-16 h-16 rounded-full ${successContent.iconColor} flex items-center justify-center mx-auto mb-6`}>
<CheckCircleIcon className={`w-10 h-10 ${successContent.iconTextColor}`} />
</div>
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{successContent.title}
</h1>
<p className="text-gray-600 mb-6">
{successContent.description}
</p>
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
{/* Multi-ticket indicator */}
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<div className="mb-4 pb-4 border-b border-gray-300">
<p className="text-lg font-semibold text-primary-dark">
{locale === 'es'
? `${bookingResult.ticketCount} tickets reservados`
: `${bookingResult.ticketCount} tickets booked`}
</p>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Cada asistente recibirá su propio código QR'
: 'Each attendee will receive their own QR code'}
</p>
</div>
)}
<div className="flex items-center justify-center gap-2 mb-4">
<TicketIcon className="w-6 h-6 text-primary-yellow" />
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
+{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}
</span>
)}
</div>
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {fmtTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
</div>
</div>
{bookingResult.paymentMethod === 'cash' && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
<strong>{t('booking.success.cashNote')}:</strong> {t('booking.success.cashDescription')}
</p>
</div>
)}
{bookingResult.paymentMethod === 'bancard' && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-800 text-sm">
{t('booking.success.cardNote')}
</p>
</div>
)}
{bookingResult.paymentMethod === 'lightning' && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800 text-sm flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5" />
{locale === 'es'
? '¡Pago con Bitcoin Lightning recibido exitosamente!'
: 'Bitcoin Lightning payment received successfully!'}
</p>
</div>
)}
<p className="text-sm text-gray-500 mb-6">
{t('booking.success.emailSent')}
</p>
{/* Download Ticket Button - only for instant confirmation (Lightning) */}
{bookingResult.paymentMethod === 'lightning' && (
<div className="mb-6">
<a
href={bookingResult.bookingId
? `/api/tickets/booking/${bookingResult.bookingId}/pdf`
: `/api/tickets/${bookingResult.ticketId}/pdf`
}
download
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
<ArrowDownTrayIcon className="w-5 h-5" />
{locale === 'es'
? (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Descargar Tickets' : 'Descargar Ticket')
: (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Download Tickets' : 'Download Ticket')}
</a>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/events">
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
</Link>
<Link href="/">
<Button>{t('booking.success.backHome')}</Button>
</Link>
</div>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
// Shared types for the booking flow.
export interface AttendeeInfo {
firstName: string;
lastName: string;
}
export type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
export interface BookingFormData {
firstName: string;
lastName: string;
email: string;
phone: string;
// Empty until the user explicitly picks a method (no default selection).
paymentMethod: PaymentMethod | '';
ruc: string;
}
export interface LightningInvoice {
paymentHash: string;
paymentRequest: string; // BOLT11 invoice
amount: number; // Amount in satoshis
fiatAmount?: number; // Original fiat amount
fiatCurrency?: string; // Original fiat currency
expiry?: string;
}
export interface BookingResult {
ticketId: string;
ticketIds?: string[]; // For multi-ticket bookings
bookingId?: string;
qrCode: string;
qrCodes?: string[]; // For multi-ticket bookings
paymentMethod: PaymentMethod;
lightningInvoice?: LightningInvoice;
ticketCount?: number;
}
export type BookingStep = 'form' | 'paying' | 'manual_payment' | 'pending_approval' | 'success';
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,17 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig, LightningInvoice } from '@/lib/api';
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 Button from '@/components/ui/Button';
import {
CheckCircleIcon,
import {
CheckCircleIcon,
ClockIcon,
XCircleIcon,
TicketIcon,
@@ -22,7 +24,7 @@ import {
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error';
type PaymentStep = 'loading' | 'manual_payment' | 'lightning_payment' | 'pending_approval' | 'confirmed' | 'error';
export default function BookingPaymentPage() {
const params = useParams();
@@ -33,10 +35,36 @@ export default function BookingPaymentPage() {
const [step, setStep] = useState<PaymentStep>('loading');
const [markingPaid, setMarkingPaid] = useState(false);
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 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
useEffect(() => {
if (!ticketId) return;
@@ -45,7 +73,7 @@ export default function BookingPaymentPage() {
try {
// Get ticket with event and payment info
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
if (!ticketData) {
setError('Booking not found');
setStep('error');
@@ -54,10 +82,24 @@ export default function BookingPaymentPage() {
setTicket(ticketData);
// Only proceed for manual payment methods
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 || '')) {
// Not a manual payment method, redirect to success page or show appropriate state
// Not a manual or Lightning payment method, show appropriate state
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
setStep('confirmed');
} else {
@@ -69,13 +111,13 @@ export default function BookingPaymentPage() {
// Get payment config for the event
if (ticketData.eventId) {
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId);
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId, ticketData.id);
setPaymentConfig(paymentOptions);
}
// Determine which step to show based on payment status
const paymentStatus = ticketData.payment?.status;
if (paymentStatus === 'paid' || ticketData.status === 'confirmed') {
setStep('confirmed');
} else if (paymentStatus === 'pending_approval') {
@@ -96,6 +138,15 @@ export default function BookingPaymentPage() {
loadBookingData();
}, [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
const handleMarkPaymentSent = async () => {
if (!ticket) return;
@@ -301,6 +352,49 @@ 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
if (step === 'manual_payment' && ticket && paymentConfig) {
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
export const metadata: Metadata = {
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.',
alternates: {
canonical: '/community',
},
openGraph: {
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.',
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItem } from '@/lib/api';
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import clsx from 'clsx';
@@ -23,7 +24,20 @@ export default function HomepageFaqSection() {
return () => { cancelled = true; };
}, []);
if (loading || faqs.length === 0) {
if (loading) {
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;
}
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { FeaturedEventSkeleton } from '@/components/ui/Skeleton';
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
interface NextEventSectionProps {
@@ -51,11 +52,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
: '';
if (loading) {
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>
);
return <FeaturedEventSkeleton />;
}
if (!nextEvent) {
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Contact Us',
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
alternates: {
canonical: '/contact',
},
openGraph: {
title: 'Contact Us Spanglish',
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
@@ -0,0 +1,535 @@
'use client';
import { useEffect, useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
dashboardApi,
authApi,
UserProfile,
UserSession,
} from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
interface AccountTabProps {
onUpdate?: () => void;
}
/**
* Merged Profile + Security tab: account info, edit-profile form, authentication
* methods, change/set password, active sessions and account deletion.
*/
export default function AccountTab({ onUpdate }: AccountTabProps) {
const { locale } = useLanguage();
const { user, updateUser } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [settingPassword, setSettingPassword] = useState(false);
const [formData, setFormData] = useState({
name: '',
phone: '',
languagePreference: '',
rucNumber: '',
});
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [newPasswordForm, setNewPasswordForm] = useState({
password: '',
confirmPassword: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [profileRes, sessionsRes] = await Promise.all([
dashboardApi.getProfile(),
dashboardApi.getSessions(),
]);
setProfile(profileRes.profile);
setSessions(sessionsRes.sessions);
setFormData({
name: profileRes.profile.name || '',
phone: profileRes.profile.phone || '',
languagePreference: profileRes.profile.languagePreference || '',
rucNumber: profileRes.profile.rucNumber || '',
});
} catch (error) {
toast.error(locale === 'es' ? 'Error al cargar datos' : 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await dashboardApi.updateProfile(formData);
toast.success(locale === 'es' ? 'Perfil actualizado' : 'Profile updated');
if (user) {
updateUser({
...user,
name: formData.name,
phone: formData.phone,
languagePreference: formData.languagePreference,
rucNumber: formData.rucNumber,
});
}
onUpdate?.();
} catch (error: any) {
toast.error(error.message || (locale === 'es' ? 'Error al actualizar' : 'Update failed'));
} finally {
setSaving(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (passwordForm.newPassword.length < 10) {
toast.error(
locale === 'es'
? 'La contraseña debe tener al menos 10 caracteres'
: 'Password must be at least 10 characters'
);
return;
}
setChangingPassword(true);
try {
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
toast.success(locale === 'es' ? 'Contraseña actualizada' : 'Password updated');
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: any) {
toast.error(
error.message || (locale === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password')
);
} finally {
setChangingPassword(false);
}
};
const handleSetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (newPasswordForm.password.length < 10) {
toast.error(
locale === 'es'
? 'La contraseña debe tener al menos 10 caracteres'
: 'Password must be at least 10 characters'
);
return;
}
setSettingPassword(true);
try {
await dashboardApi.setPassword(newPasswordForm.password);
toast.success(locale === 'es' ? 'Contraseña establecida' : 'Password set');
setNewPasswordForm({ password: '', confirmPassword: '' });
loadData();
} catch (error: any) {
toast.error(
error.message || (locale === 'es' ? 'Error al establecer contraseña' : 'Failed to set password')
);
} finally {
setSettingPassword(false);
}
};
const handleUnlinkGoogle = async () => {
if (
!confirm(
locale === 'es'
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
: 'Are you sure you want to unlink your Google account?'
)
)
return;
try {
await dashboardApi.unlinkGoogle();
toast.success(locale === 'es' ? 'Google desvinculado' : 'Google unlinked');
loadData();
} catch (error: any) {
toast.error(error.message || (locale === 'es' ? 'Error' : 'Failed'));
}
};
const handleRevokeSession = async (sessionId: string) => {
try {
await dashboardApi.revokeSession(sessionId);
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
toast.success(locale === 'es' ? 'Sesión cerrada' : 'Session revoked');
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
};
const handleRevokeAllSessions = async () => {
if (
!confirm(
locale === 'es'
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
: 'Log out of all other sessions? This session stays signed in.'
)
)
return;
try {
await dashboardApi.revokeAllSessions();
toast.success(
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
);
loadData();
} catch (error) {
toast.error(locale === 'es' ? 'Error' : 'Failed');
}
};
const formatDateTime = (dateStr: string) =>
parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
</div>
);
}
const isActive = profile?.accountStatus === 'active';
return (
<div className="max-w-2xl space-y-6">
{/* Account info */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Información de la cuenta' : 'Account information'}
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between border-b py-2">
<span className="text-gray-600">{locale === 'es' ? 'Correo' : 'Email'}</span>
<span className="font-medium">{profile?.email}</span>
</div>
<div className="flex items-center justify-between border-b py-2">
<span className="text-gray-600">{locale === 'es' ? 'Estado' : 'Status'}</span>
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${
isActive ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800'
}`}
>
{isActive
? locale === 'es'
? 'Activa'
: 'Active'
: profile?.accountStatus}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">{locale === 'es' ? 'Miembro desde' : 'Member since'}</span>
<span className="font-medium">
{profile?.memberSince
? parseDate(profile.memberSince).toLocaleDateString(
locale === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
)
: '-'}
</span>
</div>
</div>
</Card>
{/* Edit profile */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Editar perfil' : 'Edit profile'}
</h3>
<form onSubmit={handleSaveProfile} className="space-y-4">
<Input
id="name"
label={locale === 'es' ? 'Nombre' : 'Name'}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Input
id="phone"
type="tel"
label={locale === 'es' ? 'Teléfono' : 'Phone'}
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<div>
<label className="mb-1.5 block text-sm font-medium text-primary-dark">
{locale === 'es' ? 'Idioma preferido' : 'Preferred language'}
</label>
<select
value={formData.languagePreference}
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
className="w-full rounded-btn border border-secondary-light-gray px-4 py-3 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="">{locale === 'es' ? 'Seleccionar' : 'Select'}</option>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<div>
<Input
id="rucNumber"
label={locale === 'es' ? 'Número de RUC (para facturas)' : 'RUC number (for invoices)'}
value={formData.rucNumber}
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
placeholder={locale === 'es' ? 'Opcional' : 'Optional'}
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es'
? 'Tu número de RUC paraguayo para facturas fiscales'
: 'Your Paraguayan RUC number for fiscal invoices'}
</p>
</div>
<div className="pt-2">
<Button type="submit" isLoading={saving}>
{locale === 'es' ? 'Guardar cambios' : 'Save changes'}
</Button>
</div>
</form>
<p className="mt-4 border-t pt-4 text-xs text-gray-500">
{locale === 'es'
? 'Para cambiar tu correo, contacta al soporte.'
: 'To change your email, please contact support.'}
</p>
</Card>
{/* Authentication methods */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'Métodos de autenticación' : 'Authentication methods'}
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
}`}
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="font-medium">{locale === 'es' ? 'Contraseña' : 'Password'}</p>
<p className="text-sm text-gray-500">
{profile?.hasPassword
? locale === 'es' ? 'Configurada' : 'Set'
: locale === 'es' ? 'No configurada' : 'Not set'}
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full ${
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
}`}
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
</div>
<div>
<p className="font-medium">Google</p>
<p className="text-sm text-gray-500">
{profile?.hasGoogleLinked
? locale === 'es' ? 'Vinculado' : 'Linked'
: locale === 'es' ? 'No vinculado' : 'Not linked'}
</p>
</div>
</div>
{profile?.hasGoogleLinked && profile?.hasPassword && (
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
{locale === 'es' ? 'Desvincular' : 'Unlink'}
</Button>
)}
</div>
</div>
</Card>
{/* Password management */}
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{profile?.hasPassword
? locale === 'es' ? 'Cambiar contraseña' : 'Change password'
: locale === 'es' ? 'Establecer contraseña' : 'Set password'}
</h3>
{profile?.hasPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<Input
id="currentPassword"
type="password"
label={locale === 'es' ? 'Contraseña actual' : 'Current password'}
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
required
/>
<div>
<Input
id="newPassword"
type="password"
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
required
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
</div>
<Input
id="confirmPassword"
type="password"
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={changingPassword}>
{locale === 'es' ? 'Cambiar contraseña' : 'Change password'}
</Button>
</form>
) : (
<form onSubmit={handleSetPassword} className="space-y-4">
<p className="mb-2 text-sm text-gray-600">
{locale === 'es'
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
: 'You currently sign in with Google. Set a password for more access options.'}
</p>
<div>
<Input
id="password"
type="password"
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
value={newPasswordForm.password}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
required
/>
<p className="mt-1.5 text-xs text-gray-500">
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
</div>
<Input
id="confirmNewPassword"
type="password"
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
value={newPasswordForm.confirmPassword}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={settingPassword}>
{locale === 'es' ? 'Establecer contraseña' : 'Set password'}
</Button>
</form>
)}
</Card>
{/* Active sessions */}
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">
{locale === 'es' ? 'Sesiones activas' : 'Active sessions'}
</h3>
{sessions.length > 1 && (
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
{locale === 'es' ? 'Cerrar todas' : 'Log out all'}
</Button>
)}
</div>
{sessions.length === 0 ? (
<p className="text-sm text-gray-600">
{locale === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
</p>
) : (
<div className="space-y-3">
{sessions.map((session) => (
<div
key={session.id}
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{session.userAgent
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '…' : '')
: locale === 'es' ? 'Dispositivo desconocido' : 'Unknown device'}
</p>
<p className="text-xs text-gray-500">
{locale === 'es' ? 'Última actividad:' : 'Last active:'}{' '}
{formatDateTime(session.lastActiveAt)}
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{session.current ? (
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
{locale === 'es' ? 'Esta sesión' : 'This session'}
</span>
) : (
<Button variant="outline" size="sm" onClick={() => handleRevokeSession(session.id)}>
{locale === 'es' ? 'Cerrar' : 'Revoke'}
</Button>
)}
</div>
))}
</div>
)}
</Card>
{/* Danger zone */}
<Card className="border border-red-200 bg-red-50 p-6">
<h3 className="mb-2 text-lg font-semibold text-red-800">
{locale === 'es' ? 'Zona de peligro' : 'Danger zone'}
</h3>
<p className="mb-4 text-sm text-red-700">
{locale === 'es'
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
: 'If you want to delete your account, please contact support.'}
</p>
<Button
variant="outline"
size="sm"
className="border-red-300 text-red-700 hover:bg-red-100"
disabled
>
{locale === 'es' ? 'Eliminar cuenta' : 'Delete account'}
</Button>
</Card>
</div>
);
}
@@ -0,0 +1,423 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
CalendarIcon,
ClockIcon,
MapPinIcon,
ArrowTopRightOnSquareIcon,
TicketIcon,
UserPlusIcon,
QrCodeIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import type { UserTicket, NextEventInfo } from '@/lib/api';
import { useLanguage } from '@/context/LanguageContext';
import { formatDateLong, formatTime, parseDate } from '@/lib/utils';
import { StatusPill, deriveTicketStatus } from './_shared/status';
import {
groupByBooking,
isUnpaid,
isAwaitingApproval,
isOnHold,
isActionableAttention,
ticketAmount,
shareTicket,
isToday,
type BookingGroup,
} from './_shared/helpers';
import PayActions from './_shared/PayActions';
import AttentionBanner from './_shared/AttentionBanner';
import QrTicketModal from './_shared/QrTicketModal';
interface OverviewTabProps {
nextEvent: NextEventInfo | null;
tickets: UserTicket[];
locale: string;
userName: string;
onChange: () => void;
}
export default function OverviewTab({
nextEvent,
tickets,
locale,
userName,
onChange,
}: OverviewTabProps) {
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
const activeTickets = useMemo(
() => tickets.filter((t) => t.status !== 'cancelled'),
[tickets]
);
// Banner: the single booking that most needs attention (unpaid first, then
// awaiting approval), ordered by soonest event.
const attentionTicket = useMemo(() => {
const candidates = activeTickets.filter(
(t) =>
isActionableAttention(t) &&
(isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t))
);
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
candidates.sort((a, b) => {
const aUnpaid = priority(a);
const bUnpaid = priority(b);
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
const aStart = a.event?.startDatetime
? parseDate(a.event.startDatetime).getTime()
: Infinity;
const bStart = b.event?.startDatetime
? parseDate(b.event.startDatetime).getTime()
: Infinity;
return aStart - bStart;
});
return candidates[0] || null;
}, [activeTickets]);
// Hero: full tickets for the next event's booking.
const heroGroup = useMemo<BookingGroup | null>(() => {
if (!nextEvent) return null;
const primary =
activeTickets.find((t) => t.id === nextEvent.ticket.id) || null;
if (!primary) return null;
const groupTickets = primary.bookingId
? activeTickets.filter((t) => t.bookingId === primary.bookingId)
: [primary];
return { bookingId: primary.bookingId || primary.id, tickets: groupTickets };
}, [nextEvent, activeTickets]);
// "Also coming up": upcoming booking groups other than the hero.
const upcomingGroups = useMemo<BookingGroup[]>(() => {
const now = Date.now();
const groups = groupByBooking(
activeTickets.filter(
(t) => t.event && parseDate(t.event.startDatetime).getTime() > now
)
);
return groups.filter((g) => g.bookingId !== heroGroup?.bookingId);
}, [activeTickets, heroGroup]);
const handleShare = async (ticket: UserTicket) => {
const title =
(locale === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title) || 'Event';
const result = await shareTicket(ticket.id, title, locale);
if (result === 'copied')
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
else if (result === 'failed')
toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share');
};
// Empty state.
if (activeTickets.length === 0) {
return (
<Card className="p-8 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-yellow/15">
<TicketIcon className="h-7 w-7 text-primary-yellow" />
</div>
<h2 className="mb-1 text-xl font-semibold">
{locale === 'es' ? `¡Hola, ${userName}!` : `Welcome, ${userName}!`}
</h2>
<p className="mx-auto mb-6 max-w-sm text-gray-600">
{locale === 'es'
? 'Todavía no tienes reservas. Encuentra tu primer evento de intercambio de idiomas.'
: "You have no bookings yet. Find your first language exchange event."}
</p>
<Link href="/events">
<Button size="lg">
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
</Button>
</Link>
</Card>
);
}
return (
<div className="space-y-6">
{/* 1 — Attention banner (only when money is owed / awaiting). */}
{attentionTicket && (
<AttentionBanner
ticket={attentionTicket}
locale={locale}
onChange={onChange}
/>
)}
{/* 2 — Next event hero. */}
{heroGroup && heroGroup.tickets[0].event && (
<HeroCard
group={heroGroup}
locale={locale}
onChange={onChange}
onShowQr={() => setQrTickets(heroGroup.tickets)}
onShare={handleShare}
/>
)}
{/* 3 — Also coming up. */}
{upcomingGroups.length > 0 && (
<Card className="p-6">
<h3 className="mb-4 text-lg font-semibold">
{locale === 'es' ? 'También se viene' : 'Also coming up'}
</h3>
<div className="space-y-3">
{upcomingGroups.map((group) => {
const t = group.tickets[0];
const status = deriveTicketStatus(t.status, t.payment?.status);
const title =
(locale === 'es' && t.event?.titleEs
? t.event.titleEs
: t.event?.title) || 'Event';
const { amount, currency } = ticketAmount(t);
return (
<div
key={group.bookingId}
className="flex flex-col gap-3 rounded-card bg-secondary-gray p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate font-medium">{title}</p>
{group.tickets.length > 1 && (
<span className="text-xs text-gray-500">
×{group.tickets.length}
</span>
)}
</div>
<p className="mt-0.5 text-sm text-gray-600">
{t.event && formatDateLong(t.event.startDatetime, locale as 'en' | 'es')}
</p>
</div>
<div className="flex items-center gap-3">
<StatusPill status={status} locale={locale} />
{isOnHold(t) ? (
<PayActions
ticketId={t.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
onHold
/>
) : isUnpaid(t) ? (
<PayActions
ticketId={t.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
/>
) : (
(status === 'confirmed' || status === 'attended') && (
<Button
variant="outline"
size="sm"
onClick={() => setQrTickets(group.tickets)}
>
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
</Button>
)
)}
</div>
</div>
);
})}
</div>
</Card>
)}
{/* 6 — Browse events. */}
<div className="flex justify-center pt-2">
<Link href="/events">
<Button variant="outline" size="lg">
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
</Button>
</Link>
</div>
{qrTickets && (
<QrTicketModal
tickets={qrTickets}
locale={locale}
onClose={() => setQrTickets(null)}
/>
)}
</div>
);
}
function HeroCard({
group,
locale,
onChange,
onShowQr,
onShare,
}: {
group: BookingGroup;
locale: string;
onChange: () => void;
onShowQr: () => void;
onShare: (ticket: UserTicket) => void;
}) {
const { t } = useLanguage();
const ticket = group.tickets[0];
const event = ticket.event!;
const title =
(locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Event';
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
const { amount, currency } = ticketAmount(ticket);
const multi = group.tickets.length > 1;
const today = isToday(event.startDatetime);
const showQrReady = status === 'confirmed' || status === 'attended';
return (
<Card className="overflow-hidden">
{/* Image with status pill + today badge overlay. */}
<div className="relative h-44 w-full bg-secondary-gray sm:h-56">
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<TicketIcon className="h-12 w-12 text-gray-300" />
</div>
)}
<div className="absolute right-3 top-3 flex items-center gap-2">
{today && (
<span className="rounded-full bg-primary-yellow px-3 py-1 text-xs font-semibold text-primary-dark shadow-sm">
{locale === 'es' ? 'Hoy' : 'Today'}
</span>
)}
<StatusPill status={status} locale={locale} className="shadow-sm" />
</div>
</div>
<div className="p-6">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-primary-yellow">
{locale === 'es' ? 'Tu próximo evento' : 'Your next event'}
</p>
<div className="mb-4 flex items-start justify-between gap-3">
<h3 className="text-2xl font-bold leading-tight">{title}</h3>
{multi && (
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-3 py-1 text-sm font-medium text-gray-700">
{locale === 'es'
? `${group.tickets.length} entradas`
: `${group.tickets.length} tickets`}
</span>
)}
</div>
<div className="mb-5 space-y-2 text-gray-600">
<p className="flex items-center gap-3">
<CalendarIcon className="h-5 w-5 text-primary-yellow" />
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
</p>
<p className="flex items-center gap-3">
<ClockIcon className="h-5 w-5 text-primary-yellow" />
{formatTime(event.startDatetime, locale as 'en' | 'es')}
</p>
<p className="flex items-center gap-3">
<MapPinIcon className="h-5 w-5 text-primary-yellow" />
<span className="flex-1">{event.location}</span>
{event.locationUrl && (
<a
href={event.locationUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 whitespace-nowrap text-sm font-medium text-secondary-blue hover:underline"
>
{locale === 'es' ? 'Cómo llegar' : 'Get directions'}
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
</a>
)}
</p>
</div>
{/* Today + ready → show the QR prominently for check-in. */}
{today && showQrReady && (
<div className="mb-4 rounded-card bg-primary-yellow/10 p-4 text-center">
<p className="mb-3 text-sm font-medium text-primary-dark">
{locale === 'es'
? 'Muestra este código en la entrada'
: 'Show this code at the door'}
</p>
<Button onClick={onShowQr} size="lg" className="w-full sm:w-auto">
<QrCodeIcon className="mr-2 h-5 w-5" />
{locale === 'es' ? 'Mostrar código QR' : 'Show QR code'}
</Button>
</div>
)}
{/* Smart primary action. */}
{isOnHold(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="md"
onHold
/>
) : isUnpaid(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="md"
/>
) : isAwaitingApproval(ticket) ? (
<p className="rounded-card bg-amber-50 px-4 py-3 text-sm text-amber-800">
{t('dashboard.payment.receivedConfirmShortly')}
</p>
) : (
!today && (
<Button onClick={onShowQr} size="md">
<TicketIcon className="mr-2 h-5 w-5" />
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
</Button>
)
)}
{/* Multi-ticket guest reminder. */}
{multi && showQrReady && (
<div className="mt-4 flex flex-col gap-2 rounded-card border border-secondary-light-gray p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-600">
{locale === 'es'
? 'Tienes una entrada de más para un invitado.'
: 'You have a spare ticket for a guest.'}
</p>
<Button
variant="outline"
size="sm"
onClick={() => onShare(group.tickets[1])}
>
<UserPlusIcon className="mr-2 h-4 w-4" />
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
</Button>
</div>
)}
</div>
</Card>
);
}
@@ -1,94 +1,35 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { UserPayment } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import { formatDateLong } from '@/lib/utils';
import { StatusPill, derivePaymentStatus } from './_shared/status';
import { pyg, isManualProvider } from './_shared/helpers';
import PayActions from './_shared/PayActions';
interface PaymentsTabProps {
payments: UserPayment[];
language: string;
onChange: () => void;
}
export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
const [filter, setFilter] = useState<'all' | 'paid' | 'pending'>('all');
type Filter = 'all' | 'paid' | 'pending';
const filteredPayments = payments.filter((payment) => {
if (filter === 'all') return true;
if (filter === 'paid') return payment.status === 'paid';
if (filter === 'pending') return payment.status !== 'paid' && payment.status !== 'refunded';
return true;
});
export default function PaymentsTab({ payments, language: locale, onChange }: PaymentsTabProps) {
const [filter, setFilter] = useState<Filter>('all');
const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
});
};
const filtered = useMemo(
() =>
payments.filter((p) => {
if (filter === 'all') return true;
if (filter === 'paid') return p.status === 'paid';
return p.status !== 'paid' && p.status !== 'refunded';
}),
[payments, filter]
);
const formatCurrency = (amount: number, currency: string = 'PYG') => {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
paid: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
pending_approval: 'bg-orange-100 text-orange-800',
refunded: 'bg-purple-100 text-purple-800',
failed: 'bg-red-100 text-red-800',
};
const labels: Record<string, Record<string, string>> = {
en: {
paid: 'Paid',
pending: 'Pending',
pending_approval: 'Awaiting Approval',
refunded: 'Refunded',
failed: 'Failed',
},
es: {
paid: 'Pagado',
pending: 'Pendiente',
pending_approval: 'Esperando Aprobación',
refunded: 'Reembolsado',
failed: 'Fallido',
},
};
return (
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[language]?.[status] || status}
</span>
);
};
const getProviderLabel = (provider: string) => {
const labels: Record<string, Record<string, string>> = {
en: {
lightning: 'Lightning (Bitcoin)',
cash: 'Cash',
bank_transfer: 'Bank Transfer',
tpago: 'TPago',
bancard: 'Card',
},
es: {
lightning: 'Lightning (Bitcoin)',
cash: 'Efectivo',
bank_transfer: 'Transferencia Bancaria',
tpago: 'TPago',
bancard: 'Tarjeta',
},
};
return labels[language]?.[provider] || provider;
};
// Summary calculations
const totalPaid = payments
.filter((p) => p.status === 'paid')
.reduce((sum, p) => sum + Number(p.amount), 0);
@@ -96,112 +37,143 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
.filter((p) => p.status !== 'paid' && p.status !== 'refunded')
.reduce((sum, p) => sum + Number(p.amount), 0);
const providerLabel = (provider: string) => {
const labels: Record<string, { en: string; es: string }> = {
tpago: { en: 'TPago', es: 'TPago' },
bank_transfer: { en: 'Bank transfer', es: 'Transferencia bancaria' },
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
cash: { en: 'Cash', es: 'Efectivo' },
bancard: { en: 'Card', es: 'Tarjeta' },
};
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
};
const chips: { id: Filter; label: { en: string; es: string } }[] = [
{ id: 'all', label: { en: 'All', es: 'Todos' } },
{ id: 'paid', label: { en: 'Paid', es: 'Pagados' } },
{ id: 'pending', label: { en: 'Pending', es: 'Pendientes' } },
];
return (
<div className="space-y-6">
{/* Summary Cards */}
{/* Totals */}
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<p className="text-sm text-gray-600 mb-1">
{language === 'es' ? 'Total Pagado' : 'Total Paid'}
</p>
<p className="text-2xl font-bold text-green-600">
{formatCurrency(totalPaid)}
<Card className="p-5">
<p className="mb-1 text-sm text-gray-600">
{locale === 'es' ? 'Total pagado' : 'Total paid'}
</p>
<p className="text-2xl font-bold text-green-700">{pyg(totalPaid)}</p>
</Card>
<Card className="p-4">
<p className="text-sm text-gray-600 mb-1">
{language === 'es' ? 'Pendiente' : 'Pending'}
</p>
<p className="text-2xl font-bold text-yellow-600">
{formatCurrency(totalPending)}
<Card className="p-5">
<p className="mb-1 text-sm text-gray-600">
{locale === 'es' ? 'Pendiente' : 'Pending'}
</p>
<p className="text-2xl font-bold text-red-700">{pyg(totalPending)}</p>
</Card>
</div>
{/* Filter Buttons */}
<div className="flex gap-2">
{(['all', 'paid', 'pending'] as const).map((f) => (
{/* Filter chips */}
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-secondary-blue text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
key={chip.id}
onClick={() => setFilter(chip.id)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
filter === chip.id
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
}`}
>
{f === 'all' && (language === 'es' ? 'Todos' : 'All')}
{f === 'paid' && (language === 'es' ? 'Pagados' : 'Paid')}
{f === 'pending' && (language === 'es' ? 'Pendientes' : 'Pending')}
{locale === 'es' ? chip.label.es : chip.label.en}
</button>
))}
</div>
{/* Payments List */}
{filteredPayments.length === 0 ? (
{/* Records */}
{filtered.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{language === 'es' ? 'No hay pagos que mostrar' : 'No payments to display'}
{locale === 'es' ? 'No hay pagos que mostrar' : 'No payments to show'}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredPayments.map((payment) => (
<Card key={payment.id} className="p-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">
{formatCurrency(Number(payment.amount), payment.currency)}
</span>
{getStatusBadge(payment.status)}
{filtered.map((payment) => {
const status = derivePaymentStatus(payment.status);
const eventTitle =
(locale === 'es' && payment.event?.titleEs
? payment.event.titleEs
: payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event');
const canMarkPaid =
payment.status === 'pending' && isManualProvider(payment.provider);
const canRebook = payment.status === 'on_hold';
return (
<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="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="font-semibold">
{pyg(Number(payment.amount), payment.currency)}
</span>
<StatusPill status={status} locale={locale} />
</div>
<div className="space-y-0.5 text-sm text-gray-600">
<p className="font-medium text-gray-800">{eventTitle}</p>
<p>
{providerLabel(payment.provider)} ·{' '}
{formatDateLong(payment.createdAt, locale as 'en' | 'es')}
</p>
{payment.reference && (
<p className="text-xs text-gray-500">
{locale === 'es' ? 'Ref' : 'Ref'}: {payment.reference}
</p>
)}
</div>
</div>
<div className="text-sm text-gray-600 space-y-1">
{payment.event && (
<p>
{language === 'es' && payment.event.titleEs
? payment.event.titleEs
: payment.event.title}
</p>
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
{canRebook ? (
<PayActions
ticketId={payment.ticketId}
amount={Number(payment.amount)}
currency={payment.currency}
destination={eventTitle}
locale={locale}
onPaid={onChange}
layout="inline"
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"
/>
)
)}
<p>
<span className="font-medium">
{language === 'es' ? 'Método:' : 'Method:'}
</span>{' '}
{getProviderLabel(payment.provider)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(payment.createdAt)}
</p>
{payment.reference && (
<p>
<span className="font-medium">
{language === 'es' ? 'Referencia:' : 'Reference:'}
</span>{' '}
{payment.reference}
</p>
{payment.invoice && (
<a
href={payment.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
{locale === 'es' ? 'Factura' : 'Invoice'}
</Button>
</a>
)}
</div>
</div>
{payment.invoice && (
<a
href={payment.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
</Button>
</a>
)}
</div>
</Card>
))}
</Card>
);
})}
</div>
)}
</div>
@@ -1,211 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { dashboardApi, UserProfile } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
interface ProfileTabProps {
onUpdate?: () => void;
}
export default function ProfileTab({ onUpdate }: ProfileTabProps) {
const { locale: language } = useLanguage();
const { updateUser, user } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
phone: '',
languagePreference: '',
rucNumber: '',
});
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
const res = await dashboardApi.getProfile();
setProfile(res.profile);
setFormData({
name: res.profile.name || '',
phone: res.profile.phone || '',
languagePreference: res.profile.languagePreference || '',
rucNumber: res.profile.rucNumber || '',
});
} catch (error) {
toast.error(language === 'es' ? 'Error al cargar perfil' : 'Failed to load profile');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const res = await dashboardApi.updateProfile(formData);
toast.success(language === 'es' ? 'Perfil actualizado' : 'Profile updated');
// Update auth context
if (user) {
updateUser({
...user,
name: formData.name,
phone: formData.phone,
languagePreference: formData.languagePreference,
rucNumber: formData.rucNumber,
});
}
if (onUpdate) onUpdate();
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al actualizar' : 'Update failed'));
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
);
}
return (
<div className="max-w-2xl space-y-6">
{/* Account Info Card */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Información de la Cuenta' : 'Account Information'}
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">Email</span>
<span className="font-medium">{profile?.email}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">
{language === 'es' ? 'Estado de Cuenta' : 'Account Status'}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${
profile?.accountStatus === 'active'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{profile?.accountStatus === 'active'
? (language === 'es' ? 'Activo' : 'Active')
: profile?.accountStatus}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-gray-600">
{language === 'es' ? 'Miembro Desde' : 'Member Since'}
</span>
<span className="font-medium">
{profile?.memberSince
? parseDate(profile.memberSince).toLocaleDateString(
language === 'es' ? 'es-ES' : 'en-US',
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
)
: '-'}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">
{language === 'es' ? 'Días de Membresía' : 'Membership Days'}
</span>
<span className="font-medium">{profile?.membershipDays || 0}</span>
</div>
</div>
</Card>
{/* Edit Profile Form */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Editar Perfil' : 'Edit Profile'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
id="name"
label={language === 'es' ? 'Nombre' : 'Name'}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Input
id="phone"
label={language === 'es' ? 'Teléfono' : 'Phone'}
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{language === 'es' ? 'Idioma Preferido' : 'Preferred Language'}
</label>
<select
value={formData.languagePreference}
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary-blue"
>
<option value="">{language === 'es' ? 'Seleccionar' : 'Select'}</option>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<Input
id="rucNumber"
label={language === 'es' ? 'Número de RUC (para facturas)' : 'RUC Number (for invoices)'}
value={formData.rucNumber}
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
placeholder={language === 'es' ? 'Opcional' : 'Optional'}
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es'
? 'Tu número de RUC paraguayo para facturas fiscales'
: 'Your Paraguayan RUC number for fiscal invoices'}
</p>
<div className="pt-4">
<Button type="submit" isLoading={saving}>
{language === 'es' ? 'Guardar Cambios' : 'Save Changes'}
</Button>
</div>
</form>
</Card>
{/* Email Change Notice */}
<Card className="p-6 bg-gray-50">
<h3 className="text-lg font-semibold mb-2">
{language === 'es' ? 'Cambiar Email' : 'Change Email'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{language === 'es'
? 'Para cambiar tu dirección de email, por favor contacta al soporte.'
: 'To change your email address, please contact support.'}
</p>
<Button variant="outline" size="sm" disabled>
{language === 'es' ? 'Contactar Soporte' : 'Contact Support'}
</Button>
</Card>
</div>
);
}
@@ -1,382 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export default function SecurityTab() {
const { locale: language } = useLanguage();
const { logout } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const [changingPassword, setChangingPassword] = useState(false);
const [settingPassword, setSettingPassword] = useState(false);
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [newPasswordForm, setNewPasswordForm] = useState({
password: '',
confirmPassword: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [profileRes, sessionsRes] = await Promise.all([
dashboardApi.getProfile(),
dashboardApi.getSessions(),
]);
setProfile(profileRes.profile);
setSessions(sessionsRes.sessions);
} catch (error) {
toast.error(language === 'es' ? 'Error al cargar datos' : 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (passwordForm.newPassword.length < 10) {
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
return;
}
setChangingPassword(true);
try {
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
toast.success(language === 'es' ? 'Contraseña actualizada' : 'Password updated');
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password'));
} finally {
setChangingPassword(false);
}
};
const handleSetPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
return;
}
if (newPasswordForm.password.length < 10) {
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
return;
}
setSettingPassword(true);
try {
await dashboardApi.setPassword(newPasswordForm.password);
toast.success(language === 'es' ? 'Contraseña establecida' : 'Password set');
setNewPasswordForm({ password: '', confirmPassword: '' });
loadData(); // Reload to update profile
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error al establecer contraseña' : 'Failed to set password'));
} finally {
setSettingPassword(false);
}
};
const handleUnlinkGoogle = async () => {
if (!confirm(language === 'es'
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
: 'Are you sure you want to unlink your Google account?'
)) {
return;
}
try {
await dashboardApi.unlinkGoogle();
toast.success(language === 'es' ? 'Google desvinculado' : 'Google unlinked');
loadData();
} catch (error: any) {
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
}
};
const handleRevokeSession = async (sessionId: string) => {
try {
await dashboardApi.revokeSession(sessionId);
setSessions(sessions.filter((s) => s.id !== sessionId));
toast.success(language === 'es' ? 'Sesión cerrada' : 'Session revoked');
} catch (error) {
toast.error(language === 'es' ? 'Error' : 'Failed');
}
};
const handleRevokeAllSessions = async () => {
if (!confirm(language === 'es'
? '¿Cerrar todas las sesiones? Serás desconectado.'
: 'Log out of all sessions? You will be logged out.'
)) {
return;
}
try {
await dashboardApi.revokeAllSessions();
toast.success(language === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
logout();
} catch (error) {
toast.error(language === 'es' ? 'Error' : 'Failed');
}
};
const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Asuncion',
});
};
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
);
}
return (
<div className="max-w-2xl space-y-6">
{/* Authentication Methods */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Métodos de Autenticación' : 'Authentication Methods'}
</h3>
<div className="space-y-4">
{/* Password Status */}
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
}`}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="font-medium">
{language === 'es' ? 'Contraseña' : 'Password'}
</p>
<p className="text-sm text-gray-500">
{profile?.hasPassword
? (language === 'es' ? 'Configurada' : 'Set')
: (language === 'es' ? 'No configurada' : 'Not set')}
</p>
</div>
</div>
{profile?.hasPassword && (
<span className="text-green-600 text-sm font-medium">
{language === 'es' ? 'Activo' : 'Active'}
</span>
)}
</div>
{/* Google Status */}
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
}`}>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
</div>
<div>
<p className="font-medium">Google</p>
<p className="text-sm text-gray-500">
{profile?.hasGoogleLinked
? (language === 'es' ? 'Vinculado' : 'Linked')
: (language === 'es' ? 'No vinculado' : 'Not linked')}
</p>
</div>
</div>
{profile?.hasGoogleLinked && profile?.hasPassword && (
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
{language === 'es' ? 'Desvincular' : 'Unlink'}
</Button>
)}
</div>
</div>
</Card>
{/* Password Management */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{profile?.hasPassword
? (language === 'es' ? 'Cambiar Contraseña' : 'Change Password')
: (language === 'es' ? 'Establecer Contraseña' : 'Set Password')}
</h3>
{profile?.hasPassword ? (
<form onSubmit={handleChangePassword} className="space-y-4">
<Input
id="currentPassword"
type="password"
label={language === 'es' ? 'Contraseña Actual' : 'Current Password'}
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
required
/>
<Input
id="newPassword"
type="password"
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
required
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmPassword"
type="password"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={changingPassword}>
{language === 'es' ? 'Cambiar Contraseña' : 'Change Password'}
</Button>
</form>
) : (
<form onSubmit={handleSetPassword} className="space-y-4">
<p className="text-sm text-gray-600 mb-4">
{language === 'es'
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
: 'You currently sign in with Google. Set a password for more access options.'}
</p>
<Input
id="password"
type="password"
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
value={newPasswordForm.password}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
required
/>
<p className="text-xs text-gray-500 -mt-2">
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
</p>
<Input
id="confirmNewPassword"
type="password"
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
value={newPasswordForm.confirmPassword}
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
required
/>
<Button type="submit" isLoading={settingPassword}>
{language === 'es' ? 'Establecer Contraseña' : 'Set Password'}
</Button>
</form>
)}
</Card>
{/* Active Sessions */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
{language === 'es' ? 'Sesiones Activas' : 'Active Sessions'}
</h3>
{sessions.length > 1 && (
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
{language === 'es' ? 'Cerrar Todas' : 'Logout All'}
</Button>
)}
</div>
{sessions.length === 0 ? (
<p className="text-sm text-gray-600">
{language === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
</p>
) : (
<div className="space-y-3">
{sessions.map((session, index) => (
<div
key={session.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium text-sm">
{session.userAgent
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '...' : '')
: (language === 'es' ? 'Dispositivo desconocido' : 'Unknown device')}
</p>
<p className="text-xs text-gray-500">
{language === 'es' ? 'Última actividad:' : 'Last active:'} {formatDate(session.lastActiveAt)}
{session.ipAddress && `${session.ipAddress}`}
</p>
</div>
{index !== 0 && (
<Button
variant="outline"
size="sm"
onClick={() => handleRevokeSession(session.id)}
>
{language === 'es' ? 'Cerrar' : 'Revoke'}
</Button>
)}
{index === 0 && (
<span className="text-xs text-green-600 font-medium">
{language === 'es' ? 'Esta sesión' : 'This session'}
</span>
)}
</div>
))}
</div>
)}
</Card>
{/* Danger Zone */}
<Card className="p-6 border-red-200 bg-red-50">
<h3 className="text-lg font-semibold text-red-800 mb-4">
{language === 'es' ? 'Zona de Peligro' : 'Danger Zone'}
</h3>
<p className="text-sm text-red-700 mb-4">
{language === 'es'
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
: 'If you want to delete your account, please contact support.'}
</p>
<Button variant="outline" size="sm" className="border-red-300 text-red-700 hover:bg-red-100" disabled>
{language === 'es' ? 'Eliminar Cuenta' : 'Delete Account'}
</Button>
</Card>
</div>
);
}
@@ -1,209 +1,249 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
CalendarIcon,
MapPinIcon,
ArrowDownTrayIcon,
TicketIcon,
} from '@heroicons/react/24/outline';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { UserTicket } from '@/lib/api';
import { parseDate } from '@/lib/utils';
import { formatDateLong, parseDate } from '@/lib/utils';
import { StatusPill, deriveTicketStatus } from './_shared/status';
import {
groupByBooking,
isUnpaid,
isOnHold,
ticketAmount,
ticketPdfUrl,
pyg,
HOLD_THRESHOLD_HOURS,
type BookingGroup,
} from './_shared/helpers';
import PayActions from './_shared/PayActions';
import QrTicketModal from './_shared/QrTicketModal';
interface TicketsTabProps {
tickets: UserTicket[];
language: string;
onChange: () => void;
}
export default function TicketsTab({ tickets, language }: TicketsTabProps) {
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('all');
type Filter = 'all' | 'upcoming' | 'past';
const now = new Date();
const filteredTickets = tickets.filter((ticket) => {
if (filter === 'all') return true;
const eventDate = ticket.event?.startDatetime
? new Date(ticket.event.startDatetime)
: null;
if (filter === 'upcoming') return eventDate && eventDate > now;
if (filter === 'past') return eventDate && eventDate <= now;
return true;
});
export default function TicketsTab({ tickets, language: locale, onChange }: TicketsTabProps) {
const [filter, setFilter] = useState<Filter>('all');
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
const formatDate = (dateStr: string) => {
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'America/Asuncion',
// Group multi-ticket bookings so both QRs sit together, then filter by the
// group's event date.
const groups = useMemo<BookingGroup[]>(() => {
const now = Date.now();
return groupByBooking(tickets).filter((group) => {
if (filter === 'all') return true;
const start = group.tickets[0].event?.startDatetime;
if (!start) return false; // undated tickets only show under "All"
const isUpcoming = parseDate(start).getTime() > now;
return filter === 'upcoming' ? isUpcoming : !isUpcoming;
});
};
}, [tickets, filter]);
const formatCurrency = (amount: number, currency: string = 'PYG') => {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
};
const getStatusBadge = (status: string) => {
const styles: Record<string, string> = {
confirmed: 'bg-green-100 text-green-800',
checked_in: 'bg-blue-100 text-blue-800',
pending: 'bg-yellow-100 text-yellow-800',
cancelled: 'bg-red-100 text-red-800',
};
const labels: Record<string, Record<string, string>> = {
en: {
confirmed: 'Confirmed',
checked_in: 'Checked In',
pending: 'Pending',
cancelled: 'Cancelled',
},
es: {
confirmed: 'Confirmado',
checked_in: 'Registrado',
pending: 'Pendiente',
cancelled: 'Cancelado',
},
};
return (
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
{labels[language]?.[status] || status}
</span>
);
};
const chips: { id: Filter; label: { en: string; es: string } }[] = [
{ id: 'all', label: { en: 'All', es: 'Todas' } },
{ id: 'upcoming', label: { en: 'Upcoming', es: 'Próximas' } },
{ id: 'past', label: { en: 'Past', es: 'Pasadas' } },
];
return (
<div className="space-y-6">
{/* Filter Buttons */}
<div className="flex gap-2">
{(['all', 'upcoming', 'past'] as const).map((f) => (
{/* Filter chips */}
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === f
? 'bg-secondary-blue text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
key={chip.id}
onClick={() => setFilter(chip.id)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
filter === chip.id
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
}`}
>
{f === 'all' && (language === 'es' ? 'Todas' : 'All')}
{f === 'upcoming' && (language === 'es' ? 'Próximas' : 'Upcoming')}
{f === 'past' && (language === 'es' ? 'Pasadas' : 'Past')}
{locale === 'es' ? chip.label.es : chip.label.en}
</button>
))}
</div>
{/* Tickets List */}
{filteredTickets.length === 0 ? (
{groups.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600 mb-4">
{language === 'es' ? 'No tienes entradas' : 'You have no tickets'}
<p className="mb-4 text-gray-600">
{locale === 'es' ? 'No tienes entradas' : 'You have no tickets'}
</p>
<Link href="/events">
<Button>
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
</Button>
<Button>{locale === 'es' ? 'Explorar eventos' : 'Browse events'}</Button>
</Link>
</Card>
) : (
<div className="space-y-4">
{filteredTickets.map((ticket) => (
<Card key={ticket.id} className="p-4">
<div className="flex flex-col md:flex-row md:items-center gap-4">
{/* Event Image */}
{ticket.event?.bannerUrl && (
<div className="w-full md:w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
<img
src={ticket.event.bannerUrl}
alt={ticket.event.title}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Ticket Info */}
<div className="flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold">
{language === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title || 'Event'}
</h3>
{getStatusBadge(ticket.status)}
</div>
<div className="mt-2 space-y-1 text-sm text-gray-600">
{ticket.event?.startDatetime && (
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(ticket.event.startDatetime)}
</p>
)}
{ticket.event?.location && (
<p>
<span className="font-medium">
{language === 'es' ? 'Lugar:' : 'Location:'}
</span>{' '}
{ticket.event.location}
</p>
)}
{ticket.payment && (
<p>
<span className="font-medium">
{language === 'es' ? 'Pago:' : 'Payment:'}
</span>{' '}
{formatCurrency(ticket.payment.amount, ticket.payment.currency)} -
<span className={`ml-1 ${
ticket.payment.status === 'paid' ? 'text-green-600' : 'text-yellow-600'
}`}>
{ticket.payment.status === 'paid'
? (language === 'es' ? 'Pagado' : 'Paid')
: (language === 'es' ? 'Pendiente' : 'Pending')}
</span>
</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<Link href={`/booking/success/${ticket.id}`}>
<Button size="sm" className="w-full">
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button>
</Link>
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
<a
href={ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticket.id}/pdf`
}
download
className="text-center"
>
<Button variant="outline" size="sm" className="w-full">
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
</Button>
</a>
)}
{ticket.invoice && (
<a
href={ticket.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="text-center"
>
<Button variant="outline" size="sm" className="w-full">
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
</Button>
</a>
)}
</div>
</div>
</Card>
{groups.map((group) => (
<BookingCard
key={group.bookingId}
group={group}
locale={locale}
onChange={onChange}
onShowQr={() => setQrTickets(group.tickets)}
/>
))}
</div>
)}
{qrTickets && (
<QrTicketModal
tickets={qrTickets}
locale={locale}
onClose={() => setQrTickets(null)}
/>
)}
</div>
);
}
function BookingCard({
group,
locale,
onChange,
onShowQr,
}: {
group: BookingGroup;
locale: string;
onChange: () => void;
onShowQr: () => void;
}) {
const ticket = group.tickets[0];
const event = ticket.event;
const title =
(locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event';
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
const { amount, currency } = ticketAmount(ticket);
const multi = group.tickets.length > 1;
const ready = status === 'confirmed' || status === 'attended';
return (
<Card className="p-4">
<div className="flex flex-col gap-4 sm:flex-row">
{/* Image */}
<div className="h-32 w-full flex-shrink-0 overflow-hidden rounded-card bg-secondary-gray sm:h-24 sm:w-32">
{event?.bannerUrl ? (
<img src={event.bannerUrl} alt={title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<TicketIcon className="h-8 w-8 text-gray-300" />
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="flex items-center gap-2 font-semibold">
<span className="truncate">{title}</span>
{multi && (
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-2 py-0.5 text-xs font-medium text-gray-700">
{locale === 'es'
? `${group.tickets.length} entradas`
: `${group.tickets.length} tickets`}
</span>
)}
</h3>
</div>
<StatusPill status={status} locale={locale} />
</div>
<div className="mt-2 space-y-1 text-sm text-gray-600">
{event?.startDatetime && (
<p className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
</p>
)}
{event?.location && (
<p className="flex items-center gap-2">
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
<span className="truncate">{event.location}</span>
</p>
)}
<p className="text-gray-500">
{pyg(amount, currency)}
</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>
{/* Actions */}
<div className="flex flex-col gap-2 sm:w-44">
{isOnHold(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="stack"
size="sm"
onHold
/>
) : isUnpaid(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="stack"
size="sm"
/>
) : (
<>
{ready && (
<Button size="sm" className="w-full" onClick={onShowQr}>
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
</Button>
)}
{ready && (
<a href={ticketPdfUrl(ticket)} download className="w-full">
<Button variant="outline" size="sm" className="w-full">
<ArrowDownTrayIcon className="mr-1.5 h-4 w-4" />
{locale === 'es' ? 'Descargar' : 'Download'}
</Button>
</a>
)}
{ticket.invoice && (
<a
href={ticket.invoice.pdfUrl || '#'}
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Button variant="ghost" size="sm" className="w-full">
{locale === 'es' ? 'Factura' : 'Invoice'}
</Button>
</a>
)}
</>
)}
</div>
</div>
</Card>
);
}
@@ -0,0 +1,130 @@
'use client';
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import type { UserTicket } from '@/lib/api';
import { useLanguage } from '@/context/LanguageContext';
import PayActions from './PayActions';
import { HoldCountdown } from './Countdown';
import {
holdExpiry,
ticketAmount,
pyg,
isAwaitingApproval,
isOnHold,
HOLD_THRESHOLD_HOURS,
} from './helpers';
/**
* The very-top Overview banner. Shown only when a booking needs attention:
* unpaid -> red banner naming event + amount, a hold-expiry countdown,
* and the "Pay now" / "I've paid" actions.
* awaiting -> calm amber "payment received, we will confirm it
* shortly" state with no further action.
*/
export default function AttentionBanner({
ticket,
locale,
onChange,
}: {
ticket: UserTicket;
locale: string;
onChange: () => void;
}) {
const { t } = useLanguage();
const eventTitle =
(locale === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event');
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)) {
return (
<div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4">
<CheckCircleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-600" />
<div>
<p className="font-semibold text-amber-900">
{t('dashboard.payment.received')}
</p>
<p className="text-sm text-amber-800">
{t('dashboard.payment.confirmForEvent', {
amount: pyg(amount, currency),
event: eventTitle,
})}
</p>
</div>
</div>
);
}
const expiry = holdExpiry(ticket);
return (
<div className="rounded-card border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-red-900">
{locale === 'es'
? `Debes ${pyg(amount, currency)} para ${eventTitle}`
: `You owe ${pyg(amount, currency)} for ${eventTitle}`}
</p>
{expiry && (
<p className="mt-0.5 text-sm text-red-700">
<HoldCountdown target={expiry} locale={locale} />
</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"
/>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Live, human countdown to a target time. Renders a short message such as
* "Pay within 23h or your spot is released". Once the target passes it shows a
* calm "expired" message instead of a negative timer.
*/
export function HoldCountdown({
target,
locale,
}: {
target: Date;
locale: string;
}) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60 * 1000);
return () => clearInterval(id);
}, []);
const msLeft = target.getTime() - now;
if (msLeft <= 0) {
return (
<span>
{locale === 'es'
? 'Paga pronto para mantener tu lugar'
: 'Pay soon to keep your spot'}
</span>
);
}
const totalMinutes = Math.floor(msLeft / (60 * 1000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
let remaining: string;
if (hours >= 1) {
remaining = `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}`;
} else {
remaining = `${minutes}m`;
}
return (
<span>
{locale === 'es'
? `Paga en ${remaining} o tu lugar se libera`
: `Pay within ${remaining} or your spot is released`}
</span>
);
}
@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import toast from 'react-hot-toast';
import { CheckCircleIcon } from '@heroicons/react/24/outline';
import Button from '@/components/ui/Button';
import { ticketsApi } from '@/lib/api';
import { useLanguage } from '@/context/LanguageContext';
import { pyg } from './helpers';
interface PayActionsProps {
ticketId: string;
/** Amount owed, used in the confirm copy. */
amount: number;
currency?: string;
/** Where the money is going — event name or account label. */
destination: string;
locale: string;
/** Called after the payment is successfully marked as sent. */
onPaid?: () => void;
size?: 'sm' | 'md' | 'lg';
/** Stack the two buttons full-width (cards) vs inline (rows). */
layout?: 'stack' | 'inline';
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;
}
/**
* The two unpaid-ticket actions used across Overview, Tickets and Payments:
* "Pay now" -> opens the existing manual-payment page (TPago / bank details)
* "I've paid" -> short confirm step, then marks the payment as sent
* (moves the ticket to "Awaiting approval").
*/
export default function PayActions({
ticketId,
amount,
currency = 'PYG',
destination,
locale,
onPaid,
size = 'sm',
layout = 'stack',
className,
onHold = false,
}: PayActionsProps) {
const { t } = useLanguage();
const [confirming, setConfirming] = useState(false);
const [marking, setMarking] = useState(false);
const handleConfirm = async () => {
setMarking(true);
try {
await ticketsApi.markPaymentSent(ticketId);
toast.success(t('dashboard.payment.receivedConfirmSoon'));
setConfirming(false);
onPaid?.();
} catch (error: any) {
// Idempotent backend: if already marked, treat as success.
if (error?.message?.includes('already')) {
setConfirming(false);
onPaid?.();
return;
}
toast.error(
error?.message ||
(locale === 'es' ? 'No se pudo marcar el pago' : 'Could not mark payment')
);
} finally {
setMarking(false);
}
};
const containerClass =
layout === 'stack' ? 'flex flex-col gap-2' : 'flex flex-wrap gap-2';
const btnWidth = layout === 'stack' ? 'w-full' : '';
return (
<>
<div className={`${containerClass} ${className || ''}`}>
{!onHold && (
<Link href={`/booking/${ticketId}`} className={btnWidth}>
<Button size={size} className={btnWidth}>
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
</Button>
</Link>
)}
<Button
variant={onHold ? 'primary' : 'outline'}
size={size}
className={btnWidth}
onClick={() => setConfirming(true)}
>
{onHold
? (locale === 'es' ? 'Reservar de nuevo' : 'Rebook')
: (locale === 'es' ? 'Ya pagué' : "I've paid")}
</Button>
</div>
{confirming && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
onClick={() => !marking && setConfirming(false)}
>
<div
className="w-full max-w-sm rounded-card bg-white p-6 shadow-card-hover"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
<CheckCircleIcon className="h-7 w-7 text-amber-600" />
</div>
</div>
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
{onHold
? (locale === 'es' ? '¿Reservar de nuevo?' : 'Rebook your spot?')
: (locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?')}
</h3>
<p className="mb-6 text-center text-sm text-gray-600">
{onHold
? (locale === 'es'
? `Intentaremos reservar tu lugar de nuevo para ${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>
<div className="flex flex-col gap-2">
<Button isLoading={marking} className="w-full" onClick={handleConfirm}>
{onHold
? (locale === 'es' ? 'Sí, reservar de nuevo' : 'Yes, rebook')
: (locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid")}
</Button>
<Button
variant="ghost"
className="w-full"
disabled={marking}
onClick={() => setConfirming(false)}
>
{locale === 'es' ? 'Todavía no' : 'Not yet'}
</Button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,187 @@
'use client';
import { useEffect } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import toast from 'react-hot-toast';
import {
XMarkIcon,
ArrowDownTrayIcon,
CalendarDaysIcon,
CalendarIcon,
MapPinIcon,
ClockIcon,
UserPlusIcon,
} from '@heroicons/react/24/outline';
import Button from '@/components/ui/Button';
import type { UserTicket } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import {
ticketScanUrl,
ticketPdfUrl,
googleCalendarUrl,
shareTicket,
} from './helpers';
/**
* Full-screen, scannable QR view for the door. Handles single tickets and
* multi-ticket bookings (one QR per ticket, with "Share with a guest" on the
* spares so guests can be let in with their own code).
*/
export default function QrTicketModal({
tickets,
locale,
onClose,
}: {
tickets: UserTicket[];
locale: string;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [onClose]);
if (tickets.length === 0) return null;
const first = tickets[0];
const event = first.event;
const eventTitle =
(locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event';
const isMulti = tickets.length > 1;
const handleShare = async (ticketId: string) => {
const result = await shareTicket(ticketId, eventTitle, locale);
if (result === 'copied') {
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
} else if (result === 'failed') {
toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share');
}
};
return (
<div className="fixed inset-0 z-[70] overflow-y-auto bg-white">
{/* Top bar */}
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-secondary-light-gray bg-white px-4 py-3">
<span className="text-sm font-semibold text-primary-dark">
{isMulti
? locale === 'es'
? `${tickets.length} entradas`
: `${tickets.length} tickets`
: locale === 'es'
? 'Tu entrada'
: 'Your ticket'}
</span>
<button
onClick={onClose}
aria-label={locale === 'es' ? 'Cerrar' : 'Close'}
className="rounded-full p-2 text-gray-500 hover:bg-gray-100"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="mx-auto max-w-md px-4 pb-12 pt-6">
{/* Event details */}
<div className="mb-6 text-center">
<h2 className="text-xl font-bold text-primary-dark">{eventTitle}</h2>
{event && (
<div className="mt-3 space-y-1.5 text-sm text-gray-600">
<p className="flex items-center justify-center gap-2">
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
</p>
<p className="flex items-center justify-center gap-2">
<ClockIcon className="h-4 w-4 text-primary-yellow" />
{formatTime(event.startDatetime, locale as 'en' | 'es')}
</p>
<p className="flex items-center justify-center gap-2">
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
{event.location}
</p>
</div>
)}
</div>
{/* One QR per ticket */}
<div className="space-y-5">
{tickets.map((ticket, idx) => {
const attendee = [ticket.attendeeFirstName, ticket.attendeeLastName]
.filter(Boolean)
.join(' ');
const isSpare = isMulti && idx > 0;
return (
<div
key={ticket.id}
className="rounded-card border border-secondary-light-gray p-5 text-center"
>
{isMulti && (
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
{locale === 'es' ? 'Entrada' : 'Ticket'} {idx + 1}
{attendee ? `${attendee}` : ''}
{isSpare
? `${locale === 'es' ? 'Invitado' : 'Guest'}`
: ''}
</p>
)}
<div className="inline-block rounded-lg bg-white p-4 shadow-inner">
<QRCodeSVG
value={ticketScanUrl(ticket.id)}
size={220}
level="M"
includeMargin={false}
/>
</div>
<p className="mt-3 font-mono text-sm font-bold tracking-wide text-primary-dark">
{ticket.qrCode}
</p>
{isSpare && (
<button
onClick={() => handleShare(ticket.id)}
className="mx-auto mt-3 flex items-center gap-2 rounded-btn border border-secondary-light-gray px-4 py-2 text-sm font-medium text-primary-dark hover:bg-gray-50"
>
<UserPlusIcon className="h-4 w-4" />
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
</button>
)}
</div>
);
})}
</div>
{/* Download + calendar */}
<div className="mt-6 flex flex-col gap-2">
<a href={ticketPdfUrl(first)} download>
<Button className="w-full">
<ArrowDownTrayIcon className="mr-2 h-5 w-5" />
{isMulti
? locale === 'es'
? 'Descargar entradas'
: 'Download tickets'
: locale === 'es'
? 'Descargar'
: 'Download'}
</Button>
</a>
{event && (
<a
href={googleCalendarUrl(event, locale)}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" className="w-full">
<CalendarDaysIcon className="mr-2 h-5 w-5" />
{locale === 'es' ? 'Agregar al calendario' : 'Add to calendar'}
</Button>
</a>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import type { UserTicket, Event } from '@/lib/api';
import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
// Hours a booking holds a seat before the spot is released. There is no
// server-side enforcement field, so the countdown is derived from when the
// ticket was created, capped at the event start time.
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". */
export function pyg(amount: number, currency: string = 'PYG'): string {
return formatPrice(Number(amount) || 0, currency || 'PYG');
}
export function isManualProvider(provider?: string): boolean {
return provider === 'bank_transfer' || provider === 'tpago';
}
/** A ticket is unpaid when its payment is still pending (not approved/paid). */
export function isUnpaid(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
const ps = ticket.payment?.status;
return ticket.status !== 'cancelled' && (ps === 'pending' || ps === 'failed');
}
export function isAwaitingApproval(ticket: { payment?: { status?: string } | null }): boolean {
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 data needed to compute it is missing.
*/
export function holdExpiry(ticket: UserTicket): Date | null {
if (!isUnpaid(ticket)) return null;
const createdStr = ticket.payment?.createdAt || ticket.createdAt;
if (!createdStr) return null;
const created = parseDate(createdStr);
let expiry = new Date(created.getTime() + PAYMENT_HOLD_HOURS * 60 * 60 * 1000);
// Never hold past the event itself.
const eventStart = ticket.event?.startDatetime ? parseDate(ticket.event.startDatetime) : null;
if (eventStart && eventStart < expiry) expiry = eventStart;
return expiry;
}
/** Amount owed for a ticket (payment amount preferred, falls back to event price). */
export function ticketAmount(ticket: UserTicket): { amount: number; currency: string } {
const amount = ticket.payment?.amount ?? ticket.event?.price ?? 0;
const currency = ticket.payment?.currency ?? ticket.event?.currency ?? 'PYG';
return { amount: Number(amount) || 0, currency };
}
export interface BookingGroup {
bookingId: string;
tickets: UserTicket[];
}
/**
* Group tickets that belong to the same booking (multi-ticket / guest tickets).
* Tickets without a bookingId become single-ticket groups keyed by their id.
* Order of first appearance is preserved.
*/
export function groupByBooking(tickets: UserTicket[]): BookingGroup[] {
const groups = new Map<string, UserTicket[]>();
const order: string[] = [];
for (const ticket of tickets) {
const key = ticket.bookingId || ticket.id;
if (!groups.has(key)) {
groups.set(key, []);
order.push(key);
}
groups.get(key)!.push(ticket);
}
return order.map((bookingId) => ({ bookingId, tickets: groups.get(bookingId)! }));
}
/** Public URL embedded in the scannable QR (matches the PDF + scanner contract). */
export function ticketScanUrl(ticketId: string): string {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `${origin}/ticket/${ticketId}`;
}
/** Download URL for a ticket / whole booking PDF. */
export function ticketPdfUrl(ticket: UserTicket): string {
return ticket.bookingId
? `/api/tickets/booking/${ticket.bookingId}/pdf`
: `/api/tickets/${ticket.id}/pdf`;
}
/** Build a Google Calendar "add event" URL — no backend needed. */
export function googleCalendarUrl(event: Event, locale: string): string {
const start = parseDate(event.startDatetime);
const end = event.endDatetime
? parseDate(event.endDatetime)
: new Date(start.getTime() + 2 * 60 * 60 * 1000);
const fmt = (d: Date) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const title = (locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Spanglish';
const params = new URLSearchParams({
action: 'TEMPLATE',
text: title,
dates: `${fmt(start)}/${fmt(end)}`,
location: event.location || '',
ctz: EVENT_TIMEZONE,
});
return `https://www.google.com/calendar/render?${params.toString()}`;
}
/**
* Share a single (spare) ticket's QR with a guest. Uses the Web Share API when
* available, otherwise copies the ticket link to the clipboard.
*/
export async function shareTicket(
ticketId: string,
eventTitle: string,
locale: string
): Promise<'shared' | 'copied' | 'failed'> {
const url = ticketScanUrl(ticketId);
const text =
locale === 'es'
? `Tu entrada para ${eventTitle}`
: `Your ticket for ${eventTitle}`;
try {
if (typeof navigator !== 'undefined' && (navigator as any).share) {
await (navigator as any).share({ title: eventTitle, text, url });
return 'shared';
}
if (typeof navigator !== 'undefined' && navigator.clipboard) {
await navigator.clipboard.writeText(url);
return 'copied';
}
return 'failed';
} catch (err: any) {
// User cancelled the native share sheet.
if (err?.name === 'AbortError') return 'shared';
return 'failed';
}
}
/** True when the event's calendar date is today (Asunción time). */
export function isToday(dateStr?: string): boolean {
if (!dateStr) return false;
const opts: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: EVENT_TIMEZONE,
};
const fmt = (d: Date) => d.toLocaleDateString('en-CA', opts);
return fmt(parseDate(dateStr)) === fmt(new Date());
}
@@ -0,0 +1,101 @@
'use client';
import clsx from 'clsx';
import type { UserTicket, Payment } from '@/lib/api';
// One logical status per meaning, mapped to a single pale colour everywhere.
// confirmed -> pale green
// awaiting -> pale amber (user said they paid, admin checking)
// unpaid -> pale red
// attended -> pale blue (replaces the raw "checked_in" value)
// cancelled -> pale gray
// onHold -> pale slate (spot released after the payment deadline passed)
export type DashStatus =
| 'confirmed'
| 'awaiting'
| 'unpaid'
| 'attended'
| 'cancelled'
| 'onHold';
/**
* Collapse a ticket status + payment status into a single user-facing status.
* Never surface raw values like "checked_in" or "pending_approval".
*/
export function deriveTicketStatus(
ticketStatus?: string,
paymentStatus?: string
): DashStatus {
if (ticketStatus === 'checked_in') return 'attended';
if (ticketStatus === 'cancelled') return 'cancelled';
if (ticketStatus === 'on_hold' || paymentStatus === 'on_hold') return 'onHold';
if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting';
return 'unpaid';
}
/** Status for a standalone payment record (Payments tab). */
export function derivePaymentStatus(paymentStatus?: string): DashStatus {
if (paymentStatus === 'paid') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting';
if (paymentStatus === 'on_hold') return 'onHold';
if (paymentStatus === 'refunded') return 'cancelled';
return 'unpaid';
}
export function statusLabel(status: DashStatus, locale: string): string {
const labels: Record<DashStatus, { en: string; es: string }> = {
confirmed: { en: 'Confirmed', es: 'Confirmado' },
awaiting: { en: 'Awaiting approval', es: 'Esperando aprobación' },
unpaid: { en: 'Unpaid', es: 'No pagado' },
attended: { en: 'Attended', es: 'Asistió' },
cancelled: { en: 'Cancelled', es: 'Cancelado' },
onHold: { en: 'On Hold', es: 'En Espera' },
};
return locale === 'es' ? labels[status].es : labels[status].en;
}
const PILL_STYLES: Record<DashStatus, string> = {
confirmed: 'bg-green-100 text-green-800',
awaiting: 'bg-amber-100 text-amber-800',
unpaid: 'bg-red-100 text-red-700',
attended: 'bg-blue-100 text-blue-800',
cancelled: 'bg-gray-100 text-gray-600',
onHold: 'bg-slate-100 text-slate-600',
};
export function StatusPill({
status,
locale,
className,
}: {
status: DashStatus;
locale: string;
className?: string;
}) {
return (
<span
className={clsx(
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap',
PILL_STYLES[status],
className
)}
>
{statusLabel(status, locale)}
</span>
);
}
/** Convenience: derive + render a pill straight from a ticket. */
export function TicketStatusPill({
ticket,
locale,
className,
}: {
ticket: Pick<UserTicket, 'status'> & { payment?: Pick<Payment, 'status'> | null };
locale: string;
className?: string;
}) {
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
return <StatusPill status={status} locale={locale} className={className} />;
}
+47 -323
View File
@@ -4,34 +4,28 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
import { formatDateLong, formatTime } from '@/lib/utils';
import {
dashboardApi,
NextEventInfo,
UserTicket,
UserPayment,
} from '@/lib/api';
import toast from 'react-hot-toast';
import Link from 'next/link';
import {
socialConfig,
getWhatsAppUrl,
getInstagramUrl,
getTelegramUrl
} from '@/lib/socialLinks';
import { CardListSkeleton } from '@/components/ui/Skeleton';
// Tab components
import OverviewTab from './components/OverviewTab';
import TicketsTab from './components/TicketsTab';
import PaymentsTab from './components/PaymentsTab';
import ProfileTab from './components/ProfileTab';
import SecurityTab from './components/SecurityTab';
import AccountTab from './components/AccountTab';
type Tab = 'overview' | 'tickets' | 'payments' | 'profile' | 'security';
type Tab = 'overview' | 'tickets' | 'payments' | 'account';
export default function DashboardPage() {
const router = useRouter();
const { t, locale: language } = useLanguage();
const { user, isLoading: authLoading, token } = useAuth();
const { locale } = useLanguage();
const { user, isLoading: authLoading } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
const [tickets, setTickets] = useState<UserTicket[]>([]);
const [payments, setPayments] = useState<UserPayment[]>([]);
@@ -42,369 +36,99 @@ export default function DashboardPage() {
router.push('/login');
return;
}
if (user && token) {
// Auth rides on the httpOnly session cookie; once the user has resolved
// the API calls are authenticated automatically.
if (user) {
loadDashboardData();
}
}, [user, authLoading, token]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authLoading]);
const loadDashboardData = async () => {
setLoading(true);
try {
const [summaryRes, nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
dashboardApi.getSummary(),
const [nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
dashboardApi.getNextEvent(),
dashboardApi.getTickets(),
dashboardApi.getPayments(),
]);
setSummary(summaryRes.summary);
setNextEvent(nextEventRes.nextEvent);
setTickets(ticketsRes.tickets);
setPayments(paymentsRes.payments);
} catch (error: any) {
} catch (error) {
console.error('Failed to load dashboard:', error);
toast.error('Failed to load dashboard data');
toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data');
} finally {
setLoading(false);
}
};
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: language === 'es' ? 'Resumen' : 'Overview' },
{ id: 'tickets', label: language === 'es' ? 'Mis Entradas' : 'My Tickets' },
{ id: 'payments', label: language === 'es' ? 'Pagos y Facturas' : 'Payments & Invoices' },
{ id: 'profile', label: language === 'es' ? 'Perfil' : 'Profile' },
{ id: 'security', label: language === 'es' ? 'Seguridad' : 'Security' },
const tabs: { id: Tab; label: { en: string; es: string } }[] = [
{ id: 'overview', label: { en: 'Overview', es: 'Resumen' } },
{ id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } },
{ id: 'payments', label: { en: 'Payments', es: 'Pagos' } },
{ id: 'account', label: { en: 'Account', es: 'Cuenta' } },
];
if (authLoading || !user) {
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 className="section-padding flex min-h-[70vh] items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-primary-yellow" />
</div>
);
}
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
return (
<div className="section-padding min-h-[70vh]">
<div className="container-page">
{/* Welcome Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">
{language === 'es' ? `Hola, ${user.name}!` : `Welcome, ${user.name}!`}
</h1>
{summary && (
<p className="text-gray-600">
{language === 'es'
? `Miembro desde hace ${summary.user.membershipDays} días`
: `Member for ${summary.user.membershipDays} days`
}
</p>
)}
</div>
{/* Welcome header */}
<h1 className="mb-6 text-3xl font-bold">
{locale === 'es' ? `¡Hola, ${user.name}!` : `Welcome, ${user.name}!`}
</h1>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex gap-4 -mb-px overflow-x-auto">
{/* Tab navigation */}
<div className="mb-6 border-b border-secondary-light-gray">
<nav className="-mb-px flex gap-6 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm transition-colors ${
className={`whitespace-nowrap border-b-2 px-1 pb-3 text-sm font-semibold transition-colors ${
activeTab === tab.id
? 'border-secondary-blue text-secondary-blue'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
{locale === 'es' ? tab.label.es : tab.label.en}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{/* Tab content */}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
</div>
<CardListSkeleton count={3} />
) : (
<>
{activeTab === 'overview' && (
<OverviewTab
summary={summary}
nextEvent={nextEvent}
tickets={tickets}
language={language}
formatDate={formatDate}
formatTime={formatTime}
locale={locale}
userName={user.name}
onChange={loadDashboardData}
/>
)}
{activeTab === 'tickets' && (
<TicketsTab tickets={tickets} language={language} />
<TicketsTab tickets={tickets} language={locale} onChange={loadDashboardData} />
)}
{activeTab === 'payments' && (
<PaymentsTab payments={payments} language={language} />
)}
{activeTab === 'profile' && (
<ProfileTab onUpdate={loadDashboardData} />
)}
{activeTab === 'security' && (
<SecurityTab />
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
)}
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
</>
)}
</div>
</div>
);
}
// Overview Tab Component
function OverviewTab({
summary,
nextEvent,
tickets,
language,
formatDate,
formatTime,
}: {
summary: DashboardSummary | null;
nextEvent: NextEventInfo | null;
tickets: UserTicket[];
language: string;
formatDate: (date: string) => string;
formatTime: (date: string) => string;
}) {
return (
<div className="space-y-6">
{/* Stats Cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-secondary-blue">{summary.stats.totalTickets}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Total Entradas' : 'Total Tickets'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-green-600">{summary.stats.confirmedTickets}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Confirmadas' : 'Confirmed'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-purple-600">{summary.stats.upcomingEvents}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Próximos' : 'Upcoming'}
</div>
</Card>
<Card className="p-4 text-center">
<div className="text-3xl font-bold text-orange-500">{summary.stats.pendingPayments}</div>
<div className="text-sm text-gray-600">
{language === 'es' ? 'Pagos Pendientes' : 'Pending Payments'}
</div>
</Card>
</div>
)}
{/* Next Event Card */}
{nextEvent ? (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Tu Próximo Evento' : 'Your Next Event'}
</h3>
<div className="flex flex-col md:flex-row gap-6">
{nextEvent.event.bannerUrl && (
<div className="w-full md:w-48 h-32 rounded-lg overflow-hidden">
<img
src={nextEvent.event.bannerUrl}
alt={nextEvent.event.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex-1">
<h4 className="text-xl font-bold mb-2">
{language === 'es' && nextEvent.event.titleEs
? nextEvent.event.titleEs
: nextEvent.event.title}
</h4>
<div className="space-y-2 text-gray-600">
<p>
<span className="font-medium">
{language === 'es' ? 'Fecha:' : 'Date:'}
</span>{' '}
{formatDate(nextEvent.event.startDatetime)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Hora:' : 'Time:'}
</span>{' '}
{formatTime(nextEvent.event.startDatetime)}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Lugar:' : 'Location:'}
</span>{' '}
{nextEvent.event.location}
</p>
<p>
<span className="font-medium">
{language === 'es' ? 'Estado:' : 'Status:'}
</span>{' '}
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
nextEvent.payment?.status === 'paid'
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{nextEvent.payment?.status === 'paid'
? (language === 'es' ? 'Pagado' : 'Paid')
: (language === 'es' ? 'Pendiente' : 'Pending')}
</span>
</p>
</div>
<div className="mt-4 flex gap-2">
<Link href={`/booking/success/${nextEvent.ticket.id}`}>
<Button size="sm">
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
</Button>
</Link>
{nextEvent.event.locationUrl && (
<a
href={nextEvent.event.locationUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
{language === 'es' ? 'Ver Mapa' : 'View Map'}
</Button>
</a>
)}
</div>
</div>
</div>
</Card>
) : (
<Card className="p-6 text-center">
<p className="text-gray-600 mb-4">
{language === 'es'
? 'No tienes eventos próximos'
: 'You have no upcoming events'}
</p>
<Link href="/events">
<Button>
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
</Button>
</Link>
</Card>
)}
{/* Recent Tickets */}
{tickets.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Entradas Recientes' : 'Recent Tickets'}
</h3>
<div className="space-y-3">
{tickets.slice(0, 3).map((ticket) => (
<div
key={ticket.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium">
{language === 'es' && ticket.event?.titleEs
? ticket.event.titleEs
: ticket.event?.title || 'Event'}
</p>
<p className="text-sm text-gray-600">
{ticket.event?.startDatetime
? formatDate(ticket.event.startDatetime)
: ''}
</p>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
ticket.status === 'confirmed' || ticket.status === 'checked_in'
? 'bg-green-100 text-green-800'
: ticket.status === 'cancelled'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{ticket.status}
</span>
</div>
))}
</div>
{tickets.length > 3 && (
<div className="mt-4 text-center">
<Button variant="outline" size="sm" onClick={() => {}}>
{language === 'es' ? 'Ver Todas' : 'View All'}
</Button>
</div>
)}
</Card>
)}
{/* Community Links */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">
{language === 'es' ? 'Comunidad' : 'Community'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{getWhatsAppUrl(socialConfig.whatsapp) && (
<a
href={getWhatsAppUrl(socialConfig.whatsapp)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-green-50 rounded-lg hover:bg-green-100 transition-colors"
>
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
</div>
<span className="font-medium">WhatsApp</span>
</a>
)}
{getInstagramUrl(socialConfig.instagram) && (
<a
href={getInstagramUrl(socialConfig.instagram)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-pink-50 rounded-lg hover:bg-pink-100 transition-colors"
>
<div className="w-10 h-10 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</div>
<span className="font-medium">Instagram</span>
</a>
)}
{getTelegramUrl(socialConfig.telegram) && (
<a
href={getTelegramUrl(socialConfig.telegram)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
>
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
</div>
<span className="font-medium">Telegram</span>
</a>
)}
</div>
</Card>
</div>
);
}
@@ -0,0 +1,147 @@
'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>
);
}

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