Compare commits
22
Commits
dev
..
backup-prod11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb920ce6f0 | ||
|
|
c0315a705d | ||
|
|
fbc437a670 | ||
|
|
e0f0700398 | ||
|
|
defd9685e0 | ||
|
|
1ed62b0d3f | ||
|
|
91de6df04d | ||
|
|
a5d97d65e1 | ||
|
|
f0128f66b0 | ||
|
|
b33c68feb0 | ||
|
|
15655e3987 | ||
|
|
d8b3864411 | ||
|
|
194cbd6ca8 | ||
|
|
d5445c2282 | ||
|
|
dcfefc8371 | ||
|
|
b5f14335c4 | ||
|
|
d44ac949b5 | ||
|
|
a5e939221d | ||
|
|
833e3e5a9c | ||
|
|
ba1975dd6d | ||
|
|
3025ef3d21 | ||
|
|
8564f8af83 |
@@ -7,7 +7,6 @@ package-lock.json
|
|||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
.next-build/
|
|
||||||
out/
|
out/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
@@ -57,10 +56,6 @@ yarn-error.log*
|
|||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# Go photo-api service
|
|
||||||
photo-api/bin/
|
|
||||||
photo-api/data/
|
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,17 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
|
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
|
||||||
- Stable `/next` and `/featured` URLs that redirect to the current event
|
- **User dashboard**: profile, tickets, payments, sessions/security
|
||||||
- Human-readable event URL slugs (with legacy-ID redirect support)
|
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
|
||||||
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
|
|
||||||
- Lightning invoice reuse and re-payment straight from the dashboard
|
|
||||||
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
|
|
||||||
- One unified ticket-creation modal with first-class payment status
|
|
||||||
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
|
|
||||||
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
|
|
||||||
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
|
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
||||||
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
|
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
|
||||||
- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
|
|
||||||
- **Email**: `nodemailer` (SMTP) with optional provider config
|
- **Email**: `nodemailer` (SMTP) with optional provider config
|
||||||
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
|
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, SWR, Heroicons
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
@@ -29,8 +22,6 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- npm
|
- npm
|
||||||
- Go 1.26+ (for the `photo-api` service)
|
|
||||||
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
|
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -38,14 +29,12 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
npm install
|
npm install
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
cp frontend/.env.example frontend/.env
|
cp frontend/.env.example frontend/.env
|
||||||
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Initialize database (SQLite by default)
|
### Initialize database (SQLite by default)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:migrate # backend (Drizzle) tables
|
npm run db:migrate
|
||||||
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run
|
### Run
|
||||||
@@ -54,13 +43,10 @@ npm run migrate:photos # photo-api owns only the photos_* tables via its own mi
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
|
|
||||||
|
|
||||||
Default URLs:
|
Default URLs:
|
||||||
|
|
||||||
- Frontend: `http://localhost:3002`
|
- Frontend: `http://localhost:3002`
|
||||||
- Backend API: `http://localhost:3001`
|
- Backend API: `http://localhost:3001`
|
||||||
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
|
|
||||||
- API docs: `http://localhost:3001/api-docs`
|
- API docs: `http://localhost:3001/api-docs`
|
||||||
|
|
||||||
### First user becomes admin
|
### First user becomes admin
|
||||||
@@ -72,37 +58,16 @@ The first user to register becomes the **admin**. Register at `/register`.
|
|||||||
Run these from the repo root:
|
Run these from the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # backend + frontend + photo-api
|
npm run dev
|
||||||
npm run build # build all workspaces
|
npm run build
|
||||||
npm run start
|
npm run start
|
||||||
npm run db:generate
|
npm run db:generate
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run db:studio
|
npm run db:studio
|
||||||
npm run db:export # Backup database
|
npm run db:export # Backup database
|
||||||
npm run db:import # Restore from backup
|
npm run db:import # Restore from backup
|
||||||
|
|
||||||
# Photo service (Go)
|
|
||||||
npm run dev:photos # go run ./cmd/photo-api
|
|
||||||
npm run build:photos # go build -o bin/photo-api
|
|
||||||
npm run migrate:photos # apply photos_* migrations
|
|
||||||
npm run test:photos # go test ./...
|
|
||||||
|
|
||||||
# Move the photo library between storage backends (both must be configured
|
|
||||||
# in photo-api/.env; the source is left untouched, reruns skip what is there)
|
|
||||||
npm run sync:photos:to-s3 # local disk -> S3
|
|
||||||
npm run sync:photos:to-local # S3 -> local disk
|
|
||||||
npm run sync:photos -- to-s3 --dry-run # flags: --dry-run --overwrite
|
|
||||||
# --concurrency=N --gallery=<id>
|
|
||||||
|
|
||||||
# Uploads are deduplicated per gallery by content hash. Photos uploaded before
|
|
||||||
# that existed need hashing once (idempotent, deletes nothing):
|
|
||||||
npm run backfill:photos:checksums
|
|
||||||
```
|
```
|
||||||
|
|
||||||
After a sync, set `STORAGE_BACKEND=s3` (or `local`) in `photo-api/.env` and
|
|
||||||
restart the service to serve from the new backend. See
|
|
||||||
[`photo-api/README.md`](photo-api/README.md#move-the-library-between-backends).
|
|
||||||
|
|
||||||
You can also run per workspace:
|
You can also run per workspace:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -117,23 +82,10 @@ npm run dev --workspace=frontend
|
|||||||
Key settings (see `backend/.env.example` for the full list):
|
Key settings (see `backend/.env.example` for the full list):
|
||||||
|
|
||||||
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
|
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
|
||||||
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
|
- **Auth**: `JWT_SECRET` (change in production)
|
||||||
- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development.
|
|
||||||
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
||||||
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
||||||
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
|
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
|
||||||
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
|
|
||||||
|
|
||||||
### Photo service (`photo-api/.env`)
|
|
||||||
|
|
||||||
Key settings (see `photo-api/.env.example`):
|
|
||||||
|
|
||||||
- **Port**: `PORT=3003` (dev)
|
|
||||||
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
|
|
||||||
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
|
|
||||||
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
|
|
||||||
- **Storage switch**: `STORAGE_BACKEND=auto|local|s3` (`auto` = S3 when it is configured). Keep both sides configured and flip this one line to move between them; `npm run sync:photos:to-s3` / `:to-local` copies the existing library first
|
|
||||||
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
|
|
||||||
|
|
||||||
### Frontend (`frontend/.env`)
|
### Frontend (`frontend/.env`)
|
||||||
|
|
||||||
@@ -141,10 +93,8 @@ Key settings (see `frontend/.env.example`):
|
|||||||
|
|
||||||
- **Server port**: `PORT=3002`
|
- **Server port**: `PORT=3002`
|
||||||
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
|
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
|
||||||
- Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port.
|
- Leave empty to use same-origin `/api` (recommended when running behind nginx)
|
||||||
- Inlined at build time: changing it requires a rebuild, not just a restart.
|
- In local dev, Next.js rewrites `/api/*` and `/uploads/*` to the backend
|
||||||
- Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well.
|
|
||||||
- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`).
|
|
||||||
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
|
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
@@ -192,13 +142,12 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
|
|||||||
|
|
||||||
This repo includes example configs in `deploy/`:
|
This repo includes example configs in `deploy/`:
|
||||||
|
|
||||||
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
|
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
|
||||||
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
|
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
|
||||||
- Backend needs write access to `backend/data` and `backend/uploads`
|
- Backend needs write access to `backend/data` and `backend/uploads`
|
||||||
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
|
|
||||||
- **nginx**:
|
- **nginx**:
|
||||||
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
|
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
|
||||||
- `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
|
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
|
||||||
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
|
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
|
||||||
|
|
||||||
Typical production flow:
|
Typical production flow:
|
||||||
@@ -206,93 +155,11 @@ Typical production flow:
|
|||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
npm run build:photos
|
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
npm run migrate:photos
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then install/enable the systemd services and nginx configs for your server.
|
Then install/enable the systemd services and nginx configs for your server.
|
||||||
|
|
||||||
## 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
|
## Documentation
|
||||||
|
|
||||||
- **Specs / notes**: `about/`
|
- **Specs / notes**: `about/`
|
||||||
|
|||||||
+1
-91
@@ -8,83 +8,13 @@ DATABASE_URL=./data/spanglish.db
|
|||||||
# For PostgreSQL
|
# For PostgreSQL
|
||||||
# DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
|
# DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
|
||||||
|
|
||||||
# Max PostgreSQL connections per instance (default 10). When running multiple
|
# JWT Secret (change in production!)
|
||||||
# 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
|
JWT_SECRET=your-super-secret-key-change-in-production
|
||||||
|
|
||||||
# Google OAuth (optional - for Google Sign-In)
|
# Google OAuth (optional - for Google Sign-In)
|
||||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||||
# Note: The same Client ID should be used in frontend/.env
|
# Note: The same Client ID should be used in frontend/.env
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
# Only needed for the redirect OAuth flow; the Google Identity Services
|
|
||||||
# button (ID token sign-in) works with the Client ID alone.
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=3001
|
PORT=3001
|
||||||
@@ -143,23 +73,3 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
|
|||||||
# If the limit is reached, queued emails will pause and resume automatically
|
# If the limit is reached, queued emails will pause and resume automatically
|
||||||
MAX_EMAILS_PER_HOUR=30
|
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
|
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -5,7 +5,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest run",
|
|
||||||
"start": "NODE_ENV=production node dist/index.js",
|
"start": "NODE_ENV=production node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx src/db/migrate.ts",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
@@ -14,20 +13,16 @@
|
|||||||
"db:import": "tsx src/db/import.ts"
|
"db:import": "tsx src/db/import.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1075.0",
|
|
||||||
"@hono/node-server": "^1.11.4",
|
"@hono/node-server": "^1.11.4",
|
||||||
"@hono/swagger-ui": "^0.4.0",
|
"@hono/swagger-ui": "^0.4.0",
|
||||||
"@hono/zod-openapi": "^0.14.4",
|
"@hono/zod-openapi": "^0.14.4",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-auth": "1.6.25",
|
"better-sqlite3": "^11.0.0",
|
||||||
"better-sqlite3": "^12.11.1",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.31.2",
|
||||||
"hono": "^4.4.7",
|
"hono": "^4.4.7",
|
||||||
"ioredis": "^5.11.1",
|
|
||||||
"jose": "^5.4.0",
|
"jose": "^5.4.0",
|
||||||
"moment-timezone": "^0.6.2",
|
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
@@ -43,10 +38,8 @@
|
|||||||
"@types/pdfkit": "^0.17.4",
|
"@types/pdfkit": "^0.17.4",
|
||||||
"@types/pg": "^8.11.6",
|
"@types/pg": "^8.11.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.22.8",
|
||||||
"ioredis-mock": "^8.13.1",
|
|
||||||
"tsx": "^4.15.7",
|
"tsx": "^4.15.7",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.2"
|
||||||
"vitest": "^4.1.10"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
|
|
||||||
import {
|
|
||||||
pgTable,
|
|
||||||
uuid,
|
|
||||||
varchar,
|
|
||||||
text as pgText,
|
|
||||||
timestamp,
|
|
||||||
boolean as pgBoolean,
|
|
||||||
bigint,
|
|
||||||
customType as pgCustomType,
|
|
||||||
} from 'drizzle-orm/pg-core';
|
|
||||||
|
|
||||||
// Better Auth table definitions for both dialects.
|
|
||||||
//
|
|
||||||
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
|
|
||||||
// foreign key that references them) survive the auth migration untouched. Only
|
|
||||||
// the columns Better Auth reads/writes are declared here; legacy columns
|
|
||||||
// (password, google_id, token_version) stay physically present but invisible
|
|
||||||
// to Better Auth. The full application-facing definition lives in schema.ts —
|
|
||||||
// two Drizzle table objects can safely describe the same SQL table.
|
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
|
||||||
|
|
||||||
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
|
|
||||||
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
|
|
||||||
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
|
|
||||||
dataType() {
|
|
||||||
return 'text';
|
|
||||||
},
|
|
||||||
toDriver(value: Date): string {
|
|
||||||
return (value instanceof Date ? value : new Date(value)).toISOString();
|
|
||||||
},
|
|
||||||
fromDriver(value: string): Date {
|
|
||||||
return new Date(value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
|
|
||||||
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
|
|
||||||
dataType() {
|
|
||||||
return 'integer';
|
|
||||||
},
|
|
||||||
toDriver(value: boolean): number {
|
|
||||||
return value ? 1 : 0;
|
|
||||||
},
|
|
||||||
fromDriver(value: number | boolean): boolean {
|
|
||||||
return Boolean(value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==================== SQLite ====================
|
|
||||||
|
|
||||||
export const sqliteAuthUsers = sqliteTable('users', {
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
name: text('name').notNull(),
|
|
||||||
email: text('email').notNull().unique(),
|
|
||||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
|
||||||
image: text('image'),
|
|
||||||
createdAt: isoText('created_at').notNull(),
|
|
||||||
updatedAt: isoText('updated_at').notNull(),
|
|
||||||
// admin plugin fields
|
|
||||||
role: text('role').notNull().default('user'),
|
|
||||||
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
|
||||||
banReason: text('ban_reason'),
|
|
||||||
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
|
||||||
// application additionalFields
|
|
||||||
phone: text('phone'),
|
|
||||||
languagePreference: text('language_preference'),
|
|
||||||
rucNumber: text('ruc_number'),
|
|
||||||
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
|
|
||||||
accountStatus: text('account_status').notNull().default('active'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
userId: text('user_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
|
|
||||||
token: text('token').notNull().unique(),
|
|
||||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
||||||
ipAddress: text('ip_address'),
|
|
||||||
userAgent: text('user_agent'),
|
|
||||||
// admin plugin (impersonation)
|
|
||||||
impersonatedBy: text('impersonated_by'),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
userId: text('user_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
|
|
||||||
accountId: text('account_id').notNull(),
|
|
||||||
providerId: text('provider_id').notNull(),
|
|
||||||
accessToken: text('access_token'),
|
|
||||||
refreshToken: text('refresh_token'),
|
|
||||||
idToken: text('id_token'),
|
|
||||||
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
|
|
||||||
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
|
|
||||||
scope: text('scope'),
|
|
||||||
password: text('password'),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
identifier: text('identifier').notNull(),
|
|
||||||
value: text('value').notNull(),
|
|
||||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
key: text('key'),
|
|
||||||
count: integer('count'),
|
|
||||||
lastRequest: integer('last_request'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==================== PostgreSQL ====================
|
|
||||||
|
|
||||||
export const pgAuthUsers = pgTable('users', {
|
|
||||||
id: uuid('id').primaryKey(),
|
|
||||||
name: varchar('name', { length: 255 }).notNull(),
|
|
||||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
|
||||||
emailVerified: pgBoolean('email_verified').notNull().default(false),
|
|
||||||
image: pgText('image'),
|
|
||||||
createdAt: timestamp('created_at').notNull(),
|
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
|
||||||
// admin plugin fields
|
|
||||||
role: varchar('role', { length: 20 }).notNull().default('user'),
|
|
||||||
banned: pgBoolean('banned').notNull().default(false),
|
|
||||||
banReason: pgText('ban_reason'),
|
|
||||||
banExpires: timestamp('ban_expires'),
|
|
||||||
// application additionalFields
|
|
||||||
phone: varchar('phone', { length: 50 }),
|
|
||||||
languagePreference: varchar('language_preference', { length: 10 }),
|
|
||||||
rucNumber: varchar('ruc_number', { length: 15 }),
|
|
||||||
isClaimed: pgIntBool('is_claimed').notNull(),
|
|
||||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pgAuthSessions = pgTable('auth_sessions', {
|
|
||||||
id: uuid('id').primaryKey(),
|
|
||||||
userId: uuid('user_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
|
|
||||||
token: varchar('token', { length: 255 }).notNull().unique(),
|
|
||||||
expiresAt: timestamp('expires_at').notNull(),
|
|
||||||
ipAddress: varchar('ip_address', { length: 45 }),
|
|
||||||
userAgent: pgText('user_agent'),
|
|
||||||
// admin plugin (impersonation)
|
|
||||||
impersonatedBy: uuid('impersonated_by'),
|
|
||||||
createdAt: timestamp('created_at').notNull(),
|
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pgAuthAccounts = pgTable('auth_accounts', {
|
|
||||||
id: uuid('id').primaryKey(),
|
|
||||||
userId: uuid('user_id')
|
|
||||||
.notNull()
|
|
||||||
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
|
|
||||||
accountId: varchar('account_id', { length: 255 }).notNull(),
|
|
||||||
providerId: varchar('provider_id', { length: 100 }).notNull(),
|
|
||||||
accessToken: pgText('access_token'),
|
|
||||||
refreshToken: pgText('refresh_token'),
|
|
||||||
idToken: pgText('id_token'),
|
|
||||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
|
||||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
|
||||||
scope: pgText('scope'),
|
|
||||||
password: pgText('password'),
|
|
||||||
createdAt: timestamp('created_at').notNull(),
|
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pgAuthVerifications = pgTable('auth_verifications', {
|
|
||||||
id: uuid('id').primaryKey(),
|
|
||||||
identifier: varchar('identifier', { length: 255 }).notNull(),
|
|
||||||
value: pgText('value').notNull(),
|
|
||||||
expiresAt: timestamp('expires_at').notNull(),
|
|
||||||
createdAt: timestamp('created_at').notNull(),
|
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
|
|
||||||
id: varchar('id', { length: 64 }).primaryKey(),
|
|
||||||
key: varchar('key', { length: 255 }),
|
|
||||||
count: bigint('count', { mode: 'number' }),
|
|
||||||
lastRequest: bigint('last_request', { mode: 'number' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==================== Runtime-switched exports ====================
|
|
||||||
|
|
||||||
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
|
|
||||||
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
|
|
||||||
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
|
|
||||||
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
|
|
||||||
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
|
|
||||||
@@ -12,11 +12,8 @@ const dbType = process.env.DB_TYPE || 'sqlite';
|
|||||||
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
|
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
|
||||||
|
|
||||||
if (dbType === 'postgres') {
|
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({
|
const pool = new pg.Pool({
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
|
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
|
||||||
max: Number(process.env.DB_POOL_MAX || 10),
|
|
||||||
});
|
});
|
||||||
db = drizzlePg(pool, { schema });
|
db = drizzlePg(pool, { schema });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll } from 'vitest';
|
|
||||||
import { execFileSync } from 'child_process';
|
|
||||||
import { mkdtempSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
|
|
||||||
// Migration idempotency for the Better Auth backfill: seed legacy-shaped user
|
|
||||||
// rows, run migrate repeatedly, and assert the backfill is correct and never
|
|
||||||
// duplicates.
|
|
||||||
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-'));
|
|
||||||
const dbPath = join(dir, 'migrate.db');
|
|
||||||
|
|
||||||
function runMigrate() {
|
|
||||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
DB_TYPE: 'sqlite',
|
|
||||||
DATABASE_URL: dbPath,
|
|
||||||
REDIS_URL: '',
|
|
||||||
},
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let db: Database.Database;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
// First run creates the schema
|
|
||||||
runMigrate();
|
|
||||||
db = new Database(dbPath);
|
|
||||||
|
|
||||||
// Seed legacy-shaped users (pre-Better-Auth): password lives on users,
|
|
||||||
// google_id links Google, '' marks guest accounts, suspended via status.
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const insert = db.prepare(
|
|
||||||
`INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
|
||||||
);
|
|
||||||
insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now);
|
|
||||||
insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now);
|
|
||||||
insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now);
|
|
||||||
insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now);
|
|
||||||
insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now);
|
|
||||||
insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now);
|
|
||||||
|
|
||||||
// Second run performs the backfill against the seeded rows
|
|
||||||
runMigrate();
|
|
||||||
}, 240_000);
|
|
||||||
|
|
||||||
describe('Better Auth migration backfill', () => {
|
|
||||||
it('creates credential accounts for users with real passwords only', () => {
|
|
||||||
const rows = db
|
|
||||||
.prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id")
|
|
||||||
.all() as any[];
|
|
||||||
const byUser = new Map(rows.map((r) => [r.user_id, r.password]));
|
|
||||||
|
|
||||||
expect(byUser.get('legacy-argon')).toContain('$argon2id$');
|
|
||||||
expect(byUser.get('legacy-bcrypt')).toContain('$2a$');
|
|
||||||
expect(byUser.get('legacy-both')).toContain('$argon2id$');
|
|
||||||
expect(byUser.get('legacy-suspended')).toBeTruthy();
|
|
||||||
// Guests ('' password) and Google-only users get no credential account
|
|
||||||
expect(byUser.has('legacy-guest')).toBe(false);
|
|
||||||
expect(byUser.has('legacy-google')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates google accounts from google_id', () => {
|
|
||||||
const rows = db
|
|
||||||
.prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id")
|
|
||||||
.all() as any[];
|
|
||||||
expect(rows).toEqual([
|
|
||||||
{ user_id: 'legacy-both', account_id: 'google-sub-456' },
|
|
||||||
{ user_id: 'legacy-google', account_id: 'google-sub-123' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks claimed legacy users email-verified, guests not', () => {
|
|
||||||
const verified = (email: string) =>
|
|
||||||
(db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified;
|
|
||||||
expect(verified('argon@old.py')).toBe(1);
|
|
||||||
expect(verified('google@old.py')).toBe(1);
|
|
||||||
expect(verified('guest@old.py')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mirrors suspended accounts to banned', () => {
|
|
||||||
const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any;
|
|
||||||
expect(row.banned).toBe(1);
|
|
||||||
expect(row.ban_reason).toContain('suspended');
|
|
||||||
const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any;
|
|
||||||
expect(active.banned).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is idempotent: a third run adds nothing', () => {
|
|
||||||
const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n;
|
|
||||||
const before = count();
|
|
||||||
runMigrate();
|
|
||||||
expect(count()).toBe(before);
|
|
||||||
}, 60_000);
|
|
||||||
});
|
|
||||||
+2
-324
@@ -5,7 +5,7 @@ import { uniqueSlug } from '../lib/slugify.js';
|
|||||||
|
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
console.log(`Database type: ${dbType}`);
|
console.log(`Database type: ${dbType}`);
|
||||||
// Do not log DATABASE_URL: it may contain credentials.
|
console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`);
|
||||||
|
|
||||||
async function migrate() {
|
async function migrate() {
|
||||||
console.log('Running migrations...');
|
console.log('Running migrations...');
|
||||||
@@ -43,9 +43,6 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
|
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} 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
|
// Magic link tokens table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
@@ -199,19 +196,7 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
|
|
||||||
// backfilled from is_guest and the payments table on first run
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`);
|
|
||||||
await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
|
|
||||||
await (db as any).run(sql`
|
|
||||||
UPDATE tickets SET payment_status = 'paid'
|
|
||||||
WHERE is_guest = 0
|
|
||||||
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
|
|
||||||
`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||||
|
|
||||||
@@ -250,15 +235,6 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
// Invoices table
|
// Invoices table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
@@ -453,18 +429,6 @@ 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
|
// Site settings table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS site_settings (
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
@@ -544,81 +508,6 @@ async function migrate() {
|
|||||||
updated_by TEXT REFERENCES users(id)
|
updated_by TEXT REFERENCES users(id)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// ==================== Better Auth ====================
|
|
||||||
// Better Auth core + admin plugin columns on the existing users table
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
// Better Auth sessions (replaces the legacy user_sessions table).
|
|
||||||
// Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode).
|
|
||||||
await (db as any).run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
expires_at INTEGER NOT NULL,
|
|
||||||
ip_address TEXT,
|
|
||||||
user_agent TEXT,
|
|
||||||
impersonated_by TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Better Auth accounts: credential (password hash) and OAuth provider links
|
|
||||||
await (db as any).run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_accounts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
account_id TEXT NOT NULL,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
access_token TEXT,
|
|
||||||
refresh_token TEXT,
|
|
||||||
id_token TEXT,
|
|
||||||
access_token_expires_at INTEGER,
|
|
||||||
refresh_token_expires_at INTEGER,
|
|
||||||
scope TEXT,
|
|
||||||
password TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Better Auth verification values (magic links, password reset tokens)
|
|
||||||
await (db as any).run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_verifications (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
identifier TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
expires_at INTEGER NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Better Auth rate limiting (used when Redis is not configured)
|
|
||||||
await (db as any).run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
key TEXT,
|
|
||||||
count INTEGER,
|
|
||||||
last_request INTEGER
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
} else {
|
} else {
|
||||||
// PostgreSQL migrations
|
// PostgreSQL migrations
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -652,9 +541,6 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`);
|
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 */ }
|
} 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
|
// Magic link tokens table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -789,18 +675,6 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
|
|
||||||
// backfilled from is_guest and the payments table on first run
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`);
|
|
||||||
await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
|
|
||||||
await (db as any).execute(sql`
|
|
||||||
UPDATE tickets SET payment_status = 'paid'
|
|
||||||
WHERE is_guest = 0
|
|
||||||
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
|
|
||||||
`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -827,15 +701,6 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
|
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
// Invoices table
|
// Invoices table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -1028,18 +893,6 @@ 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
|
// Site settings table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS site_settings (
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
@@ -1119,181 +972,6 @@ async function migrate() {
|
|||||||
updated_by UUID REFERENCES users(id)
|
updated_by UUID REFERENCES users(id)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// ==================== Better Auth ====================
|
|
||||||
// Better Auth core + admin plugin columns on the existing users table
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
// Better Auth sessions (replaces the legacy user_sessions table)
|
|
||||||
await (db as any).execute(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
user_agent TEXT,
|
|
||||||
impersonated_by UUID,
|
|
||||||
created_at TIMESTAMP NOT NULL,
|
|
||||||
updated_at TIMESTAMP NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Better Auth accounts: credential (password hash) and OAuth provider links
|
|
||||||
await (db as any).execute(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_accounts (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
account_id VARCHAR(255) NOT NULL,
|
|
||||||
provider_id VARCHAR(100) NOT NULL,
|
|
||||||
access_token TEXT,
|
|
||||||
refresh_token TEXT,
|
|
||||||
id_token TEXT,
|
|
||||||
access_token_expires_at TIMESTAMP,
|
|
||||||
refresh_token_expires_at TIMESTAMP,
|
|
||||||
scope TEXT,
|
|
||||||
password TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL,
|
|
||||||
updated_at TIMESTAMP NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Better Auth verification values (magic links, password reset tokens)
|
|
||||||
await (db as any).execute(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_verifications (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
identifier VARCHAR(255) NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL,
|
|
||||||
updated_at TIMESTAMP NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Better Auth rate limiting (used when Redis is not configured)
|
|
||||||
await (db as any).execute(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
|
||||||
key VARCHAR(255),
|
|
||||||
count BIGINT,
|
|
||||||
last_request BIGINT
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
|
||||||
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).
|
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||||
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
|
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
// Type to determine which schema to use
|
// Type to determine which schema to use
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
@@ -18,14 +18,6 @@ export const sqliteUsers = sqliteTable('users', {
|
|||||||
googleId: text('google_id'),
|
googleId: text('google_id'),
|
||||||
rucNumber: text('ruc_number'),
|
rucNumber: text('ruc_number'),
|
||||||
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
||||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
|
||||||
tokenVersion: integer('token_version').notNull().default(0),
|
|
||||||
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
|
|
||||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
|
||||||
image: text('image'),
|
|
||||||
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
|
||||||
banReason: text('ban_reason'),
|
|
||||||
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -110,14 +102,12 @@ export const sqliteTickets = sqliteTable('tickets', {
|
|||||||
attendeePhone: text('attendee_phone'),
|
attendeePhone: text('attendee_phone'),
|
||||||
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
|
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
|
||||||
preferredLanguage: text('preferred_language'),
|
preferredLanguage: text('preferred_language'),
|
||||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'),
|
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||||
checkinAt: text('checkin_at'),
|
checkinAt: text('checkin_at'),
|
||||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||||
qrCode: text('qr_code'),
|
qrCode: text('qr_code'),
|
||||||
adminNote: text('admin_note'),
|
adminNote: text('admin_note'),
|
||||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
|
||||||
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
|
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,11 +117,8 @@ export const sqlitePayments = sqliteTable('payments', {
|
|||||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
||||||
amount: real('amount').notNull(),
|
amount: real('amount').notNull(),
|
||||||
currency: text('currency').notNull().default('PYG'),
|
currency: text('currency').notNull().default('PYG'),
|
||||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
|
||||||
reference: text('reference'),
|
reference: text('reference'),
|
||||||
lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
|
|
||||||
lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires
|
|
||||||
lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
|
|
||||||
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
||||||
payerName: text('payer_name'), // Name of payer if different from attendee
|
payerName: text('payer_name'), // Name of payer if different from attendee
|
||||||
paidAt: text('paid_at'),
|
paidAt: text('paid_at'),
|
||||||
@@ -284,18 +271,6 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
|
|||||||
updatedAt: text('updated_at').notNull(),
|
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
|
// Legal Pages table for admin-editable legal content
|
||||||
export const sqliteLegalPages = sqliteTable('legal_pages', {
|
export const sqliteLegalPages = sqliteTable('legal_pages', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -384,14 +359,6 @@ export const pgUsers = pgTable('users', {
|
|||||||
googleId: varchar('google_id', { length: 255 }),
|
googleId: varchar('google_id', { length: 255 }),
|
||||||
rucNumber: varchar('ruc_number', { length: 15 }),
|
rucNumber: varchar('ruc_number', { length: 15 }),
|
||||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
|
||||||
tokenVersion: pgInteger('token_version').notNull().default(0),
|
|
||||||
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
|
|
||||||
emailVerified: pgBoolean('email_verified').notNull().default(false),
|
|
||||||
image: pgText('image'),
|
|
||||||
banned: pgBoolean('banned').notNull().default(false),
|
|
||||||
banReason: pgText('ban_reason'),
|
|
||||||
banExpires: timestamp('ban_expires'),
|
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -482,8 +449,6 @@ export const pgTickets = pgTable('tickets', {
|
|||||||
qrCode: varchar('qr_code', { length: 255 }),
|
qrCode: varchar('qr_code', { length: 255 }),
|
||||||
adminNote: pgText('admin_note'),
|
adminNote: pgText('admin_note'),
|
||||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
|
||||||
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
|
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -495,9 +460,6 @@ export const pgPayments = pgTable('payments', {
|
|||||||
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||||
reference: varchar('reference', { length: 255 }),
|
reference: varchar('reference', { length: 255 }),
|
||||||
lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
|
|
||||||
lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires
|
|
||||||
lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
|
|
||||||
userMarkedPaidAt: timestamp('user_marked_paid_at'),
|
userMarkedPaidAt: timestamp('user_marked_paid_at'),
|
||||||
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
|
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
|
||||||
paidAt: timestamp('paid_at'),
|
paidAt: timestamp('paid_at'),
|
||||||
@@ -642,18 +604,6 @@ export const pgEmailSettings = pgTable('email_settings', {
|
|||||||
updatedAt: timestamp('updated_at').notNull(),
|
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
|
// Legal Pages table for admin-editable legal content
|
||||||
export const pgLegalPages = pgTable('legal_pages', {
|
export const pgLegalPages = pgTable('legal_pages', {
|
||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
@@ -741,7 +691,6 @@ export const auditLogs = dbType === 'postgres' ? pgAuditLogs : sqliteAuditLogs;
|
|||||||
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
|
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
|
||||||
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
|
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
|
||||||
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
|
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
|
||||||
export const emailQueue = dbType === 'postgres' ? pgEmailQueue : sqliteEmailQueue;
|
|
||||||
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
|
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
|
||||||
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
|
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
|
||||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||||
|
|||||||
+140
-263
@@ -7,9 +7,7 @@ import { logger } from 'hono/logger';
|
|||||||
import { swaggerUI } from '@hono/swagger-ui';
|
import { swaggerUI } from '@hono/swagger-ui';
|
||||||
|
|
||||||
import { serveStatic } from '@hono/node-server/serve-static';
|
import { serveStatic } from '@hono/node-server/serve-static';
|
||||||
import { auth } from './lib/betterAuth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import authExtRoutes from './routes/authExt.js';
|
|
||||||
import { getClientIp } from './lib/rateLimit.js';
|
|
||||||
import eventsRoutes from './routes/events.js';
|
import eventsRoutes from './routes/events.js';
|
||||||
import ticketsRoutes from './routes/tickets.js';
|
import ticketsRoutes from './routes/tickets.js';
|
||||||
import usersRoutes from './routes/users.js';
|
import usersRoutes from './routes/users.js';
|
||||||
@@ -26,13 +24,7 @@ import legalPagesRoutes from './routes/legal-pages.js';
|
|||||||
import legalSettingsRoutes from './routes/legal-settings.js';
|
import legalSettingsRoutes from './routes/legal-settings.js';
|
||||||
import faqRoutes from './routes/faq.js';
|
import faqRoutes from './routes/faq.js';
|
||||||
import emailService from './lib/email.js';
|
import emailService from './lib/email.js';
|
||||||
import { initEmailQueue, stopQueue } from './lib/emailQueue.js';
|
import { initEmailQueue } from './lib/emailQueue.js';
|
||||||
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
|
|
||||||
import { 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();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -59,24 +51,11 @@ app.use(
|
|||||||
if (!origin) return frontendUrl;
|
if (!origin) return frontendUrl;
|
||||||
return allowedOrigins.has(origin) ? origin : null;
|
return allowedOrigins.has(origin) ? origin : null;
|
||||||
},
|
},
|
||||||
// Session cookies must be allowed on cross-origin API calls (api.* vhost).
|
// We use bearer tokens, but keeping credentials=true matches nginx config.
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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
|
// OpenAPI specification
|
||||||
const openApiSpec = {
|
const openApiSpec = {
|
||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
@@ -112,15 +91,11 @@ const openApiSpec = {
|
|||||||
],
|
],
|
||||||
paths: {
|
paths: {
|
||||||
// ==================== Auth Endpoints ====================
|
// ==================== Auth Endpoints ====================
|
||||||
// Authentication is handled by Better Auth, mounted at /api/auth/*.
|
'/api/auth/register': {
|
||||||
// Sessions are httpOnly cookies (spanglish.session_token); the endpoints
|
|
||||||
// below are the subset the frontend uses. See https://better-auth.com/docs
|
|
||||||
// for the full endpoint reference.
|
|
||||||
'/api/auth/sign-up/email': {
|
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Register a new user (Better Auth)',
|
summary: 'Register a new user',
|
||||||
description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.',
|
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -130,8 +105,8 @@ const openApiSpec = {
|
|||||||
required: ['email', 'password', 'name'],
|
required: ['email', 'password', 'name'],
|
||||||
properties: {
|
properties: {
|
||||||
email: { type: 'string', format: 'email' },
|
email: { type: 'string', format: 'email' },
|
||||||
password: { type: 'string', minLength: 10 },
|
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
|
||||||
name: { type: 'string' },
|
name: { type: 'string', minLength: 2 },
|
||||||
phone: { type: 'string' },
|
phone: { type: 'string' },
|
||||||
languagePreference: { type: 'string', enum: ['en', 'es'] },
|
languagePreference: { type: 'string', enum: ['en', 'es'] },
|
||||||
},
|
},
|
||||||
@@ -140,17 +115,16 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'User created; session cookie set' },
|
201: { description: 'User created successfully' },
|
||||||
422: { description: 'Email already registered' },
|
400: { description: 'Email already registered or validation error' },
|
||||||
400: { description: 'Validation error' },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/sign-in/email': {
|
'/api/auth/login': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Login with email and password (Better Auth)',
|
summary: 'Login with email and password',
|
||||||
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
|
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -167,46 +141,42 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Login successful; session cookie set' },
|
200: { description: 'Login successful, returns JWT token' },
|
||||||
401: { description: 'Invalid credentials' },
|
401: { description: 'Invalid credentials' },
|
||||||
429: { description: 'Too many attempts' },
|
429: { description: 'Too many login attempts' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/sign-in/social': {
|
'/api/auth/google': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Login or register with Google (Better Auth)',
|
summary: 'Login or register with Google',
|
||||||
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
|
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['provider'],
|
required: ['credential'],
|
||||||
properties: {
|
properties: {
|
||||||
provider: { type: 'string', enum: ['google'] },
|
credential: { type: 'string', description: 'Google ID token' },
|
||||||
idToken: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { token: { type: 'string', description: 'Google ID token' } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Login successful; session cookie set' },
|
200: { description: 'Login successful' },
|
||||||
401: { description: 'Invalid Google token' },
|
400: { description: 'Invalid Google token' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/sign-in/magic-link': {
|
'/api/auth/magic-link/request': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Request magic link login (Better Auth)',
|
summary: 'Request magic link login',
|
||||||
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
|
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -216,33 +186,46 @@ const openApiSpec = {
|
|||||||
required: ['email'],
|
required: ['email'],
|
||||||
properties: {
|
properties: {
|
||||||
email: { type: 'string', format: 'email' },
|
email: { type: 'string', format: 'email' },
|
||||||
callbackURL: { type: 'string' },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: { 200: { description: 'Magic link sent (if account exists)' } },
|
responses: {
|
||||||
|
200: { description: 'Magic link sent (if account exists)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/magic-link/verify': {
|
'/api/auth/magic-link/verify': {
|
||||||
get: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Verify magic link token (Better Auth)',
|
summary: 'Verify magic link token',
|
||||||
parameters: [
|
description: 'Verify the magic link token and login user.',
|
||||||
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } },
|
requestBody: {
|
||||||
],
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['token'],
|
||||||
|
properties: {
|
||||||
|
token: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Login successful; session cookie set' },
|
200: { description: 'Login successful' },
|
||||||
400: { description: 'Invalid or expired token' },
|
400: { description: 'Invalid or expired token' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/request-password-reset': {
|
'/api/auth/password-reset/request': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Request password reset (Better Auth)',
|
summary: 'Request password reset',
|
||||||
description: 'Emails a reset link. Token expires in 30 minutes.',
|
description: 'Send a password reset link to email. Link expires in 30 minutes.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -252,30 +235,31 @@ const openApiSpec = {
|
|||||||
required: ['email'],
|
required: ['email'],
|
||||||
properties: {
|
properties: {
|
||||||
email: { type: 'string', format: 'email' },
|
email: { type: 'string', format: 'email' },
|
||||||
redirectTo: { type: 'string' },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: { 200: { description: 'Reset link sent (if account exists)' } },
|
responses: {
|
||||||
|
200: { description: 'Reset link sent (if account exists)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/reset-password': {
|
'/api/auth/password-reset/confirm': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Reset password with token (Better Auth)',
|
summary: 'Confirm password reset',
|
||||||
description: 'Sets a new password and revokes all existing sessions.',
|
description: 'Reset password using the token from email.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['newPassword', 'token'],
|
required: ['token', 'password'],
|
||||||
properties: {
|
properties: {
|
||||||
newPassword: { type: 'string', minLength: 10 },
|
|
||||||
token: { type: 'string' },
|
token: { type: 'string' },
|
||||||
|
password: { type: 'string', minLength: 10 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -287,10 +271,62 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/api/auth/claim-account/request': {
|
||||||
|
post: {
|
||||||
|
tags: ['Auth'],
|
||||||
|
summary: 'Request account claim link',
|
||||||
|
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['email'],
|
||||||
|
properties: {
|
||||||
|
email: { type: 'string', format: 'email' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Claim link sent (if unclaimed account exists)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/auth/claim-account/confirm': {
|
||||||
|
post: {
|
||||||
|
tags: ['Auth'],
|
||||||
|
summary: 'Confirm account claim',
|
||||||
|
description: 'Claim an unclaimed account by setting password or linking Google.',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['token'],
|
||||||
|
properties: {
|
||||||
|
token: { type: 'string' },
|
||||||
|
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
|
||||||
|
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Account claimed successfully' },
|
||||||
|
400: { description: 'Invalid token or missing credentials' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'/api/auth/change-password': {
|
'/api/auth/change-password': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Change password (Better Auth)',
|
summary: 'Change password',
|
||||||
|
description: 'Change password for authenticated user.',
|
||||||
security: [{ bearerAuth: [] }],
|
security: [{ bearerAuth: [] }],
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -302,7 +338,6 @@ const openApiSpec = {
|
|||||||
properties: {
|
properties: {
|
||||||
currentPassword: { type: 'string' },
|
currentPassword: { type: 'string' },
|
||||||
newPassword: { type: 'string', minLength: 10 },
|
newPassword: { type: 'string', minLength: 10 },
|
||||||
revokeOtherSessions: { type: 'boolean' },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -315,68 +350,28 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/get-session': {
|
'/api/auth/me': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Get current session and user (Better Auth)',
|
summary: 'Get current user',
|
||||||
|
description: 'Get the currently authenticated user profile.',
|
||||||
security: [{ bearerAuth: [] }],
|
security: [{ bearerAuth: [] }],
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: '{ session, user } or null when not authenticated' },
|
200: { description: 'Current user data' },
|
||||||
|
401: { description: 'Unauthorized' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth/sign-out': {
|
'/api/auth/logout': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Auth'],
|
tags: ['Auth'],
|
||||||
summary: 'Logout (Better Auth)',
|
summary: 'Logout',
|
||||||
description: 'Revokes the current session and clears the session cookie.',
|
description: 'Logout current user (client-side token removal).',
|
||||||
security: [{ bearerAuth: [] }],
|
|
||||||
responses: { 200: { description: 'Logged out' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/api/auth/list-sessions': {
|
|
||||||
get: {
|
|
||||||
tags: ['Auth'],
|
|
||||||
summary: 'List active sessions (Better Auth)',
|
|
||||||
security: [{ bearerAuth: [] }],
|
|
||||||
responses: { 200: { description: 'Active sessions for the current user' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/api/auth-ext/claim-account': {
|
|
||||||
post: {
|
|
||||||
tags: ['Auth'],
|
|
||||||
summary: 'Claim a guest-created account',
|
|
||||||
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
|
|
||||||
security: [{ bearerAuth: [] }],
|
|
||||||
requestBody: {
|
|
||||||
required: true,
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['password'],
|
|
||||||
properties: { password: { type: 'string', minLength: 10 } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Account claimed successfully' },
|
200: { description: 'Logged out' },
|
||||||
400: { description: 'Already claimed or validation error' },
|
|
||||||
401: { description: 'No session (claim link required)' },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/auth-ext/claim-eligibility': {
|
|
||||||
get: {
|
|
||||||
tags: ['Auth'],
|
|
||||||
summary: 'Check whether an email has an unclaimed account',
|
|
||||||
parameters: [
|
|
||||||
{ name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } },
|
|
||||||
],
|
|
||||||
responses: { 200: { description: '{ canClaim: boolean }' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==================== User Dashboard Endpoints ====================
|
// ==================== User Dashboard Endpoints ====================
|
||||||
'/api/dashboard/summary': {
|
'/api/dashboard/summary': {
|
||||||
@@ -662,21 +657,11 @@ 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': {
|
'/api/events/next/upcoming': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Events'],
|
tags: ['Events'],
|
||||||
summary: 'Get next upcoming event',
|
summary: 'Get next upcoming event',
|
||||||
description: 'Get the featured event if valid, otherwise the single next upcoming published event.',
|
description: 'Get the single next upcoming published event.',
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Next event or null' },
|
200: { description: 'Next event or null' },
|
||||||
},
|
},
|
||||||
@@ -1748,10 +1733,10 @@ const openApiSpec = {
|
|||||||
components: {
|
components: {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
bearerAuth: {
|
bearerAuth: {
|
||||||
type: 'apiKey',
|
type: 'http',
|
||||||
in: 'cookie',
|
scheme: 'bearer',
|
||||||
name: 'spanglish.session_token',
|
bearerFormat: 'JWT',
|
||||||
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
|
description: 'JWT token obtained from login endpoint',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -1842,72 +1827,24 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// API documentation is disabled in production to avoid exposing the full API
|
// OpenAPI JSON endpoint
|
||||||
// surface (and schema) to anonymous users. Enable locally / in non-prod only.
|
app.get('/openapi.json', (c) => {
|
||||||
if (!isProduction) {
|
return c.json(openApiSpec);
|
||||||
// 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: './' }));
|
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) => {
|
app.get('/health', (c) => {
|
||||||
return c.json({
|
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
redis: describeRedis(),
|
|
||||||
backends: describeBackends(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link,
|
app.route('/api/auth', authRoutes);
|
||||||
// password reset, Google, session management). CORS above runs first.
|
|
||||||
//
|
|
||||||
// Better Auth only sees the Request (no TCP peer address), so its per-IP rate
|
|
||||||
// limiting is fed the socket-anchored client IP resolved by getClientIp via a
|
|
||||||
// private header. The inbound value is always discarded — a client cannot
|
|
||||||
// choose its own rate-limit bucket.
|
|
||||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
|
|
||||||
const headers = new Headers(c.req.raw.headers);
|
|
||||||
headers.delete('x-client-ip');
|
|
||||||
const clientIp = getClientIp(c);
|
|
||||||
if (clientIp && clientIp !== 'unknown') {
|
|
||||||
headers.set('x-client-ip', clientIp);
|
|
||||||
}
|
|
||||||
return auth.handler(
|
|
||||||
new Request(c.req.raw, {
|
|
||||||
headers,
|
|
||||||
// Node's fetch requires duplex for requests carrying a body stream
|
|
||||||
...(c.req.raw.body ? { duplex: 'half' as const } : {}),
|
|
||||||
} as RequestInit)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
app.route('/api/auth-ext', authExtRoutes);
|
|
||||||
app.route('/api/events', eventsRoutes);
|
app.route('/api/events', eventsRoutes);
|
||||||
app.route('/api/tickets', ticketsRoutes);
|
app.route('/api/tickets', ticketsRoutes);
|
||||||
app.route('/api/users', usersRoutes);
|
app.route('/api/users', usersRoutes);
|
||||||
@@ -1940,76 +1877,16 @@ const port = parseInt(process.env.PORT || '3001');
|
|||||||
// Initialize email queue with the email service reference
|
// Initialize email queue with the email service reference
|
||||||
initEmailQueue(emailService);
|
initEmailQueue(emailService);
|
||||||
|
|
||||||
// Periodically expire abandoned pending bookings so they stop holding seats.
|
// Initialize email templates on startup
|
||||||
startBookingCleanup();
|
emailService.seedDefaultTemplates().catch(err => {
|
||||||
|
console.error('[Email] Failed to seed templates:', err);
|
||||||
// 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(`🚀 Spanglish API server starting on port ${port}`);
|
||||||
console.log(`📚 API docs available at http://localhost:${port}/api-docs`);
|
console.log(`📚 API docs available at http://localhost:${port}/api-docs`);
|
||||||
console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
|
console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
|
||||||
|
|
||||||
// Log which backend (memory/redis, local/s3) each subsystem selected.
|
serve({
|
||||||
logSelectedBackends();
|
|
||||||
|
|
||||||
const server = serve({
|
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port,
|
port,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown: stop the periodic jobs, stop accepting connections, then
|
|
||||||
// close Redis and exit. Open SSE payment streams hold sockets forever, so
|
|
||||||
// server.close() alone never completes — force-close remaining connections
|
|
||||||
// after a short grace period, with a hard exit as the final backstop.
|
|
||||||
let shuttingDown = false;
|
|
||||||
function shutdown(signal: string): void {
|
|
||||||
if (shuttingDown) return;
|
|
||||||
shuttingDown = true;
|
|
||||||
console.log(`[shutdown] ${signal} received, draining...`);
|
|
||||||
|
|
||||||
stopBookingCleanup();
|
|
||||||
stopHoldSweep();
|
|
||||||
stopEventEndSweep();
|
|
||||||
stopQueue();
|
|
||||||
|
|
||||||
server.close(() => {
|
|
||||||
console.log('[shutdown] server closed, closing redis');
|
|
||||||
closeRedis().finally(() => process.exit(0));
|
|
||||||
});
|
|
||||||
|
|
||||||
const forceClose = setTimeout(() => {
|
|
||||||
console.warn('[shutdown] force-closing remaining connections (SSE streams)');
|
|
||||||
(server as any).closeAllConnections?.();
|
|
||||||
}, 5_000);
|
|
||||||
forceClose.unref();
|
|
||||||
|
|
||||||
const forceExit = setTimeout(() => {
|
|
||||||
console.warn('[shutdown] drain timed out, forcing exit');
|
|
||||||
process.exit(1);
|
|
||||||
}, 10_000);
|
|
||||||
forceExit.unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
||||||
|
|||||||
+225
-98
@@ -1,127 +1,254 @@
|
|||||||
|
import * as jose from 'jose';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { Context } from 'hono';
|
import { Context } from 'hono';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||||
import { auth } from './betterAuth.js';
|
import { eq, and, gt } from 'drizzle-orm';
|
||||||
import { db, dbGet } from '../db/index.js';
|
import { generateId, getNow, toDbDate } from './utils.js';
|
||||||
import { authAccounts } from '../db/auth-schema.js';
|
|
||||||
|
|
||||||
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
|
||||||
// sessions validated against the auth_sessions table on every request, so
|
const JWT_ISSUER = 'spanglish';
|
||||||
// revocation (ban/suspend/password reset) applies instantly. This module keeps
|
const JWT_AUDIENCE = 'spanglish-app';
|
||||||
// the request-side helpers that the route files use.
|
|
||||||
|
|
||||||
// Re-exported for routes that hash/validate passwords outside Better Auth
|
export interface JWTPayload {
|
||||||
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
sub: string;
|
||||||
|
|
||||||
export interface AuthUser {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
|
||||||
phone: string | null;
|
|
||||||
role: string;
|
role: string;
|
||||||
languagePreference: string | null;
|
iat: number;
|
||||||
isClaimed: boolean;
|
exp: number;
|
||||||
rucNumber: string | null;
|
|
||||||
accountStatus: string;
|
|
||||||
emailVerified: boolean;
|
|
||||||
image: string | null;
|
|
||||||
createdAt: Date | string;
|
|
||||||
updatedAt: Date | string;
|
|
||||||
/** ID of the Better Auth session backing this request. */
|
|
||||||
sessionId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Password hashing with Argon2 (spec requirement)
|
||||||
* Resolve the authenticated user for a request from its Better Auth session
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
|
return argon2.hash(password, {
|
||||||
* accounts never get API access even with a live session cookie.
|
type: argon2.argon2id,
|
||||||
*/
|
memoryCost: 65536, // 64 MB
|
||||||
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
|
timeCost: 3,
|
||||||
|
parallelism: 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||||
|
if (hash.startsWith('$argon2')) {
|
||||||
|
return argon2.verify(hash, password);
|
||||||
|
}
|
||||||
|
// Legacy bcrypt support
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate secure random token for magic links
|
||||||
|
export function generateSecureToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create magic link token
|
||||||
|
export async function createMagicLinkToken(
|
||||||
|
userId: string,
|
||||||
|
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
|
||||||
|
expiresInMinutes: number = 10
|
||||||
|
): Promise<string> {
|
||||||
|
const token = generateSecureToken();
|
||||||
|
const now = getNow();
|
||||||
|
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
|
||||||
|
|
||||||
|
await (db as any).insert(magicLinkTokens).values({
|
||||||
|
id: generateId(),
|
||||||
|
userId,
|
||||||
|
token,
|
||||||
|
type,
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify and consume magic link token
|
||||||
|
export async function verifyMagicLinkToken(
|
||||||
|
token: string,
|
||||||
|
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
|
||||||
|
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
|
const tokenRecord = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(magicLinkTokens)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((magicLinkTokens as any).token, token),
|
||||||
|
eq((magicLinkTokens as any).type, type)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
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> {
|
||||||
try {
|
try {
|
||||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
||||||
if (!session?.user) {
|
issuer: JWT_ISSUER,
|
||||||
return null;
|
audience: JWT_AUDIENCE,
|
||||||
}
|
});
|
||||||
|
return payload as unknown as JWTPayload;
|
||||||
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAuthUser(c: Context): Promise<any | null> {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const payload = await verifyToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]) {
|
export function requireAuth(roles?: string[]) {
|
||||||
return async (c: Context, next: () => Promise<void>) => {
|
return async (c: Context, next: () => Promise<void>) => {
|
||||||
const user = await getAuthUser(c);
|
const user = await getAuthUser(c);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return c.json({ error: 'Unauthorized' }, 401);
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roles && !roles.includes(user.role)) {
|
if (roles && !roles.includes(user.role)) {
|
||||||
return c.json({ error: 'Forbidden' }, 403);
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
c.set('user', user);
|
c.set('user', user);
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function isFirstUser(): Promise<boolean> {
|
||||||
* Fetch only the credential password hash (never exposed via getAuthUser).
|
const result = await dbAll(
|
||||||
* Returns null when the user has no password set (Google-only or unclaimed).
|
(db as any).select().from(users).limit(1)
|
||||||
*/
|
|
||||||
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')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const hash = row?.password;
|
return !result || result.length === 0;
|
||||||
return hash && String(hash).length > 0 ? String(hash) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the user has a linked Google account. */
|
|
||||||
export async function hasGoogleAccount(userId: string): Promise<boolean> {
|
|
||||||
const row = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ id: (authAccounts as any).id })
|
|
||||||
.from(authAccounts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((authAccounts as any).userId, userId),
|
|
||||||
eq((authAccounts as any).providerId, 'google')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return !!row;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
// 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}`);
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
||||||
import { execFileSync } from 'child_process';
|
|
||||||
import { mkdtempSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
import { betterAuth } from 'better-auth';
|
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
||||||
import { magicLink, admin } from 'better-auth/plugins';
|
|
||||||
import { APIError, createAuthMiddleware } from 'better-auth/api';
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
|
||||||
import { db, dbGet, dbAll, isPostgres } from '../db/index.js';
|
|
||||||
import {
|
|
||||||
authUsers,
|
|
||||||
authSessions,
|
|
||||||
authAccounts,
|
|
||||||
authVerifications,
|
|
||||||
authRateLimits,
|
|
||||||
} from '../db/auth-schema.js';
|
|
||||||
import { generateId } from './utils.js';
|
|
||||||
import { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
|
||||||
import { sendEmail } from './email.js';
|
|
||||||
import { getLoginLockout } from './stores/loginLockout.js';
|
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
|
|
||||||
|
|
||||||
// Cookie domain shared across subdomains (e.g. ".spanglishcommunity.com") so a session
|
|
||||||
// issued by api.* is also sent to the site origin, where the frontend's Next middleware
|
|
||||||
// reads it to gate /admin and /dashboard. Leave unset in dev: localhost is single-host
|
|
||||||
// and needs a host-only cookie.
|
|
||||||
const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim();
|
|
||||||
|
|
||||||
const DEFAULT_DEV_SECRET = 'spanglish-dev-only-better-auth-secret';
|
|
||||||
const rawSecret = process.env.BETTER_AUTH_SECRET;
|
|
||||||
|
|
||||||
// Never allow a weak/default secret in production: the secret signs session
|
|
||||||
// cookies, so a guessable value means forgeable sessions = account takeover.
|
|
||||||
if (isProduction && (!rawSecret || rawSecret.length < 32 || rawSecret === DEFAULT_DEV_SECRET)) {
|
|
||||||
throw new Error(
|
|
||||||
'BETTER_AUTH_SECRET must be set to a strong value (32+ characters) in production. Refusing to start.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!rawSecret) {
|
|
||||||
console.warn('[auth] BETTER_AUTH_SECRET is not set; using an insecure development default.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// The site origin plus its www/non-www alias, mirroring the CORS allowlist in
|
|
||||||
// index.ts. Requests whose Origin is not listed here are rejected by Better
|
|
||||||
// Auth's CSRF origin check.
|
|
||||||
function computeTrustedOrigins(): string[] {
|
|
||||||
const origins = new Set<string>([frontendUrl]);
|
|
||||||
try {
|
|
||||||
const url = new URL(frontendUrl);
|
|
||||||
const alias = url.hostname.startsWith('www.')
|
|
||||||
? url.hostname.slice(4)
|
|
||||||
: `www.${url.hostname}`;
|
|
||||||
origins.add(`${url.protocol}//${alias}${url.port ? `:${url.port}` : ''}`);
|
|
||||||
} catch {
|
|
||||||
/* keep frontendUrl as-is */
|
|
||||||
}
|
|
||||||
if (process.env.API_URL) origins.add(process.env.API_URL);
|
|
||||||
if (!isProduction) {
|
|
||||||
// Dev is frequently reached through a forwarded or proxied port (SSH tunnel, editor
|
|
||||||
// port forwarding), so the browser's Origin is http://localhost:<random> and every
|
|
||||||
// POST would fail the CSRF origin check. Trust any loopback port rather than pinning
|
|
||||||
// FRONTEND_URL to a port that changes between sessions. Wildcard patterns are matched
|
|
||||||
// per better-auth's trusted-origins helper; production stays on the exact allowlist.
|
|
||||||
origins.add('http://localhost:*');
|
|
||||||
origins.add('http://127.0.0.1:*');
|
|
||||||
}
|
|
||||||
return [...origins];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paths whose request body carries a new password that must satisfy the policy
|
|
||||||
// (Better Auth's minPasswordLength alone is weaker than the app's policy).
|
|
||||||
const PASSWORD_SETTING_PATHS = new Set([
|
|
||||||
'/sign-up/email',
|
|
||||||
'/reset-password',
|
|
||||||
'/change-password',
|
|
||||||
'/set-password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function getCredentialAccount(userId: string): Promise<any | null> {
|
|
||||||
return dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(authAccounts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((authAccounts as any).userId, userId),
|
|
||||||
eq((authAccounts as any).providerId, 'credential')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const auth = betterAuth({
|
|
||||||
appName: 'Spanglish',
|
|
||||||
baseURL: process.env.BETTER_AUTH_URL || frontendUrl,
|
|
||||||
basePath: '/api/auth',
|
|
||||||
secret: rawSecret || DEFAULT_DEV_SECRET,
|
|
||||||
trustedOrigins: computeTrustedOrigins(),
|
|
||||||
telemetry: { enabled: false },
|
|
||||||
|
|
||||||
database: drizzleAdapter(db as any, {
|
|
||||||
provider: isPostgres() ? 'pg' : 'sqlite',
|
|
||||||
schema: {
|
|
||||||
user: authUsers,
|
|
||||||
session: authSessions,
|
|
||||||
account: authAccounts,
|
|
||||||
verification: authVerifications,
|
|
||||||
rateLimit: authRateLimits,
|
|
||||||
},
|
|
||||||
// better-sqlite3 cannot run Drizzle's async transactions; operations run
|
|
||||||
// sequentially instead (also the adapter default).
|
|
||||||
transaction: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
advanced: {
|
|
||||||
cookiePrefix: 'spanglish',
|
|
||||||
useSecureCookies: isProduction,
|
|
||||||
// Only adds a `Domain=` attribute — name, sameSite, secure and path are unchanged,
|
|
||||||
// so the cookie names hardcoded in the frontend middleware and the Go photo-api
|
|
||||||
// stay valid.
|
|
||||||
...(cookieDomain
|
|
||||||
? { crossSubDomainCookies: { enabled: true, domain: cookieDomain } }
|
|
||||||
: {}),
|
|
||||||
ipAddress: {
|
|
||||||
// Set by the /api/auth/* mount in index.ts from getClientIp(), which
|
|
||||||
// anchors trust in the TCP peer address (only our own proxies may speak
|
|
||||||
// for the client via X-Real-IP / X-Forwarded-For). Never read the raw
|
|
||||||
// forwarded headers here: without the socket they are spoofable, and
|
|
||||||
// Better Auth would fall back to one shared rate-limit bucket.
|
|
||||||
ipAddressHeaders: ['x-client-ip'],
|
|
||||||
},
|
|
||||||
database: {
|
|
||||||
// Match the app's existing ID convention (uuid on pg, nanoid on sqlite)
|
|
||||||
// so Better Auth rows are indistinguishable from legacy rows.
|
|
||||||
generateId: () => generateId(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
user: {
|
|
||||||
additionalFields: {
|
|
||||||
// `role`, `banned`, `banReason`, `banExpires` come from the admin plugin.
|
|
||||||
phone: { type: 'string', required: false, input: true },
|
|
||||||
languagePreference: { type: 'string', required: false, input: true },
|
|
||||||
rucNumber: { type: 'string', required: false, input: false },
|
|
||||||
isClaimed: { type: 'boolean', required: false, input: false, defaultValue: true },
|
|
||||||
accountStatus: { type: 'string', required: false, input: false, defaultValue: 'active' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
session: {
|
|
||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days, rolling
|
|
||||||
updateAge: 60 * 60 * 24, // refresh expiry at most once a day
|
|
||||||
freshAge: 60 * 60 * 24, // sensitive operations require a session younger than this
|
|
||||||
// Disabled deliberately: every request validates against the session table,
|
|
||||||
// so ban/suspend/password-reset revocations apply instantly — and the Go
|
|
||||||
// photo-api (which reads the same table) can never disagree with us.
|
|
||||||
cookieCache: { enabled: false },
|
|
||||||
storeSessionInDatabase: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
emailAndPassword: {
|
|
||||||
enabled: true,
|
|
||||||
minPasswordLength: 10,
|
|
||||||
maxPasswordLength: 128,
|
|
||||||
autoSignIn: true,
|
|
||||||
requireEmailVerification: false,
|
|
||||||
revokeSessionsOnPasswordReset: true,
|
|
||||||
resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes, matches the legacy flow
|
|
||||||
sendResetPassword: async ({ user, url }) => {
|
|
||||||
try {
|
|
||||||
await sendEmail({
|
|
||||||
to: user.email,
|
|
||||||
subject: 'Reset Your Spanglish Password',
|
|
||||||
html: `
|
|
||||||
<h2>Reset Your Password</h2>
|
|
||||||
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
|
||||||
<p><a href="${url}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
|
||||||
<p>Or copy this link: ${url}</p>
|
|
||||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send password reset email:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
// Keep the existing argon2id parameters; legacy bcrypt hashes (migrated
|
|
||||||
// into auth_accounts) still verify and are upgraded on login below.
|
|
||||||
hash: (password) => hashPassword(password),
|
|
||||||
verify: ({ hash, password }) => verifyPassword(password, hash),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
...(process.env.GOOGLE_CLIENT_ID
|
|
||||||
? {
|
|
||||||
socialProviders: {
|
|
||||||
google: {
|
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
||||||
// Not required for ID-token (Google Identity Services) sign-in,
|
|
||||||
// only for the redirect OAuth flow.
|
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
|
|
||||||
account: {
|
|
||||||
accountLinking: {
|
|
||||||
enabled: true,
|
|
||||||
// Google verifies email ownership, so linking by email is safe — this
|
|
||||||
// matches the legacy /api/auth/google auto-link behavior.
|
|
||||||
trustedProviders: ['google'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
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;
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Single source of truth for event seat accounting.
|
|
||||||
//
|
|
||||||
// A seat is held by a booking the moment the money is real or claimed to be:
|
|
||||||
// - ticket 'confirmed' or 'checked_in' (paid), or
|
|
||||||
// - ticket 'pending' whose payment is 'pending_approval' (customer clicked
|
|
||||||
// "I've paid" and is waiting for admin verification).
|
|
||||||
//
|
|
||||||
// A bare 'pending' payment — an opened checkout that was never paid nor claimed,
|
|
||||||
// on any provider — holds NO seat, so abandoned bookings can never block sales.
|
|
||||||
// The accepted trade-off is a small oversell window when several people book the
|
|
||||||
// last seats and all later pay/claim; admin approval is the backstop and may
|
|
||||||
// knowingly approve over capacity (see routes/payments.ts).
|
|
||||||
//
|
|
||||||
// 'pending_approval' is a payment status (the ticket row stays 'pending'), so
|
|
||||||
// every capacity count joins tickets to payments. There is exactly one payment
|
|
||||||
// row per ticket (created together in routes/tickets.ts).
|
|
||||||
//
|
|
||||||
// Every place that counts seats — booking creation, public availability,
|
|
||||||
// hold recovery, admin dashboards — must go through these builders so the
|
|
||||||
// formula cannot diverge between surfaces.
|
|
||||||
|
|
||||||
import { sql, and, eq, inArray } from 'drizzle-orm';
|
|
||||||
import { tickets, payments } from '../db/index.js';
|
|
||||||
|
|
||||||
// COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no
|
|
||||||
// payment row must count as "not holding" — NULL would poison NOT ...).
|
|
||||||
export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query: number of seats currently held for an event.
|
|
||||||
* `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`;
|
|
||||||
* async pg tx / plain db: await via dbGet).
|
|
||||||
*/
|
|
||||||
export function seatHolderCountQuery(executor: any, eventId: string) {
|
|
||||||
return executor
|
|
||||||
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
|
|
||||||
.from(tickets)
|
|
||||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
|
|
||||||
.where(and(eq((tickets as any).eventId, eventId), seatHoldingSql));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query: how many of the given tickets do NOT currently hold a seat (and so
|
|
||||||
* would need fresh capacity if promoted to a seat-holding state).
|
|
||||||
*/
|
|
||||||
export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) {
|
|
||||||
return executor
|
|
||||||
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
|
|
||||||
.from(tickets)
|
|
||||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
|
|
||||||
.where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
|
|
||||||
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
|
|
||||||
* Pass `eventId` to restrict to one event (still returns a grouped row).
|
|
||||||
*/
|
|
||||||
export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
|
|
||||||
const query = executor
|
|
||||||
.select({
|
|
||||||
eventId: (tickets as any).eventId,
|
|
||||||
paidCount: sql<number>`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`,
|
|
||||||
claimedCount: sql<number>`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`,
|
|
||||||
})
|
|
||||||
.from(tickets)
|
|
||||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
|
|
||||||
return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query)
|
|
||||||
.groupBy((tickets as any).eventId);
|
|
||||||
}
|
|
||||||
+1404
-56
File diff suppressed because it is too large
Load Diff
@@ -1,96 +0,0 @@
|
|||||||
// 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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// 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}`;
|
|
||||||
}
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
// 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
// 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!`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+50
-161
@@ -1,16 +1,17 @@
|
|||||||
// Durable email queue with rate limiting.
|
// In-memory email queue with rate limiting
|
||||||
// Jobs are persisted in the `email_queue` DB table so they survive process
|
// Processes emails asynchronously in the background without blocking the request thread
|
||||||
// restarts. Emails are processed asynchronously in the background without
|
|
||||||
// blocking the request thread.
|
|
||||||
|
|
||||||
import { eq, and, asc, sql } from 'drizzle-orm';
|
import { generateId } from './utils.js';
|
||||||
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 ====================
|
// ==================== Types ====================
|
||||||
|
|
||||||
|
export interface EmailJob {
|
||||||
|
id: string;
|
||||||
|
type: 'template';
|
||||||
|
params: TemplateEmailJobParams;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TemplateEmailJobParams {
|
export interface TemplateEmailJobParams {
|
||||||
templateSlug: string;
|
templateSlug: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -21,11 +22,6 @@ export interface TemplateEmailJobParams {
|
|||||||
sentBy?: string;
|
sentBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimedJob {
|
|
||||||
id: string;
|
|
||||||
params: TemplateEmailJobParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueueStatus {
|
export interface QueueStatus {
|
||||||
queued: number;
|
queued: number;
|
||||||
processing: boolean;
|
processing: boolean;
|
||||||
@@ -35,8 +31,7 @@ export interface QueueStatus {
|
|||||||
|
|
||||||
// ==================== Queue State ====================
|
// ==================== Queue State ====================
|
||||||
|
|
||||||
// Tracks send timestamps for the per-process (non-Redis) sliding-window rate
|
const queue: EmailJob[] = [];
|
||||||
// limit. The job backlog itself lives in the database, not in memory.
|
|
||||||
const sentTimestamps: number[] = [];
|
const sentTimestamps: number[] = [];
|
||||||
let processing = false;
|
let processing = false;
|
||||||
let processTimer: ReturnType<typeof setTimeout> | null = null;
|
let processTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -46,6 +41,7 @@ let _emailService: any = null;
|
|||||||
|
|
||||||
function getEmailService() {
|
function getEmailService() {
|
||||||
if (!_emailService) {
|
if (!_emailService) {
|
||||||
|
// Dynamic import to avoid circular dependency
|
||||||
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
|
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
|
||||||
}
|
}
|
||||||
return _emailService;
|
return _emailService;
|
||||||
@@ -54,31 +50,12 @@ function getEmailService() {
|
|||||||
/**
|
/**
|
||||||
* Initialize the email queue with a reference to the email service.
|
* Initialize the email queue with a reference to the email service.
|
||||||
* Must be called once at startup.
|
* 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 {
|
export function initEmailQueue(emailService: any): void {
|
||||||
_emailService = emailService;
|
_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');
|
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 ====================
|
// ==================== Rate Limiting ====================
|
||||||
|
|
||||||
function getMaxPerHour(): number {
|
function getMaxPerHour(): number {
|
||||||
@@ -97,26 +74,19 @@ function cleanOldTimestamps(): void {
|
|||||||
|
|
||||||
// ==================== Queue Operations ====================
|
// ==================== 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.
|
* Add a single email job to the queue.
|
||||||
* Returns the job ID. Persistence happens asynchronously so the caller is not
|
* Returns the job ID.
|
||||||
* blocked; processing is scheduled once the row is written.
|
|
||||||
*/
|
*/
|
||||||
export function enqueueEmail(params: TemplateEmailJobParams): string {
|
export function enqueueEmail(params: TemplateEmailJobParams): string {
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
insertJob(id, params)
|
queue.push({
|
||||||
.then(() => scheduleProcessing())
|
id,
|
||||||
.catch((err) => console.error('[EmailQueue] Failed to enqueue email:', err?.message || err));
|
type: 'template',
|
||||||
|
params,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
});
|
||||||
|
scheduleProcessing();
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,32 +95,31 @@ export function enqueueEmail(params: TemplateEmailJobParams): string {
|
|||||||
* Returns array of job IDs.
|
* Returns array of job IDs.
|
||||||
*/
|
*/
|
||||||
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
|
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
|
||||||
const ids = paramsList.map(() => generateId());
|
const ids: string[] = [];
|
||||||
if (ids.length === 0) return ids;
|
for (const params of paramsList) {
|
||||||
|
const id = generateId();
|
||||||
Promise.all(paramsList.map((params, i) => insertJob(ids[i], params)))
|
queue.push({
|
||||||
.then(() => {
|
id,
|
||||||
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
|
type: 'template',
|
||||||
scheduleProcessing();
|
params,
|
||||||
})
|
addedAt: Date.now(),
|
||||||
.catch((err) => console.error('[EmailQueue] Failed to enqueue bulk emails:', err?.message || err));
|
});
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
if (ids.length > 0) {
|
||||||
|
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
|
||||||
|
scheduleProcessing();
|
||||||
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current queue status
|
* Get current queue status
|
||||||
*/
|
*/
|
||||||
export async function getQueueStatus(): Promise<QueueStatus> {
|
export function getQueueStatus(): QueueStatus {
|
||||||
cleanOldTimestamps();
|
cleanOldTimestamps();
|
||||||
const row = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(emailQueue)
|
|
||||||
.where(eq((emailQueue as any).status, 'pending'))
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
queued: Number(row?.count || 0),
|
queued: queue.length,
|
||||||
processing,
|
processing,
|
||||||
sentInLastHour: sentTimestamps.length,
|
sentInLastHour: sentTimestamps.length,
|
||||||
maxPerHour: getMaxPerHour(),
|
maxPerHour: getMaxPerHour(),
|
||||||
@@ -166,125 +135,46 @@ function scheduleProcessing(): void {
|
|||||||
setImmediate(() => processNext());
|
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> {
|
async function processNext(): Promise<void> {
|
||||||
let job: ClaimedJob | null;
|
if (queue.length === 0) {
|
||||||
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;
|
processing = false;
|
||||||
console.log('[EmailQueue] Queue empty. Processing stopped.');
|
console.log('[EmailQueue] Queue empty. Processing stopped.');
|
||||||
return;
|
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();
|
cleanOldTimestamps();
|
||||||
const maxPerHour = getMaxPerHour();
|
const maxPerHour = getMaxPerHour();
|
||||||
let waitMs = 0;
|
|
||||||
|
|
||||||
if (isRedisEnabled()) {
|
if (sentTimestamps.length >= maxPerHour) {
|
||||||
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
|
// Calculate when the oldest timestamp in the window expires
|
||||||
waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
|
const 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(
|
console.log(
|
||||||
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
|
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
|
||||||
`Pausing for ${Math.ceil(waitMs / 1000)}s.`
|
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
|
||||||
);
|
);
|
||||||
processTimer = setTimeout(() => processNext(), waitMs);
|
processTimer = setTimeout(() => processNext(), waitMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dequeue and process
|
||||||
|
const job = queue.shift()!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const emailService = getEmailService();
|
const emailService = getEmailService();
|
||||||
await emailService.sendTemplateEmail(job.params);
|
await emailService.sendTemplateEmail(job.params);
|
||||||
sentTimestamps.push(Date.now());
|
sentTimestamps.push(Date.now());
|
||||||
await markJob(job.id, 'sent');
|
|
||||||
console.log(
|
console.log(
|
||||||
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
|
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
|
||||||
`Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await markJob(job.id, 'failed', error?.message || String(error));
|
|
||||||
console.error(
|
console.error(
|
||||||
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
|
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
|
||||||
error?.message || error
|
error?.message || error
|
||||||
);
|
);
|
||||||
// The sendTemplateEmail method already logs the failure in the email_logs
|
// The sendTemplateEmail method already logs the failure in the email_logs table,
|
||||||
// table, so we just record it on the queue row and move on.
|
// so we don't need to retry here. The error is logged and we move on.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay between sends to be gentle on the email server
|
// Small delay between sends to be gentle on the email server
|
||||||
@@ -292,8 +182,7 @@ async function processNext(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop processing (for graceful shutdown). In-flight and pending jobs remain
|
* Stop processing (for graceful shutdown)
|
||||||
* persisted in the database and resume on the next startup.
|
|
||||||
*/
|
*/
|
||||||
export function stopQueue(): void {
|
export function stopQueue(): void {
|
||||||
if (processTimer) {
|
if (processTimer) {
|
||||||
@@ -301,5 +190,5 @@ export function stopQueue(): void {
|
|||||||
processTimer = null;
|
processTimer = null;
|
||||||
}
|
}
|
||||||
processing = false;
|
processing = false;
|
||||||
console.log('[EmailQueue] Stopped. Pending jobs remain persisted in the database.');
|
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1216,26 +1216,8 @@ Spanglish`,
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Escape HTML-significant characters so substituted variable values can't inject markup.
|
// Helper function to replace template variables
|
||||||
function escapeHtmlValue(value: string): string {
|
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to replace template variables.
|
|
||||||
// When `escapeHtml` is true (HTML rendering contexts), substituted *values* are
|
|
||||||
// HTML-escaped to prevent stored-XSS via attacker-influenced variables (e.g. event
|
|
||||||
// location, attendee name). The template markup itself is never escaped. Subjects and
|
|
||||||
// plain-text bodies pass `escapeHtml=false` so they don't show literal entities.
|
|
||||||
export function replaceTemplateVariables(
|
|
||||||
template: string,
|
|
||||||
variables: Record<string, any>,
|
|
||||||
escapeHtml: boolean = false
|
|
||||||
): string {
|
|
||||||
let result = template;
|
let result = template;
|
||||||
|
|
||||||
// Handle conditional blocks {{#if variable}}...{{/if}}
|
// Handle conditional blocks {{#if variable}}...{{/if}}
|
||||||
@@ -1247,20 +1229,16 @@ export function replaceTemplateVariables(
|
|||||||
// Replace simple variables {{variable}}
|
// Replace simple variables {{variable}}
|
||||||
const variableRegex = /\{\{(\w+)\}\}/g;
|
const variableRegex = /\{\{(\w+)\}\}/g;
|
||||||
result = result.replace(variableRegex, (match, varName) => {
|
result = result.replace(variableRegex, (match, varName) => {
|
||||||
if (variables[varName] === undefined) return match;
|
return variables[varName] !== undefined ? String(variables[varName]) : match;
|
||||||
const raw = String(variables[varName]);
|
|
||||||
return escapeHtml ? escapeHtmlValue(raw) : raw;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
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 {
|
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
|
||||||
const wrappedContent = baseEmailWrapper.replace('{{content}}', () => content);
|
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
|
||||||
return replaceTemplateVariables(wrappedContent, variables, true);
|
return replaceTemplateVariables(wrappedContent, variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all available variables for a template by slug
|
// Get all available variables for a template by slug
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
// Auto-reject unconfirmed payments once their event is over.
|
|
||||||
//
|
|
||||||
// After an event ends, any booking whose payment was never confirmed
|
|
||||||
// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored.
|
|
||||||
// This job silently fails those payments and cancels their tickets so they stop
|
|
||||||
// lingering as "pending" forever. It deliberately sends NO email — unlike the
|
|
||||||
// admin reject route, this is a housekeeping sweep and users are not notified.
|
|
||||||
//
|
|
||||||
// An event is considered over when COALESCE(end_datetime, start_datetime) is in
|
|
||||||
// the past. Updates are guarded by the current status so re-running is a no-op.
|
|
||||||
|
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
|
||||||
import { db, dbAll, tickets, payments, events } from '../db/index.js';
|
|
||||||
import { getNow } from './utils.js';
|
|
||||||
import { getLock } from './stores/lock.js';
|
|
||||||
|
|
||||||
// Payment statuses that represent an unconfirmed booking.
|
|
||||||
const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold'];
|
|
||||||
// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled).
|
|
||||||
const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fail unconfirmed payments (and cancel their tickets) for events that have
|
|
||||||
* already ended. Returns the number of payments rejected.
|
|
||||||
*/
|
|
||||||
export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise<number> {
|
|
||||||
// Pull candidate rows first, then decide "ended" in JS so the comparison works
|
|
||||||
// identically for SQLite (ISO text) and Postgres (timestamp) datetime columns.
|
|
||||||
const rows = await dbAll<{
|
|
||||||
paymentId: string;
|
|
||||||
ticketId: string;
|
|
||||||
endDatetime: string | Date | null;
|
|
||||||
startDatetime: string | Date | null;
|
|
||||||
}>(
|
|
||||||
(db as any)
|
|
||||||
.select({
|
|
||||||
paymentId: (payments as any).id,
|
|
||||||
ticketId: (tickets as any).id,
|
|
||||||
endDatetime: (events as any).endDatetime,
|
|
||||||
startDatetime: (events as any).startDatetime,
|
|
||||||
})
|
|
||||||
.from(payments)
|
|
||||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
|
||||||
.innerJoin(events, eq((tickets as any).eventId, (events as any).id))
|
|
||||||
.where(and(
|
|
||||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
|
||||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const ended = rows.filter((r) => {
|
|
||||||
const ref = r.endDatetime || r.startDatetime;
|
|
||||||
if (!ref) return false;
|
|
||||||
return new Date(ref as any).getTime() < nowMs;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ended.length === 0) return 0;
|
|
||||||
|
|
||||||
const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId)));
|
|
||||||
const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id)));
|
|
||||||
const now = getNow();
|
|
||||||
|
|
||||||
// Fail the payments. The status guard keeps this idempotent and avoids
|
|
||||||
// clobbering anything that changed since we read the candidates.
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now })
|
|
||||||
.where(and(
|
|
||||||
inArray((payments as any).id, paymentIds),
|
|
||||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Cancel the associated tickets, freeing any seats they still hold.
|
|
||||||
if (ticketIds.length > 0) {
|
|
||||||
await (db as any)
|
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'cancelled' })
|
|
||||||
.where(and(
|
|
||||||
inArray((tickets as any).id, ticketIds),
|
|
||||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` +
|
|
||||||
`cancelled ${ticketIds.length} ticket(s).`
|
|
||||||
);
|
|
||||||
return paymentIds.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a periodic sweep that auto-rejects unconfirmed payments for ended
|
|
||||||
* events. Each run is guarded by a distributed lock so that, across multiple
|
|
||||||
* replicas, only one instance does the work per interval.
|
|
||||||
*/
|
|
||||||
export function startEventEndSweep(): void {
|
|
||||||
const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
getLock()
|
|
||||||
.withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () =>
|
|
||||||
rejectUnconfirmedPaymentsForEndedEvents()
|
|
||||||
)
|
|
||||||
.catch((err) =>
|
|
||||||
console.error('[EventEndSweep] Run failed:', err?.message || err)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run shortly after startup, then on the interval.
|
|
||||||
setTimeout(run, 60_000).unref?.();
|
|
||||||
sweepTimer = setInterval(run, intervalMs);
|
|
||||||
sweepTimer.unref?.();
|
|
||||||
console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopEventEndSweep(): void {
|
|
||||||
if (sweepTimer) {
|
|
||||||
clearInterval(sweepTimer);
|
|
||||||
sweepTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
|
|
||||||
//
|
|
||||||
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
|
|
||||||
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
|
|
||||||
// states — user clicking "I've paid", an admin approving/reactivating a payment —
|
|
||||||
// must atomically re-check that the event still has room, exactly like the original
|
|
||||||
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
|
|
||||||
// (e.g. reopening a failed payment) claims no seat and skips the check.
|
|
||||||
//
|
|
||||||
// Callers choose which ticket statuses are eligible to be flipped via
|
|
||||||
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
|
|
||||||
// left untouched. Tickets that already hold a seat cost no new capacity.
|
|
||||||
// `options.skipCapacityCheck` lets an admin knowingly approve over capacity —
|
|
||||||
// the UI warns first (routes/payments.ts /approve with allowOverCapacity).
|
|
||||||
|
|
||||||
import { eq, and, inArray } from 'drizzle-orm';
|
|
||||||
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
|
||||||
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
|
||||||
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
|
|
||||||
|
|
||||||
export class HoldCapacityError extends Error {
|
|
||||||
constructor(public available: number) {
|
|
||||||
super('EVENT_FULL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReserveOptions {
|
|
||||||
paidByAdminId?: string;
|
|
||||||
extraPaymentFields?: Record<string, any>;
|
|
||||||
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
|
||||||
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
|
|
||||||
/** Admin override: reserve even when it puts the event over capacity. */
|
|
||||||
skipCapacityCheck?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
|
|
||||||
* bookingId), atomically re-checking capacity before flipping their status.
|
|
||||||
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
|
|
||||||
* Capacity is asserted against the number of those tickets that don't already
|
|
||||||
* hold a seat, so re-reserving tickets that are already seated is a no-op.
|
|
||||||
* Throws HoldCapacityError if the event no longer has room (unless the target
|
|
||||||
* state holds no seat, or skipCapacityCheck is set).
|
|
||||||
*/
|
|
||||||
export async function reserveOnHoldBooking(
|
|
||||||
eventId: string,
|
|
||||||
ticketIds: string[],
|
|
||||||
targetTicketStatus: 'pending' | 'confirmed',
|
|
||||||
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
|
|
||||||
options: ReserveOptions = {}
|
|
||||||
): Promise<void> {
|
|
||||||
if (ticketIds.length === 0) return;
|
|
||||||
|
|
||||||
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
|
|
||||||
// Bare 'pending' payments hold no seat, so moving a booking back to 'pending'
|
|
||||||
// consumes no capacity and needs no check.
|
|
||||||
const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed';
|
|
||||||
const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck;
|
|
||||||
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
|
||||||
);
|
|
||||||
if (!event) {
|
|
||||||
throw new Error('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
|
||||||
const paymentUpdate: Record<string, any> = {
|
|
||||||
status: targetPaymentStatus,
|
|
||||||
updatedAt: now,
|
|
||||||
...options.extraPaymentFields,
|
|
||||||
};
|
|
||||||
if (targetPaymentStatus === 'paid') {
|
|
||||||
paymentUpdate.paidAt = now;
|
|
||||||
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the ticket-level payment flag in sync when the payment settles
|
|
||||||
const ticketUpdate: Record<string, any> = { status: targetTicketStatus };
|
|
||||||
if (targetPaymentStatus === 'paid') {
|
|
||||||
ticketUpdate.paymentStatus = 'paid';
|
|
||||||
}
|
|
||||||
|
|
||||||
// `needed` is how many of these tickets don't currently hold a seat and so must
|
|
||||||
// be found new capacity; tickets already in a seat-holding state cost nothing.
|
|
||||||
const assertCapacity = (reserved: number, needed: number) => {
|
|
||||||
if (needed <= 0) return;
|
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
|
||||||
throw new HoldCapacityError(0);
|
|
||||||
}
|
|
||||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
|
||||||
if (needed > seatsLeft) {
|
|
||||||
throw new HoldCapacityError(seatsLeft);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isSqlite()) {
|
|
||||||
(db as any).transaction((tx: any) => {
|
|
||||||
if (checkCapacity) {
|
|
||||||
const countRow = seatHolderCountQuery(tx, eventId).get();
|
|
||||||
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
|
|
||||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.update(tickets)
|
|
||||||
.set(ticketUpdate)
|
|
||||||
.where(and(
|
|
||||||
inArray((tickets as any).id, ticketIds),
|
|
||||||
inArray((tickets as any).status, fromTicketStatuses)
|
|
||||||
))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
tx.update(payments)
|
|
||||||
.set(paymentUpdate)
|
|
||||||
.where(inArray((payments as any).ticketId, ticketIds))
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await (db as any).transaction(async (tx: any) => {
|
|
||||||
if (checkCapacity) {
|
|
||||||
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
|
|
||||||
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
|
|
||||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.update(tickets)
|
|
||||||
.set(ticketUpdate)
|
|
||||||
.where(and(
|
|
||||||
inArray((tickets as any).id, ticketIds),
|
|
||||||
inArray((tickets as any).status, fromTicketStatuses)
|
|
||||||
));
|
|
||||||
|
|
||||||
await tx.update(payments)
|
|
||||||
.set(paymentUpdate)
|
|
||||||
.where(inArray((payments as any).ticketId, ticketIds));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// Auto-hold stale unsettled manual-payment bookings.
|
|
||||||
//
|
|
||||||
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
|
|
||||||
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
|
|
||||||
// the customer never clicked "I've paid" and no admin settled them. These are exempt
|
|
||||||
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
|
|
||||||
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
|
|
||||||
// dead checkouts out of the admin's pending queues.
|
|
||||||
//
|
|
||||||
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
|
|
||||||
// claimed payment keeps its seat until an admin approves or rejects it — the admin
|
|
||||||
// UI surfaces aging claims instead of silently releasing them.
|
|
||||||
//
|
|
||||||
// The user receives no notification — they can recover via "I've paid", and an
|
|
||||||
// admin can approve/reactivate directly; every recovery path re-checks capacity.
|
|
||||||
|
|
||||||
import { and, eq, lt, inArray } from 'drizzle-orm';
|
|
||||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
|
||||||
import { getNow, toDbDate } from './utils.js';
|
|
||||||
import { getLock } from './stores/lock.js';
|
|
||||||
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
|
||||||
|
|
||||||
function getThresholdMs(): number {
|
|
||||||
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
|
||||||
return (Number.isFinite(hours) && hours > 0 ? hours : 72) * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
|
|
||||||
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
|
|
||||||
* is never swept. Returns the number of payments put on hold.
|
|
||||||
*/
|
|
||||||
export async function sweepStaleApprovals(): Promise<number> {
|
|
||||||
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
|
||||||
|
|
||||||
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
|
|
||||||
(db as any)
|
|
||||||
.select({
|
|
||||||
ticketId: (payments as any).ticketId,
|
|
||||||
paymentId: (payments as any).id,
|
|
||||||
})
|
|
||||||
.from(payments)
|
|
||||||
.where(and(
|
|
||||||
eq((payments as any).status, 'pending'),
|
|
||||||
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
|
||||||
lt((payments as any).createdAt, cutoff)
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (stale.length === 0) return 0;
|
|
||||||
|
|
||||||
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
|
|
||||||
const paymentIds = stale.map((s) => s.paymentId);
|
|
||||||
const now = getNow();
|
|
||||||
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({ status: 'on_hold', updatedAt: now })
|
|
||||||
.where(inArray((payments as any).id, paymentIds));
|
|
||||||
|
|
||||||
if (ticketIds.length > 0) {
|
|
||||||
await (db as any)
|
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'on_hold' })
|
|
||||||
.where(and(
|
|
||||||
inArray((tickets as any).id, ticketIds),
|
|
||||||
eq((tickets as any).status, 'pending')
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
|
|
||||||
return stale.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a periodic sweep of stale pending-approval payments. Each run is guarded by
|
|
||||||
* a distributed lock so that, across multiple replicas, only one instance does the
|
|
||||||
* work per interval.
|
|
||||||
*/
|
|
||||||
export function startHoldSweep(): void {
|
|
||||||
const intervalMs = parseInt(process.env.HOLD_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
getLock()
|
|
||||||
.withLock('sweep-hold-stale-approvals', Math.min(intervalMs, 60_000), () =>
|
|
||||||
sweepStaleApprovals()
|
|
||||||
)
|
|
||||||
.catch((err) =>
|
|
||||||
console.error('[HoldSweep] Run failed:', err?.message || err)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run shortly after startup, then on the interval.
|
|
||||||
setTimeout(run, 45_000).unref?.();
|
|
||||||
sweepTimer = setInterval(run, intervalMs);
|
|
||||||
sweepTimer.unref?.();
|
|
||||||
console.log(`[HoldSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopHoldSweep(): void {
|
|
||||||
if (sweepTimer) {
|
|
||||||
clearInterval(sweepTimer);
|
|
||||||
sweepTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,11 +36,6 @@ export interface CreateInvoiceParams {
|
|||||||
extra?: Record<string, any>; // Additional metadata
|
extra?: Record<string, any>; // Additional metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// How long a booking's Lightning invoice is valid for, in seconds. Shared
|
|
||||||
// between initial booking creation and invoice regeneration so both produce
|
|
||||||
// invoices with the same lifetime.
|
|
||||||
export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if LNbits is configured
|
* Check if LNbits is configured
|
||||||
*/
|
*/
|
||||||
@@ -85,8 +80,11 @@ export async function createInvoice(params: CreateInvoiceParams): Promise<LNbits
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Creating LNbits invoice:', {
|
console.log('Creating LNbits invoice:', {
|
||||||
|
url: `${config.url}${apiEndpoint}`,
|
||||||
amount: params.amount,
|
amount: params.amount,
|
||||||
unit: payload.unit,
|
unit: payload.unit,
|
||||||
|
memo: params.memo,
|
||||||
|
webhook: params.webhookUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import * as argon2 from 'argon2';
|
|
||||||
import bcrypt from 'bcryptjs';
|
|
||||||
|
|
||||||
// Password hashing with Argon2 (spec requirement)
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
|
||||||
return argon2.hash(password, {
|
|
||||||
type: argon2.argon2id,
|
|
||||||
memoryCost: 65536, // 64 MB
|
|
||||||
timeCost: 3,
|
|
||||||
parallelism: 4,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
||||||
// Support both bcrypt (legacy) and argon2 hashes for migration
|
|
||||||
if (hash.startsWith('$argon2')) {
|
|
||||||
return argon2.verify(hash, password);
|
|
||||||
}
|
|
||||||
// Legacy bcrypt support
|
|
||||||
return bcrypt.compare(password, hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small blocklist of common/weak passwords (and obvious app-specific ones).
|
|
||||||
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
|
|
||||||
// "P@ssw0rd!" still matches "password".
|
|
||||||
const COMMON_PASSWORDS = new Set([
|
|
||||||
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
|
|
||||||
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
|
|
||||||
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
|
|
||||||
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Password policy: 10-128 chars, requires a mix of character types, and rejects
|
|
||||||
// common/weak passwords. Centralized so register/reset/change all share it.
|
|
||||||
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
|
||||||
if (password.length < 10) {
|
|
||||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
|
||||||
}
|
|
||||||
if (password.length > 128) {
|
|
||||||
return { valid: false, error: 'Password must be at most 128 characters long' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasLower = /[a-z]/.test(password);
|
|
||||||
const hasUpper = /[A-Z]/.test(password);
|
|
||||||
const hasDigit = /\d/.test(password);
|
|
||||||
const hasSymbol = /[^A-Za-z0-9]/.test(password);
|
|
||||||
|
|
||||||
// Require lowercase, uppercase, and at least one digit or symbol.
|
|
||||||
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
||||||
if (COMMON_PASSWORDS.has(normalized)) {
|
|
||||||
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// Payment provider registry.
|
|
||||||
//
|
|
||||||
// Every provider is either:
|
|
||||||
// - 'automatic': the gateway itself confirms the payment (webhook/invoice
|
|
||||||
// settlement) and the booking is auto-approved on success. No admin involved.
|
|
||||||
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
|
||||||
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
|
||||||
// card handled offline, cash at the door). These are never auto-confirmed
|
|
||||||
// and never auto-failed; an admin settles them by hand. Bank transfer and
|
|
||||||
// TPago additionally expose an online "I've paid" step that moves the
|
|
||||||
// payment to 'pending_approval'.
|
|
||||||
//
|
|
||||||
// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and
|
|
||||||
// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either
|
|
||||||
// kind — holds no seat, so an abandoned checkout can never block sales.
|
|
||||||
|
|
||||||
export type PaymentProviderKind = 'automatic' | 'manual';
|
|
||||||
|
|
||||||
export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> = {
|
|
||||||
lightning: { kind: 'automatic' },
|
|
||||||
tpago: { kind: 'manual' },
|
|
||||||
bank_transfer: { kind: 'manual' },
|
|
||||||
card: { kind: 'manual' },
|
|
||||||
cash: { kind: 'manual' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
|
|
||||||
(p) => PAYMENT_PROVIDERS[p].kind === 'manual'
|
|
||||||
);
|
|
||||||
|
|
||||||
export function isManualProvider(provider: string): boolean {
|
|
||||||
return PAYMENT_PROVIDERS[provider]?.kind === 'manual';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAutomaticProvider(provider: string): boolean {
|
|
||||||
return PAYMENT_PROVIDERS[provider]?.kind === 'automatic';
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
|
|
||||||
|
|
||||||
// Minimal Hono-Context stand-in: headers + the node-server env with the
|
|
||||||
// socket peer address.
|
|
||||||
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
|
|
||||||
const headers = new Map(
|
|
||||||
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
req: { header: (name: string) => headers.get(name.toLowerCase()) },
|
|
||||||
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('isTrustedProxyIp', () => {
|
|
||||||
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
|
|
||||||
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
|
|
||||||
expect(isTrustedProxyIp('::1')).toBe(true);
|
|
||||||
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
|
|
||||||
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
|
|
||||||
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
|
|
||||||
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not trust public addresses or near-miss ranges', () => {
|
|
||||||
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
|
|
||||||
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
|
|
||||||
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
|
|
||||||
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
|
|
||||||
expect(isTrustedProxyIp('')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getClientIp', () => {
|
|
||||||
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
|
|
||||||
const c = fakeContext({
|
|
||||||
peer: '127.0.0.1',
|
|
||||||
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
|
|
||||||
});
|
|
||||||
expect(getClientIp(c)).toBe('203.0.113.7');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
|
|
||||||
// spoofed prefix, then the real client appended by nginx, then the Next
|
|
||||||
// proxy hop — the rightmost untrusted entry wins
|
|
||||||
const c = fakeContext({
|
|
||||||
peer: '127.0.0.1',
|
|
||||||
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
|
|
||||||
});
|
|
||||||
expect(getClientIp(c)).toBe('203.0.113.7');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores forwarded headers entirely when the peer is untrusted', () => {
|
|
||||||
// A client hitting the API directly cannot pick its own bucket
|
|
||||||
const c = fakeContext({
|
|
||||||
peer: '198.51.100.4',
|
|
||||||
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
|
|
||||||
});
|
|
||||||
expect(getClientIp(c)).toBe('198.51.100.4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the socket address for local traffic with no headers', () => {
|
|
||||||
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
|
|
||||||
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the socket address when every forwarded hop is internal', () => {
|
|
||||||
const c = fakeContext({
|
|
||||||
peer: '127.0.0.1',
|
|
||||||
headers: { 'x-forwarded-for': '127.0.0.1' },
|
|
||||||
});
|
|
||||||
expect(getClientIp(c)).toBe('127.0.0.1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects junk header values instead of using them as bucket keys', () => {
|
|
||||||
const c = fakeContext({
|
|
||||||
peer: '127.0.0.1',
|
|
||||||
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
|
|
||||||
});
|
|
||||||
expect(getClientIp(c)).toBe('127.0.0.1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "unknown" without a socket address or trusted headers', () => {
|
|
||||||
expect(getClientIp(fakeContext({}))).toBe('unknown');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import RedisMock from 'ioredis-mock';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
|
||||||
|
|
||||||
vi.mock('../redis.js', () => ({
|
|
||||||
isRedisEnabled: () => true,
|
|
||||||
getRedis: () => mocks.redis,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js';
|
|
||||||
|
|
||||||
describe('MemoryLock', () => {
|
|
||||||
it('grants the lock, blocks contenders, and frees on release', async () => {
|
|
||||||
const lock = new MemoryLock();
|
|
||||||
const token = await lock.acquire('a', 60_000);
|
|
||||||
expect(token).toBeTruthy();
|
|
||||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
|
||||||
await lock.release('a', token!);
|
|
||||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores release with the wrong token', async () => {
|
|
||||||
const lock = new MemoryLock();
|
|
||||||
const token = await lock.acquire('a', 60_000);
|
|
||||||
await lock.release('a', 'not-the-token');
|
|
||||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
|
||||||
await lock.release('a', token!);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('withLock runs fn while held and returns null under contention', async () => {
|
|
||||||
const lock = new MemoryLock();
|
|
||||||
const held = await lock.acquire('a', 60_000);
|
|
||||||
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull();
|
|
||||||
await lock.release('a', held!);
|
|
||||||
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran');
|
|
||||||
// Released after withLock completes.
|
|
||||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RedisLock', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
mocks.redis = new RedisMock();
|
|
||||||
// ioredis-mock shares data between instances by connection string.
|
|
||||||
await mocks.redis.flushall();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('grants the lock and blocks contenders', async () => {
|
|
||||||
const lock = new RedisLock();
|
|
||||||
const token = await lock.acquire('a', 60_000);
|
|
||||||
expect(token).toBeTruthy();
|
|
||||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('release is compare-and-delete: wrong token does not free the lock', async () => {
|
|
||||||
const lock = new RedisLock();
|
|
||||||
const token = await lock.acquire('a', 60_000);
|
|
||||||
await lock.release('a', 'not-the-token');
|
|
||||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
|
||||||
await lock.release('a', token!);
|
|
||||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws LockUnavailableError when the client is not initialized', async () => {
|
|
||||||
mocks.redis = null;
|
|
||||||
const lock = new RedisLock();
|
|
||||||
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws LockUnavailableError when the backend errors', async () => {
|
|
||||||
mocks.redis = { set: () => Promise.reject(new Error('connection refused')) };
|
|
||||||
const lock = new RedisLock();
|
|
||||||
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('withLock skips (returns null, fn not called) when unavailable by default', async () => {
|
|
||||||
mocks.redis = null;
|
|
||||||
const lock = new RedisLock();
|
|
||||||
const fn = vi.fn(async () => 'ran');
|
|
||||||
expect(await lock.withLock('a', 60_000, fn)).toBeNull();
|
|
||||||
expect(fn).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('withLock runs fn without the lock when onUnavailable is "run"', async () => {
|
|
||||||
mocks.redis = null;
|
|
||||||
const lock = new RedisLock();
|
|
||||||
const fn = vi.fn(async () => 'ran');
|
|
||||||
expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran');
|
|
||||||
expect(fn).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('withLock returns fn result and releases the lock afterwards', async () => {
|
|
||||||
const lock = new RedisLock();
|
|
||||||
expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42);
|
|
||||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import RedisMock from 'ioredis-mock';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
|
||||||
|
|
||||||
vi.mock('../redis.js', () => ({
|
|
||||||
isRedisEnabled: () => true,
|
|
||||||
getRedis: () => mocks.redis,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
MemoryLoginLockout,
|
|
||||||
RedisLoginLockout,
|
|
||||||
MAX_LOGIN_ATTEMPTS,
|
|
||||||
} from './loginLockout.js';
|
|
||||||
|
|
||||||
describe('MemoryLoginLockout', () => {
|
|
||||||
it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => {
|
|
||||||
const lockout = new MemoryLoginLockout();
|
|
||||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
|
|
||||||
await lockout.recordFailure('user@example.com');
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
||||||
}
|
|
||||||
await lockout.recordFailure('user@example.com');
|
|
||||||
const status = await lockout.isLocked('user@example.com');
|
|
||||||
expect(status.locked).toBe(true);
|
|
||||||
expect(status.retryAfter).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clear removes the lockout', async () => {
|
|
||||||
const lockout = new MemoryLoginLockout();
|
|
||||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
||||||
await lockout.recordFailure('user@example.com');
|
|
||||||
}
|
|
||||||
await lockout.clear('user@example.com');
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats emails case-insensitively', async () => {
|
|
||||||
const lockout = new MemoryLoginLockout();
|
|
||||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
||||||
await lockout.recordFailure('User@Example.com');
|
|
||||||
}
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('expires after the lockout window', async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const lockout = new MemoryLoginLockout();
|
|
||||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
||||||
await lockout.recordFailure('user@example.com');
|
|
||||||
}
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
|
||||||
vi.advanceTimersByTime(16 * 60 * 1000);
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RedisLoginLockout', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
mocks.redis = new RedisMock();
|
|
||||||
// ioredis-mock shares data between instances by connection string.
|
|
||||||
await mocks.redis.flushall();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => {
|
|
||||||
const lockout = new RedisLoginLockout();
|
|
||||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
|
|
||||||
await lockout.recordFailure('user@example.com');
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
||||||
}
|
|
||||||
await lockout.recordFailure('user@example.com');
|
|
||||||
const status = await lockout.isLocked('user@example.com');
|
|
||||||
expect(status.locked).toBe(true);
|
|
||||||
expect(status.retryAfter).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets the lockout window TTL on the first failure', async () => {
|
|
||||||
const lockout = new RedisLoginLockout();
|
|
||||||
await lockout.recordFailure('user@example.com');
|
|
||||||
const ttl = await mocks.redis.pttl('lockout:user@example.com');
|
|
||||||
expect(ttl).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clear removes the lockout', async () => {
|
|
||||||
const lockout = new RedisLoginLockout();
|
|
||||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
||||||
await lockout.recordFailure('user@example.com');
|
|
||||||
}
|
|
||||||
await lockout.clear('user@example.com');
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats emails case-insensitively', async () => {
|
|
||||||
const lockout = new RedisLoginLockout();
|
|
||||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
|
||||||
await lockout.recordFailure('User@Example.com');
|
|
||||||
}
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails open when the client is not initialized', async () => {
|
|
||||||
mocks.redis = null;
|
|
||||||
const lockout = new RedisLoginLockout();
|
|
||||||
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails open when the backend errors', async () => {
|
|
||||||
mocks.redis = {
|
|
||||||
get: () => Promise.reject(new Error('connection refused')),
|
|
||||||
pttl: () => Promise.reject(new Error('connection refused')),
|
|
||||||
del: () => Promise.reject(new Error('connection refused')),
|
|
||||||
lockoutRecordFailure: () => Promise.reject(new Error('connection refused')),
|
|
||||||
};
|
|
||||||
const lockout = new RedisLoginLockout();
|
|
||||||
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
|
|
||||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
|
||||||
await expect(lockout.clear('user@example.com')).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
// Per-email login lockout abstraction with two implementations:
|
|
||||||
// - memory: per-process Map (the original routes/auth.ts behavior)
|
|
||||||
// - redis: shared counter across all instances so the lockout cannot be
|
|
||||||
// bypassed by round-robining replicas
|
|
||||||
//
|
|
||||||
// Semantics: recordFailure starts a window on the first failure; once the
|
|
||||||
// failure count reaches the max the email is locked until the window expires;
|
|
||||||
// clear removes the counter on successful login. Selection is based on
|
|
||||||
// REDIS_URL. The redis implementation fails open (never locked, failures not
|
|
||||||
// recorded) — the per-IP auth rate limit remains as a backstop during a
|
|
||||||
// Redis outage.
|
|
||||||
|
|
||||||
import type Redis from 'ioredis';
|
|
||||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
|
||||||
|
|
||||||
export const MAX_LOGIN_ATTEMPTS = 5;
|
|
||||||
export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
|
||||||
|
|
||||||
export interface LockoutStatus {
|
|
||||||
locked: boolean;
|
|
||||||
// Seconds until the lockout lifts; only set when locked.
|
|
||||||
retryAfter?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginLockout {
|
|
||||||
readonly backend: 'memory' | 'redis';
|
|
||||||
isLocked(email: string): Promise<LockoutStatus>;
|
|
||||||
recordFailure(email: string): Promise<void>;
|
|
||||||
clear(email: string): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emails are compared case-insensitively so "User@x.com" and "user@x.com"
|
|
||||||
// share one counter.
|
|
||||||
function normalize(email: string): string {
|
|
||||||
return email.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Memory implementation ====================
|
|
||||||
|
|
||||||
export class MemoryLoginLockout implements LoginLockout {
|
|
||||||
readonly backend = 'memory' as const;
|
|
||||||
private attempts = new Map<string, { count: number; resetAt: number }>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Periodically drop expired entries so the Map does not grow unbounded.
|
|
||||||
const cleanup = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, entry] of this.attempts) {
|
|
||||||
if (now > entry.resetAt) this.attempts.delete(key);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
(cleanup as any).unref?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
async isLocked(email: string): Promise<LockoutStatus> {
|
|
||||||
const entry = this.attempts.get(normalize(email));
|
|
||||||
const now = Date.now();
|
|
||||||
if (!entry || now > entry.resetAt) return { locked: false };
|
|
||||||
if (entry.count >= MAX_LOGIN_ATTEMPTS) {
|
|
||||||
return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
|
||||||
}
|
|
||||||
return { locked: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordFailure(email: string): Promise<void> {
|
|
||||||
const key = normalize(email);
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = this.attempts.get(key);
|
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
entry.count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
async clear(email: string): Promise<void> {
|
|
||||||
this.attempts.delete(normalize(email));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Redis implementation ====================
|
|
||||||
|
|
||||||
// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the
|
|
||||||
// window starts at the first failure and any TTL-less counter is repaired.
|
|
||||||
const RECORD_FAILURE_SCRIPT = `
|
|
||||||
local count = redis.call('INCR', KEYS[1])
|
|
||||||
if count == 1 then
|
|
||||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
||||||
end
|
|
||||||
if redis.call('PTTL', KEYS[1]) < 0 then
|
|
||||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
||||||
end
|
|
||||||
return count
|
|
||||||
`;
|
|
||||||
|
|
||||||
type RedisWithLockout = Redis & {
|
|
||||||
lockoutRecordFailure(key: string, windowMs: number): Promise<number>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function withLockoutCommand(redis: Redis): RedisWithLockout {
|
|
||||||
if (typeof (redis as any).lockoutRecordFailure !== 'function') {
|
|
||||||
redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT });
|
|
||||||
}
|
|
||||||
return redis as RedisWithLockout;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RedisLoginLockout implements LoginLockout {
|
|
||||||
readonly backend = 'redis' as const;
|
|
||||||
|
|
||||||
private key(email: string): string {
|
|
||||||
return `lockout:${normalize(email)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async isLocked(email: string): Promise<LockoutStatus> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return { locked: false };
|
|
||||||
try {
|
|
||||||
const [count, ttl] = await Promise.all([
|
|
||||||
redis.get(this.key(email)),
|
|
||||||
redis.pttl(this.key(email)),
|
|
||||||
]);
|
|
||||||
if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) {
|
|
||||||
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000);
|
|
||||||
return { locked: true, retryAfter };
|
|
||||||
}
|
|
||||||
return { locked: false };
|
|
||||||
} catch (err: any) {
|
|
||||||
// Fail open: the per-IP auth rate limit still applies.
|
|
||||||
console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err);
|
|
||||||
return { locked: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordFailure(email: string): Promise<void> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return;
|
|
||||||
try {
|
|
||||||
await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[loginLockout] redis error recording failure:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async clear(email: string): Promise<void> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return;
|
|
||||||
try {
|
|
||||||
await redis.del(this.key(email));
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[loginLockout] redis error clearing failures:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Selection ====================
|
|
||||||
|
|
||||||
let instance: LoginLockout | null = null;
|
|
||||||
|
|
||||||
export function getLoginLockout(): LoginLockout {
|
|
||||||
if (!instance) {
|
|
||||||
instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import RedisMock from 'ioredis-mock';
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
|
||||||
|
|
||||||
vi.mock('../redis.js', () => ({
|
|
||||||
isRedisEnabled: () => true,
|
|
||||||
getRedis: () => mocks.redis,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js';
|
|
||||||
|
|
||||||
describe('MemoryRateLimiter', () => {
|
|
||||||
it('allows up to max within the window, then blocks with retryAfter', async () => {
|
|
||||||
const limiter = new MemoryRateLimiter();
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true);
|
|
||||||
}
|
|
||||||
const blocked = await limiter.consume('k', 3, 60_000);
|
|
||||||
expect(blocked.allowed).toBe(false);
|
|
||||||
expect(blocked.retryAfter).toBeGreaterThan(0);
|
|
||||||
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets after the window elapses', async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const limiter = new MemoryRateLimiter();
|
|
||||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
|
|
||||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false);
|
|
||||||
vi.advanceTimersByTime(1_500);
|
|
||||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RedisRateLimiter', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
mocks.redis = new RedisMock();
|
|
||||||
// ioredis-mock shares data between instances by connection string.
|
|
||||||
await mocks.redis.flushall();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets the window TTL atomically on the first hit', async () => {
|
|
||||||
const limiter = new RedisRateLimiter();
|
|
||||||
expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true);
|
|
||||||
const ttl = await mocks.redis.pttl('rl:k');
|
|
||||||
expect(ttl).toBeGreaterThan(0);
|
|
||||||
expect(ttl).toBeLessThanOrEqual(60_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks over the limit with a sane retryAfter', async () => {
|
|
||||||
const limiter = new RedisRateLimiter();
|
|
||||||
for (let i = 0; i < 2; i++) {
|
|
||||||
expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true);
|
|
||||||
}
|
|
||||||
const blocked = await limiter.consume('k', 2, 60_000);
|
|
||||||
expect(blocked.allowed).toBe(false);
|
|
||||||
expect(blocked.retryAfter).toBeGreaterThan(0);
|
|
||||||
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('self-heals a counter stranded without a TTL', async () => {
|
|
||||||
await mocks.redis.set('rl:k', '3');
|
|
||||||
expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0);
|
|
||||||
const limiter = new RedisRateLimiter();
|
|
||||||
await limiter.consume('k', 10, 60_000);
|
|
||||||
expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails open when the backend errors', async () => {
|
|
||||||
mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) };
|
|
||||||
const limiter = new RedisRateLimiter();
|
|
||||||
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails open when the client is not initialized', async () => {
|
|
||||||
mocks.redis = null;
|
|
||||||
const limiter = new RedisRateLimiter();
|
|
||||||
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import moment from 'moment-timezone';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get database type (reads env var each time to handle module loading order)
|
* Get database type (reads env var each time to handle module loading order)
|
||||||
@@ -60,10 +59,17 @@ export function parseEventDatetime(
|
|||||||
return new Date(datetime);
|
return new Date(datetime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interpret the wall-clock digits as local time in `timezone` using
|
// Treat the digits as UTC so we have a stable reference instant.
|
||||||
// moment-timezone's bundled IANA data. This keeps the conversion correct
|
const fakeUTC = new Date(datetime + 'Z');
|
||||||
// regardless of the host Node runtime's (possibly stale) tz database.
|
|
||||||
return moment.tz(datetime, timezone).toDate();
|
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
|
||||||
|
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
|
||||||
|
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
|
||||||
|
|
||||||
|
// The gap between the two tells us the tz offset at this point in time.
|
||||||
|
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
|
||||||
|
|
||||||
|
return new Date(fakeUTC.getTime() + offsetMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+100
-123
@@ -3,27 +3,15 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs
|
|||||||
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
|
||||||
|
|
||||||
const adminRouter = new Hono();
|
const adminRouter = new Hono();
|
||||||
|
|
||||||
// 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)
|
// Dashboard overview stats (admin)
|
||||||
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
|
// Get upcoming events
|
||||||
// so the dashboard's capacity alerts reflect real availability.
|
const upcomingEvents = await dbAll(
|
||||||
const upcomingEventsRaw = await dbAll<any>(
|
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
@@ -36,24 +24,6 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.orderBy((events as any).startDatetime)
|
.orderBy((events as any).startDatetime)
|
||||||
.limit(5)
|
.limit(5)
|
||||||
);
|
);
|
||||||
|
|
||||||
const seatRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
|
||||||
const seatsByEvent = new Map<string, { paid: number; claimed: number }>();
|
|
||||||
for (const row of seatRows) {
|
|
||||||
seatsByEvent.set(row.eventId, {
|
|
||||||
paid: Number(row.paidCount) || 0,
|
|
||||||
claimed: Number(row.claimedCount) || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const upcomingEvents = upcomingEventsRaw.map((event: any) => {
|
|
||||||
const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
bookedCount: counts.paid,
|
|
||||||
claimedCount: counts.claimed,
|
|
||||||
availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get recent tickets
|
// Get recent tickets
|
||||||
const recentTickets = await dbAll(
|
const recentTickets = await dbAll(
|
||||||
@@ -90,37 +60,21 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.where(eq((tickets as any).status, 'confirmed'))
|
.where(eq((tickets as any).status, 'confirmed'))
|
||||||
);
|
);
|
||||||
|
|
||||||
// 'pending' = checkout opened, nothing paid or claimed (informational);
|
|
||||||
// 'pending_approval' = customer says they paid, needs admin verification (actionable).
|
|
||||||
const pendingPayments = await dbGet<any>(
|
const pendingPayments = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).status, 'pending'))
|
.where(eq((payments as any).status, 'pending'))
|
||||||
);
|
);
|
||||||
|
|
||||||
const awaitingApprovalPayments = await dbGet<any>(
|
const paidPayments = await dbAll<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select()
|
||||||
.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)
|
.from(payments)
|
||||||
.where(eq((payments as any).status, 'paid'))
|
.where(eq((payments as any).status, 'paid'))
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalRevenue = Number(revenueRow?.total || 0);
|
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||||
|
|
||||||
const newContacts = await dbGet<any>(
|
const newContacts = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -144,8 +98,6 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
totalTickets: totalTickets?.count || 0,
|
totalTickets: totalTickets?.count || 0,
|
||||||
confirmedTickets: confirmedTickets?.count || 0,
|
confirmedTickets: confirmedTickets?.count || 0,
|
||||||
pendingPayments: pendingPayments?.count || 0,
|
pendingPayments: pendingPayments?.count || 0,
|
||||||
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
|
|
||||||
onHoldPayments: onHoldPayments?.count || 0,
|
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
newContacts: newContacts?.count || 0,
|
newContacts: newContacts?.count || 0,
|
||||||
totalSubscribers: totalSubscribers?.count || 0,
|
totalSubscribers: totalSubscribers?.count || 0,
|
||||||
@@ -158,52 +110,56 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
|
|
||||||
// Get analytics data (admin)
|
// Get analytics data (admin)
|
||||||
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||||
// Get events with ticket counts using grouped aggregates (avoids 3 queries per event).
|
// Get events with ticket counts
|
||||||
const allEvents = await dbAll<any>((db as any).select().from(events));
|
const allEvents = await dbAll<any>((db as any).select().from(events));
|
||||||
|
|
||||||
const totalRows = await dbAll<any>(
|
const eventStats = await Promise.all(
|
||||||
(db as any)
|
allEvents.map(async (event: any) => {
|
||||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
const ticketCount = await dbGet<any>(
|
||||||
.from(tickets)
|
(db as any)
|
||||||
.groupBy((tickets as any).eventId)
|
.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 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({
|
return c.json({
|
||||||
analytics: {
|
analytics: {
|
||||||
@@ -223,25 +179,30 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ticketList = await dbAll<any>(query);
|
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]));
|
|
||||||
|
|
||||||
const enrichedTickets = ticketList.map((ticket: any) => {
|
// Get user and event details for each ticket
|
||||||
const user = usersById.get(ticket.userId);
|
const enrichedTickets = await Promise.all(
|
||||||
const event = eventsById.get(ticket.eventId);
|
ticketList.map(async (ticket: any) => {
|
||||||
const payment = paymentsByTicketId.get(ticket.id);
|
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))
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ticketId: ticket.id,
|
ticketId: ticket.id,
|
||||||
@@ -251,14 +212,14 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
|||||||
userName: user?.name,
|
userName: user?.name,
|
||||||
userEmail: user?.email,
|
userEmail: user?.email,
|
||||||
userPhone: user?.phone,
|
userPhone: user?.phone,
|
||||||
attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null,
|
|
||||||
eventTitle: event?.title,
|
eventTitle: event?.title,
|
||||||
eventDate: event?.startDatetime,
|
eventDate: event?.startDatetime,
|
||||||
paymentStatus: payment?.status,
|
paymentStatus: payment?.status,
|
||||||
paymentAmount: payment?.amount,
|
paymentAmount: payment?.amount,
|
||||||
createdAt: ticket.createdAt,
|
createdAt: ticket.createdAt,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ tickets: enrichedTickets });
|
return c.json({ tickets: enrichedTickets });
|
||||||
});
|
});
|
||||||
@@ -330,7 +291,6 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
|||||||
'Full Name': fullName,
|
'Full Name': fullName,
|
||||||
'Email': ticket.attendeeEmail || '',
|
'Email': ticket.attendeeEmail || '',
|
||||||
'Phone': ticket.attendeePhone || '',
|
'Phone': ticket.attendeePhone || '',
|
||||||
'RUC': ticket.attendeeRuc || '',
|
|
||||||
'Status': ticket.status,
|
'Status': ticket.status,
|
||||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||||
'Check-in Time': ticket.checkinAt || '',
|
'Check-in Time': ticket.checkinAt || '',
|
||||||
@@ -341,8 +301,18 @@ 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 = [
|
const columns = [
|
||||||
'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC',
|
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||||
'Booked At', 'Notes',
|
'Booked At', 'Notes',
|
||||||
];
|
];
|
||||||
@@ -420,13 +390,21 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'RUC', 'Status', 'Check-in Time', 'Booked At'];
|
const 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 rows = ticketList.map((ticket: any) => ({
|
const rows = ticketList.map((ticket: any) => ({
|
||||||
'Ticket ID': ticket.id,
|
'Ticket ID': ticket.id,
|
||||||
'Booking ID': ticket.bookingId || '',
|
'Booking ID': ticket.bookingId || '',
|
||||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||||
'RUC': ticket.attendeeRuc || '',
|
|
||||||
'Status': ticket.status,
|
'Status': ticket.status,
|
||||||
'Check-in Time': ticket.checkinAt || '',
|
'Check-in Time': ticket.checkinAt || '',
|
||||||
'Booked At': ticket.createdAt || '',
|
'Booked At': ticket.createdAt || '',
|
||||||
@@ -499,7 +477,6 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
|||||||
attendeeFirstName: ticket.attendeeFirstName,
|
attendeeFirstName: ticket.attendeeFirstName,
|
||||||
attendeeLastName: ticket.attendeeLastName,
|
attendeeLastName: ticket.attendeeLastName,
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
attendeeRuc: ticket.attendeeRuc || null,
|
|
||||||
eventId: event?.id,
|
eventId: event?.id,
|
||||||
eventTitle: event?.title,
|
eventTitle: event?.title,
|
||||||
eventDate: event?.startDatetime,
|
eventDate: event?.startDatetime,
|
||||||
|
|||||||
@@ -0,0 +1,672 @@
|
|||||||
|
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;
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { Hono } from 'hono';
|
|
||||||
import { zValidator } from '@hono/zod-validator';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { auth } from '../lib/betterAuth.js';
|
|
||||||
import { validatePassword } from '../lib/passwordPolicy.js';
|
|
||||||
import { db, dbGet, users } from '../db/index.js';
|
|
||||||
import { getNow, toDbBool } 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;
|
|
||||||
@@ -4,18 +4,26 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow, sanitizeHtml } from '../lib/utils.js';
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
import { emailService } from '../lib/email.js';
|
import { emailService } from '../lib/email.js';
|
||||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
|
||||||
|
|
||||||
const contactsRouter = new Hono();
|
const contactsRouter = new Hono();
|
||||||
|
|
||||||
// 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' });
|
|
||||||
|
|
||||||
// ==================== Sanitization Helpers ====================
|
// ==================== Sanitization Helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string to prevent HTML injection
|
||||||
|
* Escapes HTML special characters
|
||||||
|
*/
|
||||||
|
function sanitizeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize email header values to prevent email header injection
|
* Sanitize email header values to prevent email header injection
|
||||||
* Strips newlines and carriage returns that could be used to inject headers
|
* Strips newlines and carriage returns that could be used to inject headers
|
||||||
@@ -25,14 +33,14 @@ function sanitizeHeaderValue(str: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createContactSchema = z.object({
|
const createContactSchema = z.object({
|
||||||
name: z.string().min(2).max(200),
|
name: z.string().min(2),
|
||||||
email: z.string().email().max(254),
|
email: z.string().email(),
|
||||||
message: z.string().min(10).max(5000),
|
message: z.string().min(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscribeSchema = z.object({
|
const subscribeSchema = z.object({
|
||||||
email: z.string().email().max(254),
|
email: z.string().email(),
|
||||||
name: z.string().max(200).optional(),
|
name: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateContactSchema = z.object({
|
const updateContactSchema = z.object({
|
||||||
@@ -40,7 +48,7 @@ const updateContactSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Submit contact form (public)
|
// Submit contact form (public)
|
||||||
contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema), async (c) => {
|
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
@@ -117,7 +125,7 @@ contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to newsletter (public)
|
// Subscribe to newsletter (public)
|
||||||
contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeSchema), async (c) => {
|
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
// Check if already subscribed
|
// Check if already subscribed
|
||||||
@@ -158,30 +166,28 @@ contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeS
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Unsubscribe from newsletter (public)
|
// Unsubscribe from newsletter (public)
|
||||||
contactsRouter.post('/unsubscribe', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), async (c) => {
|
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
|
||||||
const { email } = c.req.valid('json');
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
const existing = await dbGet<any>(
|
const existing = await dbGet<any>(
|
||||||
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
|
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always return the same response whether or not the address exists, to avoid
|
if (!existing) {
|
||||||
// leaking which emails are subscribed (enumeration).
|
return c.json({ error: 'Email not found' }, 404);
|
||||||
if (existing && existing.status !== 'unsubscribed') {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailSubscribers)
|
|
||||||
.set({ status: 'unsubscribed' })
|
|
||||||
.where(eq((emailSubscribers as any).id, existing.id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ message: 'If this email was subscribed, it has been unsubscribed.' });
|
await (db as any)
|
||||||
|
.update(emailSubscribers)
|
||||||
|
.set({ status: 'unsubscribed' })
|
||||||
|
.where(eq((emailSubscribers as any).id, existing.id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Successfully unsubscribed' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all contacts (admin)
|
// Get all contacts (admin)
|
||||||
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const status = c.req.query('status');
|
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);
|
let query = (db as any).select().from(contacts);
|
||||||
|
|
||||||
@@ -189,9 +195,7 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
query = query.where(eq((contacts as any).status, status));
|
query = query.where(eq((contacts as any).status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(
|
const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
|
||||||
query.orderBy(desc((contacts as any).createdAt)).limit(limit).offset(offset)
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ contacts: result });
|
return c.json({ contacts: result });
|
||||||
});
|
});
|
||||||
@@ -251,8 +255,6 @@ contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
|||||||
// Get all subscribers (admin)
|
// Get all subscribers (admin)
|
||||||
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
|
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
|
||||||
const status = c.req.query('status');
|
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);
|
let query = (db as any).select().from(emailSubscribers);
|
||||||
|
|
||||||
@@ -260,9 +262,7 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy
|
|||||||
query = query.where(eq((emailSubscribers as any).status, status));
|
query = query.where(eq((emailSubscribers as any).status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(
|
const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
|
||||||
query.orderBy(desc((emailSubscribers as any).createdAt)).limit(limit).offset(offset)
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ subscribers: result });
|
return c.json({ subscribers: result });
|
||||||
});
|
});
|
||||||
|
|||||||
+84
-117
@@ -1,12 +1,18 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
|
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
||||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
import { eq, desc, and, gt, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
|
||||||
import { auth } from '../lib/betterAuth.js';
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
|
||||||
import { getNow } from '../lib/utils.js';
|
// User type that includes all fields (some added in schema updates)
|
||||||
|
type AuthUser = User & {
|
||||||
|
isClaimed: boolean;
|
||||||
|
googleId: string | null;
|
||||||
|
rucNumber: string | null;
|
||||||
|
accountStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
const dashboard = new Hono();
|
const dashboard = new Hono();
|
||||||
|
|
||||||
@@ -31,8 +37,6 @@ dashboard.get('/profile', async (c) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
const hasPassword = !!(await getUserPasswordHash(user.id));
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
profile: {
|
profile: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -43,8 +47,8 @@ dashboard.get('/profile', async (c) => {
|
|||||||
rucNumber: user.rucNumber,
|
rucNumber: user.rucNumber,
|
||||||
isClaimed: user.isClaimed,
|
isClaimed: user.isClaimed,
|
||||||
accountStatus: user.accountStatus,
|
accountStatus: user.accountStatus,
|
||||||
hasPassword,
|
hasPassword: !!user.password,
|
||||||
hasGoogleLinked: await hasGoogleAccount(user.id),
|
hasGoogleLinked: !!user.googleId,
|
||||||
memberSince: user.createdAt,
|
memberSince: user.createdAt,
|
||||||
membershipDays,
|
membershipDays,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
@@ -99,31 +103,34 @@ dashboard.get('/tickets', async (c) => {
|
|||||||
.where(eq((tickets as any).userId, user.id))
|
.where(eq((tickets as any).userId, user.id))
|
||||||
.orderBy(desc((tickets as any).createdAt))
|
.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]));
|
|
||||||
|
|
||||||
const ticketsWithEvents = userTickets.map((ticket: any) => {
|
// Get event details for each ticket
|
||||||
const event = eventsById.get(ticket.eventId);
|
const ticketsWithEvents = await Promise.all(
|
||||||
const payment = paymentsByTicketId.get(ticket.id);
|
userTickets.map(async (ticket: any) => {
|
||||||
const invoice = payment && payment.status === 'paid' ? invoicesByPaymentId.get(payment.id) : null;
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...ticket,
|
...ticket,
|
||||||
@@ -155,7 +162,8 @@ dashboard.get('/tickets', async (c) => {
|
|||||||
createdAt: invoice.createdAt,
|
createdAt: invoice.createdAt,
|
||||||
} : null,
|
} : null,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ tickets: ticketsWithEvents });
|
return c.json({ tickets: ticketsWithEvents });
|
||||||
});
|
});
|
||||||
@@ -417,77 +425,40 @@ dashboard.get('/invoices', async (c) => {
|
|||||||
|
|
||||||
// ==================== Security Routes ====================
|
// ==================== Security Routes ====================
|
||||||
|
|
||||||
// Get active sessions (Better Auth session table; validated per-request so
|
// Get active sessions
|
||||||
// this list is always live). Session tokens are never exposed to the client.
|
|
||||||
dashboard.get('/sessions', async (c) => {
|
dashboard.get('/sessions', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
const sessions = await dbAll<any>(
|
const sessions = await getUserSessions(user.id);
|
||||||
(db as any)
|
|
||||||
.select({
|
|
||||||
id: (authSessions as any).id,
|
|
||||||
userAgent: (authSessions as any).userAgent,
|
|
||||||
ipAddress: (authSessions as any).ipAddress,
|
|
||||||
createdAt: (authSessions as any).createdAt,
|
|
||||||
updatedAt: (authSessions as any).updatedAt,
|
|
||||||
expiresAt: (authSessions as any).expiresAt,
|
|
||||||
})
|
|
||||||
.from(authSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((authSessions as any).userId, user.id),
|
|
||||||
gt((authSessions as any).expiresAt, new Date())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc((authSessions as any).updatedAt))
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
sessions: sessions.map((s: any) => ({
|
sessions: sessions.map((s: any) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
userAgent: s.userAgent,
|
userAgent: s.userAgent,
|
||||||
ipAddress: s.ipAddress,
|
ipAddress: s.ipAddress,
|
||||||
lastActiveAt: s.updatedAt,
|
lastActiveAt: s.lastActiveAt,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
expiresAt: s.expiresAt,
|
|
||||||
current: s.id === user.sessionId,
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revoke a specific session. Deleting the row is immediately effective:
|
// Revoke a specific session
|
||||||
// sessions are validated against the table on every request (no cookie cache).
|
|
||||||
dashboard.delete('/sessions/:id', async (c) => {
|
dashboard.delete('/sessions/:id', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
const user = (c as any).get('user') as AuthUser;
|
||||||
const sessionId = c.req.param('id');
|
const sessionId = c.req.param('id');
|
||||||
|
|
||||||
await (db as any)
|
await invalidateSession(sessionId, user.id);
|
||||||
.delete(authSessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((authSessions as any).id, sessionId),
|
|
||||||
eq((authSessions as any).userId, user.id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ message: 'Session revoked' });
|
return c.json({ message: 'Session revoked' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revoke all other sessions (logout everywhere else); the current session
|
// Revoke all sessions (logout everywhere)
|
||||||
// stays valid so this device remains signed in.
|
|
||||||
dashboard.post('/sessions/revoke-all', async (c) => {
|
dashboard.post('/sessions/revoke-all', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
await (db as any)
|
await invalidateAllUserSessions(user.id);
|
||||||
.delete(authSessions)
|
|
||||||
.where(
|
return c.json({ message: 'All sessions revoked. Please log in again.' });
|
||||||
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)
|
// Set password (for users without one)
|
||||||
@@ -498,57 +469,53 @@ const setPasswordSchema = z.object({
|
|||||||
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
|
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
const user = (c as any).get('user') as AuthUser;
|
||||||
const { password } = c.req.valid('json');
|
const { password } = c.req.valid('json');
|
||||||
|
|
||||||
// Check if user already has a password
|
// Check if user already has a password
|
||||||
if (await getUserPasswordHash(user.id)) {
|
if (user.password) {
|
||||||
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
|
|
||||||
// policy hook does not cover it — validate explicitly.
|
|
||||||
const passwordValidation = validatePassword(password);
|
const passwordValidation = validatePassword(password);
|
||||||
if (!passwordValidation.valid) {
|
if (!passwordValidation.valid) {
|
||||||
return c.json({ error: passwordValidation.error }, 400);
|
return c.json({ error: passwordValidation.error }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const hashedPassword = await hashPassword(password);
|
||||||
await auth.api.setPassword({
|
const now = getNow();
|
||||||
body: { newPassword: password },
|
|
||||||
headers: c.req.raw.headers,
|
await (db as any)
|
||||||
});
|
.update(users)
|
||||||
} catch (err: any) {
|
.set({
|
||||||
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
|
password: hashedPassword,
|
||||||
}
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq((users as any).id, user.id));
|
||||||
|
|
||||||
return c.json({ message: 'Password set successfully' });
|
return c.json({ message: 'Password set successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unlink Google account (only if password is set)
|
// Unlink Google account (only if password is set)
|
||||||
dashboard.post('/unlink-google', async (c) => {
|
dashboard.post('/unlink-google', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
if (!(await hasGoogleAccount(user.id))) {
|
if (!user.googleId) {
|
||||||
return c.json({ error: 'Google account not linked' }, 400);
|
return c.json({ error: 'Google account not linked' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await getUserPasswordHash(user.id))) {
|
if (!user.password) {
|
||||||
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await (db as any)
|
const now = getNow();
|
||||||
.delete(authAccounts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((authAccounts as any).userId, user.id),
|
|
||||||
eq((authAccounts as any).providerId, 'google')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ updatedAt: getNow() })
|
.set({
|
||||||
|
googleId: null,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
.where(eq((users as any).id, user.id));
|
.where(eq((users as any).id, user.id));
|
||||||
|
|
||||||
return c.json({ message: 'Google account unlinked' });
|
return c.json({ message: 'Google account unlinked' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Hono } from 'hono';
|
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 { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
||||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
@@ -11,50 +9,6 @@ import { getQueueStatus } from '../lib/emailQueue.js';
|
|||||||
|
|
||||||
const emailsRouter = new Hono();
|
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 ====================
|
// ==================== Template Routes ====================
|
||||||
|
|
||||||
// Get all email templates
|
// Get all email templates
|
||||||
@@ -66,7 +20,7 @@ emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
// Parse variables JSON for each template
|
// Parse variables JSON for each template
|
||||||
const parsedTemplates = templates.map((t: any) => ({
|
const parsedTemplates = templates.map((t: any) => ({
|
||||||
...t,
|
...t,
|
||||||
variables: safeParseVariables(t.variables),
|
variables: t.variables ? JSON.parse(t.variables) : [],
|
||||||
isSystem: Boolean(t.isSystem),
|
isSystem: Boolean(t.isSystem),
|
||||||
isActive: Boolean(t.isActive),
|
isActive: Boolean(t.isActive),
|
||||||
}));
|
}));
|
||||||
@@ -92,7 +46,7 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
|
|||||||
return c.json({
|
return c.json({
|
||||||
template: {
|
template: {
|
||||||
...template,
|
...template,
|
||||||
variables: safeParseVariables(template.variables),
|
variables: template.variables ? JSON.parse(template.variables) : [],
|
||||||
isSystem: Boolean(template.isSystem),
|
isSystem: Boolean(template.isSystem),
|
||||||
isActive: Boolean(template.isActive),
|
isActive: Boolean(template.isActive),
|
||||||
}
|
}
|
||||||
@@ -100,10 +54,14 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create new email template
|
// Create new email template
|
||||||
emailsRouter.post('/templates', requireAuth(['admin']), zValidator('json', createTemplateSchema), async (c) => {
|
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
|
||||||
const body = c.req.valid('json');
|
const body = await c.req.json();
|
||||||
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
|
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
|
// Check if slug already exists
|
||||||
const existing = await dbGet<any>(
|
const existing = await dbGet<any>(
|
||||||
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
|
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
|
||||||
@@ -146,9 +104,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), zValidator('json', creat
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update email template
|
// Update email template
|
||||||
emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', updateTemplateSchema), async (c) => {
|
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const body = c.req.valid('json');
|
const body = await c.req.json();
|
||||||
|
|
||||||
const existing = await dbGet<any>(
|
const existing = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -163,22 +121,22 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', up
|
|||||||
|
|
||||||
const updateData: any = { updatedAt: getNow() };
|
const updateData: any = { updatedAt: getNow() };
|
||||||
|
|
||||||
// System templates cannot have their slug or isSystem flag changed; only the
|
// Only allow updating certain fields for system templates
|
||||||
// editable fields below are applied.
|
const systemProtectedFields = ['slug', 'isSystem'];
|
||||||
|
|
||||||
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
|
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
|
||||||
if (!existing.isSystem) {
|
if (!existing.isSystem) {
|
||||||
allowedFields.push('slug');
|
allowedFields.push('slug');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of allowedFields) {
|
for (const field of allowedFields) {
|
||||||
const value = (body as Record<string, unknown>)[field];
|
if (body[field] !== undefined) {
|
||||||
if (value !== undefined) {
|
|
||||||
if (field === 'variables') {
|
if (field === 'variables') {
|
||||||
updateData[field] = JSON.stringify(value);
|
updateData[field] = JSON.stringify(body[field]);
|
||||||
} else if (field === 'isActive') {
|
} else if (field === 'isActive') {
|
||||||
updateData[field] = value ? 1 : 0;
|
updateData[field] = body[field] ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
updateData[field] = value;
|
updateData[field] = body[field];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +156,7 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', up
|
|||||||
return c.json({
|
return c.json({
|
||||||
template: {
|
template: {
|
||||||
...updated,
|
...updated,
|
||||||
variables: safeParseVariables(updated.variables),
|
variables: updated.variables ? JSON.parse(updated.variables) : [],
|
||||||
isSystem: Boolean(updated.isSystem),
|
isSystem: Boolean(updated.isSystem),
|
||||||
isActive: Boolean(updated.isActive),
|
isActive: Boolean(updated.isActive),
|
||||||
},
|
},
|
||||||
@@ -245,22 +203,16 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
|
|||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { templateSlug, customVariables, recipientFilter } = body;
|
const { templateSlug, customVariables, recipientFilter } = body;
|
||||||
|
|
||||||
if (!templateSlug || typeof templateSlug !== 'string') {
|
if (!templateSlug) {
|
||||||
return c.json({ error: 'Template slug is required' }, 400);
|
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
|
// Queue emails for background processing instead of sending synchronously
|
||||||
const result = await emailService.queueEventEmails({
|
const result = await emailService.queueEventEmails({
|
||||||
eventId,
|
eventId,
|
||||||
templateSlug,
|
templateSlug,
|
||||||
customVariables,
|
customVariables,
|
||||||
recipientFilter: filter,
|
recipientFilter: recipientFilter || 'confirmed',
|
||||||
sentBy: user?.id,
|
sentBy: user?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,9 +220,14 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send custom email to specific recipients
|
// Send custom email to specific recipients
|
||||||
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidator('json', sendCustomEmailSchema), async (c) => {
|
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const user = (c as any).get('user');
|
const user = (c as any).get('user');
|
||||||
const { to, toName, subject, bodyHtml, bodyText, eventId } = c.req.valid('json');
|
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 result = await emailService.sendCustomEmail({
|
const result = await emailService.sendCustomEmail({
|
||||||
to,
|
to,
|
||||||
@@ -315,7 +272,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
: template.bodyHtml;
|
: template.bodyHtml;
|
||||||
|
|
||||||
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
||||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
|
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
||||||
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -331,9 +288,8 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const search = c.req.query('search');
|
const search = c.req.query('search');
|
||||||
// Clamp pagination so a NaN / out-of-range value can't produce undefined query behaviour.
|
const limit = parseInt(c.req.query('limit') || '50');
|
||||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1), 200);
|
const offset = parseInt(c.req.query('offset') || '0');
|
||||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
|
||||||
|
|
||||||
let query = (db as any).select().from(emailLogs);
|
let query = (db as any).select().from(emailLogs);
|
||||||
|
|
||||||
@@ -485,7 +441,7 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
|||||||
|
|
||||||
// Get email queue status
|
// Get email queue status
|
||||||
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
|
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
|
||||||
const status = await getQueueStatus();
|
const status = getQueueStatus();
|
||||||
return c.json({ status });
|
return c.json({ status });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+96
-121
@@ -7,7 +7,6 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
|
|||||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -128,13 +127,8 @@ const baseEventSchema = z.object({
|
|||||||
currency: z.string().default('PYG'),
|
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),
|
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'),
|
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||||
// Accept relative paths (/uploads/...) or http(s) URLs only — reject schemes like
|
// Accept relative paths (/uploads/...) or full URLs
|
||||||
// javascript:/data: that could be reflected into an href/src on the frontend.
|
bannerUrl: z.string().optional().nullable().or(z.literal('')),
|
||||||
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)
|
// External booking support - accept boolean or number (0/1 from DB)
|
||||||
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
||||||
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
@@ -172,60 +166,51 @@ const updateEventSchema = baseEventSchema.partial().refine(
|
|||||||
eventsRouter.get('/', async (c) => {
|
eventsRouter.get('/', async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const upcoming = c.req.query('upcoming');
|
const upcoming = c.req.query('upcoming');
|
||||||
|
|
||||||
// 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') {
|
|
||||||
// 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);
|
let query = (db as any).select().from(events);
|
||||||
if (conditions.length > 0) {
|
|
||||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
if (status) {
|
||||||
|
query = query.where(eq((events as any).status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
|
if (upcoming === 'true') {
|
||||||
|
const now = getNow();
|
||||||
// Single grouped query for seat counts across all events (avoids N+1: previously
|
query = query.where(
|
||||||
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
and(
|
||||||
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
eq((events as any).status, 'published'),
|
||||||
// so availableSeats subtracts them together — the same formula the booking-creation
|
gte((events as any).startDatetime, now)
|
||||||
// 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 result = await dbAll(query.orderBy(desc((events as any).startDatetime)));
|
||||||
const normalized = normalizeEvent(event);
|
|
||||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
// Get ticket counts for each event
|
||||||
return {
|
const eventsWithCounts = await Promise.all(
|
||||||
...normalized,
|
result.map(async (event: any) => {
|
||||||
bookedCount: counts.paid,
|
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||||
claimedCount: counts.claimed,
|
// This ensures check-in doesn't affect capacity/spots_left
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
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),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ events: eventsWithCounts });
|
return c.json({ events: eventsWithCounts });
|
||||||
});
|
});
|
||||||
@@ -238,24 +223,28 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draft events are only visible to privileged users (admin preview); hide from public.
|
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||||
if ((event as any).status === 'draft') {
|
// This ensures check-in doesn't affect capacity/spots_left
|
||||||
const authUser: any = await getAuthUser(c);
|
const ticketCount = await dbGet<any>(
|
||||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
(db as any)
|
||||||
if (!isPrivileged) {
|
.select({ count: sql<number>`count(*)` })
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
.from(tickets)
|
||||||
}
|
.where(
|
||||||
}
|
and(
|
||||||
|
eq((tickets as any).eventId, event.id),
|
||||||
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const normalized = normalizeEvent(event);
|
const normalized = normalizeEvent(event);
|
||||||
const counts = await getEventSeatCounts(event.id);
|
const bookedCount = ticketCount?.count || 0;
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount: counts.paid,
|
bookedCount,
|
||||||
claimedCount: counts.claimed,
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -267,56 +256,22 @@ async function getSiteTimezone(): Promise<string> {
|
|||||||
return settings?.timezone || 'America/Asuncion';
|
return settings?.timezone || 'America/Asuncion';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
|
// Helper function to get ticket count for an event
|
||||||
// counts for one event — see lib/capacity.ts for the seat-holding rule.
|
async function getEventTicketCount(eventId: string): Promise<number> {
|
||||||
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
|
const ticketCount = await dbGet<any>(
|
||||||
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
|
|
||||||
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)
|
(db as any)
|
||||||
.select()
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(events)
|
.from(tickets)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq((events as any).status, 'published'),
|
eq((tickets as any).eventId, eventId),
|
||||||
gte((events as any).startDatetime, now)
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.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
|
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
|
||||||
eventsRouter.get('/next/upcoming', async (c) => {
|
eventsRouter.get('/next/upcoming', async (c) => {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
@@ -367,27 +322,47 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
|
|
||||||
// If we have a valid featured event, return it
|
// If we have a valid featured event, return it
|
||||||
if (featuredEvent) {
|
if (featuredEvent) {
|
||||||
const counts = await getEventSeatCounts(featuredEvent.id);
|
const bookedCount = await getEventTicketCount(featuredEvent.id);
|
||||||
const normalized = normalizeEvent(featuredEvent);
|
const normalized = normalizeEvent(featuredEvent);
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...normalized,
|
||||||
bookedCount: counts.paid,
|
bookedCount,
|
||||||
claimedCount: counts.claimed,
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: get the next upcoming published event
|
// Fallback: get the next upcoming published event
|
||||||
const event = await getNextChronologicalUpcoming();
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ event: null });
|
return c.json({ event: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ event: { ...event, isFeatured: false } });
|
const bookedCount = await getEventTicketCount(event.id);
|
||||||
|
const normalized = normalizeEvent(event);
|
||||||
|
return c.json({
|
||||||
|
event: {
|
||||||
|
...normalized,
|
||||||
|
bookedCount,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
|
isFeatured: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create event (admin/organizer only)
|
// Create event (admin/organizer only)
|
||||||
|
|||||||
@@ -6,22 +6,6 @@ import { getNow, generateId } from '../lib/utils.js';
|
|||||||
|
|
||||||
const faqRouter = new Hono();
|
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 ====================
|
// ==================== Public Routes ====================
|
||||||
|
|
||||||
// Get FAQ list for public (only enabled; optional filter for homepage)
|
// Get FAQ list for public (only enabled; optional filter for homepage)
|
||||||
@@ -114,11 +98,6 @@ faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json({ error: 'Question and answer (EN) are required' }, 400);
|
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 now = getNow();
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
|
|
||||||
@@ -178,11 +157,6 @@ faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json({ error: 'FAQ not found' }, 404);
|
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> = {
|
const updateData: Record<string, unknown> = {
|
||||||
updatedAt: getNow(),
|
updatedAt: getNow(),
|
||||||
};
|
};
|
||||||
@@ -235,15 +209,6 @@ faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json({ error: 'ids array is required' }, 400);
|
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();
|
const now = getNow();
|
||||||
for (let i = 0; i < ids.length; i++) {
|
for (let i = 0; i < ids.length; i++) {
|
||||||
await (db as any)
|
await (db as any)
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ import path from 'path';
|
|||||||
|
|
||||||
const legalPagesRouter = new Hono();
|
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
|
// Helper: Convert markdown to plain text for editing
|
||||||
function markdownToText(markdown: string): string {
|
function markdownToText(markdown: string): string {
|
||||||
if (!markdown) return '';
|
if (!markdown) return '';
|
||||||
@@ -144,13 +162,6 @@ legalPagesRouter.get('/', async (c) => {
|
|||||||
legalPagesRouter.get('/:slug', async (c) => {
|
legalPagesRouter.get('/:slug', async (c) => {
|
||||||
const { slug } = c.req.param();
|
const { slug } = c.req.param();
|
||||||
const locale = c.req.query('locale') || 'en';
|
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
|
// First try to get from database
|
||||||
const page = await dbGet<any>(
|
const page = await dbGet<any>(
|
||||||
@@ -264,17 +275,6 @@ legalPagesRouter.put('/admin/:slug', requireAuth(['admin']), async (c) => {
|
|||||||
if (!enContent && !esContent) {
|
if (!enContent && !esContent) {
|
||||||
return c.json({ error: 'At least one language content is required' }, 400);
|
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(
|
const existing = await dbGet(
|
||||||
(db as any)
|
(db as any)
|
||||||
|
|||||||
+61
-334
@@ -1,36 +1,19 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { streamSSE } from 'hono/streaming';
|
import { streamSSE } from 'hono/streaming';
|
||||||
import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
|
||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getNow, toDbDate } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
import {
|
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||||
verifyWebhookPayment,
|
|
||||||
getPaymentStatus,
|
|
||||||
createInvoice,
|
|
||||||
isLNbitsConfigured,
|
|
||||||
LNBITS_INVOICE_EXPIRY_SECONDS,
|
|
||||||
} from '../lib/lnbits.js';
|
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { getPubSub } from '../lib/stores/pubsub.js';
|
|
||||||
import { getLock, LockUnavailableError } from '../lib/stores/lock.js';
|
|
||||||
|
|
||||||
const lnbitsRouter = new Hono();
|
const lnbitsRouter = new Hono();
|
||||||
|
|
||||||
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
|
// Store for active SSE connections (ticketId -> Set of response writers)
|
||||||
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
|
const activeConnections = new Map<string, Set<(data: any) => void>>();
|
||||||
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)
|
// Store for active background checkers (ticketId -> intervalId)
|
||||||
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
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
|
* LNbits webhook payload structure
|
||||||
*/
|
*/
|
||||||
@@ -49,85 +32,32 @@ interface LNbitsWebhookPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify every client for a ticket across all instances.
|
* Notify all connected clients for a ticket
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
async function notifyClients(ticketId: string, data: any) {
|
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);
|
const connections = activeConnections.get(ticketId);
|
||||||
if (connections) {
|
if (connections) {
|
||||||
await Promise.all(
|
connections.forEach(send => {
|
||||||
Array.from(connections).map(async (send) => {
|
try {
|
||||||
try {
|
send(data);
|
||||||
await send(data);
|
} catch (e) {
|
||||||
} catch (e) {
|
// Connection might be closed
|
||||||
// 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.
|
|
||||||
*/
|
*/
|
||||||
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
||||||
// Don't start if already checking on this instance
|
// Don't start if already checking
|
||||||
if (activeCheckers.has(ticketId)) {
|
if (activeCheckers.has(ticketId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
const startTime = Date.now();
|
||||||
|
const expiryMs = expirySeconds * 1000;
|
||||||
let checkCount = 0;
|
let checkCount = 0;
|
||||||
|
|
||||||
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
|
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
|
||||||
@@ -141,8 +71,7 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
|
|||||||
console.log(`Invoice expired for ticket ${ticketId}`);
|
console.log(`Invoice expired for ticket ${ticketId}`);
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
activeCheckers.delete(ticketId);
|
activeCheckers.delete(ticketId);
|
||||||
releaseCheckerLock(ticketId);
|
notifyClients(ticketId, { type: 'expired', ticketId });
|
||||||
await notifyClients(ticketId, { type: 'expired', ticketId });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +82,9 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
|
|||||||
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
|
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
activeCheckers.delete(ticketId);
|
activeCheckers.delete(ticketId);
|
||||||
releaseCheckerLock(ticketId);
|
|
||||||
|
|
||||||
await handlePaymentComplete(ticketId, paymentHash);
|
await handlePaymentComplete(ticketId, paymentHash);
|
||||||
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error checking payment for ticket ${ticketId}:`, error);
|
console.error(`Error checking payment for ticket ${ticketId}:`, error);
|
||||||
@@ -174,7 +102,6 @@ function stopBackgroundChecker(ticketId: string) {
|
|||||||
if (interval) {
|
if (interval) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
activeCheckers.delete(ticketId);
|
activeCheckers.delete(ticketId);
|
||||||
releaseCheckerLock(ticketId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,23 +111,13 @@ function stopBackgroundChecker(ticketId: string) {
|
|||||||
*/
|
*/
|
||||||
lnbitsRouter.post('/webhook', async (c) => {
|
lnbitsRouter.post('/webhook', async (c) => {
|
||||||
try {
|
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();
|
const payload: LNbitsWebhookPayload = await c.req.json();
|
||||||
|
|
||||||
// Log identifiers only (no full payload / PII)
|
|
||||||
console.log('LNbits webhook received:', {
|
console.log('LNbits webhook received:', {
|
||||||
paymentHash: payload.payment_hash,
|
paymentHash: payload.payment_hash,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
|
amount: payload.amount,
|
||||||
|
extra: payload.extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify the payment is actually complete by checking with LNbits
|
// Verify the payment is actually complete by checking with LNbits
|
||||||
@@ -218,27 +135,13 @@ lnbitsRouter.post('/webhook', async (c) => {
|
|||||||
return c.json({ received: true, processed: false }, 200);
|
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
|
// Stop background checker since webhook confirmed payment
|
||||||
stopBackgroundChecker(ticketId);
|
stopBackgroundChecker(ticketId);
|
||||||
|
|
||||||
await handlePaymentComplete(ticketId, payload.payment_hash);
|
await handlePaymentComplete(ticketId, payload.payment_hash);
|
||||||
|
|
||||||
// Notify connected clients via SSE
|
// Notify connected clients via SSE
|
||||||
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
|
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
|
||||||
|
|
||||||
return c.json({ received: true, processed: true }, 200);
|
return c.json({ received: true, processed: true }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -283,36 +186,28 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
|||||||
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
|
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
// Confirm all tickets in the booking
|
||||||
let transitioned = 0;
|
|
||||||
for (const ticket of ticketsToConfirm) {
|
for (const ticket of ticketsToConfirm) {
|
||||||
const result: any = await (db as any)
|
// Update ticket status to confirmed
|
||||||
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
.set({ status: 'confirmed' })
|
||||||
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
|
.where(eq((tickets as any).id, ticket.id));
|
||||||
transitioned += result?.changes ?? result?.rowCount ?? 0;
|
|
||||||
|
// Update payment status to paid
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(payments)
|
.update(payments)
|
||||||
.set({
|
.set({
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
reference: paymentHash,
|
reference: paymentHash,
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
|
.where(eq((payments as any).ticketId, ticket.id));
|
||||||
|
|
||||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the caller that actually flipped rows sends the emails. The webhook
|
|
||||||
// and the background poller (and pollers on multiple replicas during a Redis
|
|
||||||
// outage) can all land here; without this guard they would each email.
|
|
||||||
if (transitioned === 0) {
|
|
||||||
console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get primary payment for sending receipt
|
// Get primary payment for sending receipt
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -320,7 +215,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
|||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).ticketId, ticketId))
|
.where(eq((payments as any).ticketId, ticketId))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send confirmation emails asynchronously
|
// Send confirmation emails asynchronously
|
||||||
// For multi-ticket bookings, send email with all ticket info
|
// For multi-ticket bookings, send email with all ticket info
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -347,45 +242,38 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
|||||||
return c.json({ error: 'Ticket not found' }, 404);
|
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
|
// Get payment to start background checker
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start background checker if not already running (only while still pending)
|
// Start background checker if not already running
|
||||||
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
|
if (payment?.reference && !activeCheckers.has(ticketId)) {
|
||||||
await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
|
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) => {
|
return streamSSE(c, async (stream) => {
|
||||||
const sendEvent = async (data: any) => {
|
// Register this connection
|
||||||
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)) {
|
if (!activeConnections.has(ticketId)) {
|
||||||
activeConnections.set(ticketId, new Set());
|
activeConnections.set(ticketId, new Set());
|
||||||
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
|
|
||||||
void deliverLocal(ticketId, data);
|
|
||||||
});
|
|
||||||
channelUnsubs.set(ticketId, unsub);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendEvent = (data: any) => {
|
||||||
|
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
|
||||||
|
};
|
||||||
|
|
||||||
activeConnections.get(ticketId)!.add(sendEvent);
|
activeConnections.get(ticketId)!.add(sendEvent);
|
||||||
|
|
||||||
// Send initial status
|
// Send initial status
|
||||||
await sendEvent({ type: 'connected', ticketId });
|
await stream.writeSSE({
|
||||||
|
data: JSON.stringify({ type: 'connected', ticketId }),
|
||||||
|
event: 'payment'
|
||||||
|
});
|
||||||
|
|
||||||
// Keep connection alive with heartbeat
|
// Keep connection alive with heartbeat
|
||||||
const heartbeat = setInterval(async () => {
|
const heartbeat = setInterval(async () => {
|
||||||
@@ -396,186 +284,25 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
|||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
const cleanup = () => {
|
// Clean up on disconnect
|
||||||
|
stream.onAbort(() => {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
const connections = activeConnections.get(ticketId);
|
const connections = activeConnections.get(ticketId);
|
||||||
if (connections) {
|
if (connections) {
|
||||||
connections.delete(sendEvent);
|
connections.delete(sendEvent);
|
||||||
if (connections.size === 0) {
|
if (connections.size === 0) {
|
||||||
activeConnections.delete(ticketId);
|
activeConnections.delete(ticketId);
|
||||||
// Drop the pub/sub subscription once no local sockets remain.
|
|
||||||
const unsub = channelUnsubs.get(ticketId);
|
|
||||||
if (unsub) {
|
|
||||||
unsub();
|
|
||||||
channelUnsubs.delete(ticketId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Clean up on disconnect
|
// Keep the stream open
|
||||||
stream.onAbort(cleanup);
|
while (true) {
|
||||||
|
await stream.sleep(30000);
|
||||||
// Keep the stream open, and every 15s fall back to reading the ticket
|
|
||||||
// status from the DB. Pub/sub is best-effort: a message published while the
|
|
||||||
// subscriber connection was reconnecting is lost, and without this check
|
|
||||||
// the client would wait forever on a payment that already confirmed.
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
await stream.sleep(15000);
|
|
||||||
const current = await dbGet<any>(
|
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
|
||||||
);
|
|
||||||
if (current?.status === 'confirmed') {
|
|
||||||
await sendEvent({ type: 'paid', ticketId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a Lightning invoice for a ticket to pay or re-pay.
|
|
||||||
*
|
|
||||||
* Reuses the stored invoice if it still has more than 5 minutes of validity
|
|
||||||
* left; otherwise generates a fresh one from LNbits. This is what lets a user
|
|
||||||
* come back to an unpaid Lightning booking later (e.g. from "Pay now" on the
|
|
||||||
* dashboard) instead of hitting a dead end.
|
|
||||||
*/
|
|
||||||
lnbitsRouter.post('/invoice/:ticketId', async (c) => {
|
|
||||||
const ticketId = c.req.param('ticketId');
|
|
||||||
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
|
||||||
);
|
|
||||||
if (!ticket) {
|
|
||||||
return c.json({ error: 'Ticket not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
|
||||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
|
||||||
);
|
|
||||||
if (!payment) {
|
|
||||||
return c.json({ error: 'Payment not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payment.provider !== 'lightning') {
|
|
||||||
return c.json({ error: 'This booking is not a Lightning payment' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticket.status === 'confirmed' || payment.status === 'paid') {
|
|
||||||
return c.json({ alreadyPaid: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticket.status !== 'pending' || payment.status !== 'pending') {
|
|
||||||
return c.json({
|
|
||||||
error: 'This booking is no longer active. Please make a new booking.',
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather every ticket/payment in the booking group - a multi-ticket booking
|
|
||||||
// shares a single Lightning invoice for the combined total.
|
|
||||||
let groupTickets: any[] = [ticket];
|
|
||||||
if (ticket.bookingId) {
|
|
||||||
groupTickets = await dbAll<any>(
|
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const groupPayments = await dbAll<any>(
|
|
||||||
(db as any).select().from(payments).where(inArray((payments as any).ticketId, groupTickets.map((t: any) => t.id)))
|
|
||||||
);
|
|
||||||
const invoiceHolder = groupPayments.find((p: any) => p.reference) || payment;
|
|
||||||
const totalAmount = groupPayments.reduce((sum: number, p: any) => sum + Number(p.amount), 0);
|
|
||||||
const currency = invoiceHolder.currency;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
||||||
const expiresAtMs = invoiceHolder.lnbitsExpiresAt ? new Date(invoiceHolder.lnbitsExpiresAt).getTime() : 0;
|
|
||||||
|
|
||||||
if (invoiceHolder.reference && invoiceHolder.lnbitsInvoice && expiresAtMs - now > FIVE_MIN_MS) {
|
|
||||||
return c.json({
|
|
||||||
invoice: {
|
|
||||||
paymentHash: invoiceHolder.reference,
|
|
||||||
paymentRequest: invoiceHolder.lnbitsInvoice,
|
|
||||||
amount: invoiceHolder.lnbitsAmountSats || 0,
|
|
||||||
fiatAmount: totalAmount,
|
|
||||||
fiatCurrency: currency,
|
|
||||||
expiresAt: invoiceHolder.lnbitsExpiresAt,
|
|
||||||
},
|
|
||||||
reused: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// No usable stored invoice (never created, expired, or expiring soon) - get a fresh one.
|
|
||||||
if (!isLNbitsConfigured()) {
|
|
||||||
return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
|
||||||
);
|
|
||||||
|
|
||||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
|
||||||
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
|
||||||
const webhookUrl = webhookSecret
|
|
||||||
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
|
|
||||||
: `${apiUrl}/api/lnbits/webhook`;
|
|
||||||
const attendeeName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lnbitsInvoice = await createInvoice({
|
|
||||||
amount: totalAmount,
|
|
||||||
unit: currency,
|
|
||||||
memo: `Spanglish: ${event?.title || 'Event'} - ${attendeeName}${groupTickets.length > 1 ? ` (${groupTickets.length} tickets)` : ''}`,
|
|
||||||
webhookUrl,
|
|
||||||
expiry: LNBITS_INVOICE_EXPIRY_SECONDS,
|
|
||||||
extra: {
|
|
||||||
ticketId: invoiceHolder.ticketId,
|
|
||||||
bookingId: ticket.bookingId || null,
|
|
||||||
ticketIds: groupTickets.map((t: any) => t.id),
|
|
||||||
eventId: ticket.eventId,
|
|
||||||
eventTitle: event?.title,
|
|
||||||
attendeeName,
|
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
|
||||||
ticketCount: groupTickets.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const lnbitsExpiresAt = toDbDate(new Date(now + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
|
|
||||||
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({
|
|
||||||
reference: lnbitsInvoice.paymentHash,
|
|
||||||
lnbitsInvoice: lnbitsInvoice.paymentRequest,
|
|
||||||
lnbitsExpiresAt,
|
|
||||||
lnbitsAmountSats: lnbitsInvoice.amount,
|
|
||||||
updatedAt: getNow(),
|
|
||||||
})
|
|
||||||
.where(eq((payments as any).id, invoiceHolder.id));
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
invoice: {
|
|
||||||
paymentHash: lnbitsInvoice.paymentHash,
|
|
||||||
paymentRequest: lnbitsInvoice.paymentRequest,
|
|
||||||
amount: lnbitsInvoice.amount,
|
|
||||||
fiatAmount: lnbitsInvoice.fiatAmount ?? totalAmount,
|
|
||||||
fiatCurrency: lnbitsInvoice.fiatCurrency ?? currency,
|
|
||||||
expiresAt: lnbitsExpiresAt,
|
|
||||||
},
|
|
||||||
reused: false,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to create Lightning invoice:', error);
|
|
||||||
return c.json({
|
|
||||||
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`,
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get payment status for a ticket (fallback polling endpoint)
|
* Get payment status for a ticket (fallback polling endpoint)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+38
-65
@@ -1,51 +1,24 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { db, dbGet, dbAll, media } from '../db/index.js';
|
import { db, dbGet, dbAll, media } from '../db/index.js';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow } from '../lib/utils.js';
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
import { getStorage, keyFromUrl } from '../lib/storage.js';
|
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join, extname } from 'path';
|
||||||
|
|
||||||
const mediaRouter = new Hono();
|
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 =
|
const MAX_FILE_SIZE =
|
||||||
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
|
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
|
||||||
|
|
||||||
/**
|
// Ensure upload directory exists
|
||||||
* Detect a real image type from the file's magic bytes (content sniffing).
|
async function ensureUploadDir() {
|
||||||
* Returns the canonical mime + extension, or null if the content is not an
|
if (!existsSync(UPLOAD_DIR)) {
|
||||||
* allowed image. We deliberately ignore the client-supplied filename and
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
* 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
|
// Upload image
|
||||||
@@ -58,28 +31,28 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
return c.json({ error: 'No file provided' }, 400);
|
return c.json({ error: 'No file provided' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size (cheap check before reading the whole buffer)
|
// 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
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
|
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
|
||||||
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
|
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the bytes and validate the *content* (not the client-provided type/name)
|
await ensureUploadDir();
|
||||||
const arrayBuffer = await file.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)
|
// Generate unique filename
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
const filename = `${id}${detected.ext}`;
|
const ext = extname(file.name) || '.jpg';
|
||||||
|
const filename = `${id}${ext}`;
|
||||||
// Persist via the storage backend (local disk or S3-compatible object store).
|
const filepath = join(UPLOAD_DIR, filename);
|
||||||
const storage = getStorage();
|
|
||||||
await storage.put(filename, buffer, detected.mime);
|
// Write file
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
await writeFile(filepath, Buffer.from(arrayBuffer));
|
||||||
|
|
||||||
// Get related info from form data
|
// Get related info from form data
|
||||||
const relatedId = body['relatedId'] as string | undefined;
|
const relatedId = body['relatedId'] as string | undefined;
|
||||||
@@ -89,7 +62,7 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
const now = getNow();
|
const now = getNow();
|
||||||
const mediaRecord = {
|
const mediaRecord = {
|
||||||
id,
|
id,
|
||||||
fileUrl: storage.publicUrl(filename),
|
fileUrl: `/uploads/${filename}`,
|
||||||
type: 'image' as const,
|
type: 'image' as const,
|
||||||
relatedId: relatedId || null,
|
relatedId: relatedId || null,
|
||||||
relatedType: relatedType || null,
|
relatedType: relatedType || null,
|
||||||
@@ -135,9 +108,12 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
return c.json({ error: 'Media not found' }, 404);
|
return c.json({ error: 'Media not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the underlying object from the storage backend.
|
// Delete file from disk
|
||||||
try {
|
try {
|
||||||
await getStorage().delete(keyFromUrl(mediaRecord.fileUrl));
|
const filepath = join('.', mediaRecord.fileUrl);
|
||||||
|
if (existsSync(filepath)) {
|
||||||
|
await unlink(filepath);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete file:', error);
|
console.error('Failed to delete file:', error);
|
||||||
}
|
}
|
||||||
@@ -152,20 +128,17 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const relatedType = c.req.query('relatedType');
|
const relatedType = c.req.query('relatedType');
|
||||||
const relatedId = c.req.query('relatedId');
|
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);
|
let query = (db as any).select().from(media);
|
||||||
if (conditions.length > 0) {
|
|
||||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
if (relatedType) {
|
||||||
|
query = query.where(eq((media as any).relatedType, relatedType));
|
||||||
|
}
|
||||||
|
if (relatedId) {
|
||||||
|
query = query.where(eq((media as any).relatedId, relatedId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(query.limit(limit).offset(offset));
|
const result = await dbAll(query);
|
||||||
|
|
||||||
return c.json({ media: result });
|
return c.json({ media: result });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
|
import { db, dbGet, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
||||||
|
|
||||||
const paymentOptionsRouter = new Hono();
|
const paymentOptionsRouter = new Hono();
|
||||||
@@ -40,23 +40,6 @@ const updatePaymentOptionsSchema = z.object({
|
|||||||
allowDuplicateBookings: booleanOrNumber.optional(),
|
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
|
// Schema for event-level overrides
|
||||||
const updateEventOverridesSchema = z.object({
|
const updateEventOverridesSchema = z.object({
|
||||||
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
||||||
@@ -168,7 +151,6 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
|
|||||||
// Get payment options for a specific event (merged with global)
|
// Get payment options for a specific event (merged with global)
|
||||||
paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||||
const eventId = c.req.param('eventId');
|
const eventId = c.req.param('eventId');
|
||||||
const ticketId = c.req.query('ticketId');
|
|
||||||
|
|
||||||
// Get the event first to verify it exists
|
// Get the event first to verify it exists
|
||||||
const event = await dbGet(
|
const event = await dbGet(
|
||||||
@@ -247,24 +229,8 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
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({
|
return c.json({
|
||||||
paymentOptions: revealSensitive ? merged : publicPaymentOptions(merged),
|
paymentOptions: merged,
|
||||||
hasOverrides: !!overrides,
|
hasOverrides: !!overrides,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+101
-306
@@ -2,16 +2,15 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
|
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
|
||||||
import { eq, desc, and, or, sql, inArray } from 'drizzle-orm';
|
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
|
||||||
|
|
||||||
const paymentsRouter = new Hono();
|
const paymentsRouter = new Hono();
|
||||||
|
|
||||||
const updatePaymentSchema = z.object({
|
const updatePaymentSchema = z.object({
|
||||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']),
|
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
|
||||||
reference: z.string().optional(),
|
reference: z.string().optional(),
|
||||||
adminNote: z.string().optional(),
|
adminNote: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -19,9 +18,6 @@ const updatePaymentSchema = z.object({
|
|||||||
const approvePaymentSchema = z.object({
|
const approvePaymentSchema = z.object({
|
||||||
adminNote: z.string().optional(),
|
adminNote: z.string().optional(),
|
||||||
sendEmail: z.boolean().optional().default(true),
|
sendEmail: z.boolean().optional().default(true),
|
||||||
// Admin override: confirm the booking even when it puts the event over
|
|
||||||
// capacity. The UI asks for explicit confirmation before sending this.
|
|
||||||
allowOverCapacity: z.boolean().optional().default(false),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rejectPaymentSchema = z.object({
|
const rejectPaymentSchema = z.object({
|
||||||
@@ -29,10 +25,6 @@ const rejectPaymentSchema = z.object({
|
|||||||
sendEmail: z.boolean().optional().default(true),
|
sendEmail: z.boolean().optional().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const reopenPaymentSchema = z.object({
|
|
||||||
adminNote: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all payments (admin) - with ticket and event details
|
// Get all payments (admin) - with ticket and event details
|
||||||
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
@@ -93,7 +85,6 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
attendeeLastName: ticket.attendeeLastName,
|
attendeeLastName: ticket.attendeeLastName,
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
attendeePhone: ticket.attendeePhone,
|
attendeePhone: ticket.attendeePhone,
|
||||||
attendeeRuc: ticket.attendeeRuc,
|
|
||||||
status: ticket.status,
|
status: ticket.status,
|
||||||
} : null,
|
} : null,
|
||||||
event: event ? {
|
event: event ? {
|
||||||
@@ -104,7 +95,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter by event(s)
|
// Filter by event(s)
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
||||||
@@ -171,31 +162,6 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
|||||||
return c.json({ payments: enrichedPayments });
|
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)
|
// Get payment by ID (admin)
|
||||||
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
@@ -239,16 +205,10 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
return c.json({ error: 'Payment not found' }, 404);
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirming a failed payment must go through /approve, which re-checks event
|
|
||||||
// capacity before re-reserving the (previously released) seat. Block the raw path.
|
|
||||||
if (data.status === 'paid' && existing.status === 'failed') {
|
|
||||||
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
const updateData: any = { ...data, updatedAt: now };
|
const updateData: any = { ...data, updatedAt: now };
|
||||||
|
|
||||||
// If marking as paid, record who approved it and when
|
// If marking as paid, record who approved it and when
|
||||||
if (data.status === 'paid' && existing.status !== 'paid') {
|
if (data.status === 'paid' && existing.status !== 'paid') {
|
||||||
updateData.paidAt = now;
|
updateData.paidAt = now;
|
||||||
@@ -288,7 +248,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
.set({ status: 'confirmed' })
|
||||||
.where(eq((tickets as any).id, (t as any).id));
|
.where(eq((tickets as any).id, (t as any).id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,28 +280,27 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
|||||||
// Approve payment (admin) - specifically for pending_approval payments
|
// Approve payment (admin) - specifically for pending_approval payments
|
||||||
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const { adminNote, sendEmail, allowOverCapacity } = c.req.valid('json');
|
const { adminNote, sendEmail } = c.req.valid('json');
|
||||||
const user = (c as any).get('user');
|
const user = (c as any).get('user');
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).id, id))
|
.where(eq((payments as any).id, id))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!payment) {
|
if (!payment) {
|
||||||
return c.json({ error: 'Payment not found' }, 404);
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can approve pending, pending_approval, on_hold, or failed payments.
|
// Can approve pending or pending_approval payments
|
||||||
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
|
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||||
// in error; its tickets are cancelled, so recovery re-checks capacity below.
|
|
||||||
// Bare 'pending' covers customers who paid but never clicked "I've paid".
|
|
||||||
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
|
|
||||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
|
||||||
// Get the ticket associated with this payment
|
// Get the ticket associated with this payment
|
||||||
const ticket = await dbGet<any>(
|
const ticket = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -349,10 +308,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
.where(eq((tickets as any).id, payment.ticketId))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if this is part of a multi-ticket booking
|
// Check if this is part of a multi-ticket booking
|
||||||
let ticketsToConfirm: any[] = [ticket];
|
let ticketsToConfirm: any[] = [ticket];
|
||||||
|
|
||||||
if (ticket?.bookingId) {
|
if (ticket?.bookingId) {
|
||||||
// Get all tickets in this booking
|
// Get all tickets in this booking
|
||||||
ticketsToConfirm = await dbAll(
|
ticketsToConfirm = await dbAll(
|
||||||
@@ -363,57 +322,27 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
|||||||
);
|
);
|
||||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For a failed payment, only recover tickets whose own payment is also failed.
|
// Update all payments in the booking to paid
|
||||||
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
|
for (const t of ticketsToConfirm) {
|
||||||
// resurrected or having its payment flipped to paid.
|
await (db as any)
|
||||||
if (payment.status === 'failed') {
|
.update(payments)
|
||||||
const bookingPayments = await dbAll<any>(
|
.set({
|
||||||
(db as any)
|
status: 'paid',
|
||||||
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
paidAt: now,
|
||||||
.from(payments)
|
|
||||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
|
|
||||||
);
|
|
||||||
const failedTicketIds = new Set(
|
|
||||||
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
|
||||||
);
|
|
||||||
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
|
|
||||||
if (ticketsToConfirm.length === 0) {
|
|
||||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm the booking through the shared capacity-checked reservation.
|
|
||||||
// Tickets that already hold a seat ('pending_approval' claims) cost no new
|
|
||||||
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
|
|
||||||
// When the event is full, the admin gets a structured over-capacity error and
|
|
||||||
// may retry with allowOverCapacity to knowingly overbook.
|
|
||||||
try {
|
|
||||||
await reserveOnHoldBooking(
|
|
||||||
ticket.eventId,
|
|
||||||
ticketsToConfirm.map((t: any) => t.id),
|
|
||||||
'confirmed',
|
|
||||||
'paid',
|
|
||||||
{
|
|
||||||
paidByAdminId: user.id,
|
paidByAdminId: user.id,
|
||||||
fromTicketStatuses:
|
adminNote: adminNote || payment.adminNote,
|
||||||
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'],
|
updatedAt: now,
|
||||||
skipCapacityCheck: allowOverCapacity,
|
})
|
||||||
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
|
.where(eq((payments as any).ticketId, (t as any).id));
|
||||||
}
|
|
||||||
);
|
// Update ticket status to confirmed
|
||||||
} catch (err) {
|
await (db as any)
|
||||||
if (err instanceof HoldCapacityError) {
|
.update(tickets)
|
||||||
return c.json({
|
.set({ status: 'confirmed' })
|
||||||
error: 'Approving this payment puts the event over capacity.',
|
.where(eq((tickets as any).id, (t as any).id));
|
||||||
code: 'EVENT_OVER_CAPACITY',
|
|
||||||
availableSeats: err.available,
|
|
||||||
requestedSeats: ticketsToConfirm.length,
|
|
||||||
}, 409);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||||
if (sendEmail !== false) {
|
if (sendEmail !== false) {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -453,45 +382,31 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
|||||||
return c.json({ error: 'Payment not found' }, 404);
|
return c.json({ error: 'Payment not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||||
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Determine all tickets in this booking (multi-ticket bookings must be rejected together)
|
// Update payment status to failed
|
||||||
const rejectTicket = await dbGet<any>(
|
await (db as any)
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
.update(payments)
|
||||||
);
|
.set({
|
||||||
let ticketsToReject: any[] = rejectTicket ? [rejectTicket] : [];
|
status: 'failed',
|
||||||
if (rejectTicket?.bookingId) {
|
paidByAdminId: user.id,
|
||||||
ticketsToReject = await dbAll<any>(
|
adminNote: adminNote || payment.adminNote,
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, rejectTicket.bookingId))
|
updatedAt: now,
|
||||||
);
|
})
|
||||||
console.log(`[Payment] Rejecting multi-ticket booking: ${rejectTicket.bookingId}, ${ticketsToReject.length} tickets`);
|
.where(eq((payments as any).id, id));
|
||||||
}
|
|
||||||
|
// Cancel the ticket - booking is no longer valid after rejection
|
||||||
for (const t of ticketsToReject) {
|
await (db as any)
|
||||||
// Fail the payment for each ticket in the booking
|
.update(tickets)
|
||||||
await (db as any)
|
.set({
|
||||||
.update(payments)
|
status: 'cancelled',
|
||||||
.set({
|
updatedAt: now,
|
||||||
status: 'failed',
|
})
|
||||||
paidByAdminId: user.id,
|
.where(eq((tickets as any).id, payment.ticketId));
|
||||||
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)
|
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
|
||||||
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
|
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||||
@@ -512,140 +427,6 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
|||||||
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
|
|
||||||
paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
|
||||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!payment) {
|
|
||||||
return c.json({ error: 'Payment not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payment.status !== 'on_hold') {
|
|
||||||
return c.json({ error: 'Only on-hold payments can be reactivated' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
|
||||||
);
|
|
||||||
if (!ticket) {
|
|
||||||
return c.json({ error: 'Ticket not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ticketsToReactivate: any[] = [ticket];
|
|
||||||
if (ticket.bookingId) {
|
|
||||||
ticketsToReactivate = await dbAll<any>(
|
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await reserveOnHoldBooking(
|
|
||||||
ticket.eventId,
|
|
||||||
ticketsToReactivate.map((t: any) => t.id),
|
|
||||||
'pending',
|
|
||||||
'pending_approval'
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof HoldCapacityError) {
|
|
||||||
return c.json({
|
|
||||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await dbGet(
|
|
||||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
|
|
||||||
// For when a payment was failed in error (auto-fail or rejection) and should
|
|
||||||
// return to the normal pending flow (reminders, "I've paid", approve/reject).
|
|
||||||
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
const { adminNote } = c.req.valid('json');
|
|
||||||
|
|
||||||
const payment = await dbGet<any>(
|
|
||||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!payment) {
|
|
||||||
return c.json({ error: 'Payment not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payment.status !== 'failed') {
|
|
||||||
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
|
||||||
);
|
|
||||||
if (!ticket) {
|
|
||||||
return c.json({ error: 'Ticket not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ticketsToReopen: any[] = [ticket];
|
|
||||||
if (ticket.bookingId) {
|
|
||||||
ticketsToReopen = await dbAll<any>(
|
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
|
|
||||||
const bookingPayments = await dbAll<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
|
||||||
.from(payments)
|
|
||||||
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
|
|
||||||
);
|
|
||||||
const failedTicketIds = new Set(
|
|
||||||
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
|
||||||
);
|
|
||||||
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
|
|
||||||
if (ticketsToReopen.length === 0) {
|
|
||||||
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reopenIds = ticketsToReopen.map((t: any) => t.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await reserveOnHoldBooking(
|
|
||||||
ticket.eventId,
|
|
||||||
reopenIds,
|
|
||||||
'pending',
|
|
||||||
'pending',
|
|
||||||
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof HoldCapacityError) {
|
|
||||||
return c.json({
|
|
||||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminNote) {
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({ adminNote })
|
|
||||||
.where(inArray((payments as any).ticketId, reopenIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await dbGet(
|
|
||||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send payment reminder email
|
// Send payment reminder email
|
||||||
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
@@ -748,42 +529,56 @@ paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Refund all tickets/payments in the booking (multi-ticket bookings refund together)
|
// Update payment status
|
||||||
const refundTicket = await dbGet<any>(
|
await (db as any)
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
.update(payments)
|
||||||
);
|
.set({ status: 'refunded', updatedAt: now })
|
||||||
let ticketsToRefund: any[] = refundTicket ? [refundTicket] : [];
|
.where(eq((payments as any).id, id));
|
||||||
if (refundTicket?.bookingId) {
|
|
||||||
ticketsToRefund = await dbAll<any>(
|
// Cancel associated ticket
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, refundTicket.bookingId))
|
await (db as any)
|
||||||
);
|
.update(tickets)
|
||||||
console.log(`[Payment] Refunding multi-ticket booking: ${refundTicket.bookingId}, ${ticketsToRefund.length} tickets`);
|
.set({ status: 'cancelled' })
|
||||||
}
|
.where(eq((tickets as any).id, payment.ticketId));
|
||||||
|
|
||||||
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' });
|
return c.json({ message: 'Refund processed successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Payment webhook (for Stripe/MercadoPago)
|
// 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) => {
|
paymentsRouter.post('/webhook', async (c) => {
|
||||||
console.warn('Payment webhook received but provider webhooks are not implemented (no signature verification).');
|
// This would handle webhook notifications from payment providers
|
||||||
return c.json({ error: 'Webhook handling is not implemented' }, 501);
|
// 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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default paymentsRouter;
|
export default paymentsRouter;
|
||||||
|
|||||||
@@ -17,20 +17,9 @@ interface UserContext {
|
|||||||
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||||
|
|
||||||
// Validation schema for updating site settings
|
// 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({
|
const updateSiteSettingsSchema = z.object({
|
||||||
timezone: z.string().refine(isValidTimezone, { message: 'Invalid timezone' }).optional(),
|
timezone: z.string().optional(),
|
||||||
siteName: z.string().max(255).optional(),
|
siteName: z.string().optional(),
|
||||||
siteDescription: z.string().optional().nullable(),
|
siteDescription: z.string().optional().nullable(),
|
||||||
siteDescriptionEs: z.string().optional().nullable(),
|
siteDescriptionEs: z.string().optional().nullable(),
|
||||||
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
|
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
|
||||||
|
|||||||
+300
-474
File diff suppressed because it is too large
Load Diff
+33
-89
@@ -2,10 +2,9 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
||||||
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
import { eq, desc, sql } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { authSessions } from '../db/auth-schema.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
import { getNow, toDbDate } from '../lib/utils.js';
|
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,43 +27,7 @@ const updateUserSchema = z.object({
|
|||||||
// Get all users (admin only)
|
// Get all users (admin only)
|
||||||
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||||
const role = c.req.query('role');
|
const role = c.req.query('role');
|
||||||
const search = c.req.query('search');
|
|
||||||
const accountStatus = c.req.query('accountStatus');
|
|
||||||
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
|
|
||||||
const registeredAfter = c.req.query('registeredAfter');
|
|
||||||
const registeredBefore = c.req.query('registeredBefore');
|
|
||||||
const eventId = c.req.query('eventId');
|
|
||||||
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
|
|
||||||
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
|
|
||||||
|
|
||||||
const conditions: any[] = [];
|
|
||||||
if (role) conditions.push(eq((users as any).role, role));
|
|
||||||
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
|
|
||||||
if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter)));
|
|
||||||
if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore)));
|
|
||||||
if (search) {
|
|
||||||
const like = `%${search.toLowerCase()}%`;
|
|
||||||
conditions.push(sql`(
|
|
||||||
LOWER(${(users as any).name}) LIKE ${like}
|
|
||||||
OR LOWER(${(users as any).email}) LIKE ${like}
|
|
||||||
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
|
|
||||||
)`);
|
|
||||||
}
|
|
||||||
if (hasBookings === 'yes') {
|
|
||||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
|
||||||
} else if (hasBookings === 'no') {
|
|
||||||
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
|
||||||
}
|
|
||||||
if (eventId) {
|
|
||||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
|
|
||||||
}
|
|
||||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const totalQuery = whereClause
|
|
||||||
? (db as any).select({ count: sql<number>`count(*)` }).from(users).where(whereClause)
|
|
||||||
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
|
|
||||||
const totalRow = await dbGet<any>(totalQuery);
|
|
||||||
|
|
||||||
let query = (db as any).select({
|
let query = (db as any).select({
|
||||||
id: (users as any).id,
|
id: (users as any).id,
|
||||||
email: (users as any).email,
|
email: (users as any).email,
|
||||||
@@ -77,39 +40,14 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
accountStatus: (users as any).accountStatus,
|
accountStatus: (users as any).accountStatus,
|
||||||
createdAt: (users as any).createdAt,
|
createdAt: (users as any).createdAt,
|
||||||
}).from(users);
|
}).from(users);
|
||||||
|
|
||||||
if (whereClause) {
|
if (role) {
|
||||||
query = query.where(whereClause);
|
query = query.where(eq((users as any).role, role));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(
|
|
||||||
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 adminCount = await dbGet<any>(
|
const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
|
||||||
(db as any)
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(users)
|
|
||||||
.where(eq((users as any).role, 'admin'))
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({ users: result });
|
||||||
stats: {
|
|
||||||
total: totalUsers?.count || 0,
|
|
||||||
admins: adminCount?.count || 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user by ID (admin or self)
|
// Get user by ID (admin or self)
|
||||||
@@ -176,28 +114,11 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
|||||||
return c.json({ error: 'User not found' }, 404);
|
return c.json({ error: 'User not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the Better Auth admin `banned` flag in sync with accountStatus so
|
|
||||||
// sign-in is blocked at the auth layer too, and kill live sessions on
|
|
||||||
// suspension so it takes effect immediately (sessions are DB-validated on
|
|
||||||
// every request by both the backend and the photo API).
|
|
||||||
const statusMirror: Record<string, any> = {};
|
|
||||||
if (data.accountStatus === 'suspended') {
|
|
||||||
statusMirror.banned = true;
|
|
||||||
statusMirror.banReason = 'Suspended by admin';
|
|
||||||
} else if (data.accountStatus) {
|
|
||||||
statusMirror.banned = false;
|
|
||||||
statusMirror.banReason = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ ...data, ...statusMirror, updatedAt: getNow() })
|
.set({ ...data, updatedAt: getNow() })
|
||||||
.where(eq((users as any).id, id));
|
.where(eq((users as any).id, id));
|
||||||
|
|
||||||
if (data.accountStatus === 'suspended') {
|
|
||||||
await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await dbGet(
|
const updated = await dbGet(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({
|
.select({
|
||||||
@@ -365,4 +286,27 @@ 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;
|
export default usersRouter;
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"declaration": false,
|
"declaration": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
include: ['src/**/*.test.ts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -63,29 +63,6 @@ server {
|
|||||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||||
}
|
}
|
||||||
|
|
||||||
# Photo gallery service (photo-api, port 3020). ^~ wins over the /
|
|
||||||
# prefix below; bigger body cap and unbuffered uploads for photo batches.
|
|
||||||
location ^~ /api/photos/ {
|
|
||||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
|
||||||
|
|
||||||
proxy_pass http://spanglish_photos;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Strip CORS headers from the service (nginx handles CORS here)
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
|
||||||
|
|
||||||
client_max_body_size 100m;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
# Example docker-compose for running the Spanglish API as multiple replicas
|
|
||||||
# behind nginx, with Redis for shared state and Postgres as the database.
|
|
||||||
#
|
|
||||||
# This is a starting point, not a turnkey production setup. It expects a
|
|
||||||
# Dockerfile at backend/Dockerfile that builds the API and runs it on PORT.
|
|
||||||
#
|
|
||||||
# Bring it up with N API replicas:
|
|
||||||
# docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
|
|
||||||
#
|
|
||||||
# No em dashes are used in this file by design.
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: spanglish
|
|
||||||
POSTGRES_PASSWORD: spanglish
|
|
||||||
POSTGRES_DB: spanglish
|
|
||||||
# Raise max_connections if DB_POOL_MAX * replicas approaches the default 100.
|
|
||||||
command: ["postgres", "-c", "max_connections=200"]
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U spanglish"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
# noeviction is deliberate: rate-limit, lock, and lockout keys must never
|
|
||||||
# be evicted for correctness. The cache workload is a single short-TTL key,
|
|
||||||
# so memory pressure is negligible; if caching ever grows, revisit this or
|
|
||||||
# move the cache to its own DB index.
|
|
||||||
command:
|
|
||||||
[
|
|
||||||
"redis-server",
|
|
||||||
"--appendonly", "yes",
|
|
||||||
"--requirepass", "${REDIS_PASSWORD:-change-me-redis-password}",
|
|
||||||
"--maxmemory", "256mb",
|
|
||||||
"--maxmemory-policy", "noeviction",
|
|
||||||
]
|
|
||||||
environment:
|
|
||||||
REDISCLI_AUTH: "${REDIS_PASSWORD:-change-me-redis-password}"
|
|
||||||
volumes:
|
|
||||||
- redisdata:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: ../backend
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: "3001"
|
|
||||||
DB_TYPE: postgres
|
|
||||||
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
|
|
||||||
DB_POOL_MAX: "15"
|
|
||||||
REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379"
|
|
||||||
JWT_SECRET: change-me-to-a-strong-secret
|
|
||||||
FRONTEND_URL: http://localhost:8080
|
|
||||||
# Optional S3-compatible storage so uploads are shared across replicas.
|
|
||||||
# If you omit these, mount a shared volume at /app/uploads on every replica.
|
|
||||||
# S3_ENDPOINT: http://garage:3900
|
|
||||||
# S3_REGION: garage
|
|
||||||
# S3_BUCKET: spanglish-media
|
|
||||||
# S3_ACCESS_KEY_ID: ""
|
|
||||||
# S3_SECRET_ACCESS_KEY: ""
|
|
||||||
# S3_PUBLIC_URL: http://localhost:8080/media
|
|
||||||
expose:
|
|
||||||
- "3001"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# Load balancer across the scaled api replicas. nginx resolves the "api"
|
|
||||||
# service name via Docker's embedded DNS, which round-robins across replicas.
|
|
||||||
lb:
|
|
||||||
image: nginx:alpine
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
volumes:
|
|
||||||
- ./nginx.scale.conf:/etc/nginx/conf.d/default.conf:ro
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
redisdata:
|
|
||||||
@@ -18,28 +18,11 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a
|
|
||||||
# 301 so only one host is served and indexed.
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
http2 on;
|
http2 on;
|
||||||
|
|
||||||
server_name www.spanglishcommunity.com;
|
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
|
||||||
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
return 301 https://spanglishcommunity.com$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
|
|
||||||
server_name spanglishcommunity.com;
|
|
||||||
|
|
||||||
# Upload size limit (covers same-origin /api uploads via this vhost)
|
# Upload size limit (covers same-origin /api uploads via this vhost)
|
||||||
client_max_body_size 20m;
|
client_max_body_size 20m;
|
||||||
@@ -60,43 +43,6 @@ server {
|
|||||||
access_log /var/log/nginx/spanglish_frontend_access.log;
|
access_log /var/log/nginx/spanglish_frontend_access.log;
|
||||||
error_log /var/log/nginx/spanglish_frontend_error.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
|
# Proxy /api to backend
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://spanglish_backend;
|
proxy_pass http://spanglish_backend;
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# Spanglish Community - Photo Gallery API
|
|
||||||
# photos.spanglishcommunity.com
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name photos.spanglishcommunity.com;
|
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://photos.spanglishcommunity.com$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
|
|
||||||
server_name photos.spanglishcommunity.com;
|
|
||||||
|
|
||||||
# Photos can be larger than typical JSON payloads
|
|
||||||
client_max_body_size 25m;
|
|
||||||
|
|
||||||
# SSL
|
|
||||||
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
|
|
||||||
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
# Security
|
|
||||||
add_header X-Frame-Options "DENY" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
access_log /var/log/nginx/spanglish_photo_access.log;
|
|
||||||
error_log /var/log/nginx/spanglish_photo_error.log;
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
set $cors_origin "";
|
|
||||||
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
|
||||||
set $cors_origin $http_origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
|
||||||
|
|
||||||
# Ensure 413 returns JSON + CORS
|
|
||||||
error_page 413 = @payload_too_large;
|
|
||||||
location @payload_too_large {
|
|
||||||
default_type application/json;
|
|
||||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ensure 429 (rate limited) returns JSON + CORS
|
|
||||||
error_page 429 = @rate_limited;
|
|
||||||
location @rate_limited {
|
|
||||||
default_type application/json;
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
return 429 '{"error":"Too many requests. Please slow down."}';
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
limit_req zone=spanglish_photo_limit burst=40 nodelay;
|
|
||||||
|
|
||||||
# Preflight
|
|
||||||
if ($request_method = 'OPTIONS') {
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
|
||||||
add_header 'Access-Control-Max-Age' 86400 always;
|
|
||||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
|
||||||
add_header 'Content-Length' 0;
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy_pass http://spanglish_photo_api;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
|
||||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
|
|
||||||
# Buffer large image uploads to disk rather than memory
|
|
||||||
proxy_request_buffering on;
|
|
||||||
proxy_max_temp_file_size 1024m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# Spanglish Community - Backend API
|
|
||||||
# api.spanglishcommunity.com
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name api.spanglishcommunity.com;
|
|
||||||
|
|
||||||
# ACME
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Force HTTPS
|
|
||||||
location / {
|
|
||||||
return 301 https://api.spanglishcommunity.com$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
|
|
||||||
server_name api.spanglishcommunity.com;
|
|
||||||
|
|
||||||
# Upload size limit (avoid nginx 413 on media uploads)
|
|
||||||
# Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB).
|
|
||||||
client_max_body_size 20m;
|
|
||||||
|
|
||||||
# SSL
|
|
||||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
|
||||||
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
# Security (API)
|
|
||||||
add_header X-Frame-Options "DENY" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
access_log /var/log/nginx/spanglish_api_access.log;
|
|
||||||
error_log /var/log/nginx/spanglish_api_error.log;
|
|
||||||
|
|
||||||
# CORS Configuration (set once, used everywhere)
|
|
||||||
set $cors_origin "";
|
|
||||||
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
|
||||||
set $cors_origin $http_origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add CORS headers to all responses (including nginx-generated errors)
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
|
||||||
|
|
||||||
# Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked")
|
|
||||||
error_page 413 = @payload_too_large;
|
|
||||||
location @payload_too_large {
|
|
||||||
default_type application/json;
|
|
||||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
|
||||||
}
|
|
||||||
|
|
||||||
# Photo gallery service (photo-api, port 3003). ^~ wins over the "/"
|
|
||||||
# prefix below, so /api/photos/* reaches the Go photo-api instead of the
|
|
||||||
# Node backend (which has no photo routes and would 404). The admin UI
|
|
||||||
# calls this cross-origin via NEXT_PUBLIC_API_URL, so preflight + CORS
|
|
||||||
# must be handled here just like location / below.
|
|
||||||
location ^~ /api/photos/ {
|
|
||||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
|
||||||
|
|
||||||
# Handle preflight OPTIONS requests (add_header inside if{} does NOT
|
|
||||||
# inherit server-level headers, so repeat all CORS headers here).
|
|
||||||
if ($request_method = 'OPTIONS') {
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
|
||||||
add_header 'Access-Control-Max-Age' 86400 always;
|
|
||||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
|
||||||
add_header 'Content-Length' 0;
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy_pass http://spanglish_photo_api;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Strip CORS headers from the service (nginx handles CORS here)
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
|
||||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
|
||||||
|
|
||||||
# Photo batches can be large; allow bigger bodies + unbuffered upload.
|
|
||||||
client_max_body_size 100m;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
|
||||||
|
|
||||||
# Handle preflight OPTIONS requests
|
|
||||||
# NOTE: add_header inside if{} does NOT inherit server-level headers,
|
|
||||||
# so we must repeat all CORS headers here.
|
|
||||||
if ($request_method = 'OPTIONS') {
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
|
||||||
add_header 'Access-Control-Max-Age' 86400 always;
|
|
||||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
|
||||||
add_header 'Content-Length' 0;
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy_pass http://spanglish_backend;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Strip CORS headers from backend (nginx handles CORS at server level)
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
|
||||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# Spanglish Community - Frontend
|
|
||||||
# spanglishcommunity.com / www
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
|
||||||
|
|
||||||
# ACME
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Force HTTPS
|
|
||||||
location / {
|
|
||||||
return 301 https://spanglishcommunity.com$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
|
|
||||||
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
|
||||||
|
|
||||||
# Upload size limit (covers same-origin /api uploads via this vhost)
|
|
||||||
client_max_body_size 20m;
|
|
||||||
|
|
||||||
# SSL
|
|
||||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
|
||||||
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
# Security
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
access_log /var/log/nginx/spanglish_frontend_access.log;
|
|
||||||
error_log /var/log/nginx/spanglish_frontend_error.log;
|
|
||||||
|
|
||||||
# Proxy /api/photos to the photo-api (Go service, port 3003). ^~ wins over
|
|
||||||
# the /api prefix below so same-origin image/gallery requests reach the
|
|
||||||
# photo-api instead of the Node backend (which has no photo routes -> 404).
|
|
||||||
location ^~ /api/photos/ {
|
|
||||||
proxy_pass http://spanglish_photo_api;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Photo uploads/batches can be large; allow bigger bodies.
|
|
||||||
client_max_body_size 100m;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
|
|
||||||
# Let the photo-api set Cache-Control per image visibility (public vs.
|
|
||||||
# token-gated) rather than forcing a cache policy here.
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy /api to backend
|
|
||||||
location /api {
|
|
||||||
proxy_pass http://spanglish_backend;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy /uploads to backend
|
|
||||||
location /uploads {
|
|
||||||
proxy_pass http://spanglish_backend;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Cache static files
|
|
||||||
proxy_cache_valid 200 1d;
|
|
||||||
expires 1d;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Frontend App
|
|
||||||
location / {
|
|
||||||
proxy_pass http://spanglish_frontend;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# WebSocket / HMR
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# Spanglish Community - Photo Gallery API
|
|
||||||
# photos.spanglishcommunity.com
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name photos.spanglishcommunity.com;
|
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://photos.spanglishcommunity.com$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
|
|
||||||
server_name photos.spanglishcommunity.com;
|
|
||||||
|
|
||||||
# Photos can be larger than typical JSON payloads
|
|
||||||
client_max_body_size 25m;
|
|
||||||
|
|
||||||
# SSL
|
|
||||||
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
|
|
||||||
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
|
||||||
|
|
||||||
# Security
|
|
||||||
add_header X-Frame-Options "DENY" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
access_log /var/log/nginx/spanglish_photo_access.log;
|
|
||||||
error_log /var/log/nginx/spanglish_photo_error.log;
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
set $cors_origin "";
|
|
||||||
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
|
||||||
set $cors_origin $http_origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
|
||||||
|
|
||||||
# Ensure 413 returns JSON + CORS
|
|
||||||
error_page 413 = @payload_too_large;
|
|
||||||
location @payload_too_large {
|
|
||||||
default_type application/json;
|
|
||||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ensure 429 (rate limited) returns JSON + CORS
|
|
||||||
error_page 429 = @rate_limited;
|
|
||||||
location @rate_limited {
|
|
||||||
default_type application/json;
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
return 429 '{"error":"Too many requests. Please slow down."}';
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
limit_req zone=spanglish_photo_limit burst=40 nodelay;
|
|
||||||
|
|
||||||
# Preflight
|
|
||||||
if ($request_method = 'OPTIONS') {
|
|
||||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
|
||||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
|
||||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
|
||||||
add_header 'Access-Control-Max-Age' 86400 always;
|
|
||||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
|
||||||
add_header 'Content-Length' 0;
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy_pass http://spanglish_photo_api;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Headers';
|
|
||||||
proxy_hide_header 'Access-Control-Allow-Credentials';
|
|
||||||
proxy_hide_header 'Access-Control-Expose-Headers';
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
|
|
||||||
# Buffer large image uploads to disk rather than memory
|
|
||||||
proxy_request_buffering on;
|
|
||||||
proxy_max_temp_file_size 1024m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
upstream spanglish_frontend {
|
|
||||||
server 127.0.0.1:3019;
|
|
||||||
}
|
|
||||||
upstream spanglish_backend {
|
|
||||||
server 127.0.0.1:3018;
|
|
||||||
}
|
|
||||||
upstream spanglish_photo_api {
|
|
||||||
server 127.0.0.1:3003;
|
|
||||||
}
|
|
||||||
|
|
||||||
limit_req_zone $binary_remote_addr zone=spanglish_photo_limit:10m rate=20r/s;
|
|
||||||
limit_req_zone $binary_remote_addr zone=spanglish_api_limit:10m rate=30r/s;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Spanglish Photo Gallery API
|
|
||||||
Documentation=https://git.azzamo.net/Michilis/Spanglish
|
|
||||||
After=network.target spanglish-backend.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=spanglish
|
|
||||||
Group=spanglish
|
|
||||||
WorkingDirectory=/home/spanglish/Spanglish/photo-api
|
|
||||||
EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env
|
|
||||||
Environment=PORT=3020
|
|
||||||
# Apply pending photos_* migrations before serving (idempotent, advisory-locked)
|
|
||||||
ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate
|
|
||||||
ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=spanglish-photos
|
|
||||||
|
|
||||||
# Security hardening (mirrors spanglish-backend.service)
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=read-only
|
|
||||||
ReadWritePaths=/home/spanglish/Spanglish/photo-api/data
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -4,8 +4,4 @@ upstream spanglish_frontend {
|
|||||||
|
|
||||||
upstream spanglish_backend {
|
upstream spanglish_backend {
|
||||||
server 127.0.0.1:3018;
|
server 127.0.0.1:3018;
|
||||||
}
|
|
||||||
|
|
||||||
upstream spanglish_photos {
|
|
||||||
server 127.0.0.1:3020;
|
|
||||||
}
|
}
|
||||||
@@ -7,11 +7,6 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com
|
|||||||
# API URL (leave empty for same-origin proxy)
|
# API URL (leave empty for same-origin proxy)
|
||||||
NEXT_PUBLIC_API_URL=
|
NEXT_PUBLIC_API_URL=
|
||||||
|
|
||||||
# Photo gallery service (photo-api) origin, used by the /api/photos rewrite
|
|
||||||
# and server-side rendering of /photos pages.
|
|
||||||
# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020
|
|
||||||
PHOTO_API_URL=
|
|
||||||
|
|
||||||
# Google OAuth (optional - leave empty to hide Google Sign-In button)
|
# Google OAuth (optional - leave empty to hide Google Sign-In button)
|
||||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||||
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
||||||
|
|||||||
+11
-45
@@ -1,62 +1,28 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @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 = {
|
const nextConfig = {
|
||||||
// Overridable so a production build can run while `next dev` holds .next
|
|
||||||
// (e.g. NEXT_DIST_DIR=.next-build npm run build).
|
|
||||||
distDir: process.env.NEXT_DIST_DIR || '.next',
|
|
||||||
images: {
|
images: {
|
||||||
// Restrict remote image sources to a known allowlist instead of allowing any
|
domains: ['localhost', 'images.unsplash.com'],
|
||||||
// https host (which let the Next image optimizer be used as an open proxy).
|
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
|
||||||
{ protocol: 'http', hostname: 'localhost', port: '3001' },
|
|
||||||
...extraImageHosts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
protocol: 'https',
|
||||||
headers: securityHeaders,
|
hostname: '**',
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: '3001',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
source: '/api/photos/:path*',
|
|
||||||
destination: `${PHOTO_API_URL}/api/photos/:path*`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${BACKEND_URL}/api/:path*`,
|
destination: 'http://localhost:3001/api/:path*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/uploads/:path*',
|
source: '/uploads/:path*',
|
||||||
destination: `${BACKEND_URL}/uploads/:path*`,
|
destination: 'http://localhost:3001/uploads/:path*',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"@tiptap/pm": "^3.18.0",
|
"@tiptap/pm": "^3.18.0",
|
||||||
"@tiptap/react": "^3.18.0",
|
"@tiptap/react": "^3.18.0",
|
||||||
"@tiptap/starter-kit": "^3.18.0",
|
"@tiptap/starter-kit": "^3.18.0",
|
||||||
"better-auth": "1.6.25",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
@@ -23,7 +22,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"swr": "^2.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^20.14.9",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, Suspense } from 'react';
|
import { useState, Suspense } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -9,48 +9,29 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { authApi } from '@/lib/api';
|
import { authApi } from '@/lib/api';
|
||||||
import { authClient } from '@/lib/auth-client';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
/**
|
|
||||||
* Progressive-account claim. The claim email contains a magic link that signs
|
|
||||||
* the user in (via /auth/magic-link) and redirects here; this page then asks
|
|
||||||
* for a password and completes the claim against /api/auth-ext/claim-account.
|
|
||||||
*/
|
|
||||||
function ClaimAccountContent() {
|
function ClaimAccountContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { locale: language } = useLanguage();
|
const { locale: language } = useLanguage();
|
||||||
const { refreshUser } = useAuth();
|
const { setAuthData } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [checking, setChecking] = useState(true);
|
|
||||||
const [hasSession, setHasSession] = useState(false);
|
|
||||||
const [alreadyClaimed, setAlreadyClaimed] = useState(false);
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const token = searchParams.get('token');
|
||||||
let cancelled = false;
|
|
||||||
authClient
|
|
||||||
.getSession()
|
|
||||||
.then(({ data }) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const user: any = data?.user;
|
|
||||||
setHasSession(!!user);
|
|
||||||
setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setChecking(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
if (formData.password !== formData.confirmPassword) {
|
||||||
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||||
return;
|
return;
|
||||||
@@ -68,8 +49,8 @@ function ClaimAccountContent() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authApi.confirmClaimAccount(formData.password);
|
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
|
||||||
await refreshUser();
|
setAuthData({ user: result.user, token: result.token });
|
||||||
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
|
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -79,21 +60,7 @@ function ClaimAccountContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (checking) {
|
if (!token) {
|
||||||
return (
|
|
||||||
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alreadyClaimed) {
|
|
||||||
// Signed-in and already active: nothing to claim
|
|
||||||
router.push('/dashboard');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasSession) {
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding min-h-[70vh] flex items-center">
|
<div className="section-padding min-h-[70vh] flex items-center">
|
||||||
<div className="container-page">
|
<div className="container-page">
|
||||||
@@ -109,8 +76,8 @@ function ClaimAccountContent() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
{language === 'es'
|
{language === 'es'
|
||||||
? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.'
|
? 'Este enlace de activación no es válido o ha expirado.'
|
||||||
: 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}
|
: 'This activation link is invalid or has expired.'}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/login">
|
<Link href="/login">
|
||||||
<Button>
|
<Button>
|
||||||
@@ -160,7 +127,7 @@ function ClaimAccountContent() {
|
|||||||
<p className="text-xs text-gray-500 -mt-4">
|
<p className="text-xs text-gray-500 -mt-4">
|
||||||
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||||
@@ -169,7 +136,7 @@ function ClaimAccountContent() {
|
|||||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||||
{language === 'es' ? 'Activar Cuenta' : 'Activate Account'}
|
{language === 'es' ? 'Activar Cuenta' : 'Activate Account'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
|
||||||
import { redirectAfterAuth } from '@/lib/authRedirect';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
function MagicLinkContent() {
|
function MagicLinkContent() {
|
||||||
@@ -20,12 +18,11 @@ function MagicLinkContent() {
|
|||||||
const verificationAttempted = useRef(false);
|
const verificationAttempted = useRef(false);
|
||||||
|
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
|
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
|
||||||
if (verificationAttempted.current) return;
|
if (verificationAttempted.current) return;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
verificationAttempted.current = true;
|
verificationAttempted.current = true;
|
||||||
verifyToken();
|
verifyToken();
|
||||||
@@ -37,17 +34,11 @@ function MagicLinkContent() {
|
|||||||
|
|
||||||
const verifyToken = async () => {
|
const verifyToken = async () => {
|
||||||
try {
|
try {
|
||||||
const user = await loginWithMagicLink(token!);
|
await loginWithMagicLink(token!);
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
||||||
// Unclaimed accounts must finish the claim (set a password) before the
|
|
||||||
// rest of the API will accept their session.
|
|
||||||
const destination =
|
|
||||||
user && (user.isClaimed === false || user.accountStatus === 'unclaimed')
|
|
||||||
? '/auth/claim-account'
|
|
||||||
: callbackURL;
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
redirectAfterAuth(destination);
|
router.push('/dashboard');
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// 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,17 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig, LightningInvoice } from '@/lib/api';
|
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
|
||||||
import { useLightningWatcher } from '@/hooks/useLightningWatcher';
|
|
||||||
import { PayingStep } from '@/app/(public)/book/[eventId]/_steps/PayingStep';
|
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
@@ -24,7 +22,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
type PaymentStep = 'loading' | 'manual_payment' | 'lightning_payment' | 'pending_approval' | 'confirmed' | 'error';
|
type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error';
|
||||||
|
|
||||||
export default function BookingPaymentPage() {
|
export default function BookingPaymentPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -35,36 +33,10 @@ export default function BookingPaymentPage() {
|
|||||||
const [step, setStep] = useState<PaymentStep>('loading');
|
const [step, setStep] = useState<PaymentStep>('loading');
|
||||||
const [markingPaid, setMarkingPaid] = useState(false);
|
const [markingPaid, setMarkingPaid] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lightningInvoice, setLightningInvoice] = useState<LightningInvoice | null>(null);
|
|
||||||
const [invoiceExpired, setInvoiceExpired] = useState(false);
|
|
||||||
const [fetchingInvoice, setFetchingInvoice] = useState(false);
|
|
||||||
|
|
||||||
const ticketId = params.ticketId as string;
|
const ticketId = params.ticketId as string;
|
||||||
const requestedStep = searchParams.get('step');
|
const requestedStep = searchParams.get('step');
|
||||||
|
|
||||||
// Get (or refresh) the Lightning invoice for this ticket - reuses the
|
|
||||||
// stored invoice if still valid, otherwise the backend generates a new one.
|
|
||||||
const fetchLightningInvoice = useCallback(async () => {
|
|
||||||
setFetchingInvoice(true);
|
|
||||||
try {
|
|
||||||
const result = await ticketsApi.getLightningInvoice(ticketId);
|
|
||||||
if (result.alreadyPaid) {
|
|
||||||
setStep('confirmed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.invoice) {
|
|
||||||
setLightningInvoice(result.invoice);
|
|
||||||
setInvoiceExpired(false);
|
|
||||||
setStep('lightning_payment');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Failed to load Lightning invoice');
|
|
||||||
setStep('error');
|
|
||||||
} finally {
|
|
||||||
setFetchingInvoice(false);
|
|
||||||
}
|
|
||||||
}, [ticketId]);
|
|
||||||
|
|
||||||
// Fetch ticket and payment config
|
// Fetch ticket and payment config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ticketId) return;
|
if (!ticketId) return;
|
||||||
@@ -73,7 +45,7 @@ export default function BookingPaymentPage() {
|
|||||||
try {
|
try {
|
||||||
// Get ticket with event and payment info
|
// Get ticket with event and payment info
|
||||||
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
|
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
|
||||||
|
|
||||||
if (!ticketData) {
|
if (!ticketData) {
|
||||||
setError('Booking not found');
|
setError('Booking not found');
|
||||||
setStep('error');
|
setStep('error');
|
||||||
@@ -82,24 +54,10 @@ export default function BookingPaymentPage() {
|
|||||||
|
|
||||||
setTicket(ticketData);
|
setTicket(ticketData);
|
||||||
|
|
||||||
|
// Only proceed for manual payment methods
|
||||||
const paymentMethod = ticketData.payment?.provider;
|
const paymentMethod = ticketData.payment?.provider;
|
||||||
|
|
||||||
if (paymentMethod === 'lightning') {
|
|
||||||
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
|
||||||
setStep('confirmed');
|
|
||||||
} else if (ticketData.status === 'cancelled') {
|
|
||||||
setError(locale === 'es'
|
|
||||||
? 'Esta reserva ya no está activa. Por favor realiza una nueva reserva.'
|
|
||||||
: 'This booking is no longer active. Please make a new booking.');
|
|
||||||
setStep('error');
|
|
||||||
} else {
|
|
||||||
await fetchLightningInvoice();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) {
|
if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) {
|
||||||
// Not a manual or Lightning payment method, show appropriate state
|
// Not a manual payment method, redirect to success page or show appropriate state
|
||||||
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
||||||
setStep('confirmed');
|
setStep('confirmed');
|
||||||
} else {
|
} else {
|
||||||
@@ -111,13 +69,13 @@ export default function BookingPaymentPage() {
|
|||||||
|
|
||||||
// Get payment config for the event
|
// Get payment config for the event
|
||||||
if (ticketData.eventId) {
|
if (ticketData.eventId) {
|
||||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId, ticketData.id);
|
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId);
|
||||||
setPaymentConfig(paymentOptions);
|
setPaymentConfig(paymentOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which step to show based on payment status
|
// Determine which step to show based on payment status
|
||||||
const paymentStatus = ticketData.payment?.status;
|
const paymentStatus = ticketData.payment?.status;
|
||||||
|
|
||||||
if (paymentStatus === 'paid' || ticketData.status === 'confirmed') {
|
if (paymentStatus === 'paid' || ticketData.status === 'confirmed') {
|
||||||
setStep('confirmed');
|
setStep('confirmed');
|
||||||
} else if (paymentStatus === 'pending_approval') {
|
} else if (paymentStatus === 'pending_approval') {
|
||||||
@@ -138,15 +96,6 @@ export default function BookingPaymentPage() {
|
|||||||
loadBookingData();
|
loadBookingData();
|
||||||
}, [ticketId]);
|
}, [ticketId]);
|
||||||
|
|
||||||
// Watch for Lightning payment confirmation while the invoice is on screen.
|
|
||||||
useLightningWatcher(
|
|
||||||
step === 'lightning_payment' && !invoiceExpired,
|
|
||||||
ticketId,
|
|
||||||
locale,
|
|
||||||
() => setStep('confirmed'),
|
|
||||||
() => setInvoiceExpired(true)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle "I Have Paid" button click
|
// Handle "I Have Paid" button click
|
||||||
const handleMarkPaymentSent = async () => {
|
const handleMarkPaymentSent = async () => {
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
@@ -352,49 +301,6 @@ export default function BookingPaymentPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightning payment step - show the (reused or freshly generated) invoice
|
|
||||||
if (step === 'lightning_payment' && ticket) {
|
|
||||||
if (invoiceExpired) {
|
|
||||||
return (
|
|
||||||
<div className="section-padding">
|
|
||||||
<div className="container-page max-w-xl">
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-orange-100 flex items-center justify-center mx-auto mb-6">
|
|
||||||
<ClockIcon className="w-10 h-10 text-orange-600" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
|
||||||
{locale === 'es' ? 'La factura expiró' : 'Invoice expired'}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Genera una nueva factura Lightning para continuar con el pago.'
|
|
||||||
: 'Generate a new Lightning invoice to continue with payment.'}
|
|
||||||
</p>
|
|
||||||
<Button onClick={fetchLightningInvoice} isLoading={fetchingInvoice} size="lg">
|
|
||||||
{locale === 'es' ? 'Generar Nueva Factura' : 'Generate New Invoice'}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lightningInvoice) {
|
|
||||||
return (
|
|
||||||
<div className="section-padding">
|
|
||||||
<div className="container-page max-w-xl text-center">
|
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
|
||||||
<p className="mt-4 text-gray-600">
|
|
||||||
{locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <PayingStep invoice={lightningInvoice} qrCode={ticket.qrCode} locale={locale} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manual payment step - show payment details and "I have paid" button
|
// Manual payment step - show payment details and "I have paid" button
|
||||||
if (step === 'manual_payment' && ticket && paymentConfig) {
|
if (step === 'manual_payment' && ticket && paymentConfig) {
|
||||||
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import type { Metadata } from 'next';
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Join Our Language Exchange Community',
|
title: 'Join Our Language Exchange Community',
|
||||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||||
alternates: {
|
|
||||||
canonical: '/community',
|
|
||||||
},
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Join Our Language Exchange Community – Spanglish',
|
title: 'Join Our Language Exchange Community – Spanglish',
|
||||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { faqApi, FaqItem } from '@/lib/api';
|
import { faqApi, FaqItem } from '@/lib/api';
|
||||||
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
|
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -24,20 +23,7 @@ export default function HomepageFaqSection() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading || faqs.length === 0) {
|
||||||
return (
|
|
||||||
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
|
|
||||||
<div className="container-page">
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
<Skeleton className="h-9 w-72 max-w-full mx-auto mb-8" />
|
|
||||||
<FaqListSkeleton count={4} compact />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (faqs.length === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from 'next/link';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
import { FeaturedEventSkeleton } from '@/components/ui/Skeleton';
|
|
||||||
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
interface NextEventSectionProps {
|
interface NextEventSectionProps {
|
||||||
@@ -52,7 +51,11 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <FeaturedEventSkeleton />;
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextEvent) {
|
if (!nextEvent) {
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import type { Metadata } from 'next';
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Contact Us',
|
title: 'Contact Us',
|
||||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||||
alternates: {
|
|
||||||
canonical: '/contact',
|
|
||||||
},
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Contact Us – Spanglish',
|
title: 'Contact Us – Spanglish',
|
||||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||||
|
|||||||
@@ -1,535 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
'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,35 +1,94 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { UserPayment } from '@/lib/api';
|
import { UserPayment } from '@/lib/api';
|
||||||
import { formatDateLong } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import { StatusPill, derivePaymentStatus } from './_shared/status';
|
|
||||||
import { pyg, isManualProvider } from './_shared/helpers';
|
|
||||||
import PayActions from './_shared/PayActions';
|
|
||||||
|
|
||||||
interface PaymentsTabProps {
|
interface PaymentsTabProps {
|
||||||
payments: UserPayment[];
|
payments: UserPayment[];
|
||||||
language: string;
|
language: string;
|
||||||
onChange: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Filter = 'all' | 'paid' | 'pending';
|
export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
||||||
|
const [filter, setFilter] = useState<'all' | 'paid' | 'pending'>('all');
|
||||||
|
|
||||||
export default function PaymentsTab({ payments, language: locale, onChange }: PaymentsTabProps) {
|
const filteredPayments = payments.filter((payment) => {
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'paid') return payment.status === 'paid';
|
||||||
|
if (filter === 'pending') return payment.status !== 'paid' && payment.status !== 'refunded';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const filtered = useMemo(
|
const formatDate = (dateStr: string) => {
|
||||||
() =>
|
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
payments.filter((p) => {
|
year: 'numeric',
|
||||||
if (filter === 'all') return true;
|
month: 'short',
|
||||||
if (filter === 'paid') return p.status === 'paid';
|
day: 'numeric',
|
||||||
return p.status !== 'paid' && p.status !== 'refunded';
|
timeZone: 'America/Asuncion',
|
||||||
}),
|
});
|
||||||
[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
|
const totalPaid = payments
|
||||||
.filter((p) => p.status === 'paid')
|
.filter((p) => p.status === 'paid')
|
||||||
.reduce((sum, p) => sum + Number(p.amount), 0);
|
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||||
@@ -37,143 +96,112 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
|||||||
.filter((p) => p.status !== 'paid' && p.status !== 'refunded')
|
.filter((p) => p.status !== 'paid' && p.status !== 'refunded')
|
||||||
.reduce((sum, p) => sum + Number(p.amount), 0);
|
.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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Totals */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Card className="p-5">
|
<Card className="p-4">
|
||||||
<p className="mb-1 text-sm text-gray-600">
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
{locale === 'es' ? 'Total pagado' : 'Total paid'}
|
{language === 'es' ? 'Total Pagado' : 'Total Paid'}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{formatCurrency(totalPaid)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-green-700">{pyg(totalPaid)}</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-5">
|
<Card className="p-4">
|
||||||
<p className="mb-1 text-sm text-gray-600">
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
{locale === 'es' ? 'Pendiente' : 'Pending'}
|
{language === 'es' ? 'Pendiente' : 'Pending'}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-yellow-600">
|
||||||
|
{formatCurrency(totalPending)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-red-700">{pyg(totalPending)}</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter chips */}
|
{/* Filter Buttons */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex gap-2">
|
||||||
{chips.map((chip) => (
|
{(['all', 'paid', 'pending'] as const).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={chip.id}
|
key={f}
|
||||||
onClick={() => setFilter(chip.id)}
|
onClick={() => setFilter(f)}
|
||||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
filter === chip.id
|
filter === f
|
||||||
? 'bg-primary-yellow text-primary-dark'
|
? 'bg-secondary-blue text-white'
|
||||||
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{locale === 'es' ? chip.label.es : chip.label.en}
|
{f === 'all' && (language === 'es' ? 'Todos' : 'All')}
|
||||||
|
{f === 'paid' && (language === 'es' ? 'Pagados' : 'Paid')}
|
||||||
|
{f === 'pending' && (language === 'es' ? 'Pendientes' : 'Pending')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Records */}
|
{/* Payments List */}
|
||||||
{filtered.length === 0 ? (
|
{filteredPayments.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{locale === 'es' ? 'No hay pagos que mostrar' : 'No payments to show'}
|
{language === 'es' ? 'No hay pagos que mostrar' : 'No payments to display'}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filtered.map((payment) => {
|
{filteredPayments.map((payment) => (
|
||||||
const status = derivePaymentStatus(payment.status);
|
<Card key={payment.id} className="p-4">
|
||||||
const eventTitle =
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||||
(locale === 'es' && payment.event?.titleEs
|
<div className="flex-1">
|
||||||
? payment.event.titleEs
|
<div className="flex items-center gap-2 mb-1">
|
||||||
: payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event');
|
<span className="font-semibold">
|
||||||
const canMarkPaid =
|
{formatCurrency(Number(payment.amount), payment.currency)}
|
||||||
payment.status === 'pending' && isManualProvider(payment.provider);
|
</span>
|
||||||
const canRebook = payment.status === 'on_hold';
|
{getStatusBadge(payment.status)}
|
||||||
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>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
{canRebook ? (
|
{payment.event && (
|
||||||
<PayActions
|
<p>
|
||||||
ticketId={payment.ticketId}
|
{language === 'es' && payment.event.titleEs
|
||||||
amount={Number(payment.amount)}
|
? payment.event.titleEs
|
||||||
currency={payment.currency}
|
: payment.event.title}
|
||||||
destination={eventTitle}
|
</p>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
{payment.invoice && (
|
<p>
|
||||||
<a
|
<span className="font-medium">
|
||||||
href={payment.invoice.pdfUrl || '#'}
|
{language === 'es' ? 'Método:' : 'Method:'}
|
||||||
target="_blank"
|
</span>{' '}
|
||||||
rel="noopener noreferrer"
|
{getProviderLabel(payment.provider)}
|
||||||
>
|
</p>
|
||||||
<Button variant="outline" size="sm">
|
<p>
|
||||||
{locale === 'es' ? 'Factura' : 'Invoice'}
|
<span className="font-medium">
|
||||||
</Button>
|
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||||
</a>
|
</span>{' '}
|
||||||
|
{formatDate(payment.createdAt)}
|
||||||
|
</p>
|
||||||
|
{payment.reference && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">
|
||||||
|
{language === 'es' ? 'Referencia:' : 'Reference:'}
|
||||||
|
</span>{' '}
|
||||||
|
{payment.reference}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
{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>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
'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,249 +1,209 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
|
||||||
CalendarIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
ArrowDownTrayIcon,
|
|
||||||
TicketIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { UserTicket } from '@/lib/api';
|
import { UserTicket } from '@/lib/api';
|
||||||
import { formatDateLong, parseDate } from '@/lib/utils';
|
import { 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 {
|
interface TicketsTabProps {
|
||||||
tickets: UserTicket[];
|
tickets: UserTicket[];
|
||||||
language: string;
|
language: string;
|
||||||
onChange: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Filter = 'all' | 'upcoming' | 'past';
|
export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
||||||
|
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('all');
|
||||||
|
|
||||||
export default function TicketsTab({ tickets, language: locale, onChange }: TicketsTabProps) {
|
const now = new Date();
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
// Group multi-ticket bookings so both QRs sit together, then filter by the
|
const formatDate = (dateStr: string) => {
|
||||||
// group's event date.
|
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||||
const groups = useMemo<BookingGroup[]>(() => {
|
year: 'numeric',
|
||||||
const now = Date.now();
|
month: 'short',
|
||||||
return groupByBooking(tickets).filter((group) => {
|
day: 'numeric',
|
||||||
if (filter === 'all') return true;
|
timeZone: 'America/Asuncion',
|
||||||
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 chips: { id: Filter; label: { en: string; es: string } }[] = [
|
const formatCurrency = (amount: number, currency: string = 'PYG') => {
|
||||||
{ id: 'all', label: { en: 'All', es: 'Todas' } },
|
if (currency === 'PYG') {
|
||||||
{ id: 'upcoming', label: { en: 'Upcoming', es: 'Próximas' } },
|
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||||
{ id: 'past', label: { en: 'Past', es: 'Pasadas' } },
|
}
|
||||||
];
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Filter chips */}
|
{/* Filter Buttons */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex gap-2">
|
||||||
{chips.map((chip) => (
|
{(['all', 'upcoming', 'past'] as const).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={chip.id}
|
key={f}
|
||||||
onClick={() => setFilter(chip.id)}
|
onClick={() => setFilter(f)}
|
||||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
filter === chip.id
|
filter === f
|
||||||
? 'bg-primary-yellow text-primary-dark'
|
? 'bg-secondary-blue text-white'
|
||||||
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{locale === 'es' ? chip.label.es : chip.label.en}
|
{f === 'all' && (language === 'es' ? 'Todas' : 'All')}
|
||||||
|
{f === 'upcoming' && (language === 'es' ? 'Próximas' : 'Upcoming')}
|
||||||
|
{f === 'past' && (language === 'es' ? 'Pasadas' : 'Past')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.length === 0 ? (
|
{/* Tickets List */}
|
||||||
|
{filteredTickets.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<p className="mb-4 text-gray-600">
|
<p className="text-gray-600 mb-4">
|
||||||
{locale === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
{language === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/events">
|
<Link href="/events">
|
||||||
<Button>{locale === 'es' ? 'Explorar eventos' : 'Browse events'}</Button>
|
<Button>
|
||||||
|
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{groups.map((group) => (
|
{filteredTickets.map((ticket) => (
|
||||||
<BookingCard
|
<Card key={ticket.id} className="p-4">
|
||||||
key={group.bookingId}
|
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||||
group={group}
|
{/* Event Image */}
|
||||||
locale={locale}
|
{ticket.event?.bannerUrl && (
|
||||||
onChange={onChange}
|
<div className="w-full md:w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
onShowQr={() => setQrTickets(group.tickets)}
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{qrTickets && (
|
|
||||||
<QrTicketModal
|
|
||||||
tickets={qrTickets}
|
|
||||||
locale={locale}
|
|
||||||
onClose={() => setQrTickets(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
'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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user