Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b478c327d | ||
|
|
733d2459df | ||
|
|
4afa5d6fa0 | ||
|
|
617c884012 | ||
|
|
93476ac72a | ||
|
|
c4094630d9 | ||
|
|
6ec8b8400f | ||
|
|
9b2668f498 | ||
|
|
e38d14970d | ||
|
|
71c277045b | ||
|
|
c9a600b6d6 | ||
|
|
19461098a0 | ||
|
|
4772b85f3d | ||
|
|
74dcc8e5ac | ||
|
|
0d47156071 | ||
|
|
bd1043a3f4 | ||
|
|
75e317d73e | ||
|
|
d8c207c995 | ||
|
|
cb422332c8 | ||
|
|
8d5fbf18b4 | ||
|
|
78ffd0ae52 | ||
|
|
09bfb17f03 | ||
|
|
cacc52ec24 | ||
|
|
107cd9753e | ||
|
|
38526f17b5 | ||
|
|
194604004e | ||
|
|
bb920ce6f0 | ||
|
|
c0315a705d | ||
|
|
fbc437a670 | ||
|
|
e0f0700398 | ||
|
|
defd9685e0 | ||
|
|
1ed62b0d3f | ||
|
|
91de6df04d | ||
|
|
a5d97d65e1 | ||
|
|
f0128f66b0 | ||
|
|
b33c68feb0 | ||
|
|
15655e3987 | ||
|
|
d8b3864411 | ||
|
|
194cbd6ca8 | ||
|
|
d5445c2282 | ||
|
|
dcfefc8371 | ||
|
|
b5f14335c4 | ||
|
|
d44ac949b5 | ||
|
|
a5e939221d | ||
|
|
833e3e5a9c | ||
|
|
ba1975dd6d | ||
|
|
3025ef3d21 | ||
|
|
8564f8af83 |
@@ -7,6 +7,7 @@ package-lock.json
|
||||
# Build outputs
|
||||
dist/
|
||||
.next/
|
||||
.next-build/
|
||||
out/
|
||||
build/
|
||||
|
||||
@@ -56,6 +57,10 @@ yarn-error.log*
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Go photo-api service
|
||||
photo-api/bin/
|
||||
photo-api/data/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
|
||||
@@ -4,17 +4,24 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
||||
|
||||
## Features
|
||||
|
||||
- **Public site**: events, booking, contact, community, legal pages, bilingual (EN/ES)
|
||||
- **User dashboard**: profile, tickets, payments, sessions/security
|
||||
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads
|
||||
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
|
||||
- Stable `/next` and `/featured` URLs that redirect to the current event
|
||||
- Human-readable event URL slugs (with legacy-ID redirect support)
|
||||
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
|
||||
- Lightning invoice reuse and re-payment straight from the dashboard
|
||||
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
|
||||
- One unified ticket-creation modal with first-class payment status
|
||||
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
|
||||
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
|
||||
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
||||
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
|
||||
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
|
||||
- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
|
||||
- **Email**: `nodemailer` (SMTP) with optional provider config
|
||||
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
|
||||
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
|
||||
|
||||
## Local development
|
||||
|
||||
@@ -22,6 +29,8 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- Go 1.26+ (for the `photo-api` service)
|
||||
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -29,12 +38,14 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
||||
npm install
|
||||
cp backend/.env.example backend/.env
|
||||
cp frontend/.env.example frontend/.env
|
||||
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
|
||||
```
|
||||
|
||||
### Initialize database (SQLite by default)
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
npm run db:migrate # backend (Drizzle) tables
|
||||
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
|
||||
```
|
||||
|
||||
### Run
|
||||
@@ -43,10 +54,13 @@ npm run db:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
|
||||
|
||||
Default URLs:
|
||||
|
||||
- Frontend: `http://localhost:3002`
|
||||
- Backend API: `http://localhost:3001`
|
||||
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
|
||||
- API docs: `http://localhost:3001/api-docs`
|
||||
|
||||
### First user becomes admin
|
||||
@@ -58,14 +72,20 @@ The first user to register becomes the **admin**. Register at `/register`.
|
||||
Run these from the repo root:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run dev # backend + frontend + photo-api
|
||||
npm run build # build all workspaces
|
||||
npm run start
|
||||
npm run db:generate
|
||||
npm run db:migrate
|
||||
npm run db:studio
|
||||
npm run db:export # Backup database
|
||||
npm run db:import # Restore from backup
|
||||
npm run db:export # Backup database
|
||||
npm run db:import # Restore from backup
|
||||
|
||||
# Photo service (Go)
|
||||
npm run dev:photos # go run ./cmd/photo-api
|
||||
npm run build:photos # go build -o bin/photo-api
|
||||
npm run migrate:photos # apply photos_* migrations
|
||||
npm run test:photos # go test ./...
|
||||
```
|
||||
|
||||
You can also run per workspace:
|
||||
@@ -82,12 +102,22 @@ npm run dev --workspace=frontend
|
||||
Key settings (see `backend/.env.example` for the full list):
|
||||
|
||||
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
|
||||
- **Auth**: `JWT_SECRET` (change in production)
|
||||
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
|
||||
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
||||
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
||||
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
|
||||
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
|
||||
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
|
||||
|
||||
### Photo service (`photo-api/.env`)
|
||||
|
||||
Key settings (see `photo-api/.env.example`):
|
||||
|
||||
- **Port**: `PORT=3003` (dev)
|
||||
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
|
||||
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
|
||||
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
|
||||
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
|
||||
|
||||
### Frontend (`frontend/.env`)
|
||||
|
||||
Key settings (see `frontend/.env.example`):
|
||||
@@ -143,12 +173,13 @@ npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip conf
|
||||
|
||||
This repo includes example configs in `deploy/`:
|
||||
|
||||
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`
|
||||
- Backend runs on **3018**, frontend on **3019** by default (see the unit files)
|
||||
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
|
||||
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
|
||||
- Backend needs write access to `backend/data` and `backend/uploads`
|
||||
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
|
||||
- **nginx**:
|
||||
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019
|
||||
- `deploy/front-end_nginx.conf` proxies `/api` and `/uploads` to the backend and everything else to the frontend
|
||||
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
|
||||
- `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
|
||||
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
|
||||
|
||||
Typical production flow:
|
||||
@@ -156,7 +187,9 @@ Typical production flow:
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
npm run build:photos
|
||||
npm run db:migrate
|
||||
npm run migrate:photos
|
||||
```
|
||||
|
||||
Then install/enable the systemd services and nginx configs for your server.
|
||||
|
||||
+45
-4
@@ -22,10 +22,18 @@ DATABASE_URL=./data/spanglish.db
|
||||
# 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, 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.
|
||||
# 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
|
||||
@@ -42,13 +50,33 @@ DATABASE_URL=./data/spanglish.db
|
||||
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
|
||||
# S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# JWT Secret (change in production!)
|
||||
# Better Auth session secret. REQUIRED in production, 32+ characters.
|
||||
# Generate one with: openssl rand -base64 48
|
||||
# Rotating it signs everyone out (cookie signatures invalidate).
|
||||
BETTER_AUTH_SECRET=
|
||||
|
||||
# Public site origin Better Auth builds its URLs against (falls back to
|
||||
# FRONTEND_URL when unset). E.g. https://spanglishcommunity.com
|
||||
BETTER_AUTH_URL=
|
||||
|
||||
# Extra reverse-proxy IP prefixes allowed to set X-Real-IP / X-Forwarded-For
|
||||
# (comma-separated, e.g. "172.20."). Loopback and RFC1918 ranges are always
|
||||
# trusted; anything else is treated as a client and rate-limited by its
|
||||
# actual socket address.
|
||||
# TRUSTED_PROXIES=
|
||||
|
||||
# DEPRECATED: no longer used for API auth (Better Auth replaced the JWTs).
|
||||
# Still read by photo-api as the fallback secret for gallery view tokens
|
||||
# until PHOTO_VIEW_SECRET is set there; safe to remove after that.
|
||||
JWT_SECRET=your-super-secret-key-change-in-production
|
||||
|
||||
# Google OAuth (optional - for Google Sign-In)
|
||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||
# Note: The same Client ID should be used in frontend/.env
|
||||
GOOGLE_CLIENT_ID=
|
||||
# Only needed for the redirect OAuth flow; the Google Identity Services
|
||||
# button (ID token sign-in) works with the Client ID alone.
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
@@ -114,3 +142,16 @@ 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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
@@ -19,12 +20,14 @@
|
||||
"@hono/zod-openapi": "^0.14.4",
|
||||
"argon2": "^0.44.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"better-auth": "1.6.25",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.31.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.4.7",
|
||||
"ioredis": "^5.11.1",
|
||||
"jose": "^5.4.0",
|
||||
"moment-timezone": "^0.6.2",
|
||||
"nanoid": "^5.0.7",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pdfkit": "^0.17.2",
|
||||
@@ -40,8 +43,10 @@
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"drizzle-kit": "^0.22.8",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"ioredis-mock": "^8.13.1",
|
||||
"tsx": "^4.15.7",
|
||||
"typescript": "^5.5.2"
|
||||
"typescript": "^5.5.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { sqliteTable, text, integer, customType as sqliteCustomType } from 'drizzle-orm/sqlite-core';
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
text as pgText,
|
||||
timestamp,
|
||||
boolean as pgBoolean,
|
||||
bigint,
|
||||
customType as pgCustomType,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// Better Auth table definitions for both dialects.
|
||||
//
|
||||
// The `user` model maps onto the EXISTING `users` table so user IDs (and every
|
||||
// foreign key that references them) survive the auth migration untouched. Only
|
||||
// the columns Better Auth reads/writes are declared here; legacy columns
|
||||
// (password, google_id, token_version) stay physically present but invisible
|
||||
// to Better Auth. The full application-facing definition lives in schema.ts —
|
||||
// two Drizzle table objects can safely describe the same SQL table.
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
|
||||
// Better Auth hands the adapter JS Date objects, but the legacy sqlite `users`
|
||||
// timestamps are ISO-8601 TEXT columns. Bridge the two representations.
|
||||
const isoText = sqliteCustomType<{ data: Date; driverData: string }>({
|
||||
dataType() {
|
||||
return 'text';
|
||||
},
|
||||
toDriver(value: Date): string {
|
||||
return (value instanceof Date ? value : new Date(value)).toISOString();
|
||||
},
|
||||
fromDriver(value: string): Date {
|
||||
return new Date(value);
|
||||
},
|
||||
});
|
||||
|
||||
// Legacy pg `users.is_claimed` is an INTEGER 0/1 column; expose it as boolean.
|
||||
const pgIntBool = pgCustomType<{ data: boolean; driverData: number }>({
|
||||
dataType() {
|
||||
return 'integer';
|
||||
},
|
||||
toDriver(value: boolean): number {
|
||||
return value ? 1 : 0;
|
||||
},
|
||||
fromDriver(value: number | boolean): boolean {
|
||||
return Boolean(value);
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== SQLite ====================
|
||||
|
||||
export const sqliteAuthUsers = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||
image: text('image'),
|
||||
createdAt: isoText('created_at').notNull(),
|
||||
updatedAt: isoText('updated_at').notNull(),
|
||||
// admin plugin fields
|
||||
role: text('role').notNull().default('user'),
|
||||
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
||||
// application additionalFields
|
||||
phone: text('phone'),
|
||||
languagePreference: text('language_preference'),
|
||||
rucNumber: text('ruc_number'),
|
||||
isClaimed: integer('is_claimed', { mode: 'boolean' }).notNull().default(true),
|
||||
accountStatus: text('account_status').notNull().default('active'),
|
||||
});
|
||||
|
||||
export const sqliteAuthSessions = sqliteTable('auth_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
|
||||
token: text('token').notNull().unique(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
// admin plugin (impersonation)
|
||||
impersonatedBy: text('impersonated_by'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const sqliteAuthAccounts = sqliteTable('auth_accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => sqliteAuthUsers.id, { onDelete: 'cascade' }),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
|
||||
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const sqliteAuthVerifications = sqliteTable('auth_verifications', {
|
||||
id: text('id').primaryKey(),
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const sqliteAuthRateLimits = sqliteTable('auth_rate_limits', {
|
||||
id: text('id').primaryKey(),
|
||||
key: text('key'),
|
||||
count: integer('count'),
|
||||
lastRequest: integer('last_request'),
|
||||
});
|
||||
|
||||
// ==================== PostgreSQL ====================
|
||||
|
||||
export const pgAuthUsers = pgTable('users', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
emailVerified: pgBoolean('email_verified').notNull().default(false),
|
||||
image: pgText('image'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
// admin plugin fields
|
||||
role: varchar('role', { length: 20 }).notNull().default('user'),
|
||||
banned: pgBoolean('banned').notNull().default(false),
|
||||
banReason: pgText('ban_reason'),
|
||||
banExpires: timestamp('ban_expires'),
|
||||
// application additionalFields
|
||||
phone: varchar('phone', { length: 50 }),
|
||||
languagePreference: varchar('language_preference', { length: 10 }),
|
||||
rucNumber: varchar('ruc_number', { length: 15 }),
|
||||
isClaimed: pgIntBool('is_claimed').notNull(),
|
||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||
});
|
||||
|
||||
export const pgAuthSessions = pgTable('auth_sessions', {
|
||||
id: uuid('id').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
|
||||
token: varchar('token', { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
ipAddress: varchar('ip_address', { length: 45 }),
|
||||
userAgent: pgText('user_agent'),
|
||||
// admin plugin (impersonation)
|
||||
impersonatedBy: uuid('impersonated_by'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgAuthAccounts = pgTable('auth_accounts', {
|
||||
id: uuid('id').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => pgAuthUsers.id, { onDelete: 'cascade' }),
|
||||
accountId: varchar('account_id', { length: 255 }).notNull(),
|
||||
providerId: varchar('provider_id', { length: 100 }).notNull(),
|
||||
accessToken: pgText('access_token'),
|
||||
refreshToken: pgText('refresh_token'),
|
||||
idToken: pgText('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||
scope: pgText('scope'),
|
||||
password: pgText('password'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgAuthVerifications = pgTable('auth_verifications', {
|
||||
id: uuid('id').primaryKey(),
|
||||
identifier: varchar('identifier', { length: 255 }).notNull(),
|
||||
value: pgText('value').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
export const pgAuthRateLimits = pgTable('auth_rate_limits', {
|
||||
id: varchar('id', { length: 64 }).primaryKey(),
|
||||
key: varchar('key', { length: 255 }),
|
||||
count: bigint('count', { mode: 'number' }),
|
||||
lastRequest: bigint('last_request', { mode: 'number' }),
|
||||
});
|
||||
|
||||
// ==================== Runtime-switched exports ====================
|
||||
|
||||
export const authUsers = dbType === 'postgres' ? pgAuthUsers : sqliteAuthUsers;
|
||||
export const authSessions = dbType === 'postgres' ? pgAuthSessions : sqliteAuthSessions;
|
||||
export const authAccounts = dbType === 'postgres' ? pgAuthAccounts : sqliteAuthAccounts;
|
||||
export const authVerifications = dbType === 'postgres' ? pgAuthVerifications : sqliteAuthVerifications;
|
||||
export const authRateLimits = dbType === 'postgres' ? pgAuthRateLimits : sqliteAuthRateLimits;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
// Migration idempotency for the Better Auth backfill: seed legacy-shaped user
|
||||
// rows, run migrate repeatedly, and assert the backfill is correct and never
|
||||
// duplicates.
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ba-migrate-test-'));
|
||||
const dbPath = join(dir, 'migrate.db');
|
||||
|
||||
function runMigrate() {
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
|
||||
env: {
|
||||
...process.env,
|
||||
DB_TYPE: 'sqlite',
|
||||
DATABASE_URL: dbPath,
|
||||
REDIS_URL: '',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
beforeAll(() => {
|
||||
// First run creates the schema
|
||||
runMigrate();
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Seed legacy-shaped users (pre-Better-Auth): password lives on users,
|
||||
// google_id links Google, '' marks guest accounts, suspended via status.
|
||||
const now = new Date().toISOString();
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO users (id, email, password, name, role, is_claimed, google_id, account_status, token_version, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
||||
);
|
||||
insert.run('legacy-argon', 'argon@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake', 'Argon', 'user', 1, null, 'active', now, now);
|
||||
insert.run('legacy-bcrypt', 'bcrypt@old.py', '$2a$10$fakebcryptfakebcryptfakebc', 'Bcrypt', 'admin', 1, null, 'active', now, now);
|
||||
insert.run('legacy-guest', 'guest@old.py', '', 'Guest', 'user', 1, null, 'active', now, now);
|
||||
insert.run('legacy-google', 'google@old.py', null, 'Google', 'user', 1, 'google-sub-123', 'active', now, now);
|
||||
insert.run('legacy-both', 'both@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake2', 'Both', 'user', 1, 'google-sub-456', 'active', now, now);
|
||||
insert.run('legacy-suspended', 'suspended@old.py', '$argon2id$v=19$m=65536,t=3,p=4$fake3', 'Bad', 'user', 1, null, 'suspended', now, now);
|
||||
|
||||
// Second run performs the backfill against the seeded rows
|
||||
runMigrate();
|
||||
}, 240_000);
|
||||
|
||||
describe('Better Auth migration backfill', () => {
|
||||
it('creates credential accounts for users with real passwords only', () => {
|
||||
const rows = db
|
||||
.prepare("SELECT user_id, password FROM auth_accounts WHERE provider_id = 'credential' ORDER BY user_id")
|
||||
.all() as any[];
|
||||
const byUser = new Map(rows.map((r) => [r.user_id, r.password]));
|
||||
|
||||
expect(byUser.get('legacy-argon')).toContain('$argon2id$');
|
||||
expect(byUser.get('legacy-bcrypt')).toContain('$2a$');
|
||||
expect(byUser.get('legacy-both')).toContain('$argon2id$');
|
||||
expect(byUser.get('legacy-suspended')).toBeTruthy();
|
||||
// Guests ('' password) and Google-only users get no credential account
|
||||
expect(byUser.has('legacy-guest')).toBe(false);
|
||||
expect(byUser.has('legacy-google')).toBe(false);
|
||||
});
|
||||
|
||||
it('creates google accounts from google_id', () => {
|
||||
const rows = db
|
||||
.prepare("SELECT user_id, account_id FROM auth_accounts WHERE provider_id = 'google' ORDER BY user_id")
|
||||
.all() as any[];
|
||||
expect(rows).toEqual([
|
||||
{ user_id: 'legacy-both', account_id: 'google-sub-456' },
|
||||
{ user_id: 'legacy-google', account_id: 'google-sub-123' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks claimed legacy users email-verified, guests not', () => {
|
||||
const verified = (email: string) =>
|
||||
(db.prepare('SELECT email_verified FROM users WHERE email = ?').get(email) as any).email_verified;
|
||||
expect(verified('argon@old.py')).toBe(1);
|
||||
expect(verified('google@old.py')).toBe(1);
|
||||
expect(verified('guest@old.py')).toBe(0);
|
||||
});
|
||||
|
||||
it('mirrors suspended accounts to banned', () => {
|
||||
const row = db.prepare('SELECT banned, ban_reason FROM users WHERE email = ?').get('suspended@old.py') as any;
|
||||
expect(row.banned).toBe(1);
|
||||
expect(row.ban_reason).toContain('suspended');
|
||||
const active = db.prepare('SELECT banned FROM users WHERE email = ?').get('argon@old.py') as any;
|
||||
expect(active.banned).toBe(0);
|
||||
});
|
||||
|
||||
it('is idempotent: a third run adds nothing', () => {
|
||||
const count = () => (db.prepare('SELECT COUNT(*) AS n FROM auth_accounts').get() as any).n;
|
||||
const before = count();
|
||||
runMigrate();
|
||||
expect(count()).toBe(before);
|
||||
}, 60_000);
|
||||
});
|
||||
+272
-1
@@ -199,7 +199,19 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
|
||||
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
|
||||
// backfilled from is_guest and the payments table on first run
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`);
|
||||
await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
|
||||
await (db as any).run(sql`
|
||||
UPDATE tickets SET payment_status = 'paid'
|
||||
WHERE is_guest = 0
|
||||
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
|
||||
`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries)
|
||||
// SQLite doesn't support altering column constraints, so we'll just ensure new entries work
|
||||
|
||||
@@ -238,6 +250,15 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).run(sql`
|
||||
@@ -523,6 +544,81 @@ async function migrate() {
|
||||
updated_by TEXT REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// ==================== Better Auth ====================
|
||||
// Better Auth core + admin plugin columns on the existing users table
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN image TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_reason TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN ban_expires INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Better Auth sessions (replaces the legacy user_sessions table).
|
||||
// Timestamps are integer epoch-milliseconds (Drizzle timestamp_ms mode).
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
impersonated_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth accounts: credential (password hash) and OAuth provider links
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
id_token TEXT,
|
||||
access_token_expires_at INTEGER,
|
||||
refresh_token_expires_at INTEGER,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth verification values (magic links, password reset tokens)
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_verifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
identifier TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth rate limiting (used when Redis is not configured)
|
||||
await (db as any).run(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
id TEXT PRIMARY KEY,
|
||||
key TEXT,
|
||||
count INTEGER,
|
||||
last_request INTEGER
|
||||
)
|
||||
`);
|
||||
} else {
|
||||
// PostgreSQL migrations
|
||||
await (db as any).execute(sql`
|
||||
@@ -693,6 +789,18 @@ async function migrate() {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Migration: Add payment_status column to tickets (paid | unpaid | comp),
|
||||
// backfilled from is_guest and the payments table on first run
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`);
|
||||
await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`);
|
||||
await (db as any).execute(sql`
|
||||
UPDATE tickets SET payment_status = 'paid'
|
||||
WHERE is_guest = 0
|
||||
AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid')
|
||||
`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id UUID PRIMARY KEY,
|
||||
@@ -719,6 +827,15 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN reminder_sent_at TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_invoice TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_expires_at TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Invoices table
|
||||
await (db as any).execute(sql`
|
||||
@@ -1002,6 +1119,80 @@ async function migrate() {
|
||||
updated_by UUID REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// ==================== Better Auth ====================
|
||||
// Better Auth core + admin plugin columns on the existing users table
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS image TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_reason TEXT`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expires TIMESTAMP`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Better Auth sessions (replaces the legacy user_sessions table)
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
impersonated_by UUID,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth accounts: credential (password hash) and OAuth provider links
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_accounts (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id VARCHAR(255) NOT NULL,
|
||||
provider_id VARCHAR(100) NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
id_token TEXT,
|
||||
access_token_expires_at TIMESTAMP,
|
||||
refresh_token_expires_at TIMESTAMP,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth verification values (magic links, password reset tokens)
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_verifications (
|
||||
id UUID PRIMARY KEY,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Better Auth rate limiting (used when Redis is not configured)
|
||||
await (db as any).execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
key VARCHAR(255),
|
||||
count BIGINT,
|
||||
last_request BIGINT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
||||
@@ -1014,6 +1205,11 @@ async function migrate() {
|
||||
`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 {
|
||||
@@ -1025,6 +1221,81 @@ async function migrate() {
|
||||
} 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).
|
||||
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
||||
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, varchar, text as pgText, timestamp, decimal, integer as pgInteger, boolean as pgBoolean } from 'drizzle-orm/pg-core';
|
||||
|
||||
// Type to determine which schema to use
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
@@ -20,6 +20,12 @@ export const sqliteUsers = sqliteTable('users', {
|
||||
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
||||
tokenVersion: integer('token_version').notNull().default(0),
|
||||
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
|
||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||
image: text('image'),
|
||||
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
@@ -104,12 +110,14 @@ export const sqliteTickets = sqliteTable('tickets', {
|
||||
attendeePhone: text('attendee_phone'),
|
||||
attendeeRuc: text('attendee_ruc'), // Paraguayan tax ID for invoicing
|
||||
preferredLanguage: text('preferred_language'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold'] }).notNull().default('pending'),
|
||||
checkinAt: text('checkin_at'),
|
||||
checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in
|
||||
qrCode: text('qr_code'),
|
||||
adminNote: text('admin_note'),
|
||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -119,8 +127,11 @@ export const sqlitePayments = sqliteTable('payments', {
|
||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
||||
amount: real('amount').notNull(),
|
||||
currency: text('currency').notNull().default('PYG'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] }).notNull().default('pending'),
|
||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
||||
reference: text('reference'),
|
||||
lnbitsInvoice: text('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
|
||||
lnbitsExpiresAt: text('lnbits_expires_at'), // When lnbitsInvoice expires
|
||||
lnbitsAmountSats: integer('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
|
||||
userMarkedPaidAt: text('user_marked_paid_at'), // When user clicked "I Have Paid"
|
||||
payerName: text('payer_name'), // Name of payer if different from attendee
|
||||
paidAt: text('paid_at'),
|
||||
@@ -375,6 +386,12 @@ export const pgUsers = pgTable('users', {
|
||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
||||
tokenVersion: pgInteger('token_version').notNull().default(0),
|
||||
// Better Auth core + admin plugin fields (auth-schema.ts maps the same columns)
|
||||
emailVerified: pgBoolean('email_verified').notNull().default(false),
|
||||
image: pgText('image'),
|
||||
banned: pgBoolean('banned').notNull().default(false),
|
||||
banReason: pgText('ban_reason'),
|
||||
banExpires: timestamp('ban_expires'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
@@ -465,6 +482,8 @@ export const pgTickets = pgTable('tickets', {
|
||||
qrCode: varchar('qr_code', { length: 255 }),
|
||||
adminNote: pgText('admin_note'),
|
||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -476,6 +495,9 @@ export const pgPayments = pgTable('payments', {
|
||||
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
||||
reference: varchar('reference', { length: 255 }),
|
||||
lnbitsInvoice: pgText('lnbits_invoice'), // BOLT11 string, stored so an unpaid invoice can be redisplayed without a new LNbits call
|
||||
lnbitsExpiresAt: timestamp('lnbits_expires_at'), // When lnbitsInvoice expires
|
||||
lnbitsAmountSats: pgInteger('lnbits_amount_sats'), // Sats amount for display (from LNbits' fiat conversion)
|
||||
userMarkedPaidAt: timestamp('user_marked_paid_at'),
|
||||
payerName: varchar('payer_name', { length: 255 }), // Name of payer if different from attendee
|
||||
paidAt: timestamp('paid_at'),
|
||||
|
||||
+184
-128
@@ -7,7 +7,9 @@ import { logger } from 'hono/logger';
|
||||
import { swaggerUI } from '@hono/swagger-ui';
|
||||
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import { auth } from './lib/betterAuth.js';
|
||||
import authExtRoutes from './routes/authExt.js';
|
||||
import { getClientIp } from './lib/rateLimit.js';
|
||||
import eventsRoutes from './routes/events.js';
|
||||
import ticketsRoutes from './routes/tickets.js';
|
||||
import usersRoutes from './routes/users.js';
|
||||
@@ -24,8 +26,11 @@ import legalPagesRoutes from './routes/legal-pages.js';
|
||||
import legalSettingsRoutes from './routes/legal-settings.js';
|
||||
import faqRoutes from './routes/faq.js';
|
||||
import emailService from './lib/email.js';
|
||||
import { initEmailQueue } from './lib/emailQueue.js';
|
||||
import { startBookingCleanup } from './lib/bookingCleanup.js';
|
||||
import { initEmailQueue, stopQueue } from './lib/emailQueue.js';
|
||||
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
|
||||
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
|
||||
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
|
||||
import { closeRedis } from './lib/redis.js';
|
||||
import { getLock } from './lib/stores/lock.js';
|
||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
||||
|
||||
@@ -54,7 +59,7 @@ app.use(
|
||||
if (!origin) return frontendUrl;
|
||||
return allowedOrigins.has(origin) ? origin : null;
|
||||
},
|
||||
// We use bearer tokens, but keeping credentials=true matches nginx config.
|
||||
// Session cookies must be allowed on cross-origin API calls (api.* vhost).
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
@@ -107,11 +112,15 @@ const openApiSpec = {
|
||||
],
|
||||
paths: {
|
||||
// ==================== Auth Endpoints ====================
|
||||
'/api/auth/register': {
|
||||
// Authentication is handled by Better Auth, mounted at /api/auth/*.
|
||||
// Sessions are httpOnly cookies (spanglish.session_token); the endpoints
|
||||
// below are the subset the frontend uses. See https://better-auth.com/docs
|
||||
// for the full endpoint reference.
|
||||
'/api/auth/sign-up/email': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Register a new user',
|
||||
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
|
||||
summary: 'Register a new user (Better Auth)',
|
||||
description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -121,8 +130,8 @@ const openApiSpec = {
|
||||
required: ['email', 'password', 'name'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
|
||||
name: { type: 'string', minLength: 2 },
|
||||
password: { type: 'string', minLength: 10 },
|
||||
name: { type: 'string' },
|
||||
phone: { type: 'string' },
|
||||
languagePreference: { type: 'string', enum: ['en', 'es'] },
|
||||
},
|
||||
@@ -131,16 +140,17 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: 'User created successfully' },
|
||||
400: { description: 'Email already registered or validation error' },
|
||||
200: { description: 'User created; session cookie set' },
|
||||
422: { description: 'Email already registered' },
|
||||
400: { description: 'Validation error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/login': {
|
||||
'/api/auth/sign-in/email': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login with email and password',
|
||||
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
|
||||
summary: 'Login with email and password (Better Auth)',
|
||||
description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -157,42 +167,46 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Login successful, returns JWT token' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
401: { description: 'Invalid credentials' },
|
||||
429: { description: 'Too many login attempts' },
|
||||
429: { description: 'Too many attempts' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/google': {
|
||||
'/api/auth/sign-in/social': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login or register with Google',
|
||||
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
|
||||
summary: 'Login or register with Google (Better Auth)',
|
||||
description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['credential'],
|
||||
required: ['provider'],
|
||||
properties: {
|
||||
credential: { type: 'string', description: 'Google ID token' },
|
||||
provider: { type: 'string', enum: ['google'] },
|
||||
idToken: {
|
||||
type: 'object',
|
||||
properties: { token: { type: 'string', description: 'Google ID token' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Login successful' },
|
||||
400: { description: 'Invalid Google token' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
401: { description: 'Invalid Google token' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/magic-link/request': {
|
||||
'/api/auth/sign-in/magic-link': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request magic link login',
|
||||
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
|
||||
summary: 'Request magic link login (Better Auth)',
|
||||
description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -202,46 +216,33 @@ const openApiSpec = {
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
callbackURL: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Magic link sent (if account exists)' },
|
||||
},
|
||||
responses: { 200: { description: 'Magic link sent (if account exists)' } },
|
||||
},
|
||||
},
|
||||
'/api/auth/magic-link/verify': {
|
||||
post: {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Verify magic link token',
|
||||
description: 'Verify the magic link token and login user.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
summary: 'Verify magic link token (Better Auth)',
|
||||
parameters: [
|
||||
{ name: 'token', in: 'query', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: 'Login successful' },
|
||||
200: { description: 'Login successful; session cookie set' },
|
||||
400: { description: 'Invalid or expired token' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/password-reset/request': {
|
||||
'/api/auth/request-password-reset': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request password reset',
|
||||
description: 'Send a password reset link to email. Link expires in 30 minutes.',
|
||||
summary: 'Request password reset (Better Auth)',
|
||||
description: 'Emails a reset link. Token expires in 30 minutes.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -251,31 +252,30 @@ const openApiSpec = {
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
redirectTo: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Reset link sent (if account exists)' },
|
||||
},
|
||||
responses: { 200: { description: 'Reset link sent (if account exists)' } },
|
||||
},
|
||||
},
|
||||
'/api/auth/password-reset/confirm': {
|
||||
'/api/auth/reset-password': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Confirm password reset',
|
||||
description: 'Reset password using the token from email.',
|
||||
summary: 'Reset password with token (Better Auth)',
|
||||
description: 'Sets a new password and revokes all existing sessions.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token', 'password'],
|
||||
required: ['newPassword', 'token'],
|
||||
properties: {
|
||||
newPassword: { type: 'string', minLength: 10 },
|
||||
token: { type: 'string' },
|
||||
password: { type: 'string', minLength: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -287,62 +287,10 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/claim-account/request': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Request account claim link',
|
||||
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Claim link sent (if unclaimed account exists)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/claim-account/confirm': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Confirm account claim',
|
||||
description: 'Claim an unclaimed account by setting password or linking Google.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['token'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
|
||||
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Account claimed successfully' },
|
||||
400: { description: 'Invalid token or missing credentials' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/change-password': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Change password',
|
||||
description: 'Change password for authenticated user.',
|
||||
summary: 'Change password (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -354,6 +302,7 @@ const openApiSpec = {
|
||||
properties: {
|
||||
currentPassword: { type: 'string' },
|
||||
newPassword: { type: 'string', minLength: 10 },
|
||||
revokeOtherSessions: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -366,26 +315,66 @@ const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/me': {
|
||||
'/api/auth/get-session': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Get current user',
|
||||
description: 'Get the currently authenticated user profile.',
|
||||
summary: 'Get current session and user (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
200: { description: 'Current user data' },
|
||||
401: { description: 'Unauthorized' },
|
||||
200: { description: '{ session, user } or null when not authenticated' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth/logout': {
|
||||
'/api/auth/sign-out': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Logout',
|
||||
description: 'Logout current user (client-side token removal).',
|
||||
responses: {
|
||||
200: { description: 'Logged out' },
|
||||
summary: 'Logout (Better Auth)',
|
||||
description: 'Revokes the current session and clears the session cookie.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'Logged out' } },
|
||||
},
|
||||
},
|
||||
'/api/auth/list-sessions': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'List active sessions (Better Auth)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'Active sessions for the current user' } },
|
||||
},
|
||||
},
|
||||
'/api/auth-ext/claim-account': {
|
||||
post: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Claim a guest-created account',
|
||||
description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['password'],
|
||||
properties: { password: { type: 'string', minLength: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: 'Account claimed successfully' },
|
||||
400: { description: 'Already claimed or validation error' },
|
||||
401: { description: 'No session (claim link required)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/auth-ext/claim-eligibility': {
|
||||
get: {
|
||||
tags: ['Auth'],
|
||||
summary: 'Check whether an email has an unclaimed account',
|
||||
parameters: [
|
||||
{ name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } },
|
||||
],
|
||||
responses: { 200: { description: '{ canClaim: boolean }' } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1759,10 +1748,10 @@ const openApiSpec = {
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT token obtained from login endpoint',
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: 'spanglish.session_token',
|
||||
description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
@@ -1896,7 +1885,29 @@ app.get('/health', (c) => {
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.route('/api/auth', authRoutes);
|
||||
// Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link,
|
||||
// password reset, Google, session management). CORS above runs first.
|
||||
//
|
||||
// Better Auth only sees the Request (no TCP peer address), so its per-IP rate
|
||||
// limiting is fed the socket-anchored client IP resolved by getClientIp via a
|
||||
// private header. The inbound value is always discarded — a client cannot
|
||||
// choose its own rate-limit bucket.
|
||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.delete('x-client-ip');
|
||||
const clientIp = getClientIp(c);
|
||||
if (clientIp && clientIp !== 'unknown') {
|
||||
headers.set('x-client-ip', clientIp);
|
||||
}
|
||||
return auth.handler(
|
||||
new Request(c.req.raw, {
|
||||
headers,
|
||||
// Node's fetch requires duplex for requests carrying a body stream
|
||||
...(c.req.raw.body ? { duplex: 'half' as const } : {}),
|
||||
} as RequestInit)
|
||||
);
|
||||
});
|
||||
app.route('/api/auth-ext', authExtRoutes);
|
||||
app.route('/api/events', eventsRoutes);
|
||||
app.route('/api/tickets', ticketsRoutes);
|
||||
app.route('/api/users', usersRoutes);
|
||||
@@ -1932,11 +1943,20 @@ initEmailQueue(emailService);
|
||||
// Periodically expire abandoned pending bookings so they stop holding seats.
|
||||
startBookingCleanup();
|
||||
|
||||
// Periodically put stale pending-approval payments on hold, releasing their seats.
|
||||
startHoldSweep();
|
||||
|
||||
// Periodically auto-reject unconfirmed payments once their event is over (no email).
|
||||
startEventEndSweep();
|
||||
|
||||
// Initialize email templates on startup.
|
||||
// Guarded by a distributed lock so that, when running multiple replicas, only
|
||||
// one instance seeds/updates templates per boot instead of all of them racing.
|
||||
// onUnavailable 'run': at boot the Redis connection may not be ready yet, and
|
||||
// seeding is upsert-idempotent, so racing replicas are safe — never skipping
|
||||
// beats never seeding on a first boot during a Redis blip.
|
||||
getLock()
|
||||
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
|
||||
.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)');
|
||||
@@ -1953,7 +1973,43 @@ console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
|
||||
// Log which backend (memory/redis, local/s3) each subsystem selected.
|
||||
logSelectedBackends();
|
||||
|
||||
serve({
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
});
|
||||
|
||||
// Graceful shutdown: stop the periodic jobs, stop accepting connections, then
|
||||
// close Redis and exit. Open SSE payment streams hold sockets forever, so
|
||||
// server.close() alone never completes — force-close remaining connections
|
||||
// after a short grace period, with a hard exit as the final backstop.
|
||||
let shuttingDown = false;
|
||||
function shutdown(signal: string): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`[shutdown] ${signal} received, draining...`);
|
||||
|
||||
stopBookingCleanup();
|
||||
stopHoldSweep();
|
||||
stopEventEndSweep();
|
||||
stopQueue();
|
||||
|
||||
server.close(() => {
|
||||
console.log('[shutdown] server closed, closing redis');
|
||||
closeRedis().finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
const forceClose = setTimeout(() => {
|
||||
console.warn('[shutdown] force-closing remaining connections (SSE streams)');
|
||||
(server as any).closeAllConnections?.();
|
||||
}, 5_000);
|
||||
forceClose.unref();
|
||||
|
||||
const forceExit = setTimeout(() => {
|
||||
console.warn('[shutdown] drain timed out, forcing exit');
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
forceExit.unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
+92
-331
@@ -1,366 +1,127 @@
|
||||
import * as jose from 'jose';
|
||||
import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { Context } from 'hono';
|
||||
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
|
||||
import { generateId, getNow, toDbDate } from './utils.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { auth } from './betterAuth.js';
|
||||
import { db, dbGet } from '../db/index.js';
|
||||
import { authAccounts } from '../db/auth-schema.js';
|
||||
|
||||
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
|
||||
const rawJwtSecret = process.env.JWT_SECRET;
|
||||
// Auth is provided by Better Auth (lib/betterAuth.ts): httpOnly cookie
|
||||
// sessions validated against the auth_sessions table on every request, so
|
||||
// revocation (ban/suspend/password reset) applies instantly. This module keeps
|
||||
// the request-side helpers that the route files use.
|
||||
|
||||
// Never allow the insecure default in production: forgeable tokens = full account takeover.
|
||||
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
|
||||
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
|
||||
}
|
||||
if (!rawJwtSecret) {
|
||||
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
|
||||
}
|
||||
// Re-exported for routes that hash/validate passwords outside Better Auth
|
||||
export { hashPassword, verifyPassword, validatePassword } from './passwordPolicy.js';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
|
||||
const JWT_ISSUER = 'spanglish';
|
||||
const JWT_AUDIENCE = 'spanglish-app';
|
||||
|
||||
export interface JWTPayload {
|
||||
sub: string;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
role: string;
|
||||
tokenVersion?: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
languagePreference: string | null;
|
||||
isClaimed: boolean;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
/** ID of the Better Auth session backing this request. */
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Password hashing with Argon2 (spec requirement)
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||
if (hash.startsWith('$argon2')) {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
// Legacy bcrypt support
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Generate secure random token for magic links
|
||||
export function generateSecureToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Create magic link token
|
||||
export async function createMagicLinkToken(
|
||||
userId: string,
|
||||
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification',
|
||||
expiresInMinutes: number = 10
|
||||
): Promise<string> {
|
||||
const token = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = toDbDate(new Date(Date.now() + expiresInMinutes * 60 * 1000));
|
||||
|
||||
await (db as any).insert(magicLinkTokens).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
token,
|
||||
type,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Verify and consume magic link token
|
||||
export async function verifyMagicLinkToken(
|
||||
token: string,
|
||||
type: 'login' | 'reset_password' | 'claim_account' | 'email_verification'
|
||||
): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const now = getNow();
|
||||
|
||||
const tokenRecord = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(magicLinkTokens)
|
||||
.where(
|
||||
and(
|
||||
eq((magicLinkTokens as any).token, token),
|
||||
eq((magicLinkTokens as any).type, type)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Use a single generic error for all invalid states to avoid leaking token state
|
||||
const genericError = 'Invalid or expired token';
|
||||
|
||||
if (!tokenRecord) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
if (tokenRecord.usedAt) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
// Atomically consume the token: only the request that flips used_at from NULL wins.
|
||||
// This prevents a double-spend race where two concurrent requests both pass the
|
||||
// read-time "not used" check above.
|
||||
const result: any = await (db as any)
|
||||
.update(magicLinkTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(and(
|
||||
eq((magicLinkTokens as any).id, tokenRecord.id),
|
||||
isNull((magicLinkTokens as any).usedAt)
|
||||
));
|
||||
|
||||
const affected = result?.changes ?? result?.rowCount ?? 0;
|
||||
if (affected === 0) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
return { valid: true, userId: tokenRecord.userId };
|
||||
}
|
||||
|
||||
// Create user session
|
||||
export async function createUserSession(
|
||||
userId: string,
|
||||
userAgent?: string,
|
||||
ipAddress?: string
|
||||
): Promise<string> {
|
||||
const sessionToken = generateSecureToken();
|
||||
const now = getNow();
|
||||
const expiresAt = toDbDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)); // 30 days
|
||||
|
||||
await (db as any).insert(userSessions).values({
|
||||
id: generateId(),
|
||||
userId,
|
||||
token: sessionToken,
|
||||
userAgent: userAgent || null,
|
||||
ipAddress: ipAddress || null,
|
||||
lastActiveAt: now,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
// Get user's active sessions
|
||||
export async function getUserSessions(userId: string) {
|
||||
const now = getNow();
|
||||
|
||||
return dbAll(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).userId, userId),
|
||||
gt((userSessions as any).expiresAt, now)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate a specific session
|
||||
export async function invalidateSession(sessionId: string, userId: string): Promise<boolean> {
|
||||
const result = await (db as any)
|
||||
.delete(userSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((userSessions as any).id, sessionId),
|
||||
eq((userSessions as any).userId, userId)
|
||||
)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Invalidate all user sessions (logout everywhere)
|
||||
export async function invalidateAllUserSessions(userId: string): Promise<void> {
|
||||
await (db as any)
|
||||
.delete(userSessions)
|
||||
.where(eq((userSessions as any).userId, userId));
|
||||
}
|
||||
|
||||
// Small blocklist of common/weak passwords (and obvious app-specific ones).
|
||||
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
|
||||
// "P@ssw0rd!" still matches "password".
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
|
||||
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
|
||||
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
|
||||
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
|
||||
]);
|
||||
|
||||
// Password policy: 10-128 chars, requires a mix of character types, and rejects
|
||||
// common/weak passwords. Centralized so register/reset/change all share it.
|
||||
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||
if (password.length < 10) {
|
||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
||||
}
|
||||
if (password.length > 128) {
|
||||
return { valid: false, error: 'Password must be at most 128 characters long' };
|
||||
}
|
||||
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasDigit = /\d/.test(password);
|
||||
const hasSymbol = /[^A-Za-z0-9]/.test(password);
|
||||
|
||||
// Require lowercase, uppercase, and at least one digit or symbol.
|
||||
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (COMMON_PASSWORDS.has(normalized)) {
|
||||
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setAudience(JWT_AUDIENCE)
|
||||
.setExpirationTime('1d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
|
||||
export async function bumpTokenVersion(userId: string): Promise<void> {
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
|
||||
.where(eq((users as any).id, userId));
|
||||
}
|
||||
|
||||
export async function createRefreshToken(userId: string): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setExpirationTime('30d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
/**
|
||||
* Resolve the authenticated user for a request from its Better Auth session
|
||||
* cookie, or null when there is no valid session. Suspended/unclaimed/banned
|
||||
* accounts never get API access even with a live session cookie.
|
||||
*/
|
||||
export async function getAuthUser(c: Context): Promise<AuthUser | null> {
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
});
|
||||
return payload as unknown as JWTPayload;
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = session.user as any;
|
||||
|
||||
// Suspended (banned) or unclaimed accounts must not retain API access
|
||||
if (user.banned) {
|
||||
return null;
|
||||
}
|
||||
if (user.accountStatus && user.accountStatus !== 'active') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
phone: user.phone ?? null,
|
||||
role: user.role ?? 'user',
|
||||
languagePreference: user.languagePreference ?? null,
|
||||
isClaimed: Boolean(user.isClaimed),
|
||||
rucNumber: user.rucNumber ?? null,
|
||||
accountStatus: user.accountStatus ?? 'active',
|
||||
emailVerified: Boolean(user.emailVerified),
|
||||
image: user.image ?? null,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
sessionId: session.session.id,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthUser(c: Context): Promise<any | null> {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never load the password hash into request context — it is only needed for
|
||||
// explicit password-verification routes that query it separately.
|
||||
const user = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
name: (users as any).name,
|
||||
phone: (users as any).phone,
|
||||
role: (users as any).role,
|
||||
languagePreference: (users as any).languagePreference,
|
||||
isClaimed: (users as any).isClaimed,
|
||||
googleId: (users as any).googleId,
|
||||
rucNumber: (users as any).rucNumber,
|
||||
accountStatus: (users as any).accountStatus,
|
||||
tokenVersion: (users as any).tokenVersion,
|
||||
createdAt: (users as any).createdAt,
|
||||
updatedAt: (users as any).updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq((users as any).id, payload.sub))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject tokens issued before a logout-everywhere / password change
|
||||
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Suspended/unclaimed accounts must not retain API access via an old JWT
|
||||
if (user.accountStatus && user.accountStatus !== 'active') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireAuth(roles?: string[]) {
|
||||
return async (c: Context, next: () => Promise<void>) => {
|
||||
const user = await getAuthUser(c);
|
||||
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export async function isFirstUser(): Promise<boolean> {
|
||||
const result = await dbAll(
|
||||
(db as any).select().from(users).limit(1)
|
||||
);
|
||||
return !result || result.length === 0;
|
||||
}
|
||||
|
||||
/** Fetch only the password hash column (never expose via getAuthUser). */
|
||||
/**
|
||||
* Fetch only the credential password hash (never exposed via getAuthUser).
|
||||
* Returns null when the user has no password set (Google-only or unclaimed).
|
||||
*/
|
||||
export async function getUserPasswordHash(userId: string): Promise<string | null> {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ password: (users as any).password })
|
||||
.from(users)
|
||||
.where(eq((users as any).id, userId))
|
||||
.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 hash && String(hash).length > 0 ? String(hash) : null;
|
||||
}
|
||||
|
||||
/** Whether the user has a linked Google account. */
|
||||
export async function hasGoogleAccount(userId: string): Promise<boolean> {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ id: (authAccounts as any).id })
|
||||
.from(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, userId),
|
||||
eq((authAccounts as any).providerId, 'google')
|
||||
)
|
||||
)
|
||||
);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Reports which backend each scalable subsystem is using, for the health
|
||||
// endpoint and startup logging.
|
||||
|
||||
import { isRedisEnabled, isRedisHealthy } from './redis.js';
|
||||
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() {
|
||||
@@ -14,12 +15,13 @@ export function describeBackends() {
|
||||
rateLimiter: getRateLimiter().backend,
|
||||
pubsub: getPubSub().backend,
|
||||
lock: getLock().backend,
|
||||
loginLockout: getLoginLockout().backend,
|
||||
storage: getStorage().backend,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeRedis() {
|
||||
return { enabled: isRedisEnabled(), healthy: isRedisHealthy() };
|
||||
return { enabled: isRedisEnabled(), healthy: isRedisHealthy(), ...getRedisHealthDetail() };
|
||||
}
|
||||
|
||||
/** Log one line per subsystem at startup so the active backend is obvious. */
|
||||
@@ -32,5 +34,6 @@ export function logSelectedBackends(): void {
|
||||
console.log(` rate limiter: ${b.rateLimiter}`);
|
||||
console.log(` pub/sub: ${b.pubsub}`);
|
||||
console.log(` lock: ${b.lock}`);
|
||||
console.log(` login lockout:${b.loginLockout}`);
|
||||
console.log(` storage: ${b.storage}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
|
||||
// (dotenv never overrides pre-set values).
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ba-test-'));
|
||||
const dbPath = join(dir, 'test.db');
|
||||
process.env.DB_TYPE = 'sqlite';
|
||||
process.env.DATABASE_URL = dbPath;
|
||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
|
||||
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
|
||||
delete process.env.GOOGLE_CLIENT_ID;
|
||||
|
||||
// Capture outgoing auth emails (magic links, password resets)
|
||||
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
|
||||
vi.mock('./email.js', () => ({
|
||||
sendEmail: vi.fn(async (opts: any) => {
|
||||
sentEmails.push(opts);
|
||||
}),
|
||||
emailService: {},
|
||||
default: {},
|
||||
}));
|
||||
|
||||
let auth: (typeof import('./betterAuth.js'))['auth'];
|
||||
let db: any;
|
||||
let sqlite: any;
|
||||
|
||||
function lastEmailTo(email: string) {
|
||||
const found = [...sentEmails].reverse().find((e) => e.to === email);
|
||||
expect(found, `expected an email sent to ${email}`).toBeTruthy();
|
||||
return found!;
|
||||
}
|
||||
|
||||
function extractToken(html: string, param = 'token'): string {
|
||||
const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`));
|
||||
expect(match, `expected a ${param} in the email link`).toBeTruthy();
|
||||
return decodeURIComponent(match![1]);
|
||||
}
|
||||
|
||||
function cookieHeaders(setCookie: string | null): Headers {
|
||||
const sessionPart = (setCookie || '')
|
||||
.split(/,(?=[^ ;]+=)/)
|
||||
.map((c) => c.split(';')[0].trim())
|
||||
.filter((c) => c.includes('session_token'))
|
||||
.join('; ');
|
||||
return new Headers({ cookie: sessionPart });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
|
||||
env: { ...process.env },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return (async () => {
|
||||
({ auth } = await import('./betterAuth.js'));
|
||||
({ db } = await import('../db/index.js'));
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
sqlite = new Database(dbPath);
|
||||
})();
|
||||
}, 120_000);
|
||||
|
||||
describe('Better Auth integration', () => {
|
||||
it('makes the first registered user an admin, later users regular', async () => {
|
||||
const first = await auth.api.signUpEmail({
|
||||
body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' },
|
||||
});
|
||||
expect((first.user as any).id).toBeTruthy();
|
||||
|
||||
const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py');
|
||||
expect(row.role).toBe('admin');
|
||||
expect(row.account_status).toBe('active');
|
||||
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' },
|
||||
});
|
||||
const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py');
|
||||
expect(row2.role).toBe('user');
|
||||
});
|
||||
|
||||
it('stores credential passwords as argon2id in auth_accounts, not users', async () => {
|
||||
const acct = sqlite
|
||||
.prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'")
|
||||
.get('admin@test.py');
|
||||
expect(acct.password.startsWith('$argon2id$')).toBe(true);
|
||||
const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py');
|
||||
expect(user.password).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects passwords that violate the policy', async () => {
|
||||
// Too short
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } })
|
||||
).rejects.toThrow(/at least 10 characters/);
|
||||
|
||||
// Long enough but no character mix (app policy hook)
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } })
|
||||
).rejects.toThrow(/uppercase and lowercase/);
|
||||
|
||||
// Common password normalized (policy blocklist)
|
||||
await expect(
|
||||
auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } })
|
||||
).rejects.toThrow(/too common/);
|
||||
|
||||
expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0);
|
||||
});
|
||||
|
||||
it('locks an email after 5 failed sign-ins', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' },
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } })
|
||||
).rejects.toThrow();
|
||||
}
|
||||
// Correct password now also refused: locked
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } })
|
||||
).rejects.toThrow(/Too many login attempts/);
|
||||
});
|
||||
|
||||
it('refuses sign-in for banned (suspended) users and kills nothing else', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' },
|
||||
});
|
||||
sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py');
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } })
|
||||
).rejects.toThrow(/suspended|banned/i);
|
||||
});
|
||||
|
||||
it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => {
|
||||
const bcrypt = (await import('bcryptjs')).default;
|
||||
const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10);
|
||||
const su = await auth.api.signUpEmail({
|
||||
body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' },
|
||||
});
|
||||
sqlite
|
||||
.prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.run(legacyHash, (su.user as any).id);
|
||||
|
||||
const si = await auth.api.signInEmail({
|
||||
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
|
||||
});
|
||||
expect(si.user.email).toBe('legacy@test.py');
|
||||
|
||||
// Upgrade happens in the after-hook; poll briefly for it
|
||||
let upgraded = '';
|
||||
for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
upgraded = sqlite
|
||||
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.get((su.user as any).id).password;
|
||||
}
|
||||
expect(upgraded.startsWith('$argon2id$')).toBe(true);
|
||||
|
||||
// And the upgraded hash still verifies
|
||||
const again = await auth.api.signInEmail({
|
||||
body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
|
||||
});
|
||||
expect(again.user.email).toBe('legacy@test.py');
|
||||
});
|
||||
|
||||
it('magic link signs in existing users but never creates accounts', async () => {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' },
|
||||
});
|
||||
await auth.api.signInMagicLink({
|
||||
body: { email: 'magic@test.py', callbackURL: '/dashboard' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
const email = lastEmailTo('magic@test.py');
|
||||
// Emails link to the frontend page, not the raw API endpoint
|
||||
expect(email.html).toContain('http://localhost:3002/auth/magic-link?token=');
|
||||
const token = extractToken(email.html);
|
||||
|
||||
const verified = await auth.api.magicLinkVerify({
|
||||
query: { token },
|
||||
headers: new Headers(),
|
||||
});
|
||||
expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy();
|
||||
|
||||
// Unknown email: enumeration-safe success (an email may still go out),
|
||||
// but verification can never create an account (disableSignUp)
|
||||
const ghost = await auth.api.signInMagicLink({
|
||||
body: { email: 'ghost@test.py' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
expect((ghost as any).status).toBe(true);
|
||||
const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop();
|
||||
if (ghostEmail) {
|
||||
const ghostToken = extractToken(ghostEmail.html);
|
||||
await expect(
|
||||
auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() })
|
||||
).rejects.toThrow();
|
||||
}
|
||||
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0);
|
||||
});
|
||||
|
||||
it('completes the guest claim path: magic link session + setPassword', async () => {
|
||||
// Simulate tickets.ts guest creation: user row, no credential account
|
||||
const guestId = 'guest-claim-user-000001';
|
||||
const now = new Date().toISOString();
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)`
|
||||
)
|
||||
.run(guestId, 'guest@test.py', now, now);
|
||||
|
||||
await auth.api.signInMagicLink({
|
||||
body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' },
|
||||
headers: new Headers(),
|
||||
});
|
||||
const token = extractToken(lastEmailTo('guest@test.py').html);
|
||||
const verified = await auth.api.magicLinkVerify({
|
||||
query: { token },
|
||||
returnHeaders: true,
|
||||
headers: new Headers(),
|
||||
});
|
||||
const headers = cookieHeaders(verified.headers.get('set-cookie'));
|
||||
|
||||
// The session works even while unclaimed (the claim endpoint depends on this)
|
||||
const session = await auth.api.getSession({ headers });
|
||||
expect((session?.user as any)?.accountStatus).toBe('unclaimed');
|
||||
|
||||
// Set the password (what /api/auth-ext/claim-account does)
|
||||
await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers });
|
||||
const acct = sqlite
|
||||
.prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
|
||||
.get(guestId);
|
||||
expect(acct.password.startsWith('$argon2id$')).toBe(true);
|
||||
|
||||
// The claim email uses the claim template with the frontend link
|
||||
const claimEmail = lastEmailTo('guest@test.py');
|
||||
expect(claimEmail.subject).toContain('Claim');
|
||||
expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account');
|
||||
});
|
||||
|
||||
it('password reset revokes existing sessions and applies the new password', async () => {
|
||||
const su = await auth.api.signUpEmail({
|
||||
body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' },
|
||||
});
|
||||
const userId = (su.user as any).id;
|
||||
// A live session from sign-in
|
||||
await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } });
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await auth.api.requestPasswordReset({
|
||||
body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' },
|
||||
});
|
||||
const email = lastEmailTo('reset@test.py');
|
||||
// Reset URLs are either .../reset-password/{token}?... or ...?token={token}
|
||||
const html = email.html;
|
||||
const pathMatch = html.match(/reset-password\/([^?"&\s]+)/);
|
||||
const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html);
|
||||
|
||||
await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } });
|
||||
|
||||
// revokeSessionsOnPasswordReset: true
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
|
||||
).toBe(0);
|
||||
|
||||
await expect(
|
||||
auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } })
|
||||
).rejects.toThrow();
|
||||
const after = await auth.api.signInEmail({
|
||||
body: { email: 'reset@test.py', password: 'AfterReset1!x' },
|
||||
});
|
||||
expect(after.user.email).toBe('reset@test.py');
|
||||
});
|
||||
|
||||
it('sessions are stored in auth_sessions with 7-day expiry', async () => {
|
||||
const si = await auth.api.signInEmail({
|
||||
body: { email: 'admin@test.py', password: 'FirstAdmin1!x' },
|
||||
});
|
||||
const row = sqlite
|
||||
.prepare('SELECT expires_at FROM auth_sessions WHERE token = ?')
|
||||
.get((si as any).token);
|
||||
expect(row).toBeTruthy();
|
||||
const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24);
|
||||
expect(days).toBeGreaterThan(6.5);
|
||||
expect(days).toBeLessThan(7.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
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';
|
||||
|
||||
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);
|
||||
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,
|
||||
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;
|
||||
@@ -5,11 +5,20 @@
|
||||
// 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 } from 'drizzle-orm';
|
||||
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);
|
||||
@@ -20,8 +29,9 @@ function getTtlMs(): number {
|
||||
* 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) and
|
||||
* older than PENDING_BOOKING_TTL_MINUTES.
|
||||
* '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()));
|
||||
@@ -35,7 +45,8 @@ export async function cleanupStalePendingBookings(): Promise<number> {
|
||||
.from(payments)
|
||||
.where(and(
|
||||
eq((payments as any).status, 'pending'),
|
||||
lt((payments as any).createdAt, cutoff)
|
||||
notInArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||
lt((payments as any).updatedAt, cutoff)
|
||||
))
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Single source of truth for event seat accounting.
|
||||
//
|
||||
// A seat is held by a booking the moment the money is real or claimed to be:
|
||||
// - ticket 'confirmed' or 'checked_in' (paid), or
|
||||
// - ticket 'pending' whose payment is 'pending_approval' (customer clicked
|
||||
// "I've paid" and is waiting for admin verification).
|
||||
//
|
||||
// A bare 'pending' payment — an opened checkout that was never paid nor claimed,
|
||||
// on any provider — holds NO seat, so abandoned bookings can never block sales.
|
||||
// The accepted trade-off is a small oversell window when several people book the
|
||||
// last seats and all later pay/claim; admin approval is the backstop and may
|
||||
// knowingly approve over capacity (see routes/payments.ts).
|
||||
//
|
||||
// 'pending_approval' is a payment status (the ticket row stays 'pending'), so
|
||||
// every capacity count joins tickets to payments. There is exactly one payment
|
||||
// row per ticket (created together in routes/tickets.ts).
|
||||
//
|
||||
// Every place that counts seats — booking creation, public availability,
|
||||
// hold recovery, admin dashboards — must go through these builders so the
|
||||
// formula cannot diverge between surfaces.
|
||||
|
||||
import { sql, and, eq, inArray } from 'drizzle-orm';
|
||||
import { tickets, payments } from '../db/index.js';
|
||||
|
||||
// COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no
|
||||
// payment row must count as "not holding" — NULL would poison NOT ...).
|
||||
export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`;
|
||||
|
||||
/**
|
||||
* Query: number of seats currently held for an event.
|
||||
* `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`;
|
||||
* async pg tx / plain db: await via dbGet).
|
||||
*/
|
||||
export function seatHolderCountQuery(executor: any, eventId: string) {
|
||||
return executor
|
||||
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
|
||||
.from(tickets)
|
||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(and(eq((tickets as any).eventId, eventId), seatHoldingSql));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query: how many of the given tickets do NOT currently hold a seat (and so
|
||||
* would need fresh capacity if promoted to a seat-holding state).
|
||||
*/
|
||||
export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) {
|
||||
return executor
|
||||
.select({ count: sql<number>`count(distinct ${(tickets as any).id})` })
|
||||
.from(tickets)
|
||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
|
||||
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
|
||||
* Pass `eventId` to restrict to one event (still returns a grouped row).
|
||||
*/
|
||||
export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
|
||||
const query = executor
|
||||
.select({
|
||||
eventId: (tickets as any).eventId,
|
||||
paidCount: sql<number>`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`,
|
||||
claimedCount: sql<number>`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`,
|
||||
})
|
||||
.from(tickets)
|
||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
|
||||
return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query)
|
||||
.groupBy((tickets as any).eventId);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Auto-reject unconfirmed payments once their event is over.
|
||||
//
|
||||
// After an event ends, any booking whose payment was never confirmed
|
||||
// (still 'pending', 'pending_approval', or 'on_hold') can no longer be honored.
|
||||
// This job silently fails those payments and cancels their tickets so they stop
|
||||
// lingering as "pending" forever. It deliberately sends NO email — unlike the
|
||||
// admin reject route, this is a housekeeping sweep and users are not notified.
|
||||
//
|
||||
// An event is considered over when COALESCE(end_datetime, start_datetime) is in
|
||||
// the past. Updates are guarded by the current status so re-running is a no-op.
|
||||
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments, events } from '../db/index.js';
|
||||
import { getNow } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
|
||||
// Payment statuses that represent an unconfirmed booking.
|
||||
const UNCONFIRMED_PAYMENT_STATUSES = ['pending', 'pending_approval', 'on_hold'];
|
||||
// Ticket statuses that are still "live" (not already confirmed/checked-in/cancelled).
|
||||
const ACTIVE_TICKET_STATUSES = ['pending', 'on_hold'];
|
||||
|
||||
/**
|
||||
* Fail unconfirmed payments (and cancel their tickets) for events that have
|
||||
* already ended. Returns the number of payments rejected.
|
||||
*/
|
||||
export async function rejectUnconfirmedPaymentsForEndedEvents(): Promise<number> {
|
||||
// Pull candidate rows first, then decide "ended" in JS so the comparison works
|
||||
// identically for SQLite (ISO text) and Postgres (timestamp) datetime columns.
|
||||
const rows = await dbAll<{
|
||||
paymentId: string;
|
||||
ticketId: string;
|
||||
endDatetime: string | Date | null;
|
||||
startDatetime: string | Date | null;
|
||||
}>(
|
||||
(db as any)
|
||||
.select({
|
||||
paymentId: (payments as any).id,
|
||||
ticketId: (tickets as any).id,
|
||||
endDatetime: (events as any).endDatetime,
|
||||
startDatetime: (events as any).startDatetime,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||
.innerJoin(events, eq((tickets as any).eventId, (events as any).id))
|
||||
.where(and(
|
||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
||||
))
|
||||
);
|
||||
|
||||
const nowMs = Date.now();
|
||||
const ended = rows.filter((r) => {
|
||||
const ref = r.endDatetime || r.startDatetime;
|
||||
if (!ref) return false;
|
||||
return new Date(ref as any).getTime() < nowMs;
|
||||
});
|
||||
|
||||
if (ended.length === 0) return 0;
|
||||
|
||||
const paymentIds = Array.from(new Set(ended.map((r) => r.paymentId)));
|
||||
const ticketIds = Array.from(new Set(ended.map((r) => r.ticketId).filter((id): id is string => !!id)));
|
||||
const now = getNow();
|
||||
|
||||
// Fail the payments. The status guard keeps this idempotent and avoids
|
||||
// clobbering anything that changed since we read the candidates.
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'failed', adminNote: 'Auto-rejected: event ended', updatedAt: now })
|
||||
.where(and(
|
||||
inArray((payments as any).id, paymentIds),
|
||||
inArray((payments as any).status, UNCONFIRMED_PAYMENT_STATUSES),
|
||||
));
|
||||
|
||||
// Cancel the associated tickets, freeing any seats they still hold.
|
||||
if (ticketIds.length > 0) {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, ACTIVE_TICKET_STATUSES),
|
||||
));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[EventEndSweep] Auto-rejected ${paymentIds.length} unconfirmed payment(s) for ended event(s); ` +
|
||||
`cancelled ${ticketIds.length} ticket(s).`
|
||||
);
|
||||
return paymentIds.length;
|
||||
}
|
||||
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start a periodic sweep that auto-rejects unconfirmed payments for ended
|
||||
* events. Each run is guarded by a distributed lock so that, across multiple
|
||||
* replicas, only one instance does the work per interval.
|
||||
*/
|
||||
export function startEventEndSweep(): void {
|
||||
const intervalMs = parseInt(process.env.EVENT_END_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
|
||||
|
||||
const run = () => {
|
||||
getLock()
|
||||
.withLock('sweep-ended-event-payments', Math.min(intervalMs, 60_000), () =>
|
||||
rejectUnconfirmedPaymentsForEndedEvents()
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error('[EventEndSweep] Run failed:', err?.message || err)
|
||||
);
|
||||
};
|
||||
|
||||
// Run shortly after startup, then on the interval.
|
||||
setTimeout(run, 60_000).unref?.();
|
||||
sweepTimer = setInterval(run, intervalMs);
|
||||
sweepTimer.unref?.();
|
||||
console.log(`[EventEndSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
||||
}
|
||||
|
||||
export function stopEventEndSweep(): void {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Shared capacity-checked recovery for bookings that don't currently hold a seat.
|
||||
//
|
||||
// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in
|
||||
// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those
|
||||
// states — user clicking "I've paid", an admin approving/reactivating a payment —
|
||||
// must atomically re-check that the event still has room, exactly like the original
|
||||
// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending'
|
||||
// (e.g. reopening a failed payment) claims no seat and skips the check.
|
||||
//
|
||||
// Callers choose which ticket statuses are eligible to be flipped via
|
||||
// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are
|
||||
// left untouched. Tickets that already hold a seat cost no new capacity.
|
||||
// `options.skipCapacityCheck` lets an admin knowingly approve over capacity —
|
||||
// the UI warns first (routes/payments.ts /approve with allowOverCapacity).
|
||||
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js';
|
||||
import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js';
|
||||
import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js';
|
||||
|
||||
export class HoldCapacityError extends Error {
|
||||
constructor(public available: number) {
|
||||
super('EVENT_FULL');
|
||||
}
|
||||
}
|
||||
|
||||
interface ReserveOptions {
|
||||
paidByAdminId?: string;
|
||||
extraPaymentFields?: Record<string, any>;
|
||||
/** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */
|
||||
fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>;
|
||||
/** Admin override: reserve even when it puts the event over capacity. */
|
||||
skipCapacityCheck?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reserve seats for a group of released tickets (e.g. all tickets sharing a
|
||||
* bookingId), atomically re-checking capacity before flipping their status.
|
||||
* Only tickets whose current status is in `fromTicketStatuses` are flipped.
|
||||
* Capacity is asserted against the number of those tickets that don't already
|
||||
* hold a seat, so re-reserving tickets that are already seated is a no-op.
|
||||
* Throws HoldCapacityError if the event no longer has room (unless the target
|
||||
* state holds no seat, or skipCapacityCheck is set).
|
||||
*/
|
||||
export async function reserveOnHoldBooking(
|
||||
eventId: string,
|
||||
ticketIds: string[],
|
||||
targetTicketStatus: 'pending' | 'confirmed',
|
||||
targetPaymentStatus: 'pending_approval' | 'paid' | 'pending',
|
||||
options: ReserveOptions = {}
|
||||
): Promise<void> {
|
||||
if (ticketIds.length === 0) return;
|
||||
|
||||
const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold'];
|
||||
// Bare 'pending' payments hold no seat, so moving a booking back to 'pending'
|
||||
// consumes no capacity and needs no check.
|
||||
const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed';
|
||||
const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck;
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||
);
|
||||
if (!event) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
const paymentUpdate: Record<string, any> = {
|
||||
status: targetPaymentStatus,
|
||||
updatedAt: now,
|
||||
...options.extraPaymentFields,
|
||||
};
|
||||
if (targetPaymentStatus === 'paid') {
|
||||
paymentUpdate.paidAt = now;
|
||||
if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId;
|
||||
}
|
||||
|
||||
// Keep the ticket-level payment flag in sync when the payment settles
|
||||
const ticketUpdate: Record<string, any> = { status: targetTicketStatus };
|
||||
if (targetPaymentStatus === 'paid') {
|
||||
ticketUpdate.paymentStatus = 'paid';
|
||||
}
|
||||
|
||||
// `needed` is how many of these tickets don't currently hold a seat and so must
|
||||
// be found new capacity; tickets already in a seat-holding state cost nothing.
|
||||
const assertCapacity = (reserved: number, needed: number) => {
|
||||
if (needed <= 0) return;
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new HoldCapacityError(0);
|
||||
}
|
||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
||||
if (needed > seatsLeft) {
|
||||
throw new HoldCapacityError(seatsLeft);
|
||||
}
|
||||
};
|
||||
|
||||
if (isSqlite()) {
|
||||
(db as any).transaction((tx: any) => {
|
||||
if (checkCapacity) {
|
||||
const countRow = seatHolderCountQuery(tx, eventId).get();
|
||||
const neededRow = unseatedTicketCountQuery(tx, ticketIds).get();
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
}
|
||||
|
||||
tx.update(tickets)
|
||||
.set(ticketUpdate)
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, fromTicketStatuses)
|
||||
))
|
||||
.run();
|
||||
|
||||
tx.update(payments)
|
||||
.set(paymentUpdate)
|
||||
.where(inArray((payments as any).ticketId, ticketIds))
|
||||
.run();
|
||||
});
|
||||
} else {
|
||||
await (db as any).transaction(async (tx: any) => {
|
||||
if (checkCapacity) {
|
||||
const countRow = await dbGet<any>(seatHolderCountQuery(tx, eventId));
|
||||
const neededRow = await dbGet<any>(unseatedTicketCountQuery(tx, ticketIds));
|
||||
assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0));
|
||||
}
|
||||
|
||||
await tx.update(tickets)
|
||||
.set(ticketUpdate)
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
inArray((tickets as any).status, fromTicketStatuses)
|
||||
));
|
||||
|
||||
await tx.update(payments)
|
||||
.set(paymentUpdate)
|
||||
.where(inArray((payments as any).ticketId, ticketIds));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Auto-hold stale unsettled manual-payment bookings.
|
||||
//
|
||||
// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash)
|
||||
// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e.
|
||||
// the customer never clicked "I've paid" and no admin settled them. These are exempt
|
||||
// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in
|
||||
// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps
|
||||
// dead checkouts out of the admin's pending queues.
|
||||
//
|
||||
// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a
|
||||
// claimed payment keeps its seat until an admin approves or rejects it — the admin
|
||||
// UI surfaces aging claims instead of silently releasing them.
|
||||
//
|
||||
// The user receives no notification — they can recover via "I've paid", and an
|
||||
// admin can approve/reactivate directly; every recovery path re-checks capacity.
|
||||
|
||||
import { and, eq, lt, inArray } from 'drizzle-orm';
|
||||
import { db, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { getNow, toDbDate } from './utils.js';
|
||||
import { getLock } from './stores/lock.js';
|
||||
import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js';
|
||||
|
||||
function getThresholdMs(): number {
|
||||
const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10);
|
||||
return (Number.isFinite(hours) && hours > 0 ? hours : 72) * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move stale unsettled manual payments (and their tickets) to 'on_hold'.
|
||||
* Covers only bare 'pending' payments on manual providers; 'pending_approval'
|
||||
* is never swept. Returns the number of payments put on hold.
|
||||
*/
|
||||
export async function sweepStaleApprovals(): Promise<number> {
|
||||
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
|
||||
|
||||
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
|
||||
(db as any)
|
||||
.select({
|
||||
ticketId: (payments as any).ticketId,
|
||||
paymentId: (payments as any).id,
|
||||
})
|
||||
.from(payments)
|
||||
.where(and(
|
||||
eq((payments as any).status, 'pending'),
|
||||
inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]),
|
||||
lt((payments as any).createdAt, cutoff)
|
||||
))
|
||||
);
|
||||
|
||||
if (stale.length === 0) return 0;
|
||||
|
||||
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
|
||||
const paymentIds = stale.map((s) => s.paymentId);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'on_hold', updatedAt: now })
|
||||
.where(inArray((payments as any).id, paymentIds));
|
||||
|
||||
if (ticketIds.length > 0) {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'on_hold' })
|
||||
.where(and(
|
||||
inArray((tickets as any).id, ticketIds),
|
||||
eq((tickets as any).status, 'pending')
|
||||
));
|
||||
}
|
||||
|
||||
console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`);
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start a periodic sweep of stale pending-approval payments. Each run is guarded by
|
||||
* a distributed lock so that, across multiple replicas, only one instance does the
|
||||
* work per interval.
|
||||
*/
|
||||
export function startHoldSweep(): void {
|
||||
const intervalMs = parseInt(process.env.HOLD_SWEEP_INTERVAL_MS || '900000', 10); // 15 min
|
||||
|
||||
const run = () => {
|
||||
getLock()
|
||||
.withLock('sweep-hold-stale-approvals', Math.min(intervalMs, 60_000), () =>
|
||||
sweepStaleApprovals()
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error('[HoldSweep] Run failed:', err?.message || err)
|
||||
);
|
||||
};
|
||||
|
||||
// Run shortly after startup, then on the interval.
|
||||
setTimeout(run, 45_000).unref?.();
|
||||
sweepTimer = setInterval(run, intervalMs);
|
||||
sweepTimer.unref?.();
|
||||
console.log(`[HoldSweep] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
||||
}
|
||||
|
||||
export function stopHoldSweep(): void {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,11 @@ export interface CreateInvoiceParams {
|
||||
extra?: Record<string, any>; // Additional metadata
|
||||
}
|
||||
|
||||
// How long a booking's Lightning invoice is valid for, in seconds. Shared
|
||||
// between initial booking creation and invoice regeneration so both produce
|
||||
// invoices with the same lifetime.
|
||||
export const LNBITS_INVOICE_EXPIRY_SECONDS = 900; // 15 minutes
|
||||
|
||||
/**
|
||||
* Check if LNbits is configured
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as argon2 from 'argon2';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Password hashing with Argon2 (spec requirement)
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
// Support both bcrypt (legacy) and argon2 hashes for migration
|
||||
if (hash.startsWith('$argon2')) {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
// Legacy bcrypt support
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Small blocklist of common/weak passwords (and obvious app-specific ones).
|
||||
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
|
||||
// "P@ssw0rd!" still matches "password".
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
|
||||
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
|
||||
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
|
||||
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
|
||||
]);
|
||||
|
||||
// Password policy: 10-128 chars, requires a mix of character types, and rejects
|
||||
// common/weak passwords. Centralized so register/reset/change all share it.
|
||||
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||
if (password.length < 10) {
|
||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
||||
}
|
||||
if (password.length > 128) {
|
||||
return { valid: false, error: 'Password must be at most 128 characters long' };
|
||||
}
|
||||
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasDigit = /\d/.test(password);
|
||||
const hasSymbol = /[^A-Za-z0-9]/.test(password);
|
||||
|
||||
// Require lowercase, uppercase, and at least one digit or symbol.
|
||||
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (COMMON_PASSWORDS.has(normalized)) {
|
||||
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Payment provider registry.
|
||||
//
|
||||
// Every provider is either:
|
||||
// - 'automatic': the gateway itself confirms the payment (webhook/invoice
|
||||
// settlement) and the booking is auto-approved on success. No admin involved.
|
||||
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
||||
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
||||
// card handled offline, cash at the door). These are never auto-confirmed
|
||||
// and never auto-failed; an admin settles them by hand. Bank transfer and
|
||||
// TPago additionally expose an online "I've paid" step that moves the
|
||||
// payment to 'pending_approval'.
|
||||
//
|
||||
// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and
|
||||
// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either
|
||||
// kind — holds no seat, so an abandoned checkout can never block sales.
|
||||
|
||||
export type PaymentProviderKind = 'automatic' | 'manual';
|
||||
|
||||
export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> = {
|
||||
lightning: { kind: 'automatic' },
|
||||
tpago: { kind: 'manual' },
|
||||
bank_transfer: { kind: 'manual' },
|
||||
card: { kind: 'manual' },
|
||||
cash: { kind: 'manual' },
|
||||
};
|
||||
|
||||
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
|
||||
(p) => PAYMENT_PROVIDERS[p].kind === 'manual'
|
||||
);
|
||||
|
||||
export function isManualProvider(provider: string): boolean {
|
||||
return PAYMENT_PROVIDERS[provider]?.kind === 'manual';
|
||||
}
|
||||
|
||||
export function isAutomaticProvider(provider: string): boolean {
|
||||
return PAYMENT_PROVIDERS[provider]?.kind === 'automatic';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getClientIp, isTrustedProxyIp } from './rateLimit.js';
|
||||
|
||||
// Minimal Hono-Context stand-in: headers + the node-server env with the
|
||||
// socket peer address.
|
||||
function fakeContext(opts: { peer?: string; headers?: Record<string, string> }) {
|
||||
const headers = new Map(
|
||||
Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
|
||||
);
|
||||
return {
|
||||
req: { header: (name: string) => headers.get(name.toLowerCase()) },
|
||||
env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('isTrustedProxyIp', () => {
|
||||
it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => {
|
||||
expect(isTrustedProxyIp('127.0.0.1')).toBe(true);
|
||||
expect(isTrustedProxyIp('::1')).toBe(true);
|
||||
expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true);
|
||||
expect(isTrustedProxyIp('10.1.2.3')).toBe(true);
|
||||
expect(isTrustedProxyIp('172.18.0.5')).toBe(true);
|
||||
expect(isTrustedProxyIp('192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not trust public addresses or near-miss ranges', () => {
|
||||
expect(isTrustedProxyIp('203.0.113.7')).toBe(false);
|
||||
expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12
|
||||
expect(isTrustedProxyIp('172.32.0.1')).toBe(false);
|
||||
expect(isTrustedProxyIp('1270.0.0.1')).toBe(false);
|
||||
expect(isTrustedProxyIp('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClientIp', () => {
|
||||
it('prefers X-Real-IP when the peer is a trusted proxy', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('walks X-Forwarded-For from the right past our own proxy hops', () => {
|
||||
// spoofed prefix, then the real client appended by nginx, then the Next
|
||||
// proxy hop — the rightmost untrusted entry wins
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('ignores forwarded headers entirely when the peer is untrusted', () => {
|
||||
// A client hitting the API directly cannot pick its own bucket
|
||||
const c = fakeContext({
|
||||
peer: '198.51.100.4',
|
||||
headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('198.51.100.4');
|
||||
});
|
||||
|
||||
it('falls back to the socket address for local traffic with no headers', () => {
|
||||
expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1');
|
||||
expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('falls back to the socket address when every forwarded hop is internal', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': '127.0.0.1' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('rejects junk header values instead of using them as bucket keys', () => {
|
||||
const c = fakeContext({
|
||||
peer: '127.0.0.1',
|
||||
headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' },
|
||||
});
|
||||
expect(getClientIp(c)).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('returns "unknown" without a socket address or trusted headers', () => {
|
||||
expect(getClientIp(fakeContext({}))).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,68 @@ import { getRateLimiter } from './stores/rateLimiter.js';
|
||||
* (horizontal scaling). See lib/stores/rateLimiter.ts.
|
||||
*/
|
||||
|
||||
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
|
||||
// Peers allowed to speak for the client via X-Real-IP / X-Forwarded-For:
|
||||
// loopback (nginx on the same host, the Next.js proxy) and RFC1918 ranges
|
||||
// (the docker-compose scale deployment, where nginx is another container).
|
||||
// Extend with TRUSTED_PROXIES (comma-separated IP prefixes, e.g. "172.20.").
|
||||
const DEFAULT_TRUSTED_PROXY_PREFIXES = ['127.', '10.', '192.168.', '::1'];
|
||||
|
||||
function trustedProxyPrefixes(): string[] {
|
||||
const extra = (process.env.TRUSTED_PROXIES || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return [...DEFAULT_TRUSTED_PROXY_PREFIXES, ...extra];
|
||||
}
|
||||
|
||||
/** Strip the IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.". */
|
||||
function normalizeIp(ip: string | undefined | null): string {
|
||||
const trimmed = (ip || '').trim();
|
||||
return trimmed.toLowerCase().startsWith('::ffff:') ? trimmed.slice(7) : trimmed;
|
||||
}
|
||||
|
||||
export function isTrustedProxyIp(ip: string): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized) return false;
|
||||
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(normalized)) return true; // 172.16.0.0/12
|
||||
return trustedProxyPrefixes().some((prefix) => normalized === prefix || normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
// Rough shape check so a junk header value can't become a rate-limit key.
|
||||
function looksLikeIp(value: string): boolean {
|
||||
return value.length > 0 && value.length <= 45 && /^[0-9a-fA-F.:]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spoof-resistant client IP resolution.
|
||||
*
|
||||
* The TCP peer address (via @hono/node-server's env.incoming) anchors the
|
||||
* trust decision: forwarded headers are only honoured when the direct peer is
|
||||
* one of our own proxies. X-Real-IP is preferred because nginx overwrites it
|
||||
* at the edge (deploy/*.conf); X-Forwarded-For is append-only, so it is
|
||||
* walked from the right past our proxy hops — the leftmost entries are
|
||||
* client-controlled and never trusted on their own.
|
||||
*/
|
||||
export function getClientIp(c: Context): string {
|
||||
const forwarded = c.req.header('x-forwarded-for');
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return c.req.header('x-real-ip') || 'unknown';
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+80
-10
@@ -4,7 +4,15 @@
|
||||
// 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, and a health flag that callers and the health endpoint can read.
|
||||
// backoff, an active PING probe, and a health flag that callers and the health
|
||||
// endpoint can read.
|
||||
//
|
||||
// TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path
|
||||
// selects a DB index (e.g. redis://host:6379/1) when sharing an instance.
|
||||
// We deliberately do not use ioredis's keyPrefix option: all keys are already
|
||||
// namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a
|
||||
// pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not)
|
||||
// that would silently break the payment SSE channel.
|
||||
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@@ -12,6 +20,14 @@ 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 {
|
||||
@@ -23,14 +39,41 @@ 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.
|
||||
// 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);
|
||||
@@ -42,30 +85,53 @@ function buildClient(label: string): Redis {
|
||||
console.log(`[redis] (${label}) connecting`);
|
||||
});
|
||||
instance.on('ready', () => {
|
||||
healthy = true;
|
||||
console.log(`[redis] (${label}) ready`);
|
||||
setHealthy(true, `${label} ready`);
|
||||
});
|
||||
instance.on('error', (err) => {
|
||||
healthy = false;
|
||||
console.error(`[redis] (${label}) error:`, err?.message || err);
|
||||
setHealthy(false, `${label} error`);
|
||||
logErrorDebounced(label, err);
|
||||
});
|
||||
instance.on('reconnecting', () => {
|
||||
healthy = false;
|
||||
console.warn(`[redis] (${label}) reconnecting`);
|
||||
setHealthy(false, `${label} reconnecting`);
|
||||
});
|
||||
instance.on('end', () => {
|
||||
healthy = false;
|
||||
console.warn(`[redis] (${label}) connection closed`);
|
||||
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. */
|
||||
@@ -82,6 +148,10 @@ export function getSubscriber(): Redis | null {
|
||||
|
||||
/** 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));
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RedisMock from 'ioredis-mock';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
||||
|
||||
vi.mock('../redis.js', () => ({
|
||||
isRedisEnabled: () => true,
|
||||
getRedis: () => mocks.redis,
|
||||
}));
|
||||
|
||||
import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js';
|
||||
|
||||
describe('MemoryLock', () => {
|
||||
it('grants the lock, blocks contenders, and frees on release', async () => {
|
||||
const lock = new MemoryLock();
|
||||
const token = await lock.acquire('a', 60_000);
|
||||
expect(token).toBeTruthy();
|
||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
||||
await lock.release('a', token!);
|
||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores release with the wrong token', async () => {
|
||||
const lock = new MemoryLock();
|
||||
const token = await lock.acquire('a', 60_000);
|
||||
await lock.release('a', 'not-the-token');
|
||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
||||
await lock.release('a', token!);
|
||||
});
|
||||
|
||||
it('withLock runs fn while held and returns null under contention', async () => {
|
||||
const lock = new MemoryLock();
|
||||
const held = await lock.acquire('a', 60_000);
|
||||
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull();
|
||||
await lock.release('a', held!);
|
||||
expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran');
|
||||
// Released after withLock completes.
|
||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisLock', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.redis = new RedisMock();
|
||||
// ioredis-mock shares data between instances by connection string.
|
||||
await mocks.redis.flushall();
|
||||
});
|
||||
|
||||
it('grants the lock and blocks contenders', async () => {
|
||||
const lock = new RedisLock();
|
||||
const token = await lock.acquire('a', 60_000);
|
||||
expect(token).toBeTruthy();
|
||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('release is compare-and-delete: wrong token does not free the lock', async () => {
|
||||
const lock = new RedisLock();
|
||||
const token = await lock.acquire('a', 60_000);
|
||||
await lock.release('a', 'not-the-token');
|
||||
expect(await lock.acquire('a', 60_000)).toBeNull();
|
||||
await lock.release('a', token!);
|
||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('throws LockUnavailableError when the client is not initialized', async () => {
|
||||
mocks.redis = null;
|
||||
const lock = new RedisLock();
|
||||
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
|
||||
});
|
||||
|
||||
it('throws LockUnavailableError when the backend errors', async () => {
|
||||
mocks.redis = { set: () => Promise.reject(new Error('connection refused')) };
|
||||
const lock = new RedisLock();
|
||||
await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
|
||||
});
|
||||
|
||||
it('withLock skips (returns null, fn not called) when unavailable by default', async () => {
|
||||
mocks.redis = null;
|
||||
const lock = new RedisLock();
|
||||
const fn = vi.fn(async () => 'ran');
|
||||
expect(await lock.withLock('a', 60_000, fn)).toBeNull();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('withLock runs fn without the lock when onUnavailable is "run"', async () => {
|
||||
mocks.redis = null;
|
||||
const lock = new RedisLock();
|
||||
const fn = vi.fn(async () => 'ran');
|
||||
expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran');
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('withLock returns fn result and releases the lock afterwards', async () => {
|
||||
const lock = new RedisLock();
|
||||
expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42);
|
||||
expect(await lock.acquire('a', 60_000)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -5,22 +5,45 @@
|
||||
//
|
||||
// Use acquire/release for long-lived ownership (e.g. a background poller) and
|
||||
// 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>): Promise<T | null>;
|
||||
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null>;
|
||||
}
|
||||
|
||||
// ==================== Memory implementation ====================
|
||||
|
||||
class MemoryLock implements Lock {
|
||||
export class MemoryLock implements Lock {
|
||||
readonly backend = 'memory' as const;
|
||||
private held = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
@@ -58,23 +81,23 @@ class MemoryLock implements Lock {
|
||||
const RELEASE_SCRIPT =
|
||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
||||
|
||||
class RedisLock implements Lock {
|
||||
export class RedisLock implements Lock {
|
||||
readonly backend = 'redis' as const;
|
||||
|
||||
async acquire(key: string, ttlMs: number): Promise<string | null> {
|
||||
const redis = getRedis();
|
||||
if (!redis) {
|
||||
// Redis configured but unavailable: do not block critical sections.
|
||||
return randomUUID();
|
||||
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) {
|
||||
console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err);
|
||||
// Fail open so a Redis outage does not deadlock startup or jobs.
|
||||
return randomUUID();
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +111,19 @@ class RedisLock implements Lock {
|
||||
}
|
||||
}
|
||||
|
||||
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
|
||||
const token = await this.acquire(key, ttlMs);
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RedisMock from 'ioredis-mock';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
||||
|
||||
vi.mock('../redis.js', () => ({
|
||||
isRedisEnabled: () => true,
|
||||
getRedis: () => mocks.redis,
|
||||
}));
|
||||
|
||||
import {
|
||||
MemoryLoginLockout,
|
||||
RedisLoginLockout,
|
||||
MAX_LOGIN_ATTEMPTS,
|
||||
} from './loginLockout.js';
|
||||
|
||||
describe('MemoryLoginLockout', () => {
|
||||
it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => {
|
||||
const lockout = new MemoryLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
}
|
||||
await lockout.recordFailure('user@example.com');
|
||||
const status = await lockout.isLocked('user@example.com');
|
||||
expect(status.locked).toBe(true);
|
||||
expect(status.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('clear removes the lockout', async () => {
|
||||
const lockout = new MemoryLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
}
|
||||
await lockout.clear('user@example.com');
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
});
|
||||
|
||||
it('treats emails case-insensitively', async () => {
|
||||
const lockout = new MemoryLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('User@Example.com');
|
||||
}
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
||||
});
|
||||
|
||||
it('expires after the lockout window', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const lockout = new MemoryLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
}
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
||||
vi.advanceTimersByTime(16 * 60 * 1000);
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisLoginLockout', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.redis = new RedisMock();
|
||||
// ioredis-mock shares data between instances by connection string.
|
||||
await mocks.redis.flushall();
|
||||
});
|
||||
|
||||
it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => {
|
||||
const lockout = new RedisLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
}
|
||||
await lockout.recordFailure('user@example.com');
|
||||
const status = await lockout.isLocked('user@example.com');
|
||||
expect(status.locked).toBe(true);
|
||||
expect(status.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('sets the lockout window TTL on the first failure', async () => {
|
||||
const lockout = new RedisLoginLockout();
|
||||
await lockout.recordFailure('user@example.com');
|
||||
const ttl = await mocks.redis.pttl('lockout:user@example.com');
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('clear removes the lockout', async () => {
|
||||
const lockout = new RedisLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('user@example.com');
|
||||
}
|
||||
await lockout.clear('user@example.com');
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
});
|
||||
|
||||
it('treats emails case-insensitively', async () => {
|
||||
const lockout = new RedisLoginLockout();
|
||||
for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
|
||||
await lockout.recordFailure('User@Example.com');
|
||||
}
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open when the client is not initialized', async () => {
|
||||
mocks.redis = null;
|
||||
const lockout = new RedisLoginLockout();
|
||||
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when the backend errors', async () => {
|
||||
mocks.redis = {
|
||||
get: () => Promise.reject(new Error('connection refused')),
|
||||
pttl: () => Promise.reject(new Error('connection refused')),
|
||||
del: () => Promise.reject(new Error('connection refused')),
|
||||
lockoutRecordFailure: () => Promise.reject(new Error('connection refused')),
|
||||
};
|
||||
const lockout = new RedisLoginLockout();
|
||||
await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
|
||||
expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
|
||||
await expect(lockout.clear('user@example.com')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// Per-email login lockout abstraction with two implementations:
|
||||
// - memory: per-process Map (the original routes/auth.ts behavior)
|
||||
// - redis: shared counter across all instances so the lockout cannot be
|
||||
// bypassed by round-robining replicas
|
||||
//
|
||||
// Semantics: recordFailure starts a window on the first failure; once the
|
||||
// failure count reaches the max the email is locked until the window expires;
|
||||
// clear removes the counter on successful login. Selection is based on
|
||||
// REDIS_URL. The redis implementation fails open (never locked, failures not
|
||||
// recorded) — the per-IP auth rate limit remains as a backstop during a
|
||||
// Redis outage.
|
||||
|
||||
import type Redis from 'ioredis';
|
||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
||||
|
||||
export const MAX_LOGIN_ATTEMPTS = 5;
|
||||
export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export interface LockoutStatus {
|
||||
locked: boolean;
|
||||
// Seconds until the lockout lifts; only set when locked.
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
export interface LoginLockout {
|
||||
readonly backend: 'memory' | 'redis';
|
||||
isLocked(email: string): Promise<LockoutStatus>;
|
||||
recordFailure(email: string): Promise<void>;
|
||||
clear(email: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Emails are compared case-insensitively so "User@x.com" and "user@x.com"
|
||||
// share one counter.
|
||||
function normalize(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ==================== Memory implementation ====================
|
||||
|
||||
export class MemoryLoginLockout implements LoginLockout {
|
||||
readonly backend = 'memory' as const;
|
||||
private attempts = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
constructor() {
|
||||
// Periodically drop expired entries so the Map does not grow unbounded.
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.attempts) {
|
||||
if (now > entry.resetAt) this.attempts.delete(key);
|
||||
}
|
||||
}, 60_000);
|
||||
(cleanup as any).unref?.();
|
||||
}
|
||||
|
||||
async isLocked(email: string): Promise<LockoutStatus> {
|
||||
const entry = this.attempts.get(normalize(email));
|
||||
const now = Date.now();
|
||||
if (!entry || now > entry.resetAt) return { locked: false };
|
||||
if (entry.count >= MAX_LOGIN_ATTEMPTS) {
|
||||
return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
||||
}
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
async recordFailure(email: string): Promise<void> {
|
||||
const key = normalize(email);
|
||||
const now = Date.now();
|
||||
const entry = this.attempts.get(key);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
}
|
||||
|
||||
async clear(email: string): Promise<void> {
|
||||
this.attempts.delete(normalize(email));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Redis implementation ====================
|
||||
|
||||
// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the
|
||||
// window starts at the first failure and any TTL-less counter is repaired.
|
||||
const RECORD_FAILURE_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
if redis.call('PTTL', KEYS[1]) < 0 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return count
|
||||
`;
|
||||
|
||||
type RedisWithLockout = Redis & {
|
||||
lockoutRecordFailure(key: string, windowMs: number): Promise<number>;
|
||||
};
|
||||
|
||||
function withLockoutCommand(redis: Redis): RedisWithLockout {
|
||||
if (typeof (redis as any).lockoutRecordFailure !== 'function') {
|
||||
redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT });
|
||||
}
|
||||
return redis as RedisWithLockout;
|
||||
}
|
||||
|
||||
export class RedisLoginLockout implements LoginLockout {
|
||||
readonly backend = 'redis' as const;
|
||||
|
||||
private key(email: string): string {
|
||||
return `lockout:${normalize(email)}`;
|
||||
}
|
||||
|
||||
async isLocked(email: string): Promise<LockoutStatus> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return { locked: false };
|
||||
try {
|
||||
const [count, ttl] = await Promise.all([
|
||||
redis.get(this.key(email)),
|
||||
redis.pttl(this.key(email)),
|
||||
]);
|
||||
if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) {
|
||||
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000);
|
||||
return { locked: true, retryAfter };
|
||||
}
|
||||
return { locked: false };
|
||||
} catch (err: any) {
|
||||
// Fail open: the per-IP auth rate limit still applies.
|
||||
console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err);
|
||||
return { locked: false };
|
||||
}
|
||||
}
|
||||
|
||||
async recordFailure(email: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS);
|
||||
} catch (err: any) {
|
||||
console.error('[loginLockout] redis error recording failure:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async clear(email: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.del(this.key(email));
|
||||
} catch (err: any) {
|
||||
console.error('[loginLockout] redis error clearing failures:', err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Selection ====================
|
||||
|
||||
let instance: LoginLockout | null = null;
|
||||
|
||||
export function getLoginLockout(): LoginLockout {
|
||||
if (!instance) {
|
||||
instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RedisMock from 'ioredis-mock';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ redis: null as any }));
|
||||
|
||||
vi.mock('../redis.js', () => ({
|
||||
isRedisEnabled: () => true,
|
||||
getRedis: () => mocks.redis,
|
||||
}));
|
||||
|
||||
import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js';
|
||||
|
||||
describe('MemoryRateLimiter', () => {
|
||||
it('allows up to max within the window, then blocks with retryAfter', async () => {
|
||||
const limiter = new MemoryRateLimiter();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true);
|
||||
}
|
||||
const blocked = await limiter.consume('k', 3, 60_000);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfter).toBeGreaterThan(0);
|
||||
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('resets after the window elapses', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const limiter = new MemoryRateLimiter();
|
||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
|
||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false);
|
||||
vi.advanceTimersByTime(1_500);
|
||||
expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisRateLimiter', () => {
|
||||
beforeEach(async () => {
|
||||
mocks.redis = new RedisMock();
|
||||
// ioredis-mock shares data between instances by connection string.
|
||||
await mocks.redis.flushall();
|
||||
});
|
||||
|
||||
it('sets the window TTL atomically on the first hit', async () => {
|
||||
const limiter = new RedisRateLimiter();
|
||||
expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true);
|
||||
const ttl = await mocks.redis.pttl('rl:k');
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
it('blocks over the limit with a sane retryAfter', async () => {
|
||||
const limiter = new RedisRateLimiter();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true);
|
||||
}
|
||||
const blocked = await limiter.consume('k', 2, 60_000);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfter).toBeGreaterThan(0);
|
||||
expect(blocked.retryAfter).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('self-heals a counter stranded without a TTL', async () => {
|
||||
await mocks.redis.set('rl:k', '3');
|
||||
expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0);
|
||||
const limiter = new RedisRateLimiter();
|
||||
await limiter.consume('k', 10, 60_000);
|
||||
expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('fails open when the backend errors', async () => {
|
||||
mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) };
|
||||
const limiter = new RedisRateLimiter();
|
||||
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open when the client is not initialized', async () => {
|
||||
mocks.redis = null;
|
||||
const limiter = new RedisRateLimiter();
|
||||
expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// Rate limiter abstraction with two implementations:
|
||||
// - memory: per-process fixed window (the original behavior)
|
||||
// - redis: shared fixed window across all instances (INCR + PEXPIRE)
|
||||
// - redis: shared fixed window across all instances, implemented as a single
|
||||
// Lua script so INCR and PEXPIRE are atomic (a crash between separate calls
|
||||
// would otherwise strand a counter with no expiry)
|
||||
//
|
||||
// Selection happens once based on REDIS_URL. On any Redis error the limiter
|
||||
// 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 {
|
||||
@@ -24,7 +27,7 @@ interface Bucket {
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
class MemoryRateLimiter implements RateLimiter {
|
||||
export class MemoryRateLimiter implements RateLimiter {
|
||||
readonly backend = 'memory' as const;
|
||||
private buckets = new Map<string, Bucket>();
|
||||
|
||||
@@ -58,22 +61,44 @@ class MemoryRateLimiter implements RateLimiter {
|
||||
|
||||
// ==================== Redis implementation ====================
|
||||
|
||||
class RedisRateLimiter implements RateLimiter {
|
||||
// Atomically increments the window counter, sets the expiry on the first hit,
|
||||
// and repairs any counter left without a TTL (self-heals keys stranded by the
|
||||
// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}.
|
||||
export const CONSUME_SCRIPT = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local ttl = redis.call('PTTL', KEYS[1])
|
||||
if ttl < 0 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
ttl = tonumber(ARGV[1])
|
||||
end
|
||||
return {count, ttl}
|
||||
`;
|
||||
|
||||
type RedisWithConsume = Redis & {
|
||||
rlConsume(key: string, windowMs: number): Promise<[number, number]>;
|
||||
};
|
||||
|
||||
function withConsumeCommand(redis: Redis): RedisWithConsume {
|
||||
if (typeof (redis as any).rlConsume !== 'function') {
|
||||
// ioredis caches the script SHA and transparently handles NOSCRIPT.
|
||||
redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT });
|
||||
}
|
||||
return redis as RedisWithConsume;
|
||||
}
|
||||
|
||||
export class RedisRateLimiter implements RateLimiter {
|
||||
readonly backend = 'redis' as const;
|
||||
|
||||
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
||||
const redis = getRedis();
|
||||
if (!redis) return { allowed: true };
|
||||
|
||||
const redisKey = `rl:${key}`;
|
||||
try {
|
||||
const count = await redis.incr(redisKey);
|
||||
if (count === 1) {
|
||||
// First hit in this window: set the expiry that defines the window.
|
||||
await redis.pexpire(redisKey, windowMs);
|
||||
}
|
||||
const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs);
|
||||
if (count > max) {
|
||||
const ttl = await redis.pttl(redisKey);
|
||||
const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000);
|
||||
return { allowed: false, retryAfter };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { randomUUID } from 'crypto';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
/**
|
||||
* Get database type (reads env var each time to handle module loading order)
|
||||
@@ -59,17 +60,10 @@ export function parseEventDatetime(
|
||||
return new Date(datetime);
|
||||
}
|
||||
|
||||
// Treat the digits as UTC so we have a stable reference instant.
|
||||
const fakeUTC = new Date(datetime + 'Z');
|
||||
|
||||
// Ask Intl what that UTC instant looks like in both UTC and the target tz.
|
||||
const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' });
|
||||
const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone });
|
||||
|
||||
// The gap between the two tells us the tz offset at this point in time.
|
||||
const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime();
|
||||
|
||||
return new Date(fakeUTC.getTime() + offsetMs);
|
||||
// Interpret the wall-clock digits as local time in `timezone` using
|
||||
// moment-timezone's bundled IANA data. This keeps the conversion correct
|
||||
// regardless of the host Node runtime's (possibly stale) tz database.
|
||||
return moment.tz(datetime, timezone).toDate();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs
|
||||
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
|
||||
const adminRouter = new Hono();
|
||||
|
||||
@@ -20,8 +21,9 @@ const csvEscape = (value: string) => {
|
||||
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const now = getNow();
|
||||
|
||||
// Get upcoming events
|
||||
const upcomingEvents = await dbAll(
|
||||
// Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts)
|
||||
// so the dashboard's capacity alerts reflect real availability.
|
||||
const upcomingEventsRaw = await dbAll<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
@@ -34,6 +36,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.orderBy((events as any).startDatetime)
|
||||
.limit(5)
|
||||
);
|
||||
|
||||
const seatRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||
const seatsByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||
for (const row of seatRows) {
|
||||
seatsByEvent.set(row.eventId, {
|
||||
paid: Number(row.paidCount) || 0,
|
||||
claimed: Number(row.claimedCount) || 0,
|
||||
});
|
||||
}
|
||||
const upcomingEvents = upcomingEventsRaw.map((event: any) => {
|
||||
const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...event,
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed),
|
||||
};
|
||||
});
|
||||
|
||||
// Get recent tickets
|
||||
const recentTickets = await dbAll(
|
||||
@@ -70,13 +90,29 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
.where(eq((tickets as any).status, 'confirmed'))
|
||||
);
|
||||
|
||||
// 'pending' = checkout opened, nothing paid or claimed (informational);
|
||||
// 'pending_approval' = customer says they paid, needs admin verification (actionable).
|
||||
const pendingPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending'))
|
||||
);
|
||||
|
||||
|
||||
const awaitingApprovalPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'pending_approval'))
|
||||
);
|
||||
|
||||
const onHoldPayments = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(payments)
|
||||
.where(eq((payments as any).status, 'on_hold'))
|
||||
);
|
||||
|
||||
const revenueRow = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` })
|
||||
@@ -108,6 +144,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
totalTickets: totalTickets?.count || 0,
|
||||
confirmedTickets: confirmedTickets?.count || 0,
|
||||
pendingPayments: pendingPayments?.count || 0,
|
||||
awaitingApprovalPayments: awaitingApprovalPayments?.count || 0,
|
||||
onHoldPayments: onHoldPayments?.count || 0,
|
||||
totalRevenue,
|
||||
newContacts: newContacts?.count || 0,
|
||||
totalSubscribers: totalSubscribers?.count || 0,
|
||||
@@ -213,6 +251,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
||||
userName: user?.name,
|
||||
userEmail: user?.email,
|
||||
userPhone: user?.phone,
|
||||
attendeeRuc: ticket.attendeeRuc || user?.rucNumber || null,
|
||||
eventTitle: event?.title,
|
||||
eventDate: event?.startDatetime,
|
||||
paymentStatus: payment?.status,
|
||||
@@ -291,6 +330,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
'Full Name': fullName,
|
||||
'Email': ticket.attendeeEmail || '',
|
||||
'Phone': ticket.attendeePhone || '',
|
||||
'RUC': ticket.attendeeRuc || '',
|
||||
'Status': ticket.status,
|
||||
'Checked In': isCheckedIn ? 'true' : 'false',
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
@@ -302,7 +342,7 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
||||
);
|
||||
|
||||
const columns = [
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||
'Ticket ID', 'Full Name', 'Email', 'Phone', 'RUC',
|
||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||
'Booked At', 'Notes',
|
||||
];
|
||||
@@ -380,12 +420,13 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
|
||||
});
|
||||
}
|
||||
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'RUC', 'Status', 'Check-in Time', 'Booked At'];
|
||||
|
||||
const rows = ticketList.map((ticket: any) => ({
|
||||
'Ticket ID': ticket.id,
|
||||
'Booking ID': ticket.bookingId || '',
|
||||
'Attendee Name': [ticket.attendeeFirstName, ticket.attendeeLastName].filter(Boolean).join(' '),
|
||||
'RUC': ticket.attendeeRuc || '',
|
||||
'Status': ticket.status,
|
||||
'Check-in Time': ticket.checkinAt || '',
|
||||
'Booked At': ticket.createdAt || '',
|
||||
@@ -458,6 +499,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
||||
attendeeFirstName: ticket.attendeeFirstName,
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeeRuc: ticket.attendeeRuc || null,
|
||||
eventId: event?.id,
|
||||
eventTitle: event?.title,
|
||||
eventDate: event?.startDatetime,
|
||||
|
||||
@@ -1,719 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
createToken,
|
||||
createRefreshToken,
|
||||
isFirstUser,
|
||||
getAuthUser,
|
||||
validatePassword,
|
||||
createMagicLinkToken,
|
||||
verifyMagicLinkToken,
|
||||
invalidateAllUserSessions,
|
||||
bumpTokenVersion,
|
||||
requireAuth,
|
||||
getUserPasswordHash,
|
||||
} from '../lib/auth.js';
|
||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||
import { sendEmail } from '../lib/email.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
|
||||
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
|
||||
// email-dispatching flows) to curb credential stuffing and email flooding.
|
||||
const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' });
|
||||
|
||||
// User type that includes all fields (some added in schema updates)
|
||||
type AuthUser = User & {
|
||||
isClaimed: boolean;
|
||||
googleId: string | null;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
};
|
||||
|
||||
const auth = new Hono();
|
||||
|
||||
// Rate limiting store (in production, use Redis)
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } {
|
||||
const now = Date.now();
|
||||
const attempts = loginAttempts.get(email);
|
||||
|
||||
if (!attempts) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (now > attempts.resetAt) {
|
||||
loginAttempts.delete(email);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
|
||||
return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function recordFailedAttempt(email: string): void {
|
||||
const now = Date.now();
|
||||
const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION };
|
||||
attempts.count++;
|
||||
loginAttempts.set(email, attempts);
|
||||
}
|
||||
|
||||
function clearFailedAttempts(email: string): void {
|
||||
loginAttempts.delete(email);
|
||||
}
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
name: z.string().min(2),
|
||||
phone: z.string().optional(),
|
||||
languagePreference: z.enum(['en', 'es']).optional(),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
const magicLinkRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
const magicLinkVerifySchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
const passwordResetRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
const passwordResetSchema = z.object({
|
||||
token: z.string(),
|
||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
});
|
||||
|
||||
const claimAccountSchema = z.object({
|
||||
token: z.string(),
|
||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
});
|
||||
|
||||
const googleAuthSchema = z.object({
|
||||
credential: z.string(), // Google ID token
|
||||
});
|
||||
|
||||
// Register
|
||||
auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePassword(data.password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
const existing = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
||||
);
|
||||
if (existing) {
|
||||
// If user exists but is unclaimed, allow claiming
|
||||
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
|
||||
return c.json({
|
||||
error: 'Email already registered',
|
||||
canClaim: true,
|
||||
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
|
||||
}, 400);
|
||||
}
|
||||
return c.json({ error: 'Email already registered' }, 400);
|
||||
}
|
||||
|
||||
// Check if first user (becomes admin)
|
||||
const firstUser = await isFirstUser();
|
||||
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
const now = getNow();
|
||||
const id = generateId();
|
||||
|
||||
const newUser = {
|
||||
id,
|
||||
email: data.email,
|
||||
password: hashedPassword,
|
||||
name: data.name,
|
||||
phone: data.phone || null,
|
||||
role: firstUser ? 'admin' : 'user',
|
||||
languagePreference: data.languagePreference || null,
|
||||
isClaimed: toDbBool(true),
|
||||
googleId: null,
|
||||
rucNumber: null,
|
||||
accountStatus: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(users).values(newUser);
|
||||
|
||||
const token = await createToken(id, data.email, newUser.role, 0);
|
||||
const refreshToken = await createRefreshToken(id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role: newUser.role,
|
||||
isClaimed: true,
|
||||
},
|
||||
token,
|
||||
refreshToken,
|
||||
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Login with email/password
|
||||
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Check rate limit
|
||||
const rateLimit = checkRateLimit(data.email);
|
||||
if (!rateLimit.allowed) {
|
||||
return c.json({
|
||||
error: 'Too many login attempts. Please try again later.',
|
||||
retryAfter: rateLimit.retryAfter
|
||||
}, 429);
|
||||
}
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
||||
);
|
||||
if (!user) {
|
||||
recordFailedAttempt(data.email);
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
// Check if account is suspended
|
||||
if (user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||
}
|
||||
|
||||
// Check if user has a password set
|
||||
if (!user.password) {
|
||||
return c.json({
|
||||
error: 'No password set for this account',
|
||||
needsClaim: !user.isClaimed,
|
||||
message: user.isClaimed
|
||||
? 'Please use Google login or request a password reset.'
|
||||
: 'Please claim your account first.'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(data.password, user.password);
|
||||
if (!validPassword) {
|
||||
recordFailedAttempt(data.email);
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
// Clear failed attempts on successful login
|
||||
clearFailedAttempts(data.email);
|
||||
|
||||
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
|
||||
// plaintext and have verified it. Best-effort: a failure here must not block
|
||||
// the login.
|
||||
if (!String(user.password).startsWith('$argon2')) {
|
||||
try {
|
||||
const upgradedHash = await hashPassword(data.password);
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ password: upgradedHash })
|
||||
.where(eq((users as any).id, user.id));
|
||||
} catch (err: any) {
|
||||
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
||||
const refreshToken = await createRefreshToken(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: user.isClaimed,
|
||||
phone: user.phone,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
},
|
||||
token,
|
||||
refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
// Request magic link login
|
||||
auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists
|
||||
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||
}
|
||||
|
||||
if (user.accountStatus === 'suspended') {
|
||||
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||
}
|
||||
|
||||
// Create magic link token (expires in 10 minutes)
|
||||
const token = await createMagicLinkToken(user.id, 'login', 10);
|
||||
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
|
||||
|
||||
// Send email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Your Spanglish Login Link',
|
||||
html: `
|
||||
<h2>Login to Spanglish</h2>
|
||||
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
||||
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
||||
<p>Or copy this link: ${magicLink}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send magic link email:', error);
|
||||
}
|
||||
|
||||
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
||||
});
|
||||
|
||||
// Verify magic link and login
|
||||
auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => {
|
||||
const { token } = c.req.valid('json');
|
||||
|
||||
const verification = await verifyMagicLinkToken(token, 'login');
|
||||
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 400);
|
||||
}
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
||||
);
|
||||
|
||||
if (!user || user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Invalid token' }, 400);
|
||||
}
|
||||
|
||||
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
||||
const refreshToken = await createRefreshToken(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: user.isClaimed,
|
||||
phone: user.phone,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
},
|
||||
token: authToken,
|
||||
refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
// Request password reset
|
||||
auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists
|
||||
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
}
|
||||
|
||||
if (user.accountStatus === 'suspended') {
|
||||
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
}
|
||||
|
||||
// Create reset token (expires in 30 minutes)
|
||||
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
|
||||
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
|
||||
|
||||
// Send email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Reset Your Spanglish Password',
|
||||
html: `
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
||||
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
||||
<p>Or copy this link: ${resetLink}</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send password reset email:', error);
|
||||
}
|
||||
|
||||
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
||||
});
|
||||
|
||||
// Reset password
|
||||
auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => {
|
||||
const { token, password } = c.req.valid('json');
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
const verification = await verifyMagicLinkToken(token, 'reset_password');
|
||||
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 400);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, verification.userId));
|
||||
|
||||
// Invalidate all existing sessions/JWTs for security
|
||||
await invalidateAllUserSessions(verification.userId!);
|
||||
await bumpTokenVersion(verification.userId!);
|
||||
|
||||
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
|
||||
});
|
||||
|
||||
// Claim unclaimed account
|
||||
auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
||||
}
|
||||
|
||||
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
|
||||
return c.json({ error: 'Account is already claimed' }, 400);
|
||||
}
|
||||
|
||||
// Create claim token (expires in 1 hour)
|
||||
const token = await createMagicLinkToken(user.id, 'claim_account', 60);
|
||||
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
|
||||
|
||||
// Send email
|
||||
try {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Claim Your Spanglish Account',
|
||||
html: `
|
||||
<h2>Claim Your Account</h2>
|
||||
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
|
||||
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
||||
<p>Or copy this link: ${claimLink}</p>
|
||||
<p>This link expires in 1 hour.</p>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send claim account email:', error);
|
||||
}
|
||||
|
||||
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
||||
});
|
||||
|
||||
// Complete account claim
|
||||
auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
||||
const { token, password } = c.req.valid('json');
|
||||
|
||||
const verification = await verifyMagicLinkToken(token, 'claim_account');
|
||||
|
||||
if (!verification.valid) {
|
||||
return c.json({ error: verification.error }, 400);
|
||||
}
|
||||
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
// Only set a password here. Linking a Google account requires a verified Google
|
||||
// ID token via /google; we never trust a client-supplied googleId.
|
||||
const updates: Record<string, any> = {
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
password: await hashPassword(password),
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set(updates)
|
||||
.where(eq((users as any).id, verification.userId));
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
||||
);
|
||||
|
||||
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
||||
const refreshToken = await createRefreshToken(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: user.isClaimed,
|
||||
phone: user.phone,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
},
|
||||
token: authToken,
|
||||
refreshToken,
|
||||
message: 'Account claimed successfully!',
|
||||
});
|
||||
});
|
||||
|
||||
// Google OAuth login/register
|
||||
auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => {
|
||||
const { credential } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// Verify the Google ID token. Google's tokeninfo endpoint validates the
|
||||
// signature and expiry server-side; we additionally enforce the audience so a
|
||||
// token minted for a different OAuth client cannot be replayed against us.
|
||||
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
return c.json({ error: 'Invalid Google token' }, 400);
|
||||
}
|
||||
|
||||
const googleData = await response.json() as {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
email_verified: string;
|
||||
aud?: string;
|
||||
exp?: string;
|
||||
};
|
||||
|
||||
// email_verified can be returned as boolean true or string "true"
|
||||
if (String(googleData.email_verified) !== 'true') {
|
||||
return c.json({ error: 'Google email not verified' }, 400);
|
||||
}
|
||||
|
||||
// Enforce audience when a client ID is configured (closes token-confusion attacks)
|
||||
const expectedAud = process.env.GOOGLE_CLIENT_ID;
|
||||
if (expectedAud) {
|
||||
if (googleData.aud !== expectedAud) {
|
||||
return c.json({ error: 'Invalid Google token audience' }, 400);
|
||||
}
|
||||
} else {
|
||||
console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.');
|
||||
}
|
||||
|
||||
// Reject expired tokens (defense-in-depth; tokeninfo also rejects them)
|
||||
if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) {
|
||||
return c.json({ error: 'Google token expired' }, 400);
|
||||
}
|
||||
|
||||
const { sub: googleId, email, name } = googleData;
|
||||
|
||||
// Check if user exists by email or google_id
|
||||
let user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
// Check by google_id
|
||||
user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
|
||||
);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
if (user) {
|
||||
// User exists - link Google account if not already linked
|
||||
if (user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||
}
|
||||
|
||||
if (!user.googleId) {
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
googleId,
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
}
|
||||
|
||||
// Refresh user data
|
||||
user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
||||
);
|
||||
} else {
|
||||
// Create new user
|
||||
const firstUser = await isFirstUser();
|
||||
const id = generateId();
|
||||
|
||||
const newUser = {
|
||||
id,
|
||||
email,
|
||||
password: null,
|
||||
name,
|
||||
phone: null,
|
||||
role: firstUser ? 'admin' : 'user',
|
||||
languagePreference: null,
|
||||
isClaimed: toDbBool(true),
|
||||
googleId,
|
||||
rucNumber: null,
|
||||
accountStatus: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(users).values(newUser);
|
||||
user = newUser;
|
||||
}
|
||||
|
||||
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
||||
const refreshToken = await createRefreshToken(user.id);
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: user.isClaimed,
|
||||
phone: user.phone,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
},
|
||||
token: authToken,
|
||||
refreshToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Google auth error:', error);
|
||||
return c.json({ error: 'Failed to authenticate with Google' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
auth.get('/me', async (c) => {
|
||||
const user = await getAuthUser(c);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
phone: user.phone,
|
||||
isClaimed: user.isClaimed,
|
||||
rucNumber: user.rucNumber,
|
||||
languagePreference: user.languagePreference,
|
||||
accountStatus: user.accountStatus,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Change password (authenticated users)
|
||||
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const { currentPassword, newPassword } = c.req.valid('json');
|
||||
|
||||
// Validate new password
|
||||
const passwordValidation = validatePassword(newPassword);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
// Verify current password if user has one
|
||||
const existingHash = await getUserPasswordHash(user.id);
|
||||
if (existingHash) {
|
||||
const validPassword = await verifyPassword(currentPassword, existingHash);
|
||||
if (!validPassword) {
|
||||
return c.json({ error: 'Current password is incorrect' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
// Invalidate all previously issued JWTs so a stolen old token can't outlive the change,
|
||||
// then hand the current client a fresh token so it stays logged in on this device.
|
||||
await bumpTokenVersion(user.id);
|
||||
const refreshedUser = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
||||
);
|
||||
const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0);
|
||||
|
||||
return c.json({ message: 'Password changed successfully', token: newToken });
|
||||
});
|
||||
|
||||
// Logout - invalidate all previously issued JWTs for this user (logout everywhere)
|
||||
auth.post('/logout', async (c) => {
|
||||
const user = await getAuthUser(c);
|
||||
if (user) {
|
||||
await invalidateAllUserSessions(user.id);
|
||||
await bumpTokenVersion(user.id);
|
||||
}
|
||||
return c.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
export default auth;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { validatePassword } from '../lib/passwordPolicy.js';
|
||||
import { db, dbGet, users } from '../db/index.js';
|
||||
import { getNow, toDbBool } from '../lib/utils.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
|
||||
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
|
||||
// at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler.
|
||||
const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' });
|
||||
|
||||
const authExt = new Hono();
|
||||
|
||||
const claimAccountSchema = z.object({
|
||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
||||
});
|
||||
|
||||
// Complete a progressive-account claim. The user arrives here already holding
|
||||
// a session established by the claim magic link; this endpoint deliberately
|
||||
// accepts accountStatus 'unclaimed' sessions (requireAuth would reject them)
|
||||
// and is the ONLY endpoint that does.
|
||||
authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session?.user) {
|
||||
return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401);
|
||||
}
|
||||
|
||||
const user = session.user as any;
|
||||
if (user.banned || user.accountStatus === 'suspended') {
|
||||
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
||||
}
|
||||
if (user.isClaimed && user.accountStatus === 'active') {
|
||||
return c.json({ error: 'Account is already claimed' }, 400);
|
||||
}
|
||||
|
||||
const { password } = c.req.valid('json');
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
// Creates the credential account with the argon2id hash from lib/betterAuth.ts
|
||||
await auth.api.setPassword({
|
||||
body: { newPassword: password },
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
// The magic link click proved email ownership
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
isClaimed: toDbBool(true),
|
||||
accountStatus: 'active',
|
||||
emailVerified: true,
|
||||
updatedAt: getNow(),
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
return c.json({
|
||||
message: 'Account claimed successfully!',
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
isClaimed: true,
|
||||
phone: user.phone ?? null,
|
||||
rucNumber: user.rucNumber ?? null,
|
||||
languagePreference: user.languagePreference ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Whether an email belongs to an unclaimed account. Deliberate, rate-limited
|
||||
// exception to enumeration-safety, matching the legacy register/login UX that
|
||||
// surfaced "this account can be claimed".
|
||||
authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
|
||||
const email = c.req.query('email');
|
||||
if (!email || !z.string().email().safeParse(email).success) {
|
||||
return c.json({ canClaim: false });
|
||||
}
|
||||
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
||||
);
|
||||
|
||||
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
|
||||
&& (!user.isClaimed || user.accountStatus === 'unclaimed');
|
||||
|
||||
return c.json({ canClaim });
|
||||
});
|
||||
|
||||
export default authExt;
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
|
||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
|
||||
import { generateId, getNow } from '../lib/utils.js';
|
||||
|
||||
// User type that includes all fields (some added in schema updates)
|
||||
type AuthUser = User & {
|
||||
isClaimed: boolean;
|
||||
googleId: string | null;
|
||||
rucNumber: string | null;
|
||||
accountStatus: string;
|
||||
};
|
||||
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
||||
import { auth } from '../lib/betterAuth.js';
|
||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
|
||||
const dashboard = new Hono();
|
||||
|
||||
@@ -50,7 +44,7 @@ dashboard.get('/profile', async (c) => {
|
||||
isClaimed: user.isClaimed,
|
||||
accountStatus: user.accountStatus,
|
||||
hasPassword,
|
||||
hasGoogleLinked: !!user.googleId,
|
||||
hasGoogleLinked: await hasGoogleAccount(user.id),
|
||||
memberSince: user.createdAt,
|
||||
membershipDays,
|
||||
createdAt: user.createdAt,
|
||||
@@ -423,49 +417,77 @@ dashboard.get('/invoices', async (c) => {
|
||||
|
||||
// ==================== Security Routes ====================
|
||||
|
||||
// Get active sessions
|
||||
// Get active sessions (Better Auth session table; validated per-request so
|
||||
// this list is always live). Session tokens are never exposed to the client.
|
||||
dashboard.get('/sessions', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
const sessions = await getUserSessions(user.id);
|
||||
|
||||
|
||||
const sessions = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({
|
||||
id: (authSessions as any).id,
|
||||
userAgent: (authSessions as any).userAgent,
|
||||
ipAddress: (authSessions as any).ipAddress,
|
||||
createdAt: (authSessions as any).createdAt,
|
||||
updatedAt: (authSessions as any).updatedAt,
|
||||
expiresAt: (authSessions as any).expiresAt,
|
||||
})
|
||||
.from(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).userId, user.id),
|
||||
gt((authSessions as any).expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.orderBy(desc((authSessions as any).updatedAt))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
sessions: sessions.map((s: any) => ({
|
||||
id: s.id,
|
||||
userAgent: s.userAgent,
|
||||
ipAddress: s.ipAddress,
|
||||
lastActiveAt: s.lastActiveAt,
|
||||
lastActiveAt: s.updatedAt,
|
||||
createdAt: s.createdAt,
|
||||
expiresAt: s.expiresAt,
|
||||
current: s.id === user.sessionId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Revoke a specific session
|
||||
// Revoke a specific session. Deleting the row is immediately effective:
|
||||
// sessions are validated against the table on every request (no cookie cache).
|
||||
dashboard.delete('/sessions/:id', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const sessionId = c.req.param('id');
|
||||
|
||||
await invalidateSession(sessionId, user.id);
|
||||
|
||||
|
||||
await (db as any)
|
||||
.delete(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).id, sessionId),
|
||||
eq((authSessions as any).userId, user.id)
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({ message: 'Session revoked' });
|
||||
});
|
||||
|
||||
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
|
||||
// every previously issued JWT for this user, which is the actual enforcement
|
||||
// mechanism (auth is stateless JWT, not DB-session based).
|
||||
// Revoke all other sessions (logout everywhere else); the current session
|
||||
// stays valid so this device remains signed in.
|
||||
dashboard.post('/sessions/revoke-all', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
await invalidateAllUserSessions(user.id);
|
||||
await bumpTokenVersion(user.id);
|
||||
|
||||
// Issue a fresh token so the current device stays signed in
|
||||
const refreshed = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
||||
);
|
||||
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
|
||||
|
||||
return c.json({ message: 'All other sessions revoked.', token });
|
||||
await (db as any)
|
||||
.delete(authSessions)
|
||||
.where(
|
||||
and(
|
||||
eq((authSessions as any).userId, user.id),
|
||||
sql`${(authSessions as any).id} != ${user.sessionId}`
|
||||
)
|
||||
);
|
||||
|
||||
return c.json({ message: 'All other sessions revoked.' });
|
||||
});
|
||||
|
||||
// Set password (for users without one)
|
||||
@@ -476,53 +498,57 @@ const setPasswordSchema = z.object({
|
||||
dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
const { password } = c.req.valid('json');
|
||||
|
||||
|
||||
// Check if user already has a password
|
||||
if (await getUserPasswordHash(user.id)) {
|
||||
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
||||
}
|
||||
|
||||
|
||||
// setPassword is a server-only Better Auth endpoint, so the HTTP-layer
|
||||
// policy hook does not cover it — validate explicitly.
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
return c.json({ error: passwordValidation.error }, 400);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const now = getNow();
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
|
||||
try {
|
||||
await auth.api.setPassword({
|
||||
body: { newPassword: password },
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err?.body?.message || 'Failed to set password' }, 400);
|
||||
}
|
||||
|
||||
return c.json({ message: 'Password set successfully' });
|
||||
});
|
||||
|
||||
// Unlink Google account (only if password is set)
|
||||
dashboard.post('/unlink-google', async (c) => {
|
||||
const user = (c as any).get('user') as AuthUser;
|
||||
|
||||
if (!user.googleId) {
|
||||
|
||||
if (!(await hasGoogleAccount(user.id))) {
|
||||
return c.json({ error: 'Google account not linked' }, 400);
|
||||
}
|
||||
|
||||
|
||||
if (!(await getUserPasswordHash(user.id))) {
|
||||
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
await (db as any)
|
||||
.delete(authAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq((authAccounts as any).userId, user.id),
|
||||
eq((authAccounts as any).providerId, 'google')
|
||||
)
|
||||
);
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({
|
||||
googleId: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.set({ updatedAt: getNow() })
|
||||
.where(eq((users as any).id, user.id));
|
||||
|
||||
|
||||
return c.json({ message: 'Google account unlinked' });
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -201,27 +202,28 @@ eventsRouter.get('/', async (c) => {
|
||||
|
||||
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
|
||||
|
||||
// Single grouped query for booked counts across all events (avoids N+1: previously
|
||||
// this ran one COUNT query per event).
|
||||
const countRows = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
|
||||
.groupBy((tickets as any).eventId)
|
||||
);
|
||||
const countByEvent = new Map<string, number>();
|
||||
// Single grouped query for seat counts across all events (avoids N+1: previously
|
||||
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
||||
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
||||
// so availableSeats subtracts them together — the same formula the booking-creation
|
||||
// capacity check enforces (lib/capacity.ts).
|
||||
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
||||
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||
for (const row of countRows) {
|
||||
countByEvent.set(row.eventId, Number(row.count) || 0);
|
||||
countByEvent.set(row.eventId, {
|
||||
paid: Number(row.paidCount) || 0,
|
||||
claimed: Number(row.claimedCount) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
const eventsWithCounts = result.map((event: any) => {
|
||||
const normalized = normalizeEvent(event);
|
||||
const bookedCount = countByEvent.get(event.id) || 0;
|
||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -246,27 +248,14 @@ eventsRouter.get('/:id', async (c) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||
// This ensures check-in doesn't affect capacity/spots_left
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, event.id),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const normalized = normalizeEvent(event);
|
||||
const bookedCount = ticketCount?.count || 0;
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -278,20 +267,14 @@ async function getSiteTimezone(): Promise<string> {
|
||||
return settings?.timezone || 'America/Asuncion';
|
||||
}
|
||||
|
||||
// Helper function to get ticket count for an event
|
||||
async function getEventTicketCount(eventId: string): Promise<number> {
|
||||
const ticketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
return ticketCount?.count || 0;
|
||||
// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat
|
||||
// counts for one event — see lib/capacity.ts for the seat-holding rule.
|
||||
async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> {
|
||||
const row = await dbGet<any>(eventSeatBreakdownQuery(db, eventId));
|
||||
return {
|
||||
paid: Number(row?.paidCount) || 0,
|
||||
claimed: Number(row?.claimedCount) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
||||
@@ -315,12 +298,13 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookedCount = await getEventTicketCount(event.id);
|
||||
const counts = await getEventSeatCounts(event.id);
|
||||
const normalized = normalizeEvent(event);
|
||||
return {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,13 +367,14 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
||||
|
||||
// If we have a valid featured event, return it
|
||||
if (featuredEvent) {
|
||||
const bookedCount = await getEventTicketCount(featuredEvent.id);
|
||||
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||
const normalized = normalizeEvent(featuredEvent);
|
||||
return c.json({
|
||||
event: {
|
||||
...normalized,
|
||||
bookedCount,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||
bookedCount: counts.paid,
|
||||
claimedCount: counts.claimed,
|
||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
||||
isFeatured: true,
|
||||
},
|
||||
});
|
||||
|
||||
+207
-22
@@ -1,12 +1,18 @@
|
||||
import { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||
import { db, dbGet, dbAll, tickets, payments, events } from '../db/index.js';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getNow, toDbDate } from '../lib/utils.js';
|
||||
import {
|
||||
verifyWebhookPayment,
|
||||
getPaymentStatus,
|
||||
createInvoice,
|
||||
isLNbitsConfigured,
|
||||
LNBITS_INVOICE_EXPIRY_SECONDS,
|
||||
} from '../lib/lnbits.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getPubSub } from '../lib/stores/pubsub.js';
|
||||
import { getLock } from '../lib/stores/lock.js';
|
||||
import { getLock, LockUnavailableError } from '../lib/stores/lock.js';
|
||||
|
||||
const lnbitsRouter = new Hono();
|
||||
|
||||
@@ -100,12 +106,26 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
|
||||
|
||||
const expiryMs = expirySeconds * 1000;
|
||||
|
||||
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
||||
if (!lockToken) {
|
||||
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;
|
||||
}
|
||||
checkerLockTokens.set(ticketId, lockToken);
|
||||
if (lockToken) {
|
||||
checkerLockTokens.set(ticketId, lockToken);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let checkCount = 0;
|
||||
@@ -264,25 +284,35 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
||||
let transitioned = 0;
|
||||
for (const ticket of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
const result: any = await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
|
||||
|
||||
transitioned += result?.changes ?? result?.rowCount ?? 0;
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
.set({
|
||||
status: 'paid',
|
||||
reference: paymentHash,
|
||||
paidAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
|
||||
|
||||
|
||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||
}
|
||||
|
||||
|
||||
// Only the caller that actually flipped rows sends the emails. The webhook
|
||||
// and the background poller (and pollers on multiple replicas during a Redis
|
||||
// outage) can all land here; without this guard they would each email.
|
||||
if (transitioned === 0) {
|
||||
console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get primary payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -290,7 +320,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
||||
.from(payments)
|
||||
.where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
|
||||
|
||||
// Send confirmation emails asynchronously
|
||||
// For multi-ticket bookings, send email with all ticket info
|
||||
Promise.all([
|
||||
@@ -366,8 +396,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Clean up on disconnect
|
||||
stream.onAbort(() => {
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
const connections = activeConnections.get(ticketId);
|
||||
if (connections) {
|
||||
@@ -382,15 +411,171 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the stream open
|
||||
while (true) {
|
||||
await stream.sleep(30000);
|
||||
};
|
||||
|
||||
// Clean up on disconnect
|
||||
stream.onAbort(cleanup);
|
||||
|
||||
// Keep the stream open, and every 15s fall back to reading the ticket
|
||||
// status from the DB. Pub/sub is best-effort: a message published while the
|
||||
// subscriber connection was reconnecting is lost, and without this check
|
||||
// the client would wait forever on a payment that already confirmed.
|
||||
try {
|
||||
while (true) {
|
||||
await stream.sleep(15000);
|
||||
const current = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
if (current?.status === 'confirmed') {
|
||||
await sendEvent({ type: 'paid', ticketId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a Lightning invoice for a ticket to pay or re-pay.
|
||||
*
|
||||
* Reuses the stored invoice if it still has more than 5 minutes of validity
|
||||
* left; otherwise generates a fresh one from LNbits. This is what lets a user
|
||||
* come back to an unpaid Lightning booking later (e.g. from "Pay now" on the
|
||||
* dashboard) instead of hitting a dead end.
|
||||
*/
|
||||
lnbitsRouter.post('/invoice/:ticketId', async (c) => {
|
||||
const ticketId = c.req.param('ticketId');
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
||||
);
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||
);
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (payment.provider !== 'lightning') {
|
||||
return c.json({ error: 'This booking is not a Lightning payment' }, 400);
|
||||
}
|
||||
|
||||
if (ticket.status === 'confirmed' || payment.status === 'paid') {
|
||||
return c.json({ alreadyPaid: true });
|
||||
}
|
||||
|
||||
if (ticket.status !== 'pending' || payment.status !== 'pending') {
|
||||
return c.json({
|
||||
error: 'This booking is no longer active. Please make a new booking.',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Gather every ticket/payment in the booking group - a multi-ticket booking
|
||||
// shares a single Lightning invoice for the combined total.
|
||||
let groupTickets: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
groupTickets = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
const groupPayments = await dbAll<any>(
|
||||
(db as any).select().from(payments).where(inArray((payments as any).ticketId, groupTickets.map((t: any) => t.id)))
|
||||
);
|
||||
const invoiceHolder = groupPayments.find((p: any) => p.reference) || payment;
|
||||
const totalAmount = groupPayments.reduce((sum: number, p: any) => sum + Number(p.amount), 0);
|
||||
const currency = invoiceHolder.currency;
|
||||
|
||||
const now = Date.now();
|
||||
const FIVE_MIN_MS = 5 * 60 * 1000;
|
||||
const expiresAtMs = invoiceHolder.lnbitsExpiresAt ? new Date(invoiceHolder.lnbitsExpiresAt).getTime() : 0;
|
||||
|
||||
if (invoiceHolder.reference && invoiceHolder.lnbitsInvoice && expiresAtMs - now > FIVE_MIN_MS) {
|
||||
return c.json({
|
||||
invoice: {
|
||||
paymentHash: invoiceHolder.reference,
|
||||
paymentRequest: invoiceHolder.lnbitsInvoice,
|
||||
amount: invoiceHolder.lnbitsAmountSats || 0,
|
||||
fiatAmount: totalAmount,
|
||||
fiatCurrency: currency,
|
||||
expiresAt: invoiceHolder.lnbitsExpiresAt,
|
||||
},
|
||||
reused: true,
|
||||
});
|
||||
}
|
||||
|
||||
// No usable stored invoice (never created, expired, or expiring soon) - get a fresh one.
|
||||
if (!isLNbitsConfigured()) {
|
||||
return c.json({ error: 'Bitcoin Lightning payments are not available at this time' }, 400);
|
||||
}
|
||||
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, ticket.eventId))
|
||||
);
|
||||
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
||||
const webhookUrl = webhookSecret
|
||||
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
|
||||
: `${apiUrl}/api/lnbits/webhook`;
|
||||
const attendeeName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
try {
|
||||
const lnbitsInvoice = await createInvoice({
|
||||
amount: totalAmount,
|
||||
unit: currency,
|
||||
memo: `Spanglish: ${event?.title || 'Event'} - ${attendeeName}${groupTickets.length > 1 ? ` (${groupTickets.length} tickets)` : ''}`,
|
||||
webhookUrl,
|
||||
expiry: LNBITS_INVOICE_EXPIRY_SECONDS,
|
||||
extra: {
|
||||
ticketId: invoiceHolder.ticketId,
|
||||
bookingId: ticket.bookingId || null,
|
||||
ticketIds: groupTickets.map((t: any) => t.id),
|
||||
eventId: ticket.eventId,
|
||||
eventTitle: event?.title,
|
||||
attendeeName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketCount: groupTickets.length,
|
||||
},
|
||||
});
|
||||
|
||||
const lnbitsExpiresAt = toDbDate(new Date(now + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
|
||||
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
reference: lnbitsInvoice.paymentHash,
|
||||
lnbitsInvoice: lnbitsInvoice.paymentRequest,
|
||||
lnbitsExpiresAt,
|
||||
lnbitsAmountSats: lnbitsInvoice.amount,
|
||||
updatedAt: getNow(),
|
||||
})
|
||||
.where(eq((payments as any).id, invoiceHolder.id));
|
||||
|
||||
return c.json({
|
||||
invoice: {
|
||||
paymentHash: lnbitsInvoice.paymentHash,
|
||||
paymentRequest: lnbitsInvoice.paymentRequest,
|
||||
amount: lnbitsInvoice.amount,
|
||||
fiatAmount: lnbitsInvoice.fiatAmount ?? totalAmount,
|
||||
fiatCurrency: lnbitsInvoice.fiatCurrency ?? currency,
|
||||
expiresAt: lnbitsExpiresAt,
|
||||
},
|
||||
reused: false,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create Lightning invoice:', error);
|
||||
return c.json({
|
||||
error: `Failed to create Lightning invoice: ${error.message || 'Unknown error'}`,
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get payment status for a ticket (fallback polling endpoint)
|
||||
*/
|
||||
|
||||
+221
-39
@@ -2,15 +2,16 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, payments, tickets, events } from '../db/index.js';
|
||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||
import { eq, desc, and, or, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
|
||||
const paymentsRouter = new Hono();
|
||||
|
||||
const updatePaymentSchema = z.object({
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed']),
|
||||
status: z.enum(['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'on_hold']),
|
||||
reference: z.string().optional(),
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
@@ -18,6 +19,9 @@ const updatePaymentSchema = z.object({
|
||||
const approvePaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
// Admin override: confirm the booking even when it puts the event over
|
||||
// capacity. The UI asks for explicit confirmation before sending this.
|
||||
allowOverCapacity: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const rejectPaymentSchema = z.object({
|
||||
@@ -25,6 +29,10 @@ const rejectPaymentSchema = z.object({
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
const reopenPaymentSchema = z.object({
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
|
||||
// Get all payments (admin) - with ticket and event details
|
||||
paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const status = c.req.query('status');
|
||||
@@ -85,6 +93,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
attendeeLastName: ticket.attendeeLastName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
attendeeRuc: ticket.attendeeRuc,
|
||||
status: ticket.status,
|
||||
} : null,
|
||||
event: event ? {
|
||||
@@ -95,7 +104,7 @@ paymentsRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Filter by event(s)
|
||||
if (eventId) {
|
||||
enrichedPayments = enrichedPayments.filter((p: any) => p.event?.id === eventId);
|
||||
@@ -164,12 +173,13 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
||||
|
||||
// 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, revenueRow] = await Promise.all([
|
||||
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'))),
|
||||
]);
|
||||
|
||||
@@ -180,6 +190,7 @@ paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||
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),
|
||||
},
|
||||
});
|
||||
@@ -228,10 +239,16 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Confirming a failed payment must go through /approve, which re-checks event
|
||||
// capacity before re-reserving the (previously released) seat. Block the raw path.
|
||||
if (data.status === 'paid' && existing.status === 'failed') {
|
||||
return c.json({ error: 'Use the approve action to confirm a failed payment' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
const updateData: any = { ...data, updatedAt: now };
|
||||
|
||||
|
||||
// If marking as paid, record who approved it and when
|
||||
if (data.status === 'paid' && existing.status !== 'paid') {
|
||||
updateData.paidAt = now;
|
||||
@@ -271,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
}
|
||||
|
||||
@@ -303,27 +320,28 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json
|
||||
// Approve payment (admin) - specifically for pending_approval payments
|
||||
paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { adminNote, sendEmail } = c.req.valid('json');
|
||||
const { adminNote, sendEmail, allowOverCapacity } = c.req.valid('json');
|
||||
const user = (c as any).get('user');
|
||||
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
// Can approve pending or pending_approval payments
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
|
||||
// Can approve pending, pending_approval, on_hold, or failed payments.
|
||||
// 'failed' covers an admin confirming a payment that was auto-failed or rejected
|
||||
// in error; its tickets are cancelled, so recovery re-checks capacity below.
|
||||
// Bare 'pending' covers customers who paid but never clicked "I've paid".
|
||||
if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||
}
|
||||
|
||||
const now = getNow();
|
||||
|
||||
|
||||
// Get the ticket associated with this payment
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -331,10 +349,10 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
|
||||
|
||||
// Check if this is part of a multi-ticket booking
|
||||
let ticketsToConfirm: any[] = [ticket];
|
||||
|
||||
|
||||
if (ticket?.bookingId) {
|
||||
// Get all tickets in this booking
|
||||
ticketsToConfirm = await dbAll(
|
||||
@@ -345,27 +363,57 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida
|
||||
);
|
||||
console.log(`[Payment] Approving multi-ticket booking: ${ticket.bookingId}, ${ticketsToConfirm.length} tickets`);
|
||||
}
|
||||
|
||||
// Update all payments in the booking to paid
|
||||
for (const t of ticketsToConfirm) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
adminNote: adminNote || payment.adminNote,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, (t as any).id));
|
||||
|
||||
// Update ticket status to confirmed
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, (t as any).id));
|
||||
|
||||
// For a failed payment, only recover tickets whose own payment is also failed.
|
||||
// This protects mixed bookings (e.g. a sibling ticket was refunded) from being
|
||||
// resurrected or having its payment flipped to paid.
|
||||
if (payment.status === 'failed') {
|
||||
const bookingPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
||||
.from(payments)
|
||||
.where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id)))
|
||||
);
|
||||
const failedTicketIds = new Set(
|
||||
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
||||
);
|
||||
ticketsToConfirm = ticketsToConfirm.filter((t: any) => failedTicketIds.has(t.id));
|
||||
if (ticketsToConfirm.length === 0) {
|
||||
return c.json({ error: 'Payment cannot be approved in its current state' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Confirm the booking through the shared capacity-checked reservation.
|
||||
// Tickets that already hold a seat ('pending_approval' claims) cost no new
|
||||
// capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do.
|
||||
// When the event is full, the admin gets a structured over-capacity error and
|
||||
// may retry with allowOverCapacity to knowingly overbook.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{
|
||||
paidByAdminId: user.id,
|
||||
fromTicketStatuses:
|
||||
payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'],
|
||||
skipCapacityCheck: allowOverCapacity,
|
||||
...(adminNote ? { extraPaymentFields: { adminNote } } : {}),
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'Approving this payment puts the event over capacity.',
|
||||
code: 'EVENT_OVER_CAPACITY',
|
||||
availableSeats: err.available,
|
||||
requestedSeats: ticketsToConfirm.length,
|
||||
}, 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Send confirmation emails asynchronously (if sendEmail is true, which is the default)
|
||||
if (sendEmail !== false) {
|
||||
Promise.all([
|
||||
@@ -405,7 +453,7 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
||||
if (!['pending', 'pending_approval', 'on_hold'].includes(payment.status)) {
|
||||
return c.json({ error: 'Payment cannot be rejected in its current state' }, 400);
|
||||
}
|
||||
|
||||
@@ -464,6 +512,140 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
||||
return c.json({ payment: updated, message: 'Payment rejected and booking cancelled' });
|
||||
});
|
||||
|
||||
// Reactivate an on-hold payment back to pending_approval (admin) - re-reserves the seat
|
||||
paymentsRouter.post('/:id/reactivate', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (payment.status !== 'on_hold') {
|
||||
return c.json({ error: 'Only on-hold payments can be reactivated' }, 400);
|
||||
}
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
let ticketsToReactivate: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToReactivate = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToReactivate.map((t: any) => t.id),
|
||||
'pending',
|
||||
'pending_approval'
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ payment: updated, message: 'Booking reactivated and pending admin review' });
|
||||
});
|
||||
|
||||
// Reopen a failed payment back to pending (admin) - re-reserves the seat.
|
||||
// For when a payment was failed in error (auto-fail or rejection) and should
|
||||
// return to the normal pending flow (reminders, "I've paid", approve/reject).
|
||||
paymentsRouter.post('/:id/reopen', requireAuth(['admin', 'organizer']), zValidator('json', reopenPaymentSchema), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { adminNote } = c.req.valid('json');
|
||||
|
||||
const payment = await dbGet<any>(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
return c.json({ error: 'Payment not found' }, 404);
|
||||
}
|
||||
|
||||
if (payment.status !== 'failed') {
|
||||
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
||||
}
|
||||
|
||||
const ticket = await dbGet<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
||||
);
|
||||
if (!ticket) {
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
let ticketsToReopen: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToReopen = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
// Only reopen tickets whose own payment is also failed (protects mixed bookings).
|
||||
const bookingPayments = await dbAll<any>(
|
||||
(db as any)
|
||||
.select({ ticketId: (payments as any).ticketId, status: (payments as any).status })
|
||||
.from(payments)
|
||||
.where(inArray((payments as any).ticketId, ticketsToReopen.map((t: any) => t.id)))
|
||||
);
|
||||
const failedTicketIds = new Set(
|
||||
bookingPayments.filter((p: any) => p.status === 'failed').map((p: any) => p.ticketId)
|
||||
);
|
||||
ticketsToReopen = ticketsToReopen.filter((t: any) => failedTicketIds.has(t.id));
|
||||
if (ticketsToReopen.length === 0) {
|
||||
return c.json({ error: 'Only failed payments can be reopened' }, 400);
|
||||
}
|
||||
|
||||
const reopenIds = ticketsToReopen.map((t: any) => t.id);
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
reopenIds,
|
||||
'pending',
|
||||
'pending',
|
||||
{ fromTicketStatuses: ['cancelled', 'on_hold'] }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (adminNote) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ adminNote })
|
||||
.where(inArray((payments as any).ticketId, reopenIds));
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, id))
|
||||
);
|
||||
|
||||
return c.json({ payment: updated, message: 'Payment reopened and set to pending' });
|
||||
});
|
||||
|
||||
// Send payment reminder email
|
||||
paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
+234
-244
@@ -4,11 +4,13 @@ import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
|
||||
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||
|
||||
const ticketsRouter = new Hono();
|
||||
|
||||
@@ -30,11 +32,19 @@ const createTicketSchema = z.object({
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
// 'bancard' intentionally excluded: no checkout integration exists for it
|
||||
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||
// Base + optional "-" + check digit; digits-only kept for older clients, normalized to dashed form on save
|
||||
ruc: z.string().regex(/^(\d{6,10}|\d{5,8}-\d)$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
|
||||
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
|
||||
});
|
||||
|
||||
// Canonical stored RUC form is "base-checkdigit" (e.g. 1234567-9); older clients send digits only
|
||||
function normalizeRuc(ruc: string | undefined): string | null {
|
||||
if (!ruc) return null;
|
||||
if (ruc.includes('-')) return ruc;
|
||||
return `${ruc.slice(0, -1)}-${ruc.slice(-1)}`;
|
||||
}
|
||||
|
||||
// Maps a payment provider to the merged payment-option flag that enables it
|
||||
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
||||
const truthy = (v: any) => v === true || v === 1;
|
||||
@@ -53,7 +63,7 @@ function isPaymentMethodEnabled(method: string, merged: Record<string, any>): bo
|
||||
}
|
||||
|
||||
const updateTicketSchema = z.object({
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in', 'on_hold']).optional(),
|
||||
adminNote: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -75,7 +85,8 @@ const adminCreateTicketSchema = z.object({
|
||||
// Book a ticket (public) - supports single or multi-ticket bookings
|
||||
ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const rucNumber = normalizeRuc(data.ruc);
|
||||
|
||||
// Determine attendees list (use attendees array if provided, otherwise single attendee from main fields)
|
||||
const attendeesList = data.attendees && data.attendees.length > 0
|
||||
? data.attendees
|
||||
@@ -118,21 +129,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
||||
}
|
||||
|
||||
// Check capacity - count pending, confirmed AND checked_in tickets.
|
||||
// Pending reservations must hold seats to prevent overselling via unpaid bookings
|
||||
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
|
||||
// Check capacity against held seats (paid/checked-in tickets plus claimed
|
||||
// manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat.
|
||||
const existingTicketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
seatHolderCountQuery(db, data.eventId)
|
||||
);
|
||||
|
||||
|
||||
const confirmedCount = existingTicketCount?.count || 0;
|
||||
const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount);
|
||||
|
||||
@@ -162,17 +164,29 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
user = {
|
||||
id: userId,
|
||||
email: data.email,
|
||||
password: '', // No password for guest bookings
|
||||
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
|
||||
name: fullName,
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
rucNumber,
|
||||
isClaimed: toDbBool(false),
|
||||
accountStatus: 'unclaimed',
|
||||
emailVerified: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await (db as any).insert(users).values(user);
|
||||
} else if (rucNumber) {
|
||||
// Keep the user's saved RUC up to date for future bookings, but never blank
|
||||
// out an existing value if this booking didn't include one.
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ rucNumber, updatedAt: now })
|
||||
.where(eq((users as any).id, user.id));
|
||||
user.rucNumber = rucNumber;
|
||||
}
|
||||
|
||||
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
@@ -211,16 +225,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
try {
|
||||
if (isSqlite()) {
|
||||
(db as any).transaction((tx: any) => {
|
||||
const countRow = tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
.get();
|
||||
const countRow = seatHolderCountQuery(tx, data.eventId).get();
|
||||
const reserved = Number(countRow?.count || 0);
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new BookingCapacityError('SOLD_OUT');
|
||||
@@ -243,7 +248,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
attendeeRuc: rucNumber,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
@@ -271,17 +276,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
});
|
||||
} else {
|
||||
await (db as any).transaction(async (tx: any) => {
|
||||
const countRow = await dbGet<any>(
|
||||
tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
const countRow = await dbGet<any>(seatHolderCountQuery(tx, data.eventId));
|
||||
const reserved = Number(countRow?.count || 0);
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new BookingCapacityError('SOLD_OUT');
|
||||
@@ -304,7 +299,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
attendeeRuc: rucNumber,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
@@ -369,7 +364,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
for (const t of createdTickets) {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.set({ status: 'confirmed', paymentStatus: 'paid' })
|
||||
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
@@ -408,7 +403,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
|
||||
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
|
||||
webhookUrl,
|
||||
expiry: 900, // 15 minutes expiry for faster UX
|
||||
expiry: LNBITS_INVOICE_EXPIRY_SECONDS, // 15 minutes expiry for faster UX
|
||||
extra: {
|
||||
ticketId: primaryTicket.id,
|
||||
bookingId: ticketCount > 1 ? bookingId : null,
|
||||
@@ -420,13 +415,22 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
ticketCount,
|
||||
},
|
||||
});
|
||||
|
||||
// Update primary payment with LNbits payment hash reference
|
||||
|
||||
const lnbitsExpiresAt = toDbDate(new Date(Date.now() + LNBITS_INVOICE_EXPIRY_SECONDS * 1000));
|
||||
|
||||
// Update primary payment with the LNbits invoice - the BOLT11 string and
|
||||
// expiry are persisted so the "Pay now" page can redisplay this same
|
||||
// invoice later instead of erroring out on an unpaid Lightning booking.
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ reference: lnbitsInvoice.paymentHash })
|
||||
.set({
|
||||
reference: lnbitsInvoice.paymentHash,
|
||||
lnbitsInvoice: lnbitsInvoice.paymentRequest,
|
||||
lnbitsExpiresAt,
|
||||
lnbitsAmountSats: lnbitsInvoice.amount,
|
||||
})
|
||||
.where(eq((payments as any).id, primaryPayment.id));
|
||||
|
||||
|
||||
(primaryPayment as any).reference = lnbitsInvoice.paymentHash;
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create Lightning invoice:', error);
|
||||
@@ -1001,6 +1005,9 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
attendeePhone: ticket.attendeePhone,
|
||||
status: ticket.status,
|
||||
paymentStatus: ticket.paymentStatus,
|
||||
// Balance to collect at the door for unpaid tickets
|
||||
amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0,
|
||||
checkinAt: ticket.checkinAt,
|
||||
checkedInBy,
|
||||
},
|
||||
@@ -1081,10 +1088,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
return c.json({ error: 'Ticket not found' }, 404);
|
||||
}
|
||||
|
||||
if (ticket.status === 'confirmed') {
|
||||
// Confirmed/checked-in tickets can still be marked paid when they carry an
|
||||
// unpaid balance (admin-added unpaid tickets collected at the door)
|
||||
if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') {
|
||||
return c.json({ error: 'Ticket already confirmed' }, 400);
|
||||
}
|
||||
|
||||
|
||||
if (ticket.status === 'cancelled') {
|
||||
return c.json({ error: 'Cannot confirm cancelled ticket' }, 400);
|
||||
}
|
||||
@@ -1104,26 +1113,47 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm all tickets in the booking
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
if (ticket.status === 'on_hold') {
|
||||
// The seat was released when this booking went on hold - re-check capacity
|
||||
// before confirming it directly.
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToConfirm.map((t: any) => t.id),
|
||||
'confirmed',
|
||||
'paid',
|
||||
{ paidByAdminId: user.id }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
// Confirm all tickets in the booking (checked-in tickets keep their status)
|
||||
for (const t of ticketsToConfirm) {
|
||||
// Update ticket status
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' })
|
||||
.where(eq((tickets as any).id, t.id));
|
||||
|
||||
// Update payment status
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'paid',
|
||||
paidAt: now,
|
||||
paidByAdminId: user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).ticketId, t.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Get payment for sending receipt
|
||||
const payment = await dbGet<any>(
|
||||
(db as any)
|
||||
@@ -1194,18 +1224,55 @@ ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, wind
|
||||
}
|
||||
|
||||
if (payment.status === 'paid') {
|
||||
return c.json({
|
||||
payment,
|
||||
return c.json({
|
||||
payment,
|
||||
message: 'Payment has already been confirmed.',
|
||||
alreadyProcessed: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// A booking that was auto-released after the hold threshold: recover it by
|
||||
// re-reserving the seat(s) and moving back into the admin approval queue.
|
||||
if (payment.status === 'on_hold') {
|
||||
let ticketsToRecover: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToRecover = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveOnHoldBooking(
|
||||
ticket.eventId,
|
||||
ticketsToRecover.map((t: any) => t.id),
|
||||
'pending',
|
||||
'pending_approval',
|
||||
{ extraPaymentFields: { userMarkedPaidAt: getNow(), payerName: payerName?.trim() || null } }
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof HoldCapacityError) {
|
||||
return c.json({
|
||||
error: 'This event is now full. Your spot was released after the payment deadline passed.',
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const recoveredPayment = await dbGet(
|
||||
(db as any).select().from(payments).where(eq((payments as any).id, payment.id))
|
||||
);
|
||||
|
||||
return c.json({
|
||||
payment: recoveredPayment,
|
||||
message: 'Payment marked as sent. Waiting for admin approval.',
|
||||
});
|
||||
}
|
||||
|
||||
// Only allow if currently pending
|
||||
if (payment.status !== 'pending') {
|
||||
return c.json({ error: 'Payment has already been processed' }, 400);
|
||||
}
|
||||
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status to pending_approval for this ticket and any siblings
|
||||
@@ -1365,11 +1432,14 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
user = {
|
||||
id: userId,
|
||||
email: attendeeEmail,
|
||||
password: '',
|
||||
password: null,
|
||||
name: adminFullName,
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
isClaimed: toDbBool(false),
|
||||
accountStatus: 'unclaimed',
|
||||
emailVerified: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -1412,6 +1482,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: ticketStatus,
|
||||
paymentStatus: 'paid',
|
||||
qrCode,
|
||||
checkinAt: data.autoCheckin ? now : null,
|
||||
adminNote: data.adminNote || null,
|
||||
@@ -1455,148 +1526,26 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Admin create manual ticket (sends confirmation email + ticket to attendee)
|
||||
ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||
eventId: z.string(),
|
||||
firstName: z.string().min(2),
|
||||
lastName: z.string().optional().or(z.literal('')),
|
||||
email: z.string().email('Valid email is required for manual tickets'),
|
||||
phone: z.string().optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
adminNote: z.string().max(1000).optional(),
|
||||
})), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
(db as any).select().from(events).where(eq((events as any).id, data.eventId))
|
||||
);
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets)
|
||||
|
||||
const now = getNow();
|
||||
const attendeeEmail = data.email.trim();
|
||||
|
||||
// Find or create user
|
||||
let user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
||||
);
|
||||
|
||||
const fullName = data.lastName && data.lastName.trim()
|
||||
? `${data.firstName} ${data.lastName}`.trim()
|
||||
: data.firstName;
|
||||
|
||||
if (!user) {
|
||||
const userId = generateId();
|
||||
user = {
|
||||
id: userId,
|
||||
email: attendeeEmail,
|
||||
password: '',
|
||||
name: fullName,
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await (db as any).insert(users).values(user);
|
||||
}
|
||||
|
||||
// Check for existing active ticket for this user and event
|
||||
const existingTicket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).userId, user.id),
|
||||
eq((tickets as any).eventId, data.eventId)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingTicket && existingTicket.status !== 'cancelled') {
|
||||
return c.json({ error: 'This person already has a ticket for this event' }, 400);
|
||||
}
|
||||
|
||||
// Create ticket as confirmed
|
||||
const ticketId = generateId();
|
||||
const qrCode = generateTicketCode();
|
||||
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: data.firstName,
|
||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||
attendeeEmail: attendeeEmail,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'confirmed',
|
||||
qrCode,
|
||||
checkinAt: null,
|
||||
adminNote: data.adminNote || null,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(tickets).values(newTicket);
|
||||
|
||||
// Create payment record (marked as paid - manual entry)
|
||||
const paymentId = generateId();
|
||||
const adminUser = (c as any).get('user');
|
||||
const newPayment = {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: 'cash',
|
||||
amount: event.price,
|
||||
currency: event.currency,
|
||||
status: 'paid',
|
||||
reference: 'Manual ticket',
|
||||
paidAt: now,
|
||||
paidByAdminId: adminUser?.id || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(payments).values(newPayment);
|
||||
|
||||
// Send booking confirmation email + ticket (asynchronously)
|
||||
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||
if (result.success) {
|
||||
console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Email] Exception sending booking confirmation for manual ticket:', err);
|
||||
});
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
...newTicket,
|
||||
event: {
|
||||
title: event.title,
|
||||
startDatetime: event.startDatetime,
|
||||
location: event.location,
|
||||
},
|
||||
},
|
||||
payment: newPayment,
|
||||
message: 'Manual ticket created and confirmation email sent',
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// Admin invite guest ticket (free, confirmed, not counted in revenue)
|
||||
ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
|
||||
// type drives payment handling:
|
||||
// paid — email required; paid cash payment; confirmation email + QR sent
|
||||
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
|
||||
// pay-link (Bancard/TPago) email sent when an email is provided
|
||||
// guest — free comp ticket, not counted in revenue; confirmation email only
|
||||
// when an email is provided
|
||||
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
||||
eventId: z.string(),
|
||||
type: z.enum(['paid', 'unpaid', 'guest']),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().optional().or(z.literal('')),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
checkinNow: z.boolean().optional().default(false),
|
||||
adminNote: z.string().max(1000).optional(),
|
||||
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
|
||||
message: 'Email is required for paid tickets',
|
||||
path: ['email'],
|
||||
})), async (c) => {
|
||||
const data = c.req.valid('json');
|
||||
|
||||
@@ -1607,18 +1556,20 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Admin-added tickets bypass the capacity check (intentional over-capacity)
|
||||
|
||||
const now = getNow();
|
||||
const adminUser = (c as any).get('user');
|
||||
|
||||
// Find or create user (use placeholder email if none provided)
|
||||
const attendeeEmail = data.email && data.email.trim()
|
||||
? data.email.trim()
|
||||
: `guest-${generateId()}@guestinvite.local`;
|
||||
const hasEmail = !!(data.email && data.email.trim());
|
||||
const attendeeEmail = hasEmail
|
||||
? data.email!.trim()
|
||||
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
|
||||
|
||||
const fullName = data.lastName && data.lastName.trim()
|
||||
? `${data.firstName} ${data.lastName}`.trim()
|
||||
: data.firstName;
|
||||
|
||||
// Find or create user
|
||||
let user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
||||
);
|
||||
@@ -1628,19 +1579,22 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
||||
user = {
|
||||
id: userId,
|
||||
email: attendeeEmail,
|
||||
password: '',
|
||||
password: null,
|
||||
name: fullName,
|
||||
phone: data.phone || null,
|
||||
role: 'user',
|
||||
languagePreference: null,
|
||||
isClaimed: toDbBool(false),
|
||||
accountStatus: 'unclaimed',
|
||||
emailVerified: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await (db as any).insert(users).values(user);
|
||||
}
|
||||
|
||||
// Check for existing active ticket (only for real emails, not placeholder)
|
||||
if (data.email && data.email.trim()) {
|
||||
// Check for existing active ticket (only when a real email was provided)
|
||||
if (hasEmail) {
|
||||
const existingTicket = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
@@ -1659,6 +1613,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
||||
|
||||
const ticketId = generateId();
|
||||
const qrCode = generateTicketCode();
|
||||
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid';
|
||||
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
@@ -1666,50 +1621,85 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: data.firstName,
|
||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||
attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null,
|
||||
attendeeEmail: hasEmail ? data.email!.trim() : null,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'confirmed',
|
||||
isGuest: 1,
|
||||
status: data.checkinNow ? 'checked_in' : 'confirmed',
|
||||
isGuest: data.type === 'guest' ? 1 : 0,
|
||||
paymentStatus,
|
||||
qrCode,
|
||||
checkinAt: null,
|
||||
checkinAt: data.checkinNow ? now : null,
|
||||
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
|
||||
adminNote: data.adminNote || null,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(tickets).values(newTicket);
|
||||
|
||||
// Create a $0 payment record to track the invite
|
||||
// Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid
|
||||
const paymentId = generateId();
|
||||
const newPayment = {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: 'cash',
|
||||
amount: 0,
|
||||
currency: event.currency,
|
||||
status: 'paid',
|
||||
reference: 'Guest invite',
|
||||
paidAt: now,
|
||||
paidByAdminId: adminUser?.id || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const newPayment = data.type === 'unpaid'
|
||||
? {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: 'tpago',
|
||||
amount: event.price,
|
||||
currency: event.currency,
|
||||
status: 'pending',
|
||||
reference: 'Unpaid ticket — collect at door',
|
||||
paidAt: null,
|
||||
paidByAdminId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
: {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: 'cash',
|
||||
amount: data.type === 'guest' ? 0 : event.price,
|
||||
currency: event.currency,
|
||||
status: 'paid',
|
||||
reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket',
|
||||
paidAt: now,
|
||||
paidByAdminId: adminUser?.id || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(payments).values(newPayment);
|
||||
|
||||
// Send booking confirmation email if a real email was provided
|
||||
if (data.email && data.email.trim()) {
|
||||
// Emails (asynchronous): paid always confirms; guest confirms when an email
|
||||
// exists; unpaid sends the TPago (Bancard) pay-link instructions instead
|
||||
if (data.type === 'unpaid') {
|
||||
if (hasEmail) {
|
||||
emailService.sendPaymentInstructions(ticketId).then(result => {
|
||||
if (!result.success) {
|
||||
console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Email] Exception sending pay link for unpaid ticket:', err);
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'paid' || hasEmail) {
|
||||
emailService.sendBookingConfirmation(ticketId).then(result => {
|
||||
if (result.success) {
|
||||
console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`);
|
||||
} else {
|
||||
console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error);
|
||||
if (!result.success) {
|
||||
console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Email] Exception sending booking confirmation for guest ticket:', err);
|
||||
console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
paid: 'Ticket created — confirmation email sent',
|
||||
unpaid: hasEmail
|
||||
? 'Unpaid ticket created — payment link sent'
|
||||
: 'Unpaid ticket created — collect payment at the door',
|
||||
guest: hasEmail
|
||||
? 'Guest invited — confirmation email sent'
|
||||
: 'Guest invited',
|
||||
};
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
...newTicket,
|
||||
@@ -1720,7 +1710,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
||||
},
|
||||
},
|
||||
payment: newPayment,
|
||||
message: 'Guest ticket created successfully',
|
||||
message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type],
|
||||
}, 201);
|
||||
});
|
||||
|
||||
|
||||
+68
-12
@@ -2,9 +2,10 @@ import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
||||
import { requireAuth } from '../lib/auth.js';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { authSessions } from '../db/auth-schema.js';
|
||||
import { getNow, toDbDate } from '../lib/utils.js';
|
||||
|
||||
interface UserContext {
|
||||
id: string;
|
||||
@@ -27,7 +28,43 @@ const updateUserSchema = z.object({
|
||||
// Get all users (admin only)
|
||||
usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
const role = c.req.query('role');
|
||||
|
||||
const search = c.req.query('search');
|
||||
const accountStatus = c.req.query('accountStatus');
|
||||
const hasBookings = c.req.query('hasBookings'); // 'yes' | 'no'
|
||||
const registeredAfter = c.req.query('registeredAfter');
|
||||
const registeredBefore = c.req.query('registeredBefore');
|
||||
const eventId = c.req.query('eventId');
|
||||
const page = Math.max(parseInt(c.req.query('page') || '1', 10) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(parseInt(c.req.query('pageSize') || '50', 10) || 50, 1), 200);
|
||||
|
||||
const conditions: any[] = [];
|
||||
if (role) conditions.push(eq((users as any).role, role));
|
||||
if (accountStatus) conditions.push(eq((users as any).accountStatus, accountStatus));
|
||||
if (registeredAfter) conditions.push(gte((users as any).createdAt, toDbDate(registeredAfter)));
|
||||
if (registeredBefore) conditions.push(lte((users as any).createdAt, toDbDate(registeredBefore)));
|
||||
if (search) {
|
||||
const like = `%${search.toLowerCase()}%`;
|
||||
conditions.push(sql`(
|
||||
LOWER(${(users as any).name}) LIKE ${like}
|
||||
OR LOWER(${(users as any).email}) LIKE ${like}
|
||||
OR LOWER(COALESCE(${(users as any).phone}, '')) LIKE ${like}
|
||||
)`);
|
||||
}
|
||||
if (hasBookings === 'yes') {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
} else if (hasBookings === 'no') {
|
||||
conditions.push(sql`NOT EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id})`);
|
||||
}
|
||||
if (eventId) {
|
||||
conditions.push(sql`EXISTS (SELECT 1 FROM tickets WHERE tickets.user_id = ${(users as any).id} AND tickets.event_id = ${eventId})`);
|
||||
}
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const totalQuery = whereClause
|
||||
? (db as any).select({ count: sql<number>`count(*)` }).from(users).where(whereClause)
|
||||
: (db as any).select({ count: sql<number>`count(*)` }).from(users);
|
||||
const totalRow = await dbGet<any>(totalQuery);
|
||||
|
||||
let query = (db as any).select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
@@ -40,14 +77,16 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
||||
accountStatus: (users as any).accountStatus,
|
||||
createdAt: (users as any).createdAt,
|
||||
}).from(users);
|
||||
|
||||
if (role) {
|
||||
query = query.where(eq((users as any).role, role));
|
||||
|
||||
if (whereClause) {
|
||||
query = query.where(whereClause);
|
||||
}
|
||||
|
||||
const result = await dbAll(query.orderBy(desc((users as any).createdAt)));
|
||||
|
||||
return c.json({ users: result });
|
||||
|
||||
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
|
||||
@@ -137,11 +176,28 @@ usersRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing',
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
// Keep the Better Auth admin `banned` flag in sync with accountStatus so
|
||||
// sign-in is blocked at the auth layer too, and kill live sessions on
|
||||
// suspension so it takes effect immediately (sessions are DB-validated on
|
||||
// every request by both the backend and the photo API).
|
||||
const statusMirror: Record<string, any> = {};
|
||||
if (data.accountStatus === 'suspended') {
|
||||
statusMirror.banned = true;
|
||||
statusMirror.banReason = 'Suspended by admin';
|
||||
} else if (data.accountStatus) {
|
||||
statusMirror.banned = false;
|
||||
statusMirror.banReason = null;
|
||||
}
|
||||
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ ...data, updatedAt: getNow() })
|
||||
.set({ ...data, ...statusMirror, updatedAt: getNow() })
|
||||
.where(eq((users as any).id, id));
|
||||
|
||||
|
||||
if (data.accountStatus === 'suspended') {
|
||||
await (db as any).delete(authSessions).where(eq((authSessions as any).userId, id));
|
||||
}
|
||||
|
||||
const updated = await dbGet(
|
||||
(db as any)
|
||||
.select({
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declaration": false,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -63,6 +63,29 @@ server {
|
||||
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||
}
|
||||
|
||||
# Photo gallery service (photo-api, port 3020). ^~ wins over the /
|
||||
# prefix below; bigger body cap and unbuffered uploads for photo batches.
|
||||
location ^~ /api/photos/ {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
proxy_pass http://spanglish_photos;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Strip CORS headers from the service (nginx handles CORS here)
|
||||
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||
|
||||
|
||||
@@ -28,7 +28,20 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
# noeviction is deliberate: rate-limit, lock, and lockout keys must never
|
||||
# be evicted for correctness. The cache workload is a single short-TTL key,
|
||||
# so memory pressure is negligible; if caching ever grows, revisit this or
|
||||
# move the cache to its own DB index.
|
||||
command:
|
||||
[
|
||||
"redis-server",
|
||||
"--appendonly", "yes",
|
||||
"--requirepass", "${REDIS_PASSWORD:-change-me-redis-password}",
|
||||
"--maxmemory", "256mb",
|
||||
"--maxmemory-policy", "noeviction",
|
||||
]
|
||||
environment:
|
||||
REDISCLI_AUTH: "${REDIS_PASSWORD:-change-me-redis-password}"
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
@@ -46,7 +59,7 @@ services:
|
||||
DB_TYPE: postgres
|
||||
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
|
||||
DB_POOL_MAX: "15"
|
||||
REDIS_URL: redis://redis:6379
|
||||
REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379"
|
||||
JWT_SECRET: change-me-to-a-strong-secret
|
||||
FRONTEND_URL: http://localhost:8080
|
||||
# Optional S3-compatible storage so uploads are shared across replicas.
|
||||
|
||||
@@ -18,11 +18,28 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Canonical host is the apex (non-www). Redirect the www HTTPS vhost to it with a
|
||||
# 301 so only one host is served and indexed.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||
server_name www.spanglishcommunity.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
return 301 https://spanglishcommunity.com$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
|
||||
server_name spanglishcommunity.com;
|
||||
|
||||
# Upload size limit (covers same-origin /api uploads via this vhost)
|
||||
client_max_body_size 20m;
|
||||
@@ -62,6 +79,24 @@ server {
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
# Photo gallery service (photo-api, port 3020). ^~ wins over the /api
|
||||
# prefix below. Batch photo uploads are large and slow, so the body cap
|
||||
# is raised and request buffering is off for this location only.
|
||||
location ^~ /api/photos/ {
|
||||
proxy_pass http://spanglish_photos;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
client_max_body_size 100m;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
}
|
||||
|
||||
# Proxy /api to backend
|
||||
location /api {
|
||||
proxy_pass http://spanglish_backend;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Spanglish Photo Gallery API
|
||||
Documentation=https://git.azzamo.net/Michilis/Spanglish
|
||||
After=network.target spanglish-backend.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spanglish
|
||||
Group=spanglish
|
||||
WorkingDirectory=/home/spanglish/Spanglish/photo-api
|
||||
EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env
|
||||
Environment=PORT=3020
|
||||
# Apply pending photos_* migrations before serving (idempotent, advisory-locked)
|
||||
ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate
|
||||
ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=spanglish-photos
|
||||
|
||||
# Security hardening (mirrors spanglish-backend.service)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/spanglish/Spanglish/photo-api/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -4,4 +4,8 @@ upstream spanglish_frontend {
|
||||
|
||||
upstream spanglish_backend {
|
||||
server 127.0.0.1:3018;
|
||||
}
|
||||
|
||||
upstream spanglish_photos {
|
||||
server 127.0.0.1:3020;
|
||||
}
|
||||
@@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com
|
||||
# API URL (leave empty for same-origin proxy)
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
# Photo gallery service (photo-api) origin, used by the /api/photos rewrite
|
||||
# and server-side rendering of /photos pages.
|
||||
# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020
|
||||
PHOTO_API_URL=
|
||||
|
||||
# Google OAuth (optional - leave empty to hide Google Sign-In button)
|
||||
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
|
||||
# 1. Create a new OAuth 2.0 Client ID (Web application)
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
// 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(',')
|
||||
@@ -20,6 +24,9 @@ const securityHeaders = [
|
||||
];
|
||||
|
||||
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: {
|
||||
// Restrict remote image sources to a known allowlist instead of allowing any
|
||||
// https host (which let the Next image optimizer be used as an open proxy).
|
||||
@@ -39,6 +46,10 @@ const nextConfig = {
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/photos/:path*',
|
||||
destination: `${PHOTO_API_URL}/api/photos/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${BACKEND_URL}/api/:path*`,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@tiptap/pm": "^3.18.0",
|
||||
"@tiptap/react": "^3.18.0",
|
||||
"@tiptap/starter-kit": "^3.18.0",
|
||||
"better-auth": "1.6.25",
|
||||
"clsx": "^2.1.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"next": "^14.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -9,29 +9,48 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Progressive-account claim. The claim email contains a magic link that signs
|
||||
* the user in (via /auth/magic-link) and redirects here; this page then asks
|
||||
* for a password and completes the claim against /api/auth-ext/claim-account.
|
||||
*/
|
||||
function ClaimAccountContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { locale: language } = useLanguage();
|
||||
const { setAuthData } = useAuth();
|
||||
const { refreshUser } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [hasSession, setHasSession] = useState(false);
|
||||
const [alreadyClaimed, setAlreadyClaimed] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const token = searchParams.get('token');
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
authClient
|
||||
.getSession()
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return;
|
||||
const user: any = data?.user;
|
||||
setHasSession(!!user);
|
||||
setAlreadyClaimed(!!user && user.isClaimed && user.accountStatus === 'active');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setChecking(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!token) {
|
||||
toast.error(language === 'es' ? 'Token no válido' : 'Invalid token');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||
return;
|
||||
@@ -49,8 +68,8 @@ function ClaimAccountContent() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await authApi.confirmClaimAccount(token, { password: formData.password });
|
||||
setAuthData({ user: result.user, token: result.token });
|
||||
await authApi.confirmClaimAccount(formData.password);
|
||||
await refreshUser();
|
||||
toast.success(language === 'es' ? '¡Cuenta activada!' : 'Account activated!');
|
||||
router.push('/dashboard');
|
||||
} catch (error: any) {
|
||||
@@ -60,7 +79,21 @@ function ClaimAccountContent() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (alreadyClaimed) {
|
||||
// Signed-in and already active: nothing to claim
|
||||
router.push('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasSession) {
|
||||
return (
|
||||
<div className="section-padding min-h-[70vh] flex items-center">
|
||||
<div className="container-page">
|
||||
@@ -76,8 +109,8 @@ function ClaimAccountContent() {
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{language === 'es'
|
||||
? 'Este enlace de activación no es válido o ha expirado.'
|
||||
: 'This activation link is invalid or has expired.'}
|
||||
? 'Este enlace de activación no es válido o ha expirado. Solicita uno nuevo con "Enlace por Email" en la página de inicio de sesión.'
|
||||
: 'This activation link is invalid or has expired. Request a new one using "Email Link" on the login page.'}
|
||||
</p>
|
||||
<Link href="/login">
|
||||
<Button>
|
||||
@@ -127,7 +160,7 @@ function ClaimAccountContent() {
|
||||
<p className="text-xs text-gray-500 -mt-4">
|
||||
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
|
||||
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||
@@ -136,7 +169,7 @@ function ClaimAccountContent() {
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" isLoading={loading}>
|
||||
{language === 'es' ? 'Activar Cuenta' : 'Activate Account'}
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
function MagicLinkContent() {
|
||||
@@ -18,11 +19,12 @@ function MagicLinkContent() {
|
||||
const verificationAttempted = useRef(false);
|
||||
|
||||
const token = searchParams.get('token');
|
||||
const callbackURL = safeInternalPath(searchParams.get('callbackURL'), '/dashboard');
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent duplicate verification attempts (React StrictMode double-invokes effects)
|
||||
if (verificationAttempted.current) return;
|
||||
|
||||
|
||||
if (token) {
|
||||
verificationAttempted.current = true;
|
||||
verifyToken();
|
||||
@@ -34,11 +36,17 @@ function MagicLinkContent() {
|
||||
|
||||
const verifyToken = async () => {
|
||||
try {
|
||||
await loginWithMagicLink(token!);
|
||||
const user = await loginWithMagicLink(token!);
|
||||
setStatus('success');
|
||||
toast.success(language === 'es' ? '¡Bienvenido!' : 'Welcome!');
|
||||
// Unclaimed accounts must finish the claim (set a password) before the
|
||||
// rest of the API will accept their session.
|
||||
const destination =
|
||||
user && (user.isClaimed === false || user.accountStatus === 'unclaimed')
|
||||
? '/auth/claim-account'
|
||||
: callbackURL;
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard');
|
||||
router.push(destination);
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setStatus('error');
|
||||
|
||||
@@ -8,11 +8,17 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { PaymentMethod, BookingResult } from '../_types';
|
||||
|
||||
export const rucPattern = /^\d{6,10}$/;
|
||||
// Paraguayan RUC: 5-8 digit base + "-" + 1 check digit (DV), e.g. 1234567-9 or 80012345-0
|
||||
export const rucPattern = /^\d{5,8}-\d$/;
|
||||
|
||||
/** Format RUC input: digits only, max 10. */
|
||||
/** Sanitize RUC input: digits and a single user-typed dash, max 10 chars. No dash is auto-inserted. */
|
||||
export function formatRuc(value: string): string {
|
||||
return value.replace(/\D/g, '').slice(0, 10);
|
||||
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. */
|
||||
|
||||
@@ -226,25 +226,10 @@ export function BookingFormStep({
|
||||
onBlur={handleRucBlur}
|
||||
placeholder={t('booking.form.rucPlaceholder')}
|
||||
error={errors.ruc}
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
aria-label={t('booking.form.ruc')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('booking.form.preferredLanguage')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.preferredLanguage}
|
||||
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -335,7 +320,7 @@ export function BookingFormStep({
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
|
||||
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'
|
||||
@@ -377,6 +362,9 @@ export function BookingFormStep({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{errors.paymentMethod && (
|
||||
<p className="mt-3 text-sm text-red-600">{errors.paymentMethod}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Terms & Privacy agreement */}
|
||||
@@ -430,7 +418,7 @@ export function BookingFormStep({
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isLoading={submitting}
|
||||
disabled={paymentMethods.length === 0 || !agreedToTerms}
|
||||
disabled={paymentMethods.length === 0 || !formData.paymentMethod || !agreedToTerms}
|
||||
>
|
||||
{formData.paymentMethod === 'cash'
|
||||
? t('booking.form.reserveSpot')
|
||||
|
||||
@@ -12,8 +12,8 @@ export interface BookingFormData {
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
preferredLanguage: 'en' | 'es';
|
||||
paymentMethod: PaymentMethod;
|
||||
// Empty until the user explicitly picks a method (no default selection).
|
||||
paymentMethod: PaymentMethod | '';
|
||||
ruc: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||
import toast from 'react-hot-toast';
|
||||
import type {
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
PaymentMethod,
|
||||
} from './_types';
|
||||
import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking';
|
||||
import { useLightningWatcher } from './_hooks/useLightningWatcher';
|
||||
import { useLightningWatcher } from '@/hooks/useLightningWatcher';
|
||||
import { PayingStep } from './_steps/PayingStep';
|
||||
import { ManualPaymentStep } from './_steps/ManualPaymentStep';
|
||||
import { PendingApprovalStep } from './_steps/PendingApprovalStep';
|
||||
@@ -35,7 +35,6 @@ export default function BookingPage() {
|
||||
const [step, setStep] = useState<BookingStep>('form');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [bookingResult, setBookingResult] = useState<BookingResult | null>(null);
|
||||
const [, setPaymentPending] = useState(false);
|
||||
const [markingPaid, setMarkingPaid] = useState(false);
|
||||
|
||||
// State for payer name (when paid under different name)
|
||||
@@ -57,8 +56,7 @@ export default function BookingPage() {
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: locale as 'en' | 'es',
|
||||
paymentMethod: 'cash',
|
||||
paymentMethod: '',
|
||||
ruc: '',
|
||||
});
|
||||
|
||||
@@ -79,11 +77,10 @@ export default function BookingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Validate RUC on blur (optional field: 6–10 digits)
|
||||
// Validate RUC on blur (optional field: base + "-" + check digit)
|
||||
const handleRucBlur = () => {
|
||||
if (!formData.ruc) return;
|
||||
const digits = formData.ruc.replace(/\D/g, '');
|
||||
if (digits.length > 0 && !rucPattern.test(digits)) {
|
||||
if (!rucPattern.test(formData.ruc)) {
|
||||
setErrors({ ...errors, ruc: t('booking.form.errors.rucInvalidFormat') });
|
||||
}
|
||||
};
|
||||
@@ -111,16 +108,15 @@ export default function BookingPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const bookedCount = eventRes.event.bookedCount ?? 0;
|
||||
const capacity = eventRes.event.capacity ?? 0;
|
||||
const soldOut = bookedCount >= capacity;
|
||||
if (soldOut) {
|
||||
// Server-authoritative availability — same formula the booking API
|
||||
// enforces, so a sold-out event is caught here, not at submit time.
|
||||
if (isEventSoldOut(eventRes.event)) {
|
||||
toast.error(t('events.details.soldOut'));
|
||||
router.push(`/events/${eventRes.event.slug}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const spotsLeft = Math.max(0, capacity - bookedCount);
|
||||
const spotsLeft = eventSpotsLeft(eventRes.event);
|
||||
setEvent(eventRes.event);
|
||||
// Cap quantity by available spots (never allow requesting more than spotsLeft)
|
||||
setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft)));
|
||||
@@ -131,18 +127,7 @@ export default function BookingPage() {
|
||||
return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' });
|
||||
});
|
||||
setPaymentConfig(paymentRes.paymentOptions);
|
||||
|
||||
// Set default payment method based on what's enabled
|
||||
const config = paymentRes.paymentOptions;
|
||||
if (config.lightningEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'lightning' }));
|
||||
} else if (config.cashEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'cash' }));
|
||||
} else if (config.bankTransferEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'bank_transfer' }));
|
||||
} else if (config.tpagoEnabled) {
|
||||
setFormData(prev => ({ ...prev, paymentMethod: 'tpago' }));
|
||||
}
|
||||
// No payment method is pre-selected; the user must choose one.
|
||||
})
|
||||
.catch(() => router.push('/events'))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -164,8 +149,7 @@ export default function BookingPage() {
|
||||
lastName: prev.lastName || lastName,
|
||||
email: prev.email || user.email || '',
|
||||
phone: prev.phone || user.phone || '',
|
||||
preferredLanguage: (user.languagePreference as 'en' | 'es') || prev.preferredLanguage,
|
||||
ruc: prev.ruc || user.rucNumber || '',
|
||||
ruc: prev.ruc || formatRucDisplay(user.rucNumber),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -209,12 +193,18 @@ export default function BookingPage() {
|
||||
newErrors.phone = t('booking.form.errors.phoneTooShort');
|
||||
}
|
||||
|
||||
// RUC validation (optional field - 6–10 digits if filled)
|
||||
if (formData.ruc.trim()) {
|
||||
const digits = formData.ruc.replace(/\D/g, '');
|
||||
if (!/^\d{6,10}$/.test(digits)) {
|
||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||
}
|
||||
// RUC validation (optional field - base + "-" + check digit if filled)
|
||||
if (formData.ruc.trim() && !rucPattern.test(formData.ruc.trim())) {
|
||||
newErrors.ruc = t('booking.form.errors.rucInvalidFormat');
|
||||
}
|
||||
|
||||
// Payment method must be explicitly chosen and currently enabled
|
||||
const availableMethods = buildPaymentMethods(paymentConfig, locale);
|
||||
if (
|
||||
!formData.paymentMethod ||
|
||||
!availableMethods.some((m) => m.id === formData.paymentMethod)
|
||||
) {
|
||||
newErrors.paymentMethod = t('booking.form.errors.paymentMethodRequired');
|
||||
}
|
||||
|
||||
// Validate additional attendees (if multi-ticket)
|
||||
@@ -245,7 +235,13 @@ export default function BookingPage() {
|
||||
};
|
||||
|
||||
// Watch for Lightning payment confirmation while on the paying step.
|
||||
useLightningWatcher(step, bookingResult?.ticketId, locale, setPaymentPending, setStep);
|
||||
useLightningWatcher(
|
||||
step === 'paying',
|
||||
bookingResult?.ticketId,
|
||||
locale,
|
||||
() => setStep('success'),
|
||||
() => {}
|
||||
);
|
||||
|
||||
// Handle "I Have Paid" button click
|
||||
const handleMarkPaymentSent = async () => {
|
||||
@@ -298,9 +294,9 @@ export default function BookingPage() {
|
||||
lastName: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
preferredLanguage: formData.preferredLanguage,
|
||||
paymentMethod: formData.paymentMethod,
|
||||
...(formData.ruc.trim() && { ruc: formData.ruc.replace(/\D/g, '') }),
|
||||
preferredLanguage: locale as 'en' | 'es',
|
||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||
...(formData.ruc.trim() && { ruc: formData.ruc.trim() }),
|
||||
// Include attendees array for multi-ticket bookings
|
||||
...(allAttendees.length > 1 && { attendees: allAttendees }),
|
||||
});
|
||||
@@ -330,7 +326,6 @@ export default function BookingPage() {
|
||||
};
|
||||
setBookingResult(result);
|
||||
setStep('paying');
|
||||
setPaymentPending(true);
|
||||
// Payment confirmation is handled by the paying-step watcher effect.
|
||||
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
|
||||
// Manual payment methods - show payment details
|
||||
@@ -362,7 +357,7 @@ export default function BookingPage() {
|
||||
bookingId,
|
||||
qrCode: primaryTicket.qrCode,
|
||||
qrCodes: ticketsList?.map((t: any) => t.qrCode),
|
||||
paymentMethod: formData.paymentMethod,
|
||||
paymentMethod: formData.paymentMethod as PaymentMethod,
|
||||
ticketCount,
|
||||
});
|
||||
setStep('success');
|
||||
@@ -370,6 +365,23 @@ export default function BookingPage() {
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('booking.form.errors.bookingFailed'));
|
||||
// Capacity race on the last seats: refresh availability so the page
|
||||
// reflects reality (sold-out block / lower quantity cap) instead of the
|
||||
// stale counts loaded when the form was opened.
|
||||
const message = String(error?.message || '');
|
||||
if (/sold out|seats available/i.test(message)) {
|
||||
try {
|
||||
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
|
||||
setEvent(freshEvent);
|
||||
const freshSpots = eventSpotsLeft(freshEvent);
|
||||
if (freshSpots > 0) {
|
||||
setTicketQuantity((q) => Math.min(q, freshSpots));
|
||||
setAttendees((prev) => prev.slice(0, Math.max(0, freshSpots - 1)));
|
||||
}
|
||||
} catch {
|
||||
// Keep the stale event state if the refresh fails
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -392,8 +404,8 @@ export default function BookingPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||||
const spotsLeft = eventSpotsLeft(event);
|
||||
const isSoldOut = isEventSoldOut(event);
|
||||
|
||||
// Paying step - waiting for Lightning payment (compact design)
|
||||
if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig, LightningInvoice } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
|
||||
import { useLightningWatcher } from '@/hooks/useLightningWatcher';
|
||||
import { PayingStep } from '@/app/(public)/book/[eventId]/_steps/PayingStep';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
XCircleIcon,
|
||||
TicketIcon,
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type PaymentStep = 'loading' | 'manual_payment' | 'pending_approval' | 'confirmed' | 'error';
|
||||
type PaymentStep = 'loading' | 'manual_payment' | 'lightning_payment' | 'pending_approval' | 'confirmed' | 'error';
|
||||
|
||||
export default function BookingPaymentPage() {
|
||||
const params = useParams();
|
||||
@@ -33,10 +35,36 @@ export default function BookingPaymentPage() {
|
||||
const [step, setStep] = useState<PaymentStep>('loading');
|
||||
const [markingPaid, setMarkingPaid] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightningInvoice, setLightningInvoice] = useState<LightningInvoice | null>(null);
|
||||
const [invoiceExpired, setInvoiceExpired] = useState(false);
|
||||
const [fetchingInvoice, setFetchingInvoice] = useState(false);
|
||||
|
||||
const ticketId = params.ticketId as string;
|
||||
const requestedStep = searchParams.get('step');
|
||||
|
||||
// Get (or refresh) the Lightning invoice for this ticket - reuses the
|
||||
// stored invoice if still valid, otherwise the backend generates a new one.
|
||||
const fetchLightningInvoice = useCallback(async () => {
|
||||
setFetchingInvoice(true);
|
||||
try {
|
||||
const result = await ticketsApi.getLightningInvoice(ticketId);
|
||||
if (result.alreadyPaid) {
|
||||
setStep('confirmed');
|
||||
return;
|
||||
}
|
||||
if (result.invoice) {
|
||||
setLightningInvoice(result.invoice);
|
||||
setInvoiceExpired(false);
|
||||
setStep('lightning_payment');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load Lightning invoice');
|
||||
setStep('error');
|
||||
} finally {
|
||||
setFetchingInvoice(false);
|
||||
}
|
||||
}, [ticketId]);
|
||||
|
||||
// Fetch ticket and payment config
|
||||
useEffect(() => {
|
||||
if (!ticketId) return;
|
||||
@@ -45,7 +73,7 @@ export default function BookingPaymentPage() {
|
||||
try {
|
||||
// Get ticket with event and payment info
|
||||
const { ticket: ticketData } = await ticketsApi.getById(ticketId);
|
||||
|
||||
|
||||
if (!ticketData) {
|
||||
setError('Booking not found');
|
||||
setStep('error');
|
||||
@@ -54,10 +82,24 @@ export default function BookingPaymentPage() {
|
||||
|
||||
setTicket(ticketData);
|
||||
|
||||
// Only proceed for manual payment methods
|
||||
const paymentMethod = ticketData.payment?.provider;
|
||||
|
||||
if (paymentMethod === 'lightning') {
|
||||
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
||||
setStep('confirmed');
|
||||
} else if (ticketData.status === 'cancelled') {
|
||||
setError(locale === 'es'
|
||||
? 'Esta reserva ya no está activa. Por favor realiza una nueva reserva.'
|
||||
: 'This booking is no longer active. Please make a new booking.');
|
||||
setStep('error');
|
||||
} else {
|
||||
await fetchLightningInvoice();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['bank_transfer', 'tpago'].includes(paymentMethod || '')) {
|
||||
// Not a manual payment method, redirect to success page or show appropriate state
|
||||
// Not a manual or Lightning payment method, show appropriate state
|
||||
if (ticketData.status === 'confirmed' || ticketData.payment?.status === 'paid') {
|
||||
setStep('confirmed');
|
||||
} else {
|
||||
@@ -75,7 +117,7 @@ export default function BookingPaymentPage() {
|
||||
|
||||
// Determine which step to show based on payment status
|
||||
const paymentStatus = ticketData.payment?.status;
|
||||
|
||||
|
||||
if (paymentStatus === 'paid' || ticketData.status === 'confirmed') {
|
||||
setStep('confirmed');
|
||||
} else if (paymentStatus === 'pending_approval') {
|
||||
@@ -96,6 +138,15 @@ export default function BookingPaymentPage() {
|
||||
loadBookingData();
|
||||
}, [ticketId]);
|
||||
|
||||
// Watch for Lightning payment confirmation while the invoice is on screen.
|
||||
useLightningWatcher(
|
||||
step === 'lightning_payment' && !invoiceExpired,
|
||||
ticketId,
|
||||
locale,
|
||||
() => setStep('confirmed'),
|
||||
() => setInvoiceExpired(true)
|
||||
);
|
||||
|
||||
// Handle "I Have Paid" button click
|
||||
const handleMarkPaymentSent = async () => {
|
||||
if (!ticket) return;
|
||||
@@ -301,6 +352,49 @@ export default function BookingPaymentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Lightning payment step - show the (reused or freshly generated) invoice
|
||||
if (step === 'lightning_payment' && ticket) {
|
||||
if (invoiceExpired) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-orange-100 flex items-center justify-center mx-auto mb-6">
|
||||
<ClockIcon className="w-10 h-10 text-orange-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? 'La factura expiró' : 'Invoice expired'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{locale === 'es'
|
||||
? 'Genera una nueva factura Lightning para continuar con el pago.'
|
||||
: 'Generate a new Lightning invoice to continue with payment.'}
|
||||
</p>
|
||||
<Button onClick={fetchLightningInvoice} isLoading={fetchingInvoice} size="lg">
|
||||
{locale === 'es' ? 'Generar Nueva Factura' : 'Generate New Invoice'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!lightningInvoice) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
<p className="mt-4 text-gray-600">
|
||||
{locale === 'es' ? 'Preparando tu factura...' : 'Preparing your invoice...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PayingStep invoice={lightningInvoice} qrCode={ticket.qrCode} locale={locale} />;
|
||||
}
|
||||
|
||||
// Manual payment step - show payment details and "I have paid" button
|
||||
if (step === 'manual_payment' && ticket && paymentConfig) {
|
||||
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Join Our Language Exchange Community',
|
||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||
alternates: {
|
||||
canonical: '/community',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Join Our Language Exchange Community – Spanglish',
|
||||
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItem } from '@/lib/api';
|
||||
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import clsx from 'clsx';
|
||||
@@ -23,7 +24,20 @@ export default function HomepageFaqSection() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading || faqs.length === 0) {
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
|
||||
<div className="container-page">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Skeleton className="h-9 w-72 max-w-full mx-auto mb-8" />
|
||||
<FaqListSkeleton count={4} compact />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (faqs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||
import { FeaturedEventSkeleton } from '@/components/ui/Skeleton';
|
||||
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface NextEventSectionProps {
|
||||
@@ -51,11 +52,7 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
||||
: '';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
</div>
|
||||
);
|
||||
return <FeaturedEventSkeleton />;
|
||||
}
|
||||
|
||||
if (!nextEvent) {
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us',
|
||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||
alternates: {
|
||||
canonical: '/contact',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Contact Us – Spanglish',
|
||||
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
dashboardApi,
|
||||
authApi,
|
||||
UserProfile,
|
||||
UserSession,
|
||||
} from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface AccountTabProps {
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merged Profile + Security tab: account info, edit-profile form, authentication
|
||||
* methods, change/set password, active sessions and account deletion.
|
||||
*/
|
||||
export default function AccountTab({ onUpdate }: AccountTabProps) {
|
||||
const { locale } = useLanguage();
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [settingPassword, setSettingPassword] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
languagePreference: '',
|
||||
rucNumber: '',
|
||||
});
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [newPasswordForm, setNewPasswordForm] = useState({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [profileRes, sessionsRes] = await Promise.all([
|
||||
dashboardApi.getProfile(),
|
||||
dashboardApi.getSessions(),
|
||||
]);
|
||||
setProfile(profileRes.profile);
|
||||
setSessions(sessionsRes.sessions);
|
||||
setFormData({
|
||||
name: profileRes.profile.name || '',
|
||||
phone: profileRes.profile.phone || '',
|
||||
languagePreference: profileRes.profile.languagePreference || '',
|
||||
rucNumber: profileRes.profile.rucNumber || '',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(locale === 'es' ? 'Error al cargar datos' : 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await dashboardApi.updateProfile(formData);
|
||||
toast.success(locale === 'es' ? 'Perfil actualizado' : 'Profile updated');
|
||||
if (user) {
|
||||
updateUser({
|
||||
...user,
|
||||
name: formData.name,
|
||||
phone: formData.phone,
|
||||
languagePreference: formData.languagePreference,
|
||||
rucNumber: formData.rucNumber,
|
||||
});
|
||||
}
|
||||
onUpdate?.();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (locale === 'es' ? 'Error al actualizar' : 'Update failed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (passwordForm.newPassword.length < 10) {
|
||||
toast.error(
|
||||
locale === 'es'
|
||||
? 'La contraseña debe tener al menos 10 caracteres'
|
||||
: 'Password must be at least 10 characters'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setChangingPassword(true);
|
||||
try {
|
||||
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
|
||||
toast.success(locale === 'es' ? 'Contraseña actualizada' : 'Password updated');
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error.message || (locale === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password')
|
||||
);
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
|
||||
toast.error(locale === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (newPasswordForm.password.length < 10) {
|
||||
toast.error(
|
||||
locale === 'es'
|
||||
? 'La contraseña debe tener al menos 10 caracteres'
|
||||
: 'Password must be at least 10 characters'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSettingPassword(true);
|
||||
try {
|
||||
await dashboardApi.setPassword(newPasswordForm.password);
|
||||
toast.success(locale === 'es' ? 'Contraseña establecida' : 'Password set');
|
||||
setNewPasswordForm({ password: '', confirmPassword: '' });
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error.message || (locale === 'es' ? 'Error al establecer contraseña' : 'Failed to set password')
|
||||
);
|
||||
} finally {
|
||||
setSettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkGoogle = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
locale === 'es'
|
||||
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
|
||||
: 'Are you sure you want to unlink your Google account?'
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await dashboardApi.unlinkGoogle();
|
||||
toast.success(locale === 'es' ? 'Google desvinculado' : 'Google unlinked');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (locale === 'es' ? 'Error' : 'Failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
try {
|
||||
await dashboardApi.revokeSession(sessionId);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
toast.success(locale === 'es' ? 'Sesión cerrada' : 'Session revoked');
|
||||
} catch (error) {
|
||||
toast.error(locale === 'es' ? 'Error' : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
locale === 'es'
|
||||
? '¿Cerrar todas las otras sesiones? Esta sesión permanecerá activa.'
|
||||
: 'Log out of all other sessions? This session stays signed in.'
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await dashboardApi.revokeAllSessions();
|
||||
toast.success(
|
||||
locale === 'es' ? 'Todas las otras sesiones cerradas' : 'All other sessions revoked'
|
||||
);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
toast.error(locale === 'es' ? 'Error' : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string) =>
|
||||
parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-yellow" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = profile?.accountStatus === 'active';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Account info */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{locale === 'es' ? 'Información de la cuenta' : 'Account information'}
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between border-b py-2">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Correo' : 'Email'}</span>
|
||||
<span className="font-medium">{profile?.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b py-2">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Estado' : 'Status'}</span>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${
|
||||
isActive ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800'
|
||||
}`}
|
||||
>
|
||||
{isActive
|
||||
? locale === 'es'
|
||||
? 'Activa'
|
||||
: 'Active'
|
||||
: profile?.accountStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Miembro desde' : 'Member since'}</span>
|
||||
<span className="font-medium">
|
||||
{profile?.memberSince
|
||||
? parseDate(profile.memberSince).toLocaleDateString(
|
||||
locale === 'es' ? 'es-ES' : 'en-US',
|
||||
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Edit profile */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{locale === 'es' ? 'Editar perfil' : 'Edit profile'}
|
||||
</h3>
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
label={locale === 'es' ? 'Nombre' : 'Name'}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
label={locale === 'es' ? 'Teléfono' : 'Phone'}
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-primary-dark">
|
||||
{locale === 'es' ? 'Idioma preferido' : 'Preferred language'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.languagePreference}
|
||||
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
|
||||
className="w-full rounded-btn border border-secondary-light-gray px-4 py-3 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="">{locale === 'es' ? 'Seleccionar' : 'Select'}</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="rucNumber"
|
||||
label={locale === 'es' ? 'Número de RUC (para facturas)' : 'RUC number (for invoices)'}
|
||||
value={formData.rucNumber}
|
||||
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
|
||||
placeholder={locale === 'es' ? 'Opcional' : 'Optional'}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Tu número de RUC paraguayo para facturas fiscales'
|
||||
: 'Your Paraguayan RUC number for fiscal invoices'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{locale === 'es' ? 'Guardar cambios' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-4 border-t pt-4 text-xs text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Para cambiar tu correo, contacta al soporte.'
|
||||
: 'To change your email, please contact support.'}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Authentication methods */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{locale === 'es' ? 'Métodos de autenticación' : 'Authentication methods'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full ${
|
||||
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{locale === 'es' ? 'Contraseña' : 'Password'}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{profile?.hasPassword
|
||||
? locale === 'es' ? 'Configurada' : 'Set'
|
||||
: locale === 'es' ? 'No configurada' : 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-card bg-secondary-gray p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full ${
|
||||
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Google</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{profile?.hasGoogleLinked
|
||||
? locale === 'es' ? 'Vinculado' : 'Linked'
|
||||
: locale === 'es' ? 'No vinculado' : 'Not linked'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile?.hasGoogleLinked && profile?.hasPassword && (
|
||||
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
|
||||
{locale === 'es' ? 'Desvincular' : 'Unlink'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password management */}
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{profile?.hasPassword
|
||||
? locale === 'es' ? 'Cambiar contraseña' : 'Change password'
|
||||
: locale === 'es' ? 'Establecer contraseña' : 'Set password'}
|
||||
</h3>
|
||||
{profile?.hasPassword ? (
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Contraseña actual' : 'Current password'}
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-gray-500">
|
||||
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={changingPassword}>
|
||||
{locale === 'es' ? 'Cambiar contraseña' : 'Change password'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSetPassword} className="space-y-4">
|
||||
<p className="mb-2 text-sm text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
|
||||
: 'You currently sign in with Google. Set a password for more access options.'}
|
||||
</p>
|
||||
<div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Nueva contraseña' : 'New password'}
|
||||
value={newPasswordForm.password}
|
||||
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-gray-500">
|
||||
{locale === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="confirmNewPassword"
|
||||
type="password"
|
||||
label={locale === 'es' ? 'Confirmar contraseña' : 'Confirm password'}
|
||||
value={newPasswordForm.confirmPassword}
|
||||
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={settingPassword}>
|
||||
{locale === 'es' ? 'Establecer contraseña' : 'Set password'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Active sessions */}
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{locale === 'es' ? 'Sesiones activas' : 'Active sessions'}
|
||||
</h3>
|
||||
{sessions.length > 1 && (
|
||||
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
|
||||
{locale === 'es' ? 'Cerrar todas' : 'Log out all'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between rounded-card bg-secondary-gray p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{session.userAgent
|
||||
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '…' : '')
|
||||
: locale === 'es' ? 'Dispositivo desconocido' : 'Unknown device'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{locale === 'es' ? 'Última actividad:' : 'Last active:'}{' '}
|
||||
{formatDateTime(session.lastActiveAt)}
|
||||
{session.ipAddress && ` • ${session.ipAddress}`}
|
||||
</p>
|
||||
</div>
|
||||
{session.current ? (
|
||||
<span className="ml-3 whitespace-nowrap text-xs font-medium text-green-600">
|
||||
{locale === 'es' ? 'Esta sesión' : 'This session'}
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => handleRevokeSession(session.id)}>
|
||||
{locale === 'es' ? 'Cerrar' : 'Revoke'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Danger zone */}
|
||||
<Card className="border border-red-200 bg-red-50 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-red-800">
|
||||
{locale === 'es' ? 'Zona de peligro' : 'Danger zone'}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-red-700">
|
||||
{locale === 'es'
|
||||
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
|
||||
: 'If you want to delete your account, please contact support.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-300 text-red-700 hover:bg-red-100"
|
||||
disabled
|
||||
>
|
||||
{locale === 'es' ? 'Eliminar cuenta' : 'Delete account'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
MapPinIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
TicketIcon,
|
||||
UserPlusIcon,
|
||||
QrCodeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import type { UserTicket, NextEventInfo } from '@/lib/api';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { formatDateLong, formatTime, parseDate } from '@/lib/utils';
|
||||
import { StatusPill, deriveTicketStatus } from './_shared/status';
|
||||
import {
|
||||
groupByBooking,
|
||||
isUnpaid,
|
||||
isAwaitingApproval,
|
||||
isOnHold,
|
||||
isActionableAttention,
|
||||
ticketAmount,
|
||||
shareTicket,
|
||||
isToday,
|
||||
type BookingGroup,
|
||||
} from './_shared/helpers';
|
||||
import PayActions from './_shared/PayActions';
|
||||
import AttentionBanner from './_shared/AttentionBanner';
|
||||
import QrTicketModal from './_shared/QrTicketModal';
|
||||
|
||||
interface OverviewTabProps {
|
||||
nextEvent: NextEventInfo | null;
|
||||
tickets: UserTicket[];
|
||||
locale: string;
|
||||
userName: string;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export default function OverviewTab({
|
||||
nextEvent,
|
||||
tickets,
|
||||
locale,
|
||||
userName,
|
||||
onChange,
|
||||
}: OverviewTabProps) {
|
||||
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
|
||||
|
||||
const activeTickets = useMemo(
|
||||
() => tickets.filter((t) => t.status !== 'cancelled'),
|
||||
[tickets]
|
||||
);
|
||||
|
||||
// Banner: the single booking that most needs attention (unpaid first, then
|
||||
// awaiting approval), ordered by soonest event.
|
||||
const attentionTicket = useMemo(() => {
|
||||
const candidates = activeTickets.filter(
|
||||
(t) =>
|
||||
isActionableAttention(t) &&
|
||||
(isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t))
|
||||
);
|
||||
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
|
||||
candidates.sort((a, b) => {
|
||||
const aUnpaid = priority(a);
|
||||
const bUnpaid = priority(b);
|
||||
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
|
||||
const aStart = a.event?.startDatetime
|
||||
? parseDate(a.event.startDatetime).getTime()
|
||||
: Infinity;
|
||||
const bStart = b.event?.startDatetime
|
||||
? parseDate(b.event.startDatetime).getTime()
|
||||
: Infinity;
|
||||
return aStart - bStart;
|
||||
});
|
||||
return candidates[0] || null;
|
||||
}, [activeTickets]);
|
||||
|
||||
// Hero: full tickets for the next event's booking.
|
||||
const heroGroup = useMemo<BookingGroup | null>(() => {
|
||||
if (!nextEvent) return null;
|
||||
const primary =
|
||||
activeTickets.find((t) => t.id === nextEvent.ticket.id) || null;
|
||||
if (!primary) return null;
|
||||
const groupTickets = primary.bookingId
|
||||
? activeTickets.filter((t) => t.bookingId === primary.bookingId)
|
||||
: [primary];
|
||||
return { bookingId: primary.bookingId || primary.id, tickets: groupTickets };
|
||||
}, [nextEvent, activeTickets]);
|
||||
|
||||
// "Also coming up": upcoming booking groups other than the hero.
|
||||
const upcomingGroups = useMemo<BookingGroup[]>(() => {
|
||||
const now = Date.now();
|
||||
const groups = groupByBooking(
|
||||
activeTickets.filter(
|
||||
(t) => t.event && parseDate(t.event.startDatetime).getTime() > now
|
||||
)
|
||||
);
|
||||
return groups.filter((g) => g.bookingId !== heroGroup?.bookingId);
|
||||
}, [activeTickets, heroGroup]);
|
||||
|
||||
const handleShare = async (ticket: UserTicket) => {
|
||||
const title =
|
||||
(locale === 'es' && ticket.event?.titleEs
|
||||
? ticket.event.titleEs
|
||||
: ticket.event?.title) || 'Event';
|
||||
const result = await shareTicket(ticket.id, title, locale);
|
||||
if (result === 'copied')
|
||||
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
|
||||
else if (result === 'failed')
|
||||
toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share');
|
||||
};
|
||||
|
||||
// Empty state.
|
||||
if (activeTickets.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-primary-yellow/15">
|
||||
<TicketIcon className="h-7 w-7 text-primary-yellow" />
|
||||
</div>
|
||||
<h2 className="mb-1 text-xl font-semibold">
|
||||
{locale === 'es' ? `¡Hola, ${userName}!` : `Welcome, ${userName}!`}
|
||||
</h2>
|
||||
<p className="mx-auto mb-6 max-w-sm text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Todavía no tienes reservas. Encuentra tu primer evento de intercambio de idiomas.'
|
||||
: "You have no bookings yet. Find your first language exchange event."}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button size="lg">
|
||||
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1 — Attention banner (only when money is owed / awaiting). */}
|
||||
{attentionTicket && (
|
||||
<AttentionBanner
|
||||
ticket={attentionTicket}
|
||||
locale={locale}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 2 — Next event hero. */}
|
||||
{heroGroup && heroGroup.tickets[0].event && (
|
||||
<HeroCard
|
||||
group={heroGroup}
|
||||
locale={locale}
|
||||
onChange={onChange}
|
||||
onShowQr={() => setQrTickets(heroGroup.tickets)}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3 — Also coming up. */}
|
||||
{upcomingGroups.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">
|
||||
{locale === 'es' ? 'También se viene' : 'Also coming up'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{upcomingGroups.map((group) => {
|
||||
const t = group.tickets[0];
|
||||
const status = deriveTicketStatus(t.status, t.payment?.status);
|
||||
const title =
|
||||
(locale === 'es' && t.event?.titleEs
|
||||
? t.event.titleEs
|
||||
: t.event?.title) || 'Event';
|
||||
const { amount, currency } = ticketAmount(t);
|
||||
return (
|
||||
<div
|
||||
key={group.bookingId}
|
||||
className="flex flex-col gap-3 rounded-card bg-secondary-gray p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate font-medium">{title}</p>
|
||||
{group.tickets.length > 1 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
×{group.tickets.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-gray-600">
|
||||
{t.event && formatDateLong(t.event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill status={status} locale={locale} />
|
||||
{isOnHold(t) ? (
|
||||
<PayActions
|
||||
ticketId={t.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
onHold
|
||||
/>
|
||||
) : isUnpaid(t) ? (
|
||||
<PayActions
|
||||
ticketId={t.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
(status === 'confirmed' || status === 'attended') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQrTickets(group.tickets)}
|
||||
>
|
||||
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 6 — Browse events. */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<Link href="/events">
|
||||
<Button variant="outline" size="lg">
|
||||
{locale === 'es' ? 'Explorar eventos' : 'Browse events'}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{qrTickets && (
|
||||
<QrTicketModal
|
||||
tickets={qrTickets}
|
||||
locale={locale}
|
||||
onClose={() => setQrTickets(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroCard({
|
||||
group,
|
||||
locale,
|
||||
onChange,
|
||||
onShowQr,
|
||||
onShare,
|
||||
}: {
|
||||
group: BookingGroup;
|
||||
locale: string;
|
||||
onChange: () => void;
|
||||
onShowQr: () => void;
|
||||
onShare: (ticket: UserTicket) => void;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
const ticket = group.tickets[0];
|
||||
const event = ticket.event!;
|
||||
const title =
|
||||
(locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Event';
|
||||
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
|
||||
const { amount, currency } = ticketAmount(ticket);
|
||||
const multi = group.tickets.length > 1;
|
||||
const today = isToday(event.startDatetime);
|
||||
const showQrReady = status === 'confirmed' || status === 'attended';
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Image with status pill + today badge overlay. */}
|
||||
<div className="relative h-44 w-full bg-secondary-gray sm:h-56">
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<TicketIcon className="h-12 w-12 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||||
{today && (
|
||||
<span className="rounded-full bg-primary-yellow px-3 py-1 text-xs font-semibold text-primary-dark shadow-sm">
|
||||
{locale === 'es' ? 'Hoy' : 'Today'}
|
||||
</span>
|
||||
)}
|
||||
<StatusPill status={status} locale={locale} className="shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-primary-yellow">
|
||||
{locale === 'es' ? 'Tu próximo evento' : 'Your next event'}
|
||||
</p>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<h3 className="text-2xl font-bold leading-tight">{title}</h3>
|
||||
{multi && (
|
||||
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-3 py-1 text-sm font-medium text-gray-700">
|
||||
{locale === 'es'
|
||||
? `${group.tickets.length} entradas`
|
||||
: `${group.tickets.length} tickets`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 space-y-2 text-gray-600">
|
||||
<p className="flex items-center gap-3">
|
||||
<CalendarIcon className="h-5 w-5 text-primary-yellow" />
|
||||
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<ClockIcon className="h-5 w-5 text-primary-yellow" />
|
||||
{formatTime(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<MapPinIcon className="h-5 w-5 text-primary-yellow" />
|
||||
<span className="flex-1">{event.location}</span>
|
||||
{event.locationUrl && (
|
||||
<a
|
||||
href={event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap text-sm font-medium text-secondary-blue hover:underline"
|
||||
>
|
||||
{locale === 'es' ? 'Cómo llegar' : 'Get directions'}
|
||||
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Today + ready → show the QR prominently for check-in. */}
|
||||
{today && showQrReady && (
|
||||
<div className="mb-4 rounded-card bg-primary-yellow/10 p-4 text-center">
|
||||
<p className="mb-3 text-sm font-medium text-primary-dark">
|
||||
{locale === 'es'
|
||||
? 'Muestra este código en la entrada'
|
||||
: 'Show this code at the door'}
|
||||
</p>
|
||||
<Button onClick={onShowQr} size="lg" className="w-full sm:w-auto">
|
||||
<QrCodeIcon className="mr-2 h-5 w-5" />
|
||||
{locale === 'es' ? 'Mostrar código QR' : 'Show QR code'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart primary action. */}
|
||||
{isOnHold(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="md"
|
||||
onHold
|
||||
/>
|
||||
) : isUnpaid(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="md"
|
||||
/>
|
||||
) : isAwaitingApproval(ticket) ? (
|
||||
<p className="rounded-card bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{t('dashboard.payment.receivedConfirmShortly')}
|
||||
</p>
|
||||
) : (
|
||||
!today && (
|
||||
<Button onClick={onShowQr} size="md">
|
||||
<TicketIcon className="mr-2 h-5 w-5" />
|
||||
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Multi-ticket guest reminder. */}
|
||||
{multi && showQrReady && (
|
||||
<div className="mt-4 flex flex-col gap-2 rounded-card border border-secondary-light-gray p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Tienes una entrada de más para un invitado.'
|
||||
: 'You have a spare ticket for a guest.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onShare(group.tickets[1])}
|
||||
>
|
||||
<UserPlusIcon className="mr-2 h-4 w-4" />
|
||||
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { UserPayment } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import { formatDateLong } from '@/lib/utils';
|
||||
import { StatusPill, derivePaymentStatus } from './_shared/status';
|
||||
import { pyg, isManualProvider } from './_shared/helpers';
|
||||
import PayActions from './_shared/PayActions';
|
||||
|
||||
interface PaymentsTabProps {
|
||||
payments: UserPayment[];
|
||||
language: string;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
||||
const [filter, setFilter] = useState<'all' | 'paid' | 'pending'>('all');
|
||||
type Filter = 'all' | 'paid' | 'pending';
|
||||
|
||||
const filteredPayments = payments.filter((payment) => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'paid') return payment.status === 'paid';
|
||||
if (filter === 'pending') return payment.status !== 'paid' && payment.status !== 'refunded';
|
||||
return true;
|
||||
});
|
||||
export default function PaymentsTab({ payments, language: locale, onChange }: PaymentsTabProps) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
payments.filter((p) => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'paid') return p.status === 'paid';
|
||||
return p.status !== 'paid' && p.status !== 'refunded';
|
||||
}),
|
||||
[payments, filter]
|
||||
);
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = 'PYG') => {
|
||||
if (currency === 'PYG') {
|
||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||
}
|
||||
return `$${amount.toFixed(2)} ${currency}`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
pending_approval: 'bg-orange-100 text-orange-800',
|
||||
refunded: 'bg-purple-100 text-purple-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
};
|
||||
const labels: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
paid: 'Paid',
|
||||
pending: 'Pending',
|
||||
pending_approval: 'Awaiting Approval',
|
||||
refunded: 'Refunded',
|
||||
failed: 'Failed',
|
||||
},
|
||||
es: {
|
||||
paid: 'Pagado',
|
||||
pending: 'Pendiente',
|
||||
pending_approval: 'Esperando Aprobación',
|
||||
refunded: 'Reembolsado',
|
||||
failed: 'Fallido',
|
||||
},
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{labels[language]?.[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: string) => {
|
||||
const labels: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
lightning: 'Lightning (Bitcoin)',
|
||||
cash: 'Cash',
|
||||
bank_transfer: 'Bank Transfer',
|
||||
tpago: 'TPago',
|
||||
bancard: 'Card',
|
||||
},
|
||||
es: {
|
||||
lightning: 'Lightning (Bitcoin)',
|
||||
cash: 'Efectivo',
|
||||
bank_transfer: 'Transferencia Bancaria',
|
||||
tpago: 'TPago',
|
||||
bancard: 'Tarjeta',
|
||||
},
|
||||
};
|
||||
return labels[language]?.[provider] || provider;
|
||||
};
|
||||
|
||||
// Summary calculations
|
||||
const totalPaid = payments
|
||||
.filter((p) => p.status === 'paid')
|
||||
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||
@@ -96,112 +37,143 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) {
|
||||
.filter((p) => p.status !== 'paid' && p.status !== 'refunded')
|
||||
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||
|
||||
const providerLabel = (provider: string) => {
|
||||
const labels: Record<string, { en: string; es: string }> = {
|
||||
tpago: { en: 'TPago', es: 'TPago' },
|
||||
bank_transfer: { en: 'Bank transfer', es: 'Transferencia bancaria' },
|
||||
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
|
||||
cash: { en: 'Cash', es: 'Efectivo' },
|
||||
bancard: { en: 'Card', es: 'Tarjeta' },
|
||||
};
|
||||
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
|
||||
};
|
||||
|
||||
const chips: { id: Filter; label: { en: string; es: string } }[] = [
|
||||
{ id: 'all', label: { en: 'All', es: 'Todos' } },
|
||||
{ id: 'paid', label: { en: 'Paid', es: 'Pagados' } },
|
||||
{ id: 'pending', label: { en: 'Pending', es: 'Pendientes' } },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
{/* Totals */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
{language === 'es' ? 'Total Pagado' : 'Total Paid'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(totalPaid)}
|
||||
<Card className="p-5">
|
||||
<p className="mb-1 text-sm text-gray-600">
|
||||
{locale === 'es' ? 'Total pagado' : 'Total paid'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-700">{pyg(totalPaid)}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
{language === 'es' ? 'Pendiente' : 'Pending'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">
|
||||
{formatCurrency(totalPending)}
|
||||
<Card className="p-5">
|
||||
<p className="mb-1 text-sm text-gray-600">
|
||||
{locale === 'es' ? 'Pendiente' : 'Pending'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-700">{pyg(totalPending)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'paid', 'pending'] as const).map((f) => (
|
||||
{/* Filter chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-secondary-blue text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
key={chip.id}
|
||||
onClick={() => setFilter(chip.id)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
filter === chip.id
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' && (language === 'es' ? 'Todos' : 'All')}
|
||||
{f === 'paid' && (language === 'es' ? 'Pagados' : 'Paid')}
|
||||
{f === 'pending' && (language === 'es' ? 'Pendientes' : 'Pending')}
|
||||
{locale === 'es' ? chip.label.es : chip.label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Payments List */}
|
||||
{filteredPayments.length === 0 ? (
|
||||
{/* Records */}
|
||||
{filtered.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
{language === 'es' ? 'No hay pagos que mostrar' : 'No payments to display'}
|
||||
{locale === 'es' ? 'No hay pagos que mostrar' : 'No payments to show'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredPayments.map((payment) => (
|
||||
<Card key={payment.id} className="p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold">
|
||||
{formatCurrency(Number(payment.amount), payment.currency)}
|
||||
</span>
|
||||
{getStatusBadge(payment.status)}
|
||||
{filtered.map((payment) => {
|
||||
const status = derivePaymentStatus(payment.status);
|
||||
const eventTitle =
|
||||
(locale === 'es' && payment.event?.titleEs
|
||||
? payment.event.titleEs
|
||||
: payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event');
|
||||
const canMarkPaid =
|
||||
payment.status === 'pending' && isManualProvider(payment.provider);
|
||||
const canRebook = payment.status === 'on_hold';
|
||||
return (
|
||||
<Card key={payment.id} className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="font-semibold">
|
||||
{pyg(Number(payment.amount), payment.currency)}
|
||||
</span>
|
||||
<StatusPill status={status} locale={locale} />
|
||||
</div>
|
||||
<div className="space-y-0.5 text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-800">{eventTitle}</p>
|
||||
<p>
|
||||
{providerLabel(payment.provider)} ·{' '}
|
||||
{formatDateLong(payment.createdAt, locale as 'en' | 'es')}
|
||||
</p>
|
||||
{payment.reference && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{locale === 'es' ? 'Ref' : 'Ref'}: {payment.reference}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
{payment.event && (
|
||||
<p>
|
||||
{language === 'es' && payment.event.titleEs
|
||||
? payment.event.titleEs
|
||||
: payment.event.title}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
|
||||
{canRebook ? (
|
||||
<PayActions
|
||||
ticketId={payment.ticketId}
|
||||
amount={Number(payment.amount)}
|
||||
currency={payment.currency}
|
||||
destination={eventTitle}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
onHold
|
||||
/>
|
||||
) : (
|
||||
canMarkPaid && (
|
||||
<PayActions
|
||||
ticketId={payment.ticketId}
|
||||
amount={Number(payment.amount)}
|
||||
currency={payment.currency}
|
||||
destination={eventTitle}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Método:' : 'Method:'}
|
||||
</span>{' '}
|
||||
{getProviderLabel(payment.provider)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||
</span>{' '}
|
||||
{formatDate(payment.createdAt)}
|
||||
</p>
|
||||
{payment.reference && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Referencia:' : 'Reference:'}
|
||||
</span>{' '}
|
||||
{payment.reference}
|
||||
</p>
|
||||
{payment.invoice && (
|
||||
<a
|
||||
href={payment.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{locale === 'es' ? 'Factura' : 'Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{payment.invoice && (
|
||||
<a
|
||||
href={payment.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { dashboardApi, UserProfile } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ProfileTabProps {
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default function ProfileTab({ onUpdate }: ProfileTabProps) {
|
||||
const { locale: language } = useLanguage();
|
||||
const { updateUser, user } = useAuth();
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
languagePreference: '',
|
||||
rucNumber: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const res = await dashboardApi.getProfile();
|
||||
setProfile(res.profile);
|
||||
setFormData({
|
||||
name: res.profile.name || '',
|
||||
phone: res.profile.phone || '',
|
||||
languagePreference: res.profile.languagePreference || '',
|
||||
rucNumber: res.profile.rucNumber || '',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(language === 'es' ? 'Error al cargar perfil' : 'Failed to load profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const res = await dashboardApi.updateProfile(formData);
|
||||
toast.success(language === 'es' ? 'Perfil actualizado' : 'Profile updated');
|
||||
|
||||
// Update auth context
|
||||
if (user) {
|
||||
updateUser({
|
||||
...user,
|
||||
name: formData.name,
|
||||
phone: formData.phone,
|
||||
languagePreference: formData.languagePreference,
|
||||
rucNumber: formData.rucNumber,
|
||||
});
|
||||
}
|
||||
|
||||
if (onUpdate) onUpdate();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (language === 'es' ? 'Error al actualizar' : 'Update failed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Account Info Card */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Información de la Cuenta' : 'Account Information'}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-gray-600">Email</span>
|
||||
<span className="font-medium">{profile?.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-gray-600">
|
||||
{language === 'es' ? 'Estado de Cuenta' : 'Account Status'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
profile?.accountStatus === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{profile?.accountStatus === 'active'
|
||||
? (language === 'es' ? 'Activo' : 'Active')
|
||||
: profile?.accountStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-gray-600">
|
||||
{language === 'es' ? 'Miembro Desde' : 'Member Since'}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{profile?.memberSince
|
||||
? parseDate(profile.memberSince).toLocaleDateString(
|
||||
language === 'es' ? 'es-ES' : 'en-US',
|
||||
{ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' }
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-600">
|
||||
{language === 'es' ? 'Días de Membresía' : 'Membership Days'}
|
||||
</span>
|
||||
<span className="font-medium">{profile?.membershipDays || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Edit Profile Form */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Editar Perfil' : 'Edit Profile'}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
id="name"
|
||||
label={language === 'es' ? 'Nombre' : 'Name'}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="phone"
|
||||
label={language === 'es' ? 'Teléfono' : 'Phone'}
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{language === 'es' ? 'Idioma Preferido' : 'Preferred Language'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.languagePreference}
|
||||
onChange={(e) => setFormData({ ...formData, languagePreference: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary-blue"
|
||||
>
|
||||
<option value="">{language === 'es' ? 'Seleccionar' : 'Select'}</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="rucNumber"
|
||||
label={language === 'es' ? 'Número de RUC (para facturas)' : 'RUC Number (for invoices)'}
|
||||
value={formData.rucNumber}
|
||||
onChange={(e) => setFormData({ ...formData, rucNumber: e.target.value })}
|
||||
placeholder={language === 'es' ? 'Opcional' : 'Optional'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
{language === 'es'
|
||||
? 'Tu número de RUC paraguayo para facturas fiscales'
|
||||
: 'Your Paraguayan RUC number for fiscal invoices'}
|
||||
</p>
|
||||
|
||||
<div className="pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{language === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Email Change Notice */}
|
||||
<Card className="p-6 bg-gray-50">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{language === 'es' ? 'Cambiar Email' : 'Change Email'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{language === 'es'
|
||||
? 'Para cambiar tu dirección de email, por favor contacta al soporte.'
|
||||
: 'To change your email address, please contact support.'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
{language === 'es' ? 'Contactar Soporte' : 'Contact Support'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function SecurityTab() {
|
||||
const { locale: language } = useLanguage();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [settingPassword, setSettingPassword] = useState(false);
|
||||
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const [newPasswordForm, setNewPasswordForm] = useState({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [profileRes, sessionsRes] = await Promise.all([
|
||||
dashboardApi.getProfile(),
|
||||
dashboardApi.getSessions(),
|
||||
]);
|
||||
setProfile(profileRes.profile);
|
||||
setSessions(sessionsRes.sessions);
|
||||
} catch (error) {
|
||||
toast.error(language === 'es' ? 'Error al cargar datos' : 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.newPassword.length < 10) {
|
||||
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setChangingPassword(true);
|
||||
|
||||
try {
|
||||
await authApi.changePassword(passwordForm.currentPassword, passwordForm.newPassword);
|
||||
toast.success(language === 'es' ? 'Contraseña actualizada' : 'Password updated');
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (language === 'es' ? 'Error al cambiar contraseña' : 'Failed to change password'));
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (newPasswordForm.password !== newPasswordForm.confirmPassword) {
|
||||
toast.error(language === 'es' ? 'Las contraseñas no coinciden' : 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPasswordForm.password.length < 10) {
|
||||
toast.error(language === 'es' ? 'La contraseña debe tener al menos 10 caracteres' : 'Password must be at least 10 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setSettingPassword(true);
|
||||
|
||||
try {
|
||||
await dashboardApi.setPassword(newPasswordForm.password);
|
||||
toast.success(language === 'es' ? 'Contraseña establecida' : 'Password set');
|
||||
setNewPasswordForm({ password: '', confirmPassword: '' });
|
||||
loadData(); // Reload to update profile
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (language === 'es' ? 'Error al establecer contraseña' : 'Failed to set password'));
|
||||
} finally {
|
||||
setSettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkGoogle = async () => {
|
||||
if (!confirm(language === 'es'
|
||||
? '¿Estás seguro de que quieres desvincular tu cuenta de Google?'
|
||||
: 'Are you sure you want to unlink your Google account?'
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dashboardApi.unlinkGoogle();
|
||||
toast.success(language === 'es' ? 'Google desvinculado' : 'Google unlinked');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || (language === 'es' ? 'Error' : 'Failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
try {
|
||||
await dashboardApi.revokeSession(sessionId);
|
||||
setSessions(sessions.filter((s) => s.id !== sessionId));
|
||||
toast.success(language === 'es' ? 'Sesión cerrada' : 'Session revoked');
|
||||
} catch (error) {
|
||||
toast.error(language === 'es' ? 'Error' : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
if (!confirm(language === 'es'
|
||||
? '¿Cerrar todas las sesiones? Serás desconectado.'
|
||||
: 'Log out of all sessions? You will be logged out.'
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dashboardApi.revokeAllSessions();
|
||||
toast.success(language === 'es' ? 'Todas las sesiones cerradas' : 'All sessions revoked');
|
||||
logout();
|
||||
} catch (error) {
|
||||
toast.error(language === 'es' ? 'Error' : 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Authentication Methods */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Métodos de Autenticación' : 'Authentication Methods'}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Password Status */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
profile?.hasPassword ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{language === 'es' ? 'Contraseña' : 'Password'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{profile?.hasPassword
|
||||
? (language === 'es' ? 'Configurada' : 'Set')
|
||||
: (language === 'es' ? 'No configurada' : 'Not set')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile?.hasPassword && (
|
||||
<span className="text-green-600 text-sm font-medium">
|
||||
{language === 'es' ? 'Activo' : 'Active'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Google Status */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
profile?.hasGoogleLinked ? 'bg-red-100 text-red-600' : 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Google</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{profile?.hasGoogleLinked
|
||||
? (language === 'es' ? 'Vinculado' : 'Linked')
|
||||
: (language === 'es' ? 'No vinculado' : 'Not linked')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile?.hasGoogleLinked && profile?.hasPassword && (
|
||||
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
|
||||
{language === 'es' ? 'Desvincular' : 'Unlink'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password Management */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{profile?.hasPassword
|
||||
? (language === 'es' ? 'Cambiar Contraseña' : 'Change Password')
|
||||
: (language === 'es' ? 'Establecer Contraseña' : 'Set Password')}
|
||||
</h3>
|
||||
|
||||
{profile?.hasPassword ? (
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Contraseña Actual' : 'Current Password'}
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={changingPassword}>
|
||||
{language === 'es' ? 'Cambiar Contraseña' : 'Change Password'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSetPassword} className="space-y-4">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{language === 'es'
|
||||
? 'Actualmente inicias sesión con Google. Establece una contraseña para más opciones de acceso.'
|
||||
: 'You currently sign in with Google. Set a password for more access options.'}
|
||||
</p>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Nueva Contraseña' : 'New Password'}
|
||||
value={newPasswordForm.password}
|
||||
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
{language === 'es' ? 'Mínimo 10 caracteres' : 'Minimum 10 characters'}
|
||||
</p>
|
||||
<Input
|
||||
id="confirmNewPassword"
|
||||
type="password"
|
||||
label={language === 'es' ? 'Confirmar Contraseña' : 'Confirm Password'}
|
||||
value={newPasswordForm.confirmPassword}
|
||||
onChange={(e) => setNewPasswordForm({ ...newPasswordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" isLoading={settingPassword}>
|
||||
{language === 'es' ? 'Establecer Contraseña' : 'Set Password'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Active Sessions */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{language === 'es' ? 'Sesiones Activas' : 'Active Sessions'}
|
||||
</h3>
|
||||
{sessions.length > 1 && (
|
||||
<Button variant="outline" size="sm" onClick={handleRevokeAllSessions}>
|
||||
{language === 'es' ? 'Cerrar Todas' : 'Logout All'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'No hay sesiones activas' : 'No active sessions'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session, index) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{session.userAgent
|
||||
? session.userAgent.substring(0, 50) + (session.userAgent.length > 50 ? '...' : '')
|
||||
: (language === 'es' ? 'Dispositivo desconocido' : 'Unknown device')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{language === 'es' ? 'Última actividad:' : 'Last active:'} {formatDate(session.lastActiveAt)}
|
||||
{session.ipAddress && ` • ${session.ipAddress}`}
|
||||
</p>
|
||||
</div>
|
||||
{index !== 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
>
|
||||
{language === 'es' ? 'Cerrar' : 'Revoke'}
|
||||
</Button>
|
||||
)}
|
||||
{index === 0 && (
|
||||
<span className="text-xs text-green-600 font-medium">
|
||||
{language === 'es' ? 'Esta sesión' : 'This session'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="p-6 border-red-200 bg-red-50">
|
||||
<h3 className="text-lg font-semibold text-red-800 mb-4">
|
||||
{language === 'es' ? 'Zona de Peligro' : 'Danger Zone'}
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 mb-4">
|
||||
{language === 'es'
|
||||
? 'Si deseas eliminar tu cuenta, contacta al soporte.'
|
||||
: 'If you want to delete your account, please contact support.'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="border-red-300 text-red-700 hover:bg-red-100" disabled>
|
||||
{language === 'es' ? 'Eliminar Cuenta' : 'Delete Account'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
ArrowDownTrayIcon,
|
||||
TicketIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { UserTicket } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import { formatDateLong, parseDate } from '@/lib/utils';
|
||||
import { StatusPill, deriveTicketStatus } from './_shared/status';
|
||||
import {
|
||||
groupByBooking,
|
||||
isUnpaid,
|
||||
isOnHold,
|
||||
ticketAmount,
|
||||
ticketPdfUrl,
|
||||
pyg,
|
||||
HOLD_THRESHOLD_HOURS,
|
||||
type BookingGroup,
|
||||
} from './_shared/helpers';
|
||||
import PayActions from './_shared/PayActions';
|
||||
import QrTicketModal from './_shared/QrTicketModal';
|
||||
|
||||
interface TicketsTabProps {
|
||||
tickets: UserTicket[];
|
||||
language: string;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export default function TicketsTab({ tickets, language }: TicketsTabProps) {
|
||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('all');
|
||||
type Filter = 'all' | 'upcoming' | 'past';
|
||||
|
||||
const now = new Date();
|
||||
const filteredTickets = tickets.filter((ticket) => {
|
||||
if (filter === 'all') return true;
|
||||
const eventDate = ticket.event?.startDatetime
|
||||
? new Date(ticket.event.startDatetime)
|
||||
: null;
|
||||
if (filter === 'upcoming') return eventDate && eventDate > now;
|
||||
if (filter === 'past') return eventDate && eventDate <= now;
|
||||
return true;
|
||||
});
|
||||
export default function TicketsTab({ tickets, language: locale, onChange }: TicketsTabProps) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [qrTickets, setQrTickets] = useState<UserTicket[] | null>(null);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
// Group multi-ticket bookings so both QRs sit together, then filter by the
|
||||
// group's event date.
|
||||
const groups = useMemo<BookingGroup[]>(() => {
|
||||
const now = Date.now();
|
||||
return groupByBooking(tickets).filter((group) => {
|
||||
if (filter === 'all') return true;
|
||||
const start = group.tickets[0].event?.startDatetime;
|
||||
if (!start) return false; // undated tickets only show under "All"
|
||||
const isUpcoming = parseDate(start).getTime() > now;
|
||||
return filter === 'upcoming' ? isUpcoming : !isUpcoming;
|
||||
});
|
||||
};
|
||||
}, [tickets, filter]);
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = 'PYG') => {
|
||||
if (currency === 'PYG') {
|
||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||
}
|
||||
return `$${amount.toFixed(2)} ${currency}`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-800',
|
||||
checked_in: 'bg-blue-100 text-blue-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
};
|
||||
const labels: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
confirmed: 'Confirmed',
|
||||
checked_in: 'Checked In',
|
||||
pending: 'Pending',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
es: {
|
||||
confirmed: 'Confirmado',
|
||||
checked_in: 'Registrado',
|
||||
pending: 'Pendiente',
|
||||
cancelled: 'Cancelado',
|
||||
},
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{labels[language]?.[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const chips: { id: Filter; label: { en: string; es: string } }[] = [
|
||||
{ id: 'all', label: { en: 'All', es: 'Todas' } },
|
||||
{ id: 'upcoming', label: { en: 'Upcoming', es: 'Próximas' } },
|
||||
{ id: 'past', label: { en: 'Past', es: 'Pasadas' } },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filter Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'upcoming', 'past'] as const).map((f) => (
|
||||
{/* Filter chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-secondary-blue text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
key={chip.id}
|
||||
onClick={() => setFilter(chip.id)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
filter === chip.id
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'bg-secondary-gray text-gray-700 hover:bg-secondary-light-gray'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' && (language === 'es' ? 'Todas' : 'All')}
|
||||
{f === 'upcoming' && (language === 'es' ? 'Próximas' : 'Upcoming')}
|
||||
{f === 'past' && (language === 'es' ? 'Pasadas' : 'Past')}
|
||||
{locale === 'es' ? chip.label.es : chip.label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tickets List */}
|
||||
{filteredTickets.length === 0 ? (
|
||||
{groups.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
{language === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
||||
<p className="mb-4 text-gray-600">
|
||||
{locale === 'es' ? 'No tienes entradas' : 'You have no tickets'}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button>
|
||||
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
|
||||
</Button>
|
||||
<Button>{locale === 'es' ? 'Explorar eventos' : 'Browse events'}</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTickets.map((ticket) => (
|
||||
<Card key={ticket.id} className="p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
{/* Event Image */}
|
||||
{ticket.event?.bannerUrl && (
|
||||
<div className="w-full md:w-32 h-24 rounded-lg overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={ticket.event.bannerUrl}
|
||||
alt={ticket.event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold">
|
||||
{language === 'es' && ticket.event?.titleEs
|
||||
? ticket.event.titleEs
|
||||
: ticket.event?.title || 'Event'}
|
||||
</h3>
|
||||
{getStatusBadge(ticket.status)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
{ticket.event?.startDatetime && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||
</span>{' '}
|
||||
{formatDate(ticket.event.startDatetime)}
|
||||
</p>
|
||||
)}
|
||||
{ticket.event?.location && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Lugar:' : 'Location:'}
|
||||
</span>{' '}
|
||||
{ticket.event.location}
|
||||
</p>
|
||||
)}
|
||||
{ticket.payment && (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Pago:' : 'Payment:'}
|
||||
</span>{' '}
|
||||
{formatCurrency(ticket.payment.amount, ticket.payment.currency)} -
|
||||
<span className={`ml-1 ${
|
||||
ticket.payment.status === 'paid' ? 'text-green-600' : 'text-yellow-600'
|
||||
}`}>
|
||||
{ticket.payment.status === 'paid'
|
||||
? (language === 'es' ? 'Pagado' : 'Paid')
|
||||
: (language === 'es' ? 'Pendiente' : 'Pending')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link href={`/booking/success/${ticket.id}`}>
|
||||
<Button size="sm" className="w-full">
|
||||
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
|
||||
</Button>
|
||||
</Link>
|
||||
{(ticket.status === 'confirmed' || ticket.status === 'checked_in') && (
|
||||
<a
|
||||
href={ticket.bookingId
|
||||
? `/api/tickets/booking/${ticket.bookingId}/pdf`
|
||||
: `/api/tickets/${ticket.id}/pdf`
|
||||
}
|
||||
download
|
||||
className="text-center"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{language === 'es' ? 'Descargar Ticket(s)' : 'Download Ticket(s)'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{ticket.invoice && (
|
||||
<a
|
||||
href={ticket.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-center"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
{language === 'es' ? 'Descargar Factura' : 'Download Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{groups.map((group) => (
|
||||
<BookingCard
|
||||
key={group.bookingId}
|
||||
group={group}
|
||||
locale={locale}
|
||||
onChange={onChange}
|
||||
onShowQr={() => setQrTickets(group.tickets)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrTickets && (
|
||||
<QrTicketModal
|
||||
tickets={qrTickets}
|
||||
locale={locale}
|
||||
onClose={() => setQrTickets(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingCard({
|
||||
group,
|
||||
locale,
|
||||
onChange,
|
||||
onShowQr,
|
||||
}: {
|
||||
group: BookingGroup;
|
||||
locale: string;
|
||||
onChange: () => void;
|
||||
onShowQr: () => void;
|
||||
}) {
|
||||
const ticket = group.tickets[0];
|
||||
const event = ticket.event;
|
||||
const title =
|
||||
(locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event';
|
||||
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
|
||||
const { amount, currency } = ticketAmount(ticket);
|
||||
const multi = group.tickets.length > 1;
|
||||
const ready = status === 'confirmed' || status === 'attended';
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
{/* Image */}
|
||||
<div className="h-32 w-full flex-shrink-0 overflow-hidden rounded-card bg-secondary-gray sm:h-24 sm:w-32">
|
||||
{event?.bannerUrl ? (
|
||||
<img src={event.bannerUrl} alt={title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<TicketIcon className="h-8 w-8 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="flex items-center gap-2 font-semibold">
|
||||
<span className="truncate">{title}</span>
|
||||
{multi && (
|
||||
<span className="whitespace-nowrap rounded-full bg-secondary-gray px-2 py-0.5 text-xs font-medium text-gray-700">
|
||||
{locale === 'es'
|
||||
? `${group.tickets.length} entradas`
|
||||
: `${group.tickets.length} tickets`}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusPill status={status} locale={locale} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
{event?.startDatetime && (
|
||||
<p className="flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
)}
|
||||
{event?.location && (
|
||||
<p className="flex items-center gap-2">
|
||||
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-500">
|
||||
{pyg(amount, currency)}
|
||||
</p>
|
||||
{isOnHold(ticket) && (
|
||||
<p className="text-slate-600">
|
||||
{locale === 'es'
|
||||
? `Tu lugar fue liberado porque el pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
|
||||
: `Your spot has been released because payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 sm:w-44">
|
||||
{isOnHold(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="stack"
|
||||
size="sm"
|
||||
onHold
|
||||
/>
|
||||
) : isUnpaid(ticket) ? (
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={title}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="stack"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{ready && (
|
||||
<Button size="sm" className="w-full" onClick={onShowQr}>
|
||||
{locale === 'es' ? 'Ver entrada' : 'View ticket'}
|
||||
</Button>
|
||||
)}
|
||||
{ready && (
|
||||
<a href={ticketPdfUrl(ticket)} download className="w-full">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ArrowDownTrayIcon className="mr-1.5 h-4 w-4" />
|
||||
{locale === 'es' ? 'Descargar' : 'Download'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{ticket.invoice && (
|
||||
<a
|
||||
href={ticket.invoice.pdfUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
{locale === 'es' ? 'Factura' : 'Invoice'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { UserTicket } from '@/lib/api';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import PayActions from './PayActions';
|
||||
import { HoldCountdown } from './Countdown';
|
||||
import {
|
||||
holdExpiry,
|
||||
ticketAmount,
|
||||
pyg,
|
||||
isAwaitingApproval,
|
||||
isOnHold,
|
||||
HOLD_THRESHOLD_HOURS,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* The very-top Overview banner. Shown only when a booking needs attention:
|
||||
* • unpaid -> red banner naming event + amount, a hold-expiry countdown,
|
||||
* and the "Pay now" / "I've paid" actions.
|
||||
* • awaiting -> calm amber "payment received, we will confirm it
|
||||
* shortly" state with no further action.
|
||||
*/
|
||||
export default function AttentionBanner({
|
||||
ticket,
|
||||
locale,
|
||||
onChange,
|
||||
}: {
|
||||
ticket: UserTicket;
|
||||
locale: string;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
const eventTitle =
|
||||
(locale === 'es' && ticket.event?.titleEs
|
||||
? ticket.event.titleEs
|
||||
: ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event');
|
||||
const { amount, currency } = ticketAmount(ticket);
|
||||
|
||||
if (isOnHold(ticket)) {
|
||||
return (
|
||||
<div className="rounded-card border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-slate-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-slate-800">
|
||||
{locale === 'es'
|
||||
? `Tu lugar para ${eventTitle} fue liberado`
|
||||
: `Your spot for ${eventTitle} was released`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-slate-600">
|
||||
{locale === 'es'
|
||||
? `El pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
|
||||
: `Payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 sm:pl-9">
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={eventTitle}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
onHold
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAwaitingApproval(ticket)) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4">
|
||||
<CheckCircleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-amber-900">
|
||||
{t('dashboard.payment.received')}
|
||||
</p>
|
||||
<p className="text-sm text-amber-800">
|
||||
{t('dashboard.payment.confirmForEvent', {
|
||||
amount: pyg(amount, currency),
|
||||
event: eventTitle,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const expiry = holdExpiry(ticket);
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-red-900">
|
||||
{locale === 'es'
|
||||
? `Debes ${pyg(amount, currency)} para ${eventTitle}`
|
||||
: `You owe ${pyg(amount, currency)} for ${eventTitle}`}
|
||||
</p>
|
||||
{expiry && (
|
||||
<p className="mt-0.5 text-sm text-red-700">
|
||||
<HoldCountdown target={expiry} locale={locale} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 sm:pl-9">
|
||||
<PayActions
|
||||
ticketId={ticket.id}
|
||||
amount={amount}
|
||||
currency={currency}
|
||||
destination={eventTitle}
|
||||
locale={locale}
|
||||
onPaid={onChange}
|
||||
layout="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Live, human countdown to a target time. Renders a short message such as
|
||||
* "Pay within 23h or your spot is released". Once the target passes it shows a
|
||||
* calm "expired" message instead of a negative timer.
|
||||
*/
|
||||
export function HoldCountdown({
|
||||
target,
|
||||
locale,
|
||||
}: {
|
||||
target: Date;
|
||||
locale: string;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const msLeft = target.getTime() - now;
|
||||
|
||||
if (msLeft <= 0) {
|
||||
return (
|
||||
<span>
|
||||
{locale === 'es'
|
||||
? 'Paga pronto para mantener tu lugar'
|
||||
: 'Pay soon to keep your spot'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const totalMinutes = Math.floor(msLeft / (60 * 1000));
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
let remaining: string;
|
||||
if (hours >= 1) {
|
||||
remaining = `${hours}h${minutes > 0 ? ` ${minutes}m` : ''}`;
|
||||
} else {
|
||||
remaining = `${minutes}m`;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{locale === 'es'
|
||||
? `Paga en ${remaining} o tu lugar se libera`
|
||||
: `Pay within ${remaining} or your spot is released`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import toast from 'react-hot-toast';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ticketsApi } from '@/lib/api';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { pyg } from './helpers';
|
||||
|
||||
interface PayActionsProps {
|
||||
ticketId: string;
|
||||
/** Amount owed, used in the confirm copy. */
|
||||
amount: number;
|
||||
currency?: string;
|
||||
/** Where the money is going — event name or account label. */
|
||||
destination: string;
|
||||
locale: string;
|
||||
/** Called after the payment is successfully marked as sent. */
|
||||
onPaid?: () => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Stack the two buttons full-width (cards) vs inline (rows). */
|
||||
layout?: 'stack' | 'inline';
|
||||
className?: string;
|
||||
/**
|
||||
* The booking's spot was released after the hold threshold passed. Hides the
|
||||
* "Pay now" link (money was already sent) and labels the retry "Rebook".
|
||||
*/
|
||||
onHold?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two unpaid-ticket actions used across Overview, Tickets and Payments:
|
||||
* • "Pay now" -> opens the existing manual-payment page (TPago / bank details)
|
||||
* • "I've paid" -> short confirm step, then marks the payment as sent
|
||||
* (moves the ticket to "Awaiting approval").
|
||||
*/
|
||||
export default function PayActions({
|
||||
ticketId,
|
||||
amount,
|
||||
currency = 'PYG',
|
||||
destination,
|
||||
locale,
|
||||
onPaid,
|
||||
size = 'sm',
|
||||
layout = 'stack',
|
||||
className,
|
||||
onHold = false,
|
||||
}: PayActionsProps) {
|
||||
const { t } = useLanguage();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [marking, setMarking] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setMarking(true);
|
||||
try {
|
||||
await ticketsApi.markPaymentSent(ticketId);
|
||||
toast.success(t('dashboard.payment.receivedConfirmSoon'));
|
||||
setConfirming(false);
|
||||
onPaid?.();
|
||||
} catch (error: any) {
|
||||
// Idempotent backend: if already marked, treat as success.
|
||||
if (error?.message?.includes('already')) {
|
||||
setConfirming(false);
|
||||
onPaid?.();
|
||||
return;
|
||||
}
|
||||
toast.error(
|
||||
error?.message ||
|
||||
(locale === 'es' ? 'No se pudo marcar el pago' : 'Could not mark payment')
|
||||
);
|
||||
} finally {
|
||||
setMarking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const containerClass =
|
||||
layout === 'stack' ? 'flex flex-col gap-2' : 'flex flex-wrap gap-2';
|
||||
const btnWidth = layout === 'stack' ? 'w-full' : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${containerClass} ${className || ''}`}>
|
||||
{!onHold && (
|
||||
<Link href={`/booking/${ticketId}`} className={btnWidth}>
|
||||
<Button size={size} className={btnWidth}>
|
||||
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant={onHold ? 'primary' : 'outline'}
|
||||
size={size}
|
||||
className={btnWidth}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{onHold
|
||||
? (locale === 'es' ? 'Reservar de nuevo' : 'Rebook')
|
||||
: (locale === 'es' ? 'Ya pagué' : "I've paid")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{confirming && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={() => !marking && setConfirming(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-card bg-white p-6 shadow-card-hover"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
|
||||
<CheckCircleIcon className="h-7 w-7 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
|
||||
{onHold
|
||||
? (locale === 'es' ? '¿Reservar de nuevo?' : 'Rebook your spot?')
|
||||
: (locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?')}
|
||||
</h3>
|
||||
<p className="mb-6 text-center text-sm text-gray-600">
|
||||
{onHold
|
||||
? (locale === 'es'
|
||||
? `Intentaremos reservar tu lugar de nuevo para ${destination}.`
|
||||
: `We'll try to re-reserve your spot for ${destination}.`)
|
||||
: (locale === 'es'
|
||||
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
|
||||
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button isLoading={marking} className="w-full" onClick={handleConfirm}>
|
||||
{onHold
|
||||
? (locale === 'es' ? 'Sí, reservar de nuevo' : 'Yes, rebook')
|
||||
: (locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={marking}
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{locale === 'es' ? 'Todavía no' : 'Not yet'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
XMarkIcon,
|
||||
ArrowDownTrayIcon,
|
||||
CalendarDaysIcon,
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
ClockIcon,
|
||||
UserPlusIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Button from '@/components/ui/Button';
|
||||
import type { UserTicket } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import {
|
||||
ticketScanUrl,
|
||||
ticketPdfUrl,
|
||||
googleCalendarUrl,
|
||||
shareTicket,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* Full-screen, scannable QR view for the door. Handles single tickets and
|
||||
* multi-ticket bookings (one QR per ticket, with "Share with a guest" on the
|
||||
* spares so guests can be let in with their own code).
|
||||
*/
|
||||
export default function QrTicketModal({
|
||||
tickets,
|
||||
locale,
|
||||
onClose,
|
||||
}: {
|
||||
tickets: UserTicket[];
|
||||
locale: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (tickets.length === 0) return null;
|
||||
|
||||
const first = tickets[0];
|
||||
const event = first.event;
|
||||
const eventTitle =
|
||||
(locale === 'es' && event?.titleEs ? event.titleEs : event?.title) || 'Event';
|
||||
const isMulti = tickets.length > 1;
|
||||
|
||||
const handleShare = async (ticketId: string) => {
|
||||
const result = await shareTicket(ticketId, eventTitle, locale);
|
||||
if (result === 'copied') {
|
||||
toast.success(locale === 'es' ? 'Enlace copiado' : 'Link copied');
|
||||
} else if (result === 'failed') {
|
||||
toast.error(locale === 'es' ? 'No se pudo compartir' : 'Could not share');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] overflow-y-auto bg-white">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-secondary-light-gray bg-white px-4 py-3">
|
||||
<span className="text-sm font-semibold text-primary-dark">
|
||||
{isMulti
|
||||
? locale === 'es'
|
||||
? `${tickets.length} entradas`
|
||||
: `${tickets.length} tickets`
|
||||
: locale === 'es'
|
||||
? 'Tu entrada'
|
||||
: 'Your ticket'}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={locale === 'es' ? 'Cerrar' : 'Close'}
|
||||
className="rounded-full p-2 text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-md px-4 pb-12 pt-6">
|
||||
{/* Event details */}
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold text-primary-dark">{eventTitle}</h2>
|
||||
{event && (
|
||||
<div className="mt-3 space-y-1.5 text-sm text-gray-600">
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{formatDateLong(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<ClockIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{formatTime(event.startDatetime, locale as 'en' | 'es')}
|
||||
</p>
|
||||
<p className="flex items-center justify-center gap-2">
|
||||
<MapPinIcon className="h-4 w-4 text-primary-yellow" />
|
||||
{event.location}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One QR per ticket */}
|
||||
<div className="space-y-5">
|
||||
{tickets.map((ticket, idx) => {
|
||||
const attendee = [ticket.attendeeFirstName, ticket.attendeeLastName]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const isSpare = isMulti && idx > 0;
|
||||
return (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className="rounded-card border border-secondary-light-gray p-5 text-center"
|
||||
>
|
||||
{isMulti && (
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
{locale === 'es' ? 'Entrada' : 'Ticket'} {idx + 1}
|
||||
{attendee ? ` • ${attendee}` : ''}
|
||||
{isSpare
|
||||
? ` • ${locale === 'es' ? 'Invitado' : 'Guest'}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
<div className="inline-block rounded-lg bg-white p-4 shadow-inner">
|
||||
<QRCodeSVG
|
||||
value={ticketScanUrl(ticket.id)}
|
||||
size={220}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wide text-primary-dark">
|
||||
{ticket.qrCode}
|
||||
</p>
|
||||
|
||||
{isSpare && (
|
||||
<button
|
||||
onClick={() => handleShare(ticket.id)}
|
||||
className="mx-auto mt-3 flex items-center gap-2 rounded-btn border border-secondary-light-gray px-4 py-2 text-sm font-medium text-primary-dark hover:bg-gray-50"
|
||||
>
|
||||
<UserPlusIcon className="h-4 w-4" />
|
||||
{locale === 'es' ? 'Compartir con un invitado' : 'Share with a guest'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Download + calendar */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<a href={ticketPdfUrl(first)} download>
|
||||
<Button className="w-full">
|
||||
<ArrowDownTrayIcon className="mr-2 h-5 w-5" />
|
||||
{isMulti
|
||||
? locale === 'es'
|
||||
? 'Descargar entradas'
|
||||
: 'Download tickets'
|
||||
: locale === 'es'
|
||||
? 'Descargar'
|
||||
: 'Download'}
|
||||
</Button>
|
||||
</a>
|
||||
{event && (
|
||||
<a
|
||||
href={googleCalendarUrl(event, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" className="w-full">
|
||||
<CalendarDaysIcon className="mr-2 h-5 w-5" />
|
||||
{locale === 'es' ? 'Agregar al calendario' : 'Add to calendar'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { UserTicket, Event } from '@/lib/api';
|
||||
import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
|
||||
// Hours a booking holds a seat before the spot is released. There is no
|
||||
// server-side enforcement field, so the countdown is derived from when the
|
||||
// ticket was created, capped at the event start time.
|
||||
export const PAYMENT_HOLD_HOURS = 24;
|
||||
|
||||
// Hours a pending-approval booking (payment already marked as sent) can wait
|
||||
// for admin review before the auto-hold sweep releases the spot. Mirrors the
|
||||
// backend's HOLD_THRESHOLD_HOURS env default - keep these in sync.
|
||||
export const HOLD_THRESHOLD_HOURS = 72;
|
||||
|
||||
/** True once a booking has been auto-released after the approval hold window. */
|
||||
export function isOnHold(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
|
||||
return ticket.status === 'on_hold' || ticket.payment?.status === 'on_hold';
|
||||
}
|
||||
|
||||
/** Currency is Guarani with no decimals, e.g. "21 PYG". */
|
||||
export function pyg(amount: number, currency: string = 'PYG'): string {
|
||||
return formatPrice(Number(amount) || 0, currency || 'PYG');
|
||||
}
|
||||
|
||||
export function isManualProvider(provider?: string): boolean {
|
||||
return provider === 'bank_transfer' || provider === 'tpago';
|
||||
}
|
||||
|
||||
/** A ticket is unpaid when its payment is still pending (not approved/paid). */
|
||||
export function isUnpaid(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
|
||||
const ps = ticket.payment?.status;
|
||||
return ticket.status !== 'cancelled' && (ps === 'pending' || ps === 'failed');
|
||||
}
|
||||
|
||||
export function isAwaitingApproval(ticket: { payment?: { status?: string } | null }): boolean {
|
||||
return ticket.payment?.status === 'pending_approval';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an attention banner (e.g. "your spot was released", "payment pending")
|
||||
* is still worth showing to the user. It only makes sense to nudge the user when
|
||||
* the event still exists, is still bookable (published/unlisted), and has not
|
||||
* already ended. This suppresses stale banners for deleted, unpublished,
|
||||
* cancelled/completed/archived, or past events.
|
||||
*/
|
||||
export function isActionableAttention(ticket: UserTicket): boolean {
|
||||
const event = ticket.event;
|
||||
if (!event) return false;
|
||||
if (event.status !== 'published' && event.status !== 'unlisted') return false;
|
||||
const refDate = event.endDatetime || event.startDatetime;
|
||||
if (!refDate) return false;
|
||||
return parseDate(refDate).getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment the seat hold expires. null when paid/awaiting/cancelled or when
|
||||
* the data needed to compute it is missing.
|
||||
*/
|
||||
export function holdExpiry(ticket: UserTicket): Date | null {
|
||||
if (!isUnpaid(ticket)) return null;
|
||||
const createdStr = ticket.payment?.createdAt || ticket.createdAt;
|
||||
if (!createdStr) return null;
|
||||
const created = parseDate(createdStr);
|
||||
let expiry = new Date(created.getTime() + PAYMENT_HOLD_HOURS * 60 * 60 * 1000);
|
||||
// Never hold past the event itself.
|
||||
const eventStart = ticket.event?.startDatetime ? parseDate(ticket.event.startDatetime) : null;
|
||||
if (eventStart && eventStart < expiry) expiry = eventStart;
|
||||
return expiry;
|
||||
}
|
||||
|
||||
/** Amount owed for a ticket (payment amount preferred, falls back to event price). */
|
||||
export function ticketAmount(ticket: UserTicket): { amount: number; currency: string } {
|
||||
const amount = ticket.payment?.amount ?? ticket.event?.price ?? 0;
|
||||
const currency = ticket.payment?.currency ?? ticket.event?.currency ?? 'PYG';
|
||||
return { amount: Number(amount) || 0, currency };
|
||||
}
|
||||
|
||||
export interface BookingGroup {
|
||||
bookingId: string;
|
||||
tickets: UserTicket[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tickets that belong to the same booking (multi-ticket / guest tickets).
|
||||
* Tickets without a bookingId become single-ticket groups keyed by their id.
|
||||
* Order of first appearance is preserved.
|
||||
*/
|
||||
export function groupByBooking(tickets: UserTicket[]): BookingGroup[] {
|
||||
const groups = new Map<string, UserTicket[]>();
|
||||
const order: string[] = [];
|
||||
for (const ticket of tickets) {
|
||||
const key = ticket.bookingId || ticket.id;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
order.push(key);
|
||||
}
|
||||
groups.get(key)!.push(ticket);
|
||||
}
|
||||
return order.map((bookingId) => ({ bookingId, tickets: groups.get(bookingId)! }));
|
||||
}
|
||||
|
||||
/** Public URL embedded in the scannable QR (matches the PDF + scanner contract). */
|
||||
export function ticketScanUrl(ticketId: string): string {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `${origin}/ticket/${ticketId}`;
|
||||
}
|
||||
|
||||
/** Download URL for a ticket / whole booking PDF. */
|
||||
export function ticketPdfUrl(ticket: UserTicket): string {
|
||||
return ticket.bookingId
|
||||
? `/api/tickets/booking/${ticket.bookingId}/pdf`
|
||||
: `/api/tickets/${ticket.id}/pdf`;
|
||||
}
|
||||
|
||||
/** Build a Google Calendar "add event" URL — no backend needed. */
|
||||
export function googleCalendarUrl(event: Event, locale: string): string {
|
||||
const start = parseDate(event.startDatetime);
|
||||
const end = event.endDatetime
|
||||
? parseDate(event.endDatetime)
|
||||
: new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
const fmt = (d: Date) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
const title = (locale === 'es' && event.titleEs ? event.titleEs : event.title) || 'Spanglish';
|
||||
const params = new URLSearchParams({
|
||||
action: 'TEMPLATE',
|
||||
text: title,
|
||||
dates: `${fmt(start)}/${fmt(end)}`,
|
||||
location: event.location || '',
|
||||
ctz: EVENT_TIMEZONE,
|
||||
});
|
||||
return `https://www.google.com/calendar/render?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a single (spare) ticket's QR with a guest. Uses the Web Share API when
|
||||
* available, otherwise copies the ticket link to the clipboard.
|
||||
*/
|
||||
export async function shareTicket(
|
||||
ticketId: string,
|
||||
eventTitle: string,
|
||||
locale: string
|
||||
): Promise<'shared' | 'copied' | 'failed'> {
|
||||
const url = ticketScanUrl(ticketId);
|
||||
const text =
|
||||
locale === 'es'
|
||||
? `Tu entrada para ${eventTitle}`
|
||||
: `Your ticket for ${eventTitle}`;
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && (navigator as any).share) {
|
||||
await (navigator as any).share({ title: eventTitle, text, url });
|
||||
return 'shared';
|
||||
}
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
return 'copied';
|
||||
}
|
||||
return 'failed';
|
||||
} catch (err: any) {
|
||||
// User cancelled the native share sheet.
|
||||
if (err?.name === 'AbortError') return 'shared';
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the event's calendar date is today (Asunción time). */
|
||||
export function isToday(dateStr?: string): boolean {
|
||||
if (!dateStr) return false;
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: EVENT_TIMEZONE,
|
||||
};
|
||||
const fmt = (d: Date) => d.toLocaleDateString('en-CA', opts);
|
||||
return fmt(parseDate(dateStr)) === fmt(new Date());
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import type { UserTicket, Payment } from '@/lib/api';
|
||||
|
||||
// One logical status per meaning, mapped to a single pale colour everywhere.
|
||||
// confirmed -> pale green
|
||||
// awaiting -> pale amber (user said they paid, admin checking)
|
||||
// unpaid -> pale red
|
||||
// attended -> pale blue (replaces the raw "checked_in" value)
|
||||
// cancelled -> pale gray
|
||||
// onHold -> pale slate (spot released after the payment deadline passed)
|
||||
export type DashStatus =
|
||||
| 'confirmed'
|
||||
| 'awaiting'
|
||||
| 'unpaid'
|
||||
| 'attended'
|
||||
| 'cancelled'
|
||||
| 'onHold';
|
||||
|
||||
/**
|
||||
* Collapse a ticket status + payment status into a single user-facing status.
|
||||
* Never surface raw values like "checked_in" or "pending_approval".
|
||||
*/
|
||||
export function deriveTicketStatus(
|
||||
ticketStatus?: string,
|
||||
paymentStatus?: string
|
||||
): DashStatus {
|
||||
if (ticketStatus === 'checked_in') return 'attended';
|
||||
if (ticketStatus === 'cancelled') return 'cancelled';
|
||||
if (ticketStatus === 'on_hold' || paymentStatus === 'on_hold') return 'onHold';
|
||||
if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed';
|
||||
if (paymentStatus === 'pending_approval') return 'awaiting';
|
||||
return 'unpaid';
|
||||
}
|
||||
|
||||
/** Status for a standalone payment record (Payments tab). */
|
||||
export function derivePaymentStatus(paymentStatus?: string): DashStatus {
|
||||
if (paymentStatus === 'paid') return 'confirmed';
|
||||
if (paymentStatus === 'pending_approval') return 'awaiting';
|
||||
if (paymentStatus === 'on_hold') return 'onHold';
|
||||
if (paymentStatus === 'refunded') return 'cancelled';
|
||||
return 'unpaid';
|
||||
}
|
||||
|
||||
export function statusLabel(status: DashStatus, locale: string): string {
|
||||
const labels: Record<DashStatus, { en: string; es: string }> = {
|
||||
confirmed: { en: 'Confirmed', es: 'Confirmado' },
|
||||
awaiting: { en: 'Awaiting approval', es: 'Esperando aprobación' },
|
||||
unpaid: { en: 'Unpaid', es: 'No pagado' },
|
||||
attended: { en: 'Attended', es: 'Asistió' },
|
||||
cancelled: { en: 'Cancelled', es: 'Cancelado' },
|
||||
onHold: { en: 'On Hold', es: 'En Espera' },
|
||||
};
|
||||
return locale === 'es' ? labels[status].es : labels[status].en;
|
||||
}
|
||||
|
||||
const PILL_STYLES: Record<DashStatus, string> = {
|
||||
confirmed: 'bg-green-100 text-green-800',
|
||||
awaiting: 'bg-amber-100 text-amber-800',
|
||||
unpaid: 'bg-red-100 text-red-700',
|
||||
attended: 'bg-blue-100 text-blue-800',
|
||||
cancelled: 'bg-gray-100 text-gray-600',
|
||||
onHold: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
export function StatusPill({
|
||||
status,
|
||||
locale,
|
||||
className,
|
||||
}: {
|
||||
status: DashStatus;
|
||||
locale: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap',
|
||||
PILL_STYLES[status],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{statusLabel(status, locale)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convenience: derive + render a pill straight from a ticket. */
|
||||
export function TicketStatusPill({
|
||||
ticket,
|
||||
locale,
|
||||
className,
|
||||
}: {
|
||||
ticket: Pick<UserTicket, 'status'> & { payment?: Pick<Payment, 'status'> | null };
|
||||
locale: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const status = deriveTicketStatus(ticket.status, ticket.payment?.status);
|
||||
return <StatusPill status={status} locale={locale} className={className} />;
|
||||
}
|
||||
@@ -4,34 +4,28 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { dashboardApi, DashboardSummary, NextEventInfo, UserTicket, UserPayment } from '@/lib/api';
|
||||
import { formatDateLong, formatTime } from '@/lib/utils';
|
||||
import {
|
||||
dashboardApi,
|
||||
NextEventInfo,
|
||||
UserTicket,
|
||||
UserPayment,
|
||||
} from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
socialConfig,
|
||||
getWhatsAppUrl,
|
||||
getInstagramUrl,
|
||||
getTelegramUrl
|
||||
} from '@/lib/socialLinks';
|
||||
import { CardListSkeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
// Tab components
|
||||
import OverviewTab from './components/OverviewTab';
|
||||
import TicketsTab from './components/TicketsTab';
|
||||
import PaymentsTab from './components/PaymentsTab';
|
||||
import ProfileTab from './components/ProfileTab';
|
||||
import SecurityTab from './components/SecurityTab';
|
||||
import AccountTab from './components/AccountTab';
|
||||
|
||||
type Tab = 'overview' | 'tickets' | 'payments' | 'profile' | 'security';
|
||||
type Tab = 'overview' | 'tickets' | 'payments' | 'account';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { t, locale: language } = useLanguage();
|
||||
const { user, isLoading: authLoading, token } = useAuth();
|
||||
|
||||
const { locale } = useLanguage();
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
|
||||
const [tickets, setTickets] = useState<UserTicket[]>([]);
|
||||
const [payments, setPayments] = useState<UserPayment[]>([]);
|
||||
@@ -42,369 +36,99 @@ export default function DashboardPage() {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user && token) {
|
||||
// Auth rides on the httpOnly session cookie; once the user has resolved
|
||||
// the API calls are authenticated automatically.
|
||||
if (user) {
|
||||
loadDashboardData();
|
||||
}
|
||||
}, [user, authLoading, token]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, authLoading]);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [summaryRes, nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
|
||||
dashboardApi.getSummary(),
|
||||
const [nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
|
||||
dashboardApi.getNextEvent(),
|
||||
dashboardApi.getTickets(),
|
||||
dashboardApi.getPayments(),
|
||||
]);
|
||||
|
||||
setSummary(summaryRes.summary);
|
||||
setNextEvent(nextEventRes.nextEvent);
|
||||
setTickets(ticketsRes.tickets);
|
||||
setPayments(paymentsRes.payments);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error);
|
||||
toast.error('Failed to load dashboard data');
|
||||
toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: language === 'es' ? 'Resumen' : 'Overview' },
|
||||
{ id: 'tickets', label: language === 'es' ? 'Mis Entradas' : 'My Tickets' },
|
||||
{ id: 'payments', label: language === 'es' ? 'Pagos y Facturas' : 'Payments & Invoices' },
|
||||
{ id: 'profile', label: language === 'es' ? 'Perfil' : 'Profile' },
|
||||
{ id: 'security', label: language === 'es' ? 'Seguridad' : 'Security' },
|
||||
const tabs: { id: Tab; label: { en: string; es: string } }[] = [
|
||||
{ id: 'overview', label: { en: 'Overview', es: 'Resumen' } },
|
||||
{ id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } },
|
||||
{ id: 'payments', label: { en: 'Payments', es: 'Pagos' } },
|
||||
{ id: 'account', label: { en: 'Account', es: 'Cuenta' } },
|
||||
];
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="section-padding min-h-[70vh] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary-blue"></div>
|
||||
<div className="section-padding flex min-h-[70vh] items-center justify-center">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-primary-yellow" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, language as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, language as 'en' | 'es');
|
||||
|
||||
return (
|
||||
<div className="section-padding min-h-[70vh]">
|
||||
<div className="container-page">
|
||||
{/* Welcome Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">
|
||||
{language === 'es' ? `Hola, ${user.name}!` : `Welcome, ${user.name}!`}
|
||||
</h1>
|
||||
{summary && (
|
||||
<p className="text-gray-600">
|
||||
{language === 'es'
|
||||
? `Miembro desde hace ${summary.user.membershipDays} días`
|
||||
: `Member for ${summary.user.membershipDays} days`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Welcome header */}
|
||||
<h1 className="mb-6 text-3xl font-bold">
|
||||
{locale === 'es' ? `¡Hola, ${user.name}!` : `Welcome, ${user.name}!`}
|
||||
</h1>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="flex gap-4 -mb-px overflow-x-auto">
|
||||
{/* Tab navigation */}
|
||||
<div className="mb-6 border-b border-secondary-light-gray">
|
||||
<nav className="-mb-px flex gap-6 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
className={`whitespace-nowrap border-b-2 px-1 pb-3 text-sm font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-secondary-blue text-secondary-blue'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary-yellow text-primary-dark'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{locale === 'es' ? tab.label.es : tab.label.en}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{/* Tab content */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-secondary-blue"></div>
|
||||
</div>
|
||||
<CardListSkeleton count={3} />
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
summary={summary}
|
||||
nextEvent={nextEvent}
|
||||
tickets={tickets}
|
||||
language={language}
|
||||
formatDate={formatDate}
|
||||
formatTime={formatTime}
|
||||
locale={locale}
|
||||
userName={user.name}
|
||||
onChange={loadDashboardData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'tickets' && (
|
||||
<TicketsTab tickets={tickets} language={language} />
|
||||
<TicketsTab tickets={tickets} language={locale} onChange={loadDashboardData} />
|
||||
)}
|
||||
|
||||
{activeTab === 'payments' && (
|
||||
<PaymentsTab payments={payments} language={language} />
|
||||
)}
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<ProfileTab onUpdate={loadDashboardData} />
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<SecurityTab />
|
||||
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
|
||||
)}
|
||||
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Overview Tab Component
|
||||
function OverviewTab({
|
||||
summary,
|
||||
nextEvent,
|
||||
tickets,
|
||||
language,
|
||||
formatDate,
|
||||
formatTime,
|
||||
}: {
|
||||
summary: DashboardSummary | null;
|
||||
nextEvent: NextEventInfo | null;
|
||||
tickets: UserTicket[];
|
||||
language: string;
|
||||
formatDate: (date: string) => string;
|
||||
formatTime: (date: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-secondary-blue">{summary.stats.totalTickets}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'Total Entradas' : 'Total Tickets'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{summary.stats.confirmedTickets}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'Confirmadas' : 'Confirmed'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{summary.stats.upcomingEvents}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'Próximos' : 'Upcoming'}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-orange-500">{summary.stats.pendingPayments}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{language === 'es' ? 'Pagos Pendientes' : 'Pending Payments'}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Event Card */}
|
||||
{nextEvent ? (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Tu Próximo Evento' : 'Your Next Event'}
|
||||
</h3>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{nextEvent.event.bannerUrl && (
|
||||
<div className="w-full md:w-48 h-32 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={nextEvent.event.bannerUrl}
|
||||
alt={nextEvent.event.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-xl font-bold mb-2">
|
||||
{language === 'es' && nextEvent.event.titleEs
|
||||
? nextEvent.event.titleEs
|
||||
: nextEvent.event.title}
|
||||
</h4>
|
||||
<div className="space-y-2 text-gray-600">
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Fecha:' : 'Date:'}
|
||||
</span>{' '}
|
||||
{formatDate(nextEvent.event.startDatetime)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Hora:' : 'Time:'}
|
||||
</span>{' '}
|
||||
{formatTime(nextEvent.event.startDatetime)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Lugar:' : 'Location:'}
|
||||
</span>{' '}
|
||||
{nextEvent.event.location}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{language === 'es' ? 'Estado:' : 'Status:'}
|
||||
</span>{' '}
|
||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
|
||||
nextEvent.payment?.status === 'paid'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{nextEvent.payment?.status === 'paid'
|
||||
? (language === 'es' ? 'Pagado' : 'Paid')
|
||||
: (language === 'es' ? 'Pendiente' : 'Pending')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link href={`/booking/success/${nextEvent.ticket.id}`}>
|
||||
<Button size="sm">
|
||||
{language === 'es' ? 'Ver Entrada' : 'View Ticket'}
|
||||
</Button>
|
||||
</Link>
|
||||
{nextEvent.event.locationUrl && (
|
||||
<a
|
||||
href={nextEvent.event.locationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{language === 'es' ? 'Ver Mapa' : 'View Map'}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-6 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
{language === 'es'
|
||||
? 'No tienes eventos próximos'
|
||||
: 'You have no upcoming events'}
|
||||
</p>
|
||||
<Link href="/events">
|
||||
<Button>
|
||||
{language === 'es' ? 'Explorar Eventos' : 'Explore Events'}
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Tickets */}
|
||||
{tickets.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Entradas Recientes' : 'Recent Tickets'}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{tickets.slice(0, 3).map((ticket) => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{language === 'es' && ticket.event?.titleEs
|
||||
? ticket.event.titleEs
|
||||
: ticket.event?.title || 'Event'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{ticket.event?.startDatetime
|
||||
? formatDate(ticket.event.startDatetime)
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
ticket.status === 'confirmed' || ticket.status === 'checked_in'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: ticket.status === 'cancelled'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tickets.length > 3 && (
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" size="sm" onClick={() => {}}>
|
||||
{language === 'es' ? 'Ver Todas' : 'View All'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Community Links */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{language === 'es' ? 'Comunidad' : 'Community'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{getWhatsAppUrl(socialConfig.whatsapp) && (
|
||||
<a
|
||||
href={getWhatsAppUrl(socialConfig.whatsapp)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-green-50 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium">WhatsApp</span>
|
||||
</a>
|
||||
)}
|
||||
{getInstagramUrl(socialConfig.instagram) && (
|
||||
<a
|
||||
href={getInstagramUrl(socialConfig.instagram)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-pink-50 rounded-lg hover:bg-pink-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium">Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
{getTelegramUrl(socialConfig.telegram) && (
|
||||
<a
|
||||
href={getTelegramUrl(socialConfig.telegram)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-medium">Telegram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Receives the event list already fetched on the server so event titles, dates,
|
||||
// and locations are present in the initial HTML. The upcoming/past filter below
|
||||
// is client-side interactivity layered on top of the server-rendered data.
|
||||
export default function EventsClient({ initialEvents }: { initialEvents: Event[] }) {
|
||||
const { t, locale } = useLanguage();
|
||||
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
|
||||
|
||||
const now = new Date();
|
||||
const upcomingEvents = initialEvents.filter(e =>
|
||||
e.status === 'published' && new Date(e.startDatetime) >= now
|
||||
);
|
||||
const pastEvents = initialEvents.filter(e =>
|
||||
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
|
||||
);
|
||||
|
||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||
|
||||
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const getStatusBadge = (event: Event) => {
|
||||
if (event.status === 'cancelled') {
|
||||
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
|
||||
}
|
||||
if (event.availableSeats === 0) {
|
||||
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<h1 className="section-title">{t('events.title')}</h1>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="mt-8 flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('upcoming')}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-btn font-medium transition-colors',
|
||||
filter === 'upcoming'
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{t('events.upcoming')} ({upcomingEvents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('past')}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-btn font-medium transition-colors',
|
||||
filter === 'past'
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{t('events.past')} ({pastEvents.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Events grid */}
|
||||
<div className="mt-8">
|
||||
{displayedEvents.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-lg">{t('events.noEvents')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayedEvents.map((event) => (
|
||||
<Link key={event.id} href={`/events/${event.slug}`} className="block">
|
||||
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
|
||||
{/* Event banner */}
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||
className="h-40 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
|
||||
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-lg text-primary-dark">
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h3>
|
||||
{getStatusBadge(event)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="w-4 h-4" />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</div>
|
||||
{!event.externalBookingEnabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="w-4 h-4" />
|
||||
<span>
|
||||
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<span className="font-bold text-xl text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: formatPrice(event.price, event.currency)}
|
||||
</span>
|
||||
<Button size="sm">
|
||||
{t('common.moreInfo')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
@@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
.catch(console.error);
|
||||
}, [eventId]);
|
||||
|
||||
// Spots left: never negative; sold out when confirmed >= capacity
|
||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||||
// Server-authoritative availability (paid + claimed seats count; abandoned
|
||||
// pending bookings don't) — matches the booking API's sold-out check exactly.
|
||||
const spotsLeft = eventSpotsLeft(event);
|
||||
const isSoldOut = isEventSoldOut(event);
|
||||
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||
import GalleryClient from '@/app/(public)/photos/[slug]/GalleryClient';
|
||||
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
interface PageProps {
|
||||
params: { id: string }; // event slug, same param name as the parent route
|
||||
}
|
||||
|
||||
// Public event galleries render server-side; link/ticket galleries return
|
||||
// null here and are fetched client-side with the viewer's token.
|
||||
async function getEventGallery(
|
||||
eventSlug: string
|
||||
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${photoApiUrl}/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery`,
|
||||
{ next: { revalidate: 300 } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const data = await getEventGallery(params.id);
|
||||
if (!data) {
|
||||
return { title: 'Event Photos', robots: { index: false } };
|
||||
}
|
||||
return {
|
||||
title: `${data.gallery.title} – Photos`,
|
||||
description:
|
||||
data.gallery.description ||
|
||||
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventGalleryPage({ params }: PageProps) {
|
||||
const initial = await getEventGallery(params.id);
|
||||
return (
|
||||
// Suspense boundary required by useSearchParams() in the client child.
|
||||
<Suspense>
|
||||
<GalleryClient eventSlug={params.id} initial={initial} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Skeleton, ArticleSkeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
// Route-level skeleton for the event detail page: back link, banner, article
|
||||
// body on the left and the booking card in the sidebar.
|
||||
export default function EventDetailLoading() {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<ArticleSkeleton withBanner />
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-white rounded-card shadow-card p-6" aria-hidden="true">
|
||||
<div className="text-center">
|
||||
<Skeleton className="h-4 w-16 mx-auto" />
|
||||
<Skeleton className="mt-3 h-10 w-32 mx-auto" />
|
||||
</div>
|
||||
<Skeleton className="mt-6 h-12 w-full rounded-btn" />
|
||||
<Skeleton className="mt-3 h-4 w-3/4 mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,13 +117,18 @@ function generateEventJsonLd(event: Event) {
|
||||
'@type': 'Offer',
|
||||
price: event.price,
|
||||
priceCurrency: event.currency,
|
||||
availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0
|
||||
availability:
|
||||
(typeof event.availableSeats === 'number'
|
||||
? event.availableSeats
|
||||
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
validFrom: new Date().toISOString(),
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
image: event.bannerUrl
|
||||
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
|
||||
: `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
// Note: the page title for the listing lives on events/page.tsx, not here. A
|
||||
// plain-string title in this layout would reset the root title template for the
|
||||
// child /events/[id] route, stripping the brand suffix from event detail titles.
|
||||
export const metadata: Metadata = {
|
||||
title: 'Upcoming Language Exchange Events in Asunción',
|
||||
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
|
||||
alternates: {
|
||||
canonical: '/events',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Upcoming Language Exchange Events in Asunción – Spanglish',
|
||||
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Skeleton, EventGridSkeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
// Route-level skeleton shown during navigation while the server fetches the
|
||||
// event list. Mirrors the EventsClient shell: title, filter tabs, card grid.
|
||||
export default function EventsLoading() {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<div className="mt-8 flex gap-2">
|
||||
<Skeleton className="h-10 w-32 rounded-btn" />
|
||||
<Skeleton className="h-10 w-28 rounded-btn" />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<EventGridSkeleton count={6} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +1,29 @@
|
||||
'use client';
|
||||
import type { Metadata } from 'next';
|
||||
import { Event } from '@/lib/api';
|
||||
import EventsClient from './EventsClient';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function EventsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
|
||||
// Listing title lives here (not in the layout) so the root title template still
|
||||
// applies to the sibling /events/[id] detail route. Picks up "%s – Spanglish".
|
||||
export const metadata: Metadata = {
|
||||
title: 'Upcoming Language Exchange Events in Asunción',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
eventsApi.getAll()
|
||||
.then(({ events }) => setEvents(events))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
// Fetch the public (published) event list on the server so event titles, dates,
|
||||
// and locations appear in the initial HTML rather than only after JS runs.
|
||||
async function getEvents(): Promise<Event[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/events`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.events || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const upcomingEvents = events.filter(e =>
|
||||
e.status === 'published' && new Date(e.startDatetime) >= now
|
||||
);
|
||||
const pastEvents = events.filter(e =>
|
||||
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
|
||||
);
|
||||
|
||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||
|
||||
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const getStatusBadge = (event: Event) => {
|
||||
if (event.status === 'cancelled') {
|
||||
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
|
||||
}
|
||||
if (event.availableSeats === 0) {
|
||||
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<h1 className="section-title">{t('events.title')}</h1>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="mt-8 flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('upcoming')}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-btn font-medium transition-colors',
|
||||
filter === 'upcoming'
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{t('events.upcoming')} ({upcomingEvents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('past')}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-btn font-medium transition-colors',
|
||||
filter === 'past'
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
|
||||
)}
|
||||
>
|
||||
{t('events.past')} ({pastEvents.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Events grid */}
|
||||
<div className="mt-8">
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
|
||||
</div>
|
||||
) : displayedEvents.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-lg">{t('events.noEvents')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayedEvents.map((event) => (
|
||||
<Link key={event.id} href={`/events/${event.slug}`} className="block">
|
||||
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
|
||||
{/* Event banner */}
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={`${event.title} - Spanglish language exchange event in Asunción`}
|
||||
className="h-40 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
|
||||
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-lg text-primary-dark">
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h3>
|
||||
{getStatusBadge(event)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPinIcon className="w-4 h-4" />
|
||||
<span className="truncate">{event.location}</span>
|
||||
</div>
|
||||
{!event.externalBookingEnabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserGroupIcon className="w-4 h-4" />
|
||||
<span>
|
||||
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<span className="font-bold text-xl text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: formatPrice(event.price, event.currency)}
|
||||
</span>
|
||||
<Button size="sm">
|
||||
{t('common.moreInfo')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default async function EventsPage() {
|
||||
const events = await getEvents();
|
||||
return <EventsClient initialEvents={events} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { FaqItem } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Receives the FAQ list already fetched on the server so the questions and
|
||||
// answers are present in the initial HTML for crawlers. The accordion below is
|
||||
// purely a visual toggle; the answer text stays in the DOM either way.
|
||||
export default function FaqClient({ initialFaqs }: { initialFaqs: FaqItem[] }) {
|
||||
const { locale } = useLanguage();
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
const toggleFAQ = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-primary-dark mb-4">
|
||||
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
|
||||
: 'Find answers to the most common questions about Spanglish'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{initialFaqs.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'No hay preguntas frecuentes publicadas en este momento.'
|
||||
: 'No FAQ questions are published at the moment.'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{initialFaqs.map((faq, index) => (
|
||||
<Card key={faq.id} className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleFAQ(index)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-primary-dark pr-4">
|
||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||
openIndex === index && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
openIndex === index ? 'max-h-96' : 'max-h-0'
|
||||
)}
|
||||
>
|
||||
<div className="px-6 pb-4 text-gray-600">
|
||||
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
|
||||
<h2 className="text-xl font-semibold text-primary-dark mb-2">
|
||||
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{locale === 'es'
|
||||
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
|
||||
: "Don't hesitate to reach out. We're here to help!"}
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ async function getFaqForSchema(): Promise<{ question: string; answer: string }[]
|
||||
export const metadata: Metadata = {
|
||||
title: 'Frequently Asked Questions',
|
||||
description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.',
|
||||
alternates: {
|
||||
canonical: '/faq',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Frequently Asked Questions – Spanglish',
|
||||
description: 'Find answers to common questions about Spanglish language exchange events in Asunción.',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Skeleton, FaqListSkeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
// Route-level skeleton for the FAQ page: centered header + accordion rows.
|
||||
export default function FaqLoading() {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl">
|
||||
<div className="text-center mb-12">
|
||||
<Skeleton className="h-10 w-80 max-w-full mx-auto" />
|
||||
<Skeleton className="mt-4 h-4 w-96 max-w-full mx-auto" />
|
||||
</div>
|
||||
<FaqListSkeleton count={6} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +1,22 @@
|
||||
'use client';
|
||||
import { FaqItem } from '@/lib/api';
|
||||
import FaqClient from './FaqClient';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItem } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function FAQPage() {
|
||||
const { locale } = useLanguage();
|
||||
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
faqApi.getList().then((res) => {
|
||||
if (!cancelled) {
|
||||
setFaqs(res.faqs);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const toggleFAQ = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl flex justify-center py-20">
|
||||
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Fetch the published FAQ list on the server so the questions and answers are
|
||||
// rendered into the initial HTML (crawlers see the content without running JS).
|
||||
async function getFaqs(): Promise<FaqItem[]> {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.faqs || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-3xl">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-primary-dark mb-4">
|
||||
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
|
||||
: 'Find answers to the most common questions about Spanglish'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{faqs.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'No hay preguntas frecuentes publicadas en este momento.'
|
||||
: 'No FAQ questions are published at the moment.'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={faq.id} className="overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleFAQ(index)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-primary-dark pr-4">
|
||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||
openIndex === index && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
openIndex === index ? 'max-h-96' : 'max-h-0'
|
||||
)}
|
||||
>
|
||||
<div className="px-6 pb-4 text-gray-600">
|
||||
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
|
||||
<h2 className="text-xl font-semibold text-primary-dark mb-2">
|
||||
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{locale === 'es'
|
||||
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
|
||||
: "Don't hesitate to reach out. We're here to help!"}
|
||||
</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default async function FAQPage() {
|
||||
const faqs = await getFaqs();
|
||||
return <FaqClient initialFaqs={faqs} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Skeleton, SkeletonGroup, SkeletonText } from '@/components/ui/Skeleton';
|
||||
|
||||
// Route-level skeleton for legal pages: back link, bordered header, prose body.
|
||||
export default function LegalPageLoading() {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-4xl">
|
||||
<SkeletonGroup>
|
||||
<Skeleton className="h-5 w-32 mb-8" />
|
||||
<div className="mb-8 pb-6 border-b border-gray-200">
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="mt-3 h-4 w-44" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<SkeletonText lines={4} />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<SkeletonText lines={5} />
|
||||
<Skeleton className="h-6 w-2/5" />
|
||||
<SkeletonText lines={4} />
|
||||
</div>
|
||||
</SkeletonGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,8 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${legalPage.title} – Spanglish`,
|
||||
// The root layout's title template appends " – Spanglish"; do not repeat it here.
|
||||
title: legalPage.title,
|
||||
description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`,
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -59,7 +59,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
if (!event) {
|
||||
return {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
// Title already carries the brand, so bypass the "%s – Spanglish"
|
||||
// template to avoid doubling it.
|
||||
title: { absolute: 'Spanglish – Language Exchange Events in Asunción' },
|
||||
description:
|
||||
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
|
||||
};
|
||||
@@ -76,7 +78,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const description = `Next event: ${eventDate} – ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
|
||||
|
||||
return {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
// Title already carries the brand, so bypass the "%s – Spanglish"
|
||||
// template to avoid doubling it.
|
||||
title: { absolute: 'Spanglish – Language Exchange Events in Asunción' },
|
||||
description,
|
||||
openGraph: {
|
||||
title: 'Spanglish – Language Exchange Events in Asunción',
|
||||
@@ -142,7 +146,9 @@ function generateNextEventJsonLd(event: NextEvent) {
|
||||
: 'https://schema.org/SoldOut',
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
},
|
||||
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
|
||||
image: event.bannerUrl
|
||||
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
|
||||
: `${siteUrl}/images/og-image.jpg`,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import type { PhotoGallery } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { CameraIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) {
|
||||
const { locale } = useLanguage();
|
||||
const es = locale === 'es';
|
||||
|
||||
const formatDate = (iso?: string) => {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<h1 className="section-title text-center mb-2">
|
||||
{es ? 'Galerías de Fotos' : 'Photo Galleries'}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 mb-10">
|
||||
{es
|
||||
? 'Recuerdos de nuestros intercambios de idiomas en Asunción'
|
||||
: 'Memories from our language exchanges in Asunción'}
|
||||
</p>
|
||||
|
||||
{galleries.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">
|
||||
{es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{galleries.map((g) => (
|
||||
// Event-linked galleries live under the event's URL.
|
||||
<Link key={g.id} href={g.event ? `/events/${g.event.slug}/gallery` : `/photos/${g.slug}`}>
|
||||
<Card variant="elevated" className="overflow-hidden hover:shadow-card-hover transition-shadow h-full">
|
||||
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||
{g.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<CameraIcon className="w-12 h-12 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h2 className="font-heading font-semibold text-lg text-primary-dark">
|
||||
{es && g.titleEs ? g.titleEs : g.title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{g.photoCount} {es ? 'fotos' : 'photos'}
|
||||
{g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}</>}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||
import Lightbox from '@/components/Lightbox';
|
||||
import LoginModal from '@/components/LoginModal';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
CalendarIcon,
|
||||
CameraIcon,
|
||||
LinkIcon,
|
||||
LockClosedIcon,
|
||||
TicketIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface GalleryClientProps {
|
||||
/** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */
|
||||
slug?: string;
|
||||
/** … or by event slug (event galleries at /events/[slug]/gallery). */
|
||||
eventSlug?: string;
|
||||
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
|
||||
}
|
||||
|
||||
type DeniedState = 'login' | 'ticket' | 'private' | 'link' | 'notfound' | null;
|
||||
|
||||
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
|
||||
const { locale } = useLanguage();
|
||||
const es = locale === 'es';
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const shareToken = searchParams.get('token') || undefined;
|
||||
|
||||
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
|
||||
const [photos, setPhotos] = useState<Photo[]>(initial?.photos ?? []);
|
||||
const [loading, setLoading] = useState(!initial);
|
||||
const [denied, setDenied] = useState<DeniedState>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
// Server-rendered public galleries need no client fetch. Everything else
|
||||
// (link/ticket/private) is fetched here with the share token and/or the
|
||||
// viewer's own auth token attached.
|
||||
useEffect(() => {
|
||||
if (initial) return;
|
||||
if (authLoading) return; // wait until the session state has resolved
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const fetcher = eventSlug
|
||||
? photosApi.getEventGallery(eventSlug, shareToken)
|
||||
: photosApi.getPublicGallery(slug || '', shareToken);
|
||||
fetcher
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setGallery(data.gallery);
|
||||
setPhotos(data.photos);
|
||||
setDenied(null);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return;
|
||||
// The photo-api returns a distinct message per visibility mode so
|
||||
// each gets its own gate page (see accessDenial in access.go).
|
||||
const msg = err.message || '';
|
||||
if (msg.includes('Authentication required')) setDenied('login');
|
||||
else if (msg.includes('attendees')) setDenied('ticket');
|
||||
else if (msg.includes('private')) setDenied('private');
|
||||
else if (msg.includes('share link')) setDenied('link');
|
||||
else setDenied('notfound');
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
|
||||
|
||||
if (loading || (authLoading && !initial)) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<ImageGridSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Gate pages for restricted galleries. After a successful login in the
|
||||
// pop-up, AuthContext's user changes, which re-runs the fetch effect —
|
||||
// the gate resolves by itself when access is granted.
|
||||
const loginModal = (
|
||||
<LoginModal
|
||||
open={loginOpen}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
message={
|
||||
es
|
||||
? 'Inicia sesión para ver esta galería.'
|
||||
: 'Log in to view this gallery.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (denied === 'login') {
|
||||
return (
|
||||
<>
|
||||
<GateMessage
|
||||
icon={TicketIcon}
|
||||
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||
body={
|
||||
es
|
||||
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
|
||||
: 'This gallery is for event attendees. Log in with the account you used to book.'
|
||||
}
|
||||
action={
|
||||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||
}
|
||||
/>
|
||||
{loginModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'ticket') {
|
||||
return (
|
||||
<>
|
||||
<GateMessage
|
||||
icon={TicketIcon}
|
||||
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||||
body={
|
||||
es
|
||||
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada. ¿Reservaste con otra cuenta?'
|
||||
: 'This gallery is only available to people who attended the event with a confirmed ticket. Booked with a different account?'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{(eventSlug || gallery?.event) && (
|
||||
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
|
||||
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button onClick={() => setLoginOpen(true)}>
|
||||
{es ? 'Cambiar de cuenta' : 'Switch account'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{loginModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'private') {
|
||||
return (
|
||||
<>
|
||||
<GateMessage
|
||||
icon={LockClosedIcon}
|
||||
title={es ? 'Esta galería es privada' : 'This gallery is private'}
|
||||
body={
|
||||
es
|
||||
? 'Solo los organizadores pueden verla. Si eres parte del equipo, inicia sesión.'
|
||||
: 'Only the organizers can see it. If that’s you, log in.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/photos">
|
||||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||
</Link>
|
||||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{loginModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'link') {
|
||||
return (
|
||||
<>
|
||||
<GateMessage
|
||||
icon={LinkIcon}
|
||||
title={es ? 'Esta galería necesita su enlace' : 'This gallery needs its share link'}
|
||||
body={
|
||||
es
|
||||
? 'Solo se puede abrir con el enlace que compartieron los organizadores. Pídeles el enlace completo, o inicia sesión si eres parte del equipo.'
|
||||
: 'It can only be opened with the link the organizers shared. Ask them for the full link, or log in if you’re part of the team.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/photos">
|
||||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||
</Link>
|
||||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{loginModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'notfound' || !gallery) {
|
||||
return (
|
||||
<GateMessage
|
||||
icon={CameraIcon}
|
||||
title={es ? 'Galería no encontrada' : 'Gallery not found'}
|
||||
body={
|
||||
es
|
||||
? 'Este enlace no existe o ya no está disponible.'
|
||||
: 'This link does not exist or is no longer available.'
|
||||
}
|
||||
action={
|
||||
<Link href="/photos">
|
||||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||
const lightboxItems = readyPhotos.map((p) => ({
|
||||
id: p.id,
|
||||
previewUrl: p.urls.preview || p.urls.original,
|
||||
downloadUrl: p.urls.original,
|
||||
filename: p.originalFilename,
|
||||
thumbUrl: p.urls.thumb,
|
||||
}));
|
||||
|
||||
const title = es && gallery.titleEs ? gallery.titleEs : gallery.title;
|
||||
const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description;
|
||||
const eventTitle = gallery.event
|
||||
? es && gallery.event.titleEs
|
||||
? gallery.event.titleEs
|
||||
: gallery.event.title
|
||||
: null;
|
||||
const eventDate = gallery.event?.startDatetime
|
||||
? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
})
|
||||
: null;
|
||||
|
||||
// Hero backdrop: the cover photo's preview when available, else the
|
||||
// first photo. Falls back to a navy gradient with no image.
|
||||
const coverPhoto =
|
||||
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
||||
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<div className="relative bg-brand-navy overflow-hidden">
|
||||
{heroUrl && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={heroUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
|
||||
</>
|
||||
)}
|
||||
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
|
||||
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
|
||||
<CameraIcon className="w-4 h-4" />
|
||||
{readyPhotos.length} {es ? 'fotos' : 'photos'}
|
||||
</span>
|
||||
{gallery.event && (
|
||||
<Link
|
||||
href={`/events/${gallery.event.slug}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
{eventTitle}
|
||||
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masonry grid */}
|
||||
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
|
||||
{readyPhotos.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">
|
||||
{es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
|
||||
{readyPhotos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setLightboxIndex(i);
|
||||
}
|
||||
}}
|
||||
className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
style={
|
||||
photo.width && photo.height
|
||||
? { aspectRatio: `${photo.width} / ${photo.height}` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photo.urls.thumb}
|
||||
alt=""
|
||||
loading={i < 8 ? 'eager' : 'lazy'}
|
||||
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
<a
|
||||
href={photo.urls.original}
|
||||
download={photo.originalFilename || true}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
|
||||
aria-label={es ? 'Descargar' : 'Download'}
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
items={lightboxItems}
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onNavigate={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Call to action: send attendees to their dashboard, everyone else to
|
||||
the next event. Auth state comes from the same useAuth() the gate
|
||||
pages use. */}
|
||||
<div className="bg-brand-navy">
|
||||
<div className="container-page px-4 py-12 md:py-16 text-center">
|
||||
<h2 className="font-heading font-bold text-2xl md:text-3xl text-white">
|
||||
{user
|
||||
? es
|
||||
? '¿Listo para lo que sigue?'
|
||||
: 'Ready for what’s next?'
|
||||
: es
|
||||
? '¿Te gustó lo que viste?'
|
||||
: 'Liked what you saw?'}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl mx-auto text-white/80 text-sm md:text-base">
|
||||
{user
|
||||
? es
|
||||
? 'Revisa tus entradas y próximos eventos en tu panel.'
|
||||
: 'Check your tickets and upcoming events from your dashboard.'
|
||||
: es
|
||||
? 'Únete a nuestro próximo evento y sé parte de las próximas fotos.'
|
||||
: 'Join our next event and be part of the next set of photos.'}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href={user ? '/dashboard' : '/next'}>
|
||||
<Button size="lg">
|
||||
{user
|
||||
? es
|
||||
? 'Ir a mi panel'
|
||||
: 'Go to dashboard'
|
||||
: es
|
||||
? 'Únete al próximo evento'
|
||||
: 'Join the next event'}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GateMessage({
|
||||
icon: Icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
}: {
|
||||
icon: typeof CameraIcon;
|
||||
title: string;
|
||||
body: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-lg mx-auto text-center py-16">
|
||||
<Icon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">{title}</h1>
|
||||
<p className="text-gray-600 mb-6">{body}</p>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||
import GalleryClient from './GalleryClient';
|
||||
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
interface PageProps {
|
||||
params: { slug: string };
|
||||
}
|
||||
|
||||
// Public galleries render server-side (SEO + fast first paint); link and
|
||||
// ticket galleries return null here and are fetched client-side with the
|
||||
// viewer's token / share token.
|
||||
async function getPublicGallery(
|
||||
slug: string
|
||||
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`,
|
||||
{ next: { revalidate: 300 } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const data = await getPublicGallery(params.slug);
|
||||
if (!data) {
|
||||
return { title: 'Photo Gallery', robots: { index: false } };
|
||||
}
|
||||
return {
|
||||
title: `${data.gallery.title} – Photos`,
|
||||
description:
|
||||
data.gallery.description ||
|
||||
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function GalleryPage({ params }: PageProps) {
|
||||
const initial = await getPublicGallery(params.slug);
|
||||
return (
|
||||
// Suspense boundary required by useSearchParams() in the client child.
|
||||
<Suspense>
|
||||
<GalleryClient slug={params.slug} initial={initial} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { PhotoGallery } from '@/lib/api';
|
||||
import PhotosIndexClient from './PhotosIndexClient';
|
||||
|
||||
// Server-side calls go straight to the photo-api service; the browser uses
|
||||
// the same-origin /api/photos path via the Next rewrite / nginx.
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Event Photo Galleries',
|
||||
description: 'Photos from past Spanglish language exchange events in Asunción.',
|
||||
};
|
||||
|
||||
async function getGalleries(): Promise<PhotoGallery[]> {
|
||||
try {
|
||||
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.galleries || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PhotosPage() {
|
||||
const galleries = await getGalleries();
|
||||
return <PhotosIndexClient galleries={galleries} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function AdminCatchAll() {
|
||||
notFound();
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
TicketIcon,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
PhoneIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -101,6 +103,20 @@ export default function AdminBookingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivate = async (ticket: TicketWithDetails) => {
|
||||
if (!ticket.payment?.id) return;
|
||||
setProcessing(ticket.id);
|
||||
try {
|
||||
await paymentsApi.reactivate(ticket.payment.id);
|
||||
toast.success('Booking reactivated');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reactivate booking');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (ticketId: string) => {
|
||||
if (!confirm('Are you sure you want to cancel this booking?')) return;
|
||||
|
||||
@@ -133,6 +149,7 @@ export default function AdminBookingsPage() {
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||
case 'checked_in': return 'bg-blue-100 text-blue-800';
|
||||
case 'on_hold': return 'bg-slate-100 text-slate-600';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
@@ -144,6 +161,7 @@ export default function AdminBookingsPage() {
|
||||
case 'failed':
|
||||
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||
case 'refunded': return 'bg-purple-100 text-purple-800';
|
||||
case 'on_hold': return 'bg-slate-100 text-slate-600';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
@@ -191,6 +209,7 @@ export default function AdminBookingsPage() {
|
||||
confirmed: tickets.filter(t => t.status === 'confirmed').length,
|
||||
checkedIn: tickets.filter(t => t.status === 'checked_in').length,
|
||||
cancelled: tickets.filter(t => t.status === 'cancelled').length,
|
||||
onHold: tickets.filter(t => t.status === 'on_hold').length,
|
||||
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
||||
};
|
||||
|
||||
@@ -218,6 +237,9 @@ export default function AdminBookingsPage() {
|
||||
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
||||
}
|
||||
if (ticket.status === 'on_hold') {
|
||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
||||
}
|
||||
if (ticket.status === 'confirmed') {
|
||||
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
|
||||
}
|
||||
@@ -225,11 +247,7 @@ export default function AdminBookingsPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={7} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -239,7 +257,7 @@ export default function AdminBookingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6">
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-2 md:gap-4 mb-6">
|
||||
<Card className="p-3 md:p-4 text-center">
|
||||
<p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Total</p>
|
||||
@@ -260,6 +278,10 @@ export default function AdminBookingsPage() {
|
||||
<p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Cancelled</p>
|
||||
</Card>
|
||||
<Card className="p-3 md:p-4 text-center border-l-4 border-slate-400">
|
||||
<p className="text-xl md:text-2xl font-bold text-slate-600">{stats.onHold}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">On Hold</p>
|
||||
</Card>
|
||||
<Card className="p-3 md:p-4 text-center border-l-4 border-orange-400">
|
||||
<p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
|
||||
<p className="text-xs md:text-sm text-gray-500">Pending Pay</p>
|
||||
@@ -305,6 +327,7 @@ export default function AdminBookingsPage() {
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="checked_in">Checked In</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="on_hold">On Hold</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -316,6 +339,7 @@ export default function AdminBookingsPage() {
|
||||
<option value="paid">Paid</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="on_hold">On Hold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,6 +392,7 @@ export default function AdminBookingsPage() {
|
||||
<thead className="bg-secondary-gray">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">RUC</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Payment</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
@@ -378,7 +403,7 @@ export default function AdminBookingsPage() {
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{sortedTickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-gray-500 text-sm">
|
||||
No bookings found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -392,6 +417,7 @@ export default function AdminBookingsPage() {
|
||||
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{formatRucDisplay(ticket.attendeeRuc) || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm truncate max-w-[150px] block">
|
||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||
@@ -431,6 +457,18 @@ export default function AdminBookingsPage() {
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'on_hold' && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
|
||||
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
||||
Mark Paid
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleReactivate(ticket)}
|
||||
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
||||
Reactivate
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||
@@ -519,6 +557,13 @@ export default function AdminBookingsPage() {
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
)}
|
||||
{ticket.status === 'on_hold' && (
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleReactivate(ticket)}>
|
||||
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<span className="text-[10px] text-green-600 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
|
||||
@@ -557,6 +602,7 @@ export default function AdminBookingsPage() {
|
||||
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
|
||||
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
|
||||
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
|
||||
{ value: 'on_hold', label: `On Hold (${stats.onHold})` },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -581,6 +627,7 @@ export default function AdminBookingsPage() {
|
||||
{ value: 'paid', label: 'Paid' },
|
||||
{ value: 'refunded', label: 'Refunded' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'on_hold', label: 'On Hold' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { contactsApi, Contact } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Skeleton, SkeletonText, CardListSkeleton } from '@/components/ui/Skeleton';
|
||||
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
@@ -65,8 +66,21 @@ export default function AdminContactsPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-1">
|
||||
<CardListSkeleton count={5} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-card shadow-card p-6">
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<SkeletonText lines={4} className="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
@@ -106,11 +107,7 @@ export default function AdminEmailsPage() {
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/events', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||
},
|
||||
});
|
||||
const res = await fetch('/api/events', { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEvents(data.events || []);
|
||||
@@ -168,9 +165,7 @@ export default function AdminEmailsPage() {
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
@@ -418,11 +413,7 @@ export default function AdminEmailsPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
return <AdminPageSkeleton cols={5} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
|
||||
confirmed: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
checked_in: 'bg-blue-100 text-blue-800',
|
||||
on_hold: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
return (
|
||||
<span className={clsx(
|
||||
@@ -17,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Ticket payment status: Paid (revenue), Unpaid (balance due at door), Comp (free guest).
|
||||
// Tickets created before the payment_status column default to unpaid via backfill.
|
||||
export function PaymentBadge({ paymentStatus, compact = false }: { paymentStatus?: string; compact?: boolean }) {
|
||||
if (!paymentStatus) return null;
|
||||
const styles: Record<string, string> = {
|
||||
paid: 'bg-emerald-100 text-emerald-700',
|
||||
unpaid: 'bg-orange-100 text-orange-700',
|
||||
comp: 'bg-amber-100 text-amber-700',
|
||||
};
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center rounded-full font-medium',
|
||||
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
|
||||
styles[paymentStatus] || 'bg-gray-100 text-gray-800'
|
||||
)}>
|
||||
{paymentStatus === 'comp' ? 'Comp' : paymentStatus === 'unpaid' ? 'Unpaid' : 'Paid'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
BanknotesIcon,
|
||||
CheckCircleIcon,
|
||||
EnvelopeIcon,
|
||||
LinkIcon,
|
||||
StarIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { AddTicketType, AddTicketFormState } from '../_types';
|
||||
|
||||
interface AddTicketModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
form: AddTicketFormState;
|
||||
setForm: Dispatch<SetStateAction<AddTicketFormState>>;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
submitting: boolean;
|
||||
eventPriceLabel: string;
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
|
||||
{ value: 'paid', label: 'Paid' },
|
||||
{ value: 'unpaid', label: 'Unpaid' },
|
||||
{ value: 'guest', label: 'Guest' },
|
||||
];
|
||||
|
||||
const SUBMIT_LABELS: Record<AddTicketType, string> = {
|
||||
paid: 'Create & send ticket',
|
||||
unpaid: 'Create & send pay link',
|
||||
guest: 'Invite guest',
|
||||
};
|
||||
|
||||
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
|
||||
paid: EnvelopeIcon,
|
||||
unpaid: LinkIcon,
|
||||
guest: StarIcon,
|
||||
};
|
||||
|
||||
// Live "what happens" preview lines for the selected type / email / check-in combo
|
||||
function previewLines(form: AddTicketFormState, eventPriceLabel: string): string[] {
|
||||
const hasEmail = !!form.email.trim();
|
||||
const lines: string[] = [];
|
||||
if (form.type === 'paid') {
|
||||
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
|
||||
lines.push('Confirmation email with QR ticket sent');
|
||||
} else if (form.type === 'unpaid') {
|
||||
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
|
||||
lines.push('QR code issued, flagged "unpaid" for door staff');
|
||||
lines.push(hasEmail
|
||||
? 'Bancard (TPago) payment link emailed to the attendee'
|
||||
: 'No email — no pay link sent, payment collected at the door');
|
||||
} else {
|
||||
lines.push('Free guest ticket (comp) — not counted in revenue');
|
||||
lines.push('Auto-confirmed with QR code');
|
||||
lines.push(hasEmail ? 'Confirmation email sent' : 'No email — nothing is sent');
|
||||
}
|
||||
if (form.checkinNow) {
|
||||
lines.push('Checked in immediately');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
|
||||
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
|
||||
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
|
||||
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
|
||||
};
|
||||
|
||||
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
|
||||
paid: CheckCircleIcon,
|
||||
unpaid: BanknotesIcon,
|
||||
guest: StarIcon,
|
||||
};
|
||||
|
||||
export function AddTicketModal({
|
||||
open,
|
||||
onClose,
|
||||
form,
|
||||
setForm,
|
||||
onSubmit,
|
||||
submitting,
|
||||
eventPriceLabel,
|
||||
}: AddTicketModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const emailRequired = form.type === 'paid';
|
||||
const style = PREVIEW_STYLES[form.type];
|
||||
const PreviewIcon = PREVIEW_ICONS[form.type];
|
||||
const SubmitIcon = SUBMIT_ICONS[form.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">Add Ticket</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
{/* Segmented ticket-type control */}
|
||||
<div className="flex rounded-btn bg-gray-100 p-1">
|
||||
{TYPE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
|
||||
className={clsx(
|
||||
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors',
|
||||
form.type === option.value
|
||||
? 'bg-white shadow-sm text-primary-dark'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={form.firstName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={form.lastName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email {emailRequired && '*'}</label>
|
||||
<input type="email" required={emailRequired} value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
|
||||
<p className="text-[10px] text-gray-500 mt-1">
|
||||
{form.type === 'paid' && 'Ticket will be sent to this email'}
|
||||
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
|
||||
{form.type === 'guest' && 'If provided, a confirmation email will be sent'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={form.adminNote}
|
||||
onChange={(e) => setForm((f) => ({ ...f, adminNote: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="checkinNow" checked={form.checkinNow}
|
||||
onChange={(e) => setForm((f) => ({ ...f, checkinNow: e.target.checked }))}
|
||||
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
||||
<label htmlFor="checkinNow" className="text-sm font-medium">Check in now</label>
|
||||
</div>
|
||||
|
||||
{/* Live preview of what this submission does */}
|
||||
<div className={clsx('border rounded-lg p-3', style.box)}>
|
||||
<div className="flex items-start gap-2">
|
||||
<PreviewIcon className={clsx('w-4 h-4 mt-0.5 flex-shrink-0', style.icon)} />
|
||||
<div className={clsx('text-xs', style.text)}>
|
||||
<p className="font-medium">What happens:</p>
|
||||
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
|
||||
{previewLines(form, eventPriceLabel).map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||
<SubmitIcon className="w-4 h-4 mr-1.5" />
|
||||
{SUBMIT_LABELS[form.type]}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from 'react';
|
||||
import { Ticket } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { BottomSheet } from '@/components/admin/MobileComponents';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
BanknotesIcon,
|
||||
CheckCircleIcon,
|
||||
EnvelopeIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
|
||||
import type { AttendeeStatusFilter, AddTicketType } from '../_types';
|
||||
|
||||
interface EventModalsProps {
|
||||
// counts + filter
|
||||
@@ -20,6 +19,7 @@ interface EventModalsProps {
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
cancelledCount: number;
|
||||
onHoldCount: number;
|
||||
statusFilter: AttendeeStatusFilter;
|
||||
setStatusFilter: (value: AttendeeStatusFilter) => void;
|
||||
// mobile filter sheet
|
||||
@@ -28,6 +28,7 @@ interface EventModalsProps {
|
||||
// add ticket sheet
|
||||
showAddTicketSheet: boolean;
|
||||
setShowAddTicketSheet: (value: boolean) => void;
|
||||
openAddTicket: (type: AddTicketType) => void;
|
||||
// export sheets
|
||||
showExportSheet: boolean;
|
||||
setShowExportSheet: (value: boolean) => void;
|
||||
@@ -35,24 +36,6 @@ interface EventModalsProps {
|
||||
showTicketExportSheet: boolean;
|
||||
setShowTicketExportSheet: (value: boolean) => void;
|
||||
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
||||
// add at door
|
||||
showAddAtDoorModal: boolean;
|
||||
setShowAddAtDoorModal: (value: boolean) => void;
|
||||
addAtDoorForm: AddAtDoorFormState;
|
||||
setAddAtDoorForm: Dispatch<SetStateAction<AddAtDoorFormState>>;
|
||||
handleAddAtDoor: (e: FormEvent) => void;
|
||||
// manual ticket
|
||||
showManualTicketModal: boolean;
|
||||
setShowManualTicketModal: (value: boolean) => void;
|
||||
manualTicketForm: AttendeeFormState;
|
||||
setManualTicketForm: Dispatch<SetStateAction<AttendeeFormState>>;
|
||||
handleManualTicket: (e: FormEvent) => void;
|
||||
// invite guest
|
||||
showInviteGuestModal: boolean;
|
||||
setShowInviteGuestModal: (value: boolean) => void;
|
||||
inviteGuestForm: AttendeeFormState;
|
||||
setInviteGuestForm: Dispatch<SetStateAction<AttendeeFormState>>;
|
||||
handleInviteGuest: (e: FormEvent) => void;
|
||||
// shared submit flag
|
||||
submitting: boolean;
|
||||
// note modal
|
||||
@@ -75,33 +58,20 @@ export function EventModals(props: EventModalsProps) {
|
||||
confirmedCount,
|
||||
checkedInCount,
|
||||
cancelledCount,
|
||||
onHoldCount,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
mobileFilterOpen,
|
||||
setMobileFilterOpen,
|
||||
showAddTicketSheet,
|
||||
setShowAddTicketSheet,
|
||||
openAddTicket,
|
||||
showExportSheet,
|
||||
setShowExportSheet,
|
||||
handleExportAttendees,
|
||||
showTicketExportSheet,
|
||||
setShowTicketExportSheet,
|
||||
handleExportTickets,
|
||||
showAddAtDoorModal,
|
||||
setShowAddAtDoorModal,
|
||||
addAtDoorForm,
|
||||
setAddAtDoorForm,
|
||||
handleAddAtDoor,
|
||||
showManualTicketModal,
|
||||
setShowManualTicketModal,
|
||||
manualTicketForm,
|
||||
setManualTicketForm,
|
||||
handleManualTicket,
|
||||
showInviteGuestModal,
|
||||
setShowInviteGuestModal,
|
||||
inviteGuestForm,
|
||||
setInviteGuestForm,
|
||||
handleInviteGuest,
|
||||
submitting,
|
||||
showNoteModal,
|
||||
setShowNoteModal,
|
||||
@@ -129,6 +99,7 @@ export function EventModals(props: EventModalsProps) {
|
||||
{ value: 'confirmed', label: `Confirmed (${confirmedCount})` },
|
||||
{ value: 'checked_in', label: `Checked In (${checkedInCount})` },
|
||||
{ value: 'cancelled', label: `Cancelled (${cancelledCount})` },
|
||||
{ value: 'on_hold', label: `On Hold (${onHoldCount})` },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
@@ -153,27 +124,27 @@ export function EventModals(props: EventModalsProps) {
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => { setShowManualTicketModal(true); setShowAddTicketSheet(false); }}
|
||||
onClick={() => { openAddTicket('paid'); setShowAddTicketSheet(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||
>
|
||||
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Manual Ticket</p>
|
||||
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
|
||||
<p className="font-medium">Paid Ticket</p>
|
||||
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketSheet(false); }}
|
||||
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 text-gray-500" />
|
||||
<BanknotesIcon className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Add at Door</p>
|
||||
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
||||
<p className="font-medium">Unpaid Ticket</p>
|
||||
<p className="text-xs text-gray-500">Pay link now or collect at the door</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
|
||||
onClick={() => { openAddTicket('guest'); setShowAddTicketSheet(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||
>
|
||||
<StarIcon className="w-5 h-5 text-gray-500" />
|
||||
@@ -234,243 +205,6 @@ export function EventModals(props: EventModalsProps) {
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Add at Door Modal */}
|
||||
{showAddAtDoorModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowAddAtDoorModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">Add Attendee at Door</h2>
|
||||
<button
|
||||
onClick={() => setShowAddAtDoorModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddAtDoor} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={addAtDoorForm.firstName}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, firstName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={addAtDoorForm.lastName}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email</label>
|
||||
<input type="email" value={addAtDoorForm.email}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, email: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="email@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={addAtDoorForm.phone}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, phone: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={addAtDoorForm.adminNote}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="autoCheckin" checked={addAtDoorForm.autoCheckin}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, autoCheckin: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
||||
<label htmlFor="autoCheckin" className="text-sm font-medium">Auto check-in immediately</label>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowAddAtDoorModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">Add Attendee</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Ticket Modal */}
|
||||
{showManualTicketModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowManualTicketModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Create Manual Ticket</h2>
|
||||
<p className="text-xs text-gray-500">Confirmation email will be sent</p>
|
||||
</div>
|
||||
<button onClick={() => setShowManualTicketModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleManualTicket} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={manualTicketForm.firstName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={manualTicketForm.lastName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email *</label>
|
||||
<input type="email" required value={manualTicketForm.email}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="email@example.com" />
|
||||
<p className="text-[10px] text-gray-500 mt-1">Ticket will be sent to this email</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={manualTicketForm.phone}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={manualTicketForm.adminNote}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<EnvelopeIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium">This will send:</p>
|
||||
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
|
||||
<li>Booking confirmation email</li>
|
||||
<li>Ticket with QR code</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowManualTicketModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||
<EnvelopeIcon className="w-4 h-4 mr-1.5" />
|
||||
Create & Send
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite Guest Modal */}
|
||||
{showInviteGuestModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowInviteGuestModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Invite Guest</h2>
|
||||
<p className="text-xs text-gray-500">Free ticket — not counted in revenue</p>
|
||||
</div>
|
||||
<button onClick={() => setShowInviteGuestModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={inviteGuestForm.firstName}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, firstName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={inviteGuestForm.lastName}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email</label>
|
||||
<input type="email" value={inviteGuestForm.email}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="email@example.com (optional)" />
|
||||
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={inviteGuestForm.phone}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={inviteGuestForm.adminNote}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-amber-800">
|
||||
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||
<StarIcon className="w-4 h-4 mr-1.5" />
|
||||
Invite Guest
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Modal */}
|
||||
{showNoteModal && selectedTicket && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user