Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0315a705d | ||
|
|
fbc437a670 | ||
|
|
e0f0700398 | ||
|
|
defd9685e0 | ||
|
|
1ed62b0d3f | ||
|
|
91de6df04d | ||
|
|
a5d97d65e1 | ||
|
|
f0128f66b0 | ||
|
|
b33c68feb0 | ||
|
|
15655e3987 | ||
|
|
d8b3864411 | ||
|
|
194cbd6ca8 | ||
|
|
d5445c2282 | ||
|
|
dcfefc8371 | ||
|
|
b5f14335c4 | ||
|
|
d44ac949b5 | ||
|
|
a5e939221d | ||
|
|
833e3e5a9c | ||
|
|
ba1975dd6d | ||
|
|
3025ef3d21 | ||
|
|
8564f8af83 |
@@ -14,7 +14,7 @@ A full-stack web app for organizing and managing language exchange events (Asunc
|
|||||||
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
||||||
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
|
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
|
||||||
- **Email**: `nodemailer` (SMTP) with optional provider config
|
- **Email**: `nodemailer` (SMTP) with optional provider config
|
||||||
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
|
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, SWR, Heroicons
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
@@ -86,7 +86,6 @@ Key settings (see `backend/.env.example` for the full list):
|
|||||||
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
||||||
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
||||||
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
|
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
|
||||||
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
|
|
||||||
|
|
||||||
### Frontend (`frontend/.env`)
|
### Frontend (`frontend/.env`)
|
||||||
|
|
||||||
@@ -161,86 +160,6 @@ npm run db:migrate
|
|||||||
|
|
||||||
Then install/enable the systemd services and nginx configs for your server.
|
Then install/enable the systemd services and nginx configs for your server.
|
||||||
|
|
||||||
## Horizontal scaling
|
|
||||||
|
|
||||||
The backend can run as a single instance with zero extra configuration (the
|
|
||||||
default), or as multiple replicas behind a load balancer. Scaling support is
|
|
||||||
fully optional and backward compatible: if you set none of the variables below,
|
|
||||||
the app behaves exactly as before with in-memory state and local-disk uploads.
|
|
||||||
|
|
||||||
### Requirements for multiple instances
|
|
||||||
|
|
||||||
- **Use PostgreSQL.** Set `DB_TYPE=postgres`. SQLite is a single local file and
|
|
||||||
cannot be shared safely across instances.
|
|
||||||
- **Set `REDIS_URL`.** This makes the following subsystems shared across
|
|
||||||
instances instead of per process:
|
|
||||||
- distributed cache
|
|
||||||
- rate limiting (shared sliding/fixed window)
|
|
||||||
- pub/sub for real-time payment events, so an SSE client connected to one
|
|
||||||
instance still receives an event when the LNbits webhook lands on another
|
|
||||||
- distributed locks (so only one instance seeds email templates per boot and
|
|
||||||
only one instance polls LNbits per pending ticket)
|
|
||||||
- the email hourly cap (`MAX_EMAILS_PER_HOUR`) becomes a global cap
|
|
||||||
- **Tune the DB pool.** `DB_POOL_MAX` is the max Postgres connections per
|
|
||||||
instance (default 10). Keep `DB_POOL_MAX * replicas` below the Postgres
|
|
||||||
`max_connections` setting (default 100). For example, 5 replicas at
|
|
||||||
`DB_POOL_MAX=15` uses up to 75 connections.
|
|
||||||
|
|
||||||
If Redis is configured but becomes unreachable at runtime, each subsystem
|
|
||||||
degrades gracefully (rate limiter fails open, cache misses fall through to the
|
|
||||||
DB, locks proceed) and the API keeps serving rather than crashing.
|
|
||||||
|
|
||||||
### Uploads across instances
|
|
||||||
|
|
||||||
Media uploads default to local disk (`./uploads`). With more than one instance
|
|
||||||
you must use shared storage so a file uploaded on one instance is readable on
|
|
||||||
the others. Two options:
|
|
||||||
|
|
||||||
- **S3-compatible storage (recommended):** set `S3_ENDPOINT`, `S3_BUCKET`,
|
|
||||||
`S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (and optionally `S3_PUBLIC_URL`,
|
|
||||||
`S3_REGION`, `S3_FORCE_PATH_STYLE`). Works with Garage, MinIO, or AWS S3.
|
|
||||||
- **Shared volume:** mount the same `./uploads` directory (e.g. NFS) into every
|
|
||||||
instance.
|
|
||||||
|
|
||||||
### Real-time payment SSE behind a load balancer
|
|
||||||
|
|
||||||
The payment status stream (`/api/lnbits/stream/:ticketId`) is a long-lived SSE
|
|
||||||
connection. With Redis pub/sub enabled, any instance can deliver the payment
|
|
||||||
event regardless of which instance holds the socket, so sticky sessions are not
|
|
||||||
strictly required. Enabling sticky sessions (IP hash) for the SSE path is still
|
|
||||||
a reasonable optimization.
|
|
||||||
|
|
||||||
### Health and observability
|
|
||||||
|
|
||||||
`GET /health` always returns 200 and reports Redis connectivity and which
|
|
||||||
backend each subsystem selected, for example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"redis": { "enabled": true, "healthy": true },
|
|
||||||
"backends": {
|
|
||||||
"cache": "redis",
|
|
||||||
"rateLimiter": "redis",
|
|
||||||
"pubsub": "redis",
|
|
||||||
"lock": "redis",
|
|
||||||
"storage": "s3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The same selection is logged once at startup.
|
|
||||||
|
|
||||||
### docker-compose example (N replicas + Redis)
|
|
||||||
|
|
||||||
A ready-to-edit snippet lives at `deploy/docker-compose.scale.yml`. It runs
|
|
||||||
Postgres, Redis, and the API scaled to multiple replicas behind nginx. Bring it
|
|
||||||
up with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- **Specs / notes**: `about/`
|
- **Specs / notes**: `about/`
|
||||||
|
|||||||
@@ -8,40 +8,6 @@ DATABASE_URL=./data/spanglish.db
|
|||||||
# For PostgreSQL
|
# For PostgreSQL
|
||||||
# DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
|
# DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
|
||||||
|
|
||||||
# Max PostgreSQL connections per instance (default 10). When running multiple
|
|
||||||
# replicas, keep DB_POOL_MAX * replicas below the Postgres max_connections limit.
|
|
||||||
# DB_POOL_MAX=10
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Horizontal scaling (all optional)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Leave everything below UNSET to run as a single instance with in-memory
|
|
||||||
# backends and local-disk uploads (zero-config, identical to the original
|
|
||||||
# behavior). Set them to run multiple API replicas behind a load balancer.
|
|
||||||
#
|
|
||||||
# Note: running more than one instance requires DB_TYPE=postgres. SQLite is a
|
|
||||||
# single local file and cannot be shared safely across instances.
|
|
||||||
|
|
||||||
# Redis connection URL. When set, the cache, rate limiter, 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_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Optional S3-compatible object storage for media uploads (e.g. Garage, MinIO,
|
|
||||||
# AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and
|
|
||||||
# are shared across instances. When unset, uploads are written to ./uploads on
|
|
||||||
# local disk (the default).
|
|
||||||
# S3_ENDPOINT=https://garage.example.com
|
|
||||||
# S3_REGION=garage
|
|
||||||
# S3_BUCKET=spanglish-media
|
|
||||||
# S3_ACCESS_KEY_ID=
|
|
||||||
# S3_SECRET_ACCESS_KEY=
|
|
||||||
# Public base URL used to build fileUrl for stored objects (CDN or web endpoint).
|
|
||||||
# If unset, a path-style URL against S3_ENDPOINT/S3_BUCKET is used.
|
|
||||||
# S3_PUBLIC_URL=https://media.example.com
|
|
||||||
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
|
|
||||||
# S3_FORCE_PATH_STYLE=true
|
|
||||||
|
|
||||||
# JWT Secret (change in production!)
|
# JWT Secret (change in production!)
|
||||||
JWT_SECRET=your-super-secret-key-change-in-production
|
JWT_SECRET=your-super-secret-key-change-in-production
|
||||||
|
|
||||||
@@ -107,10 +73,3 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
|
|||||||
# If the limit is reached, queued emails will pause and resume automatically
|
# If the limit is reached, queued emails will pause and resume automatically
|
||||||
MAX_EMAILS_PER_HOUR=30
|
MAX_EMAILS_PER_HOUR=30
|
||||||
|
|
||||||
# Pending Booking Cleanup
|
|
||||||
# Pending bookings whose payment is still unpaid (not awaiting admin approval)
|
|
||||||
# are cancelled after this many minutes, freeing the seats (default: 30)
|
|
||||||
PENDING_BOOKING_TTL_MINUTES=30
|
|
||||||
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
|
|
||||||
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"db:import": "tsx src/db/import.ts"
|
"db:import": "tsx src/db/import.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1075.0",
|
|
||||||
"@hono/node-server": "^1.11.4",
|
"@hono/node-server": "^1.11.4",
|
||||||
"@hono/swagger-ui": "^0.4.0",
|
"@hono/swagger-ui": "^0.4.0",
|
||||||
"@hono/zod-openapi": "^0.14.4",
|
"@hono/zod-openapi": "^0.14.4",
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.31.2",
|
"drizzle-orm": "^0.31.2",
|
||||||
"hono": "^4.4.7",
|
"hono": "^4.4.7",
|
||||||
"ioredis": "^5.11.1",
|
|
||||||
"jose": "^5.4.0",
|
"jose": "^5.4.0",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nodemailer": "^7.0.13",
|
"nodemailer": "^7.0.13",
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ const dbType = process.env.DB_TYPE || 'sqlite';
|
|||||||
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
|
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
|
||||||
|
|
||||||
if (dbType === 'postgres') {
|
if (dbType === 'postgres') {
|
||||||
// Cap connections per instance so that, when running multiple replicas,
|
|
||||||
// DB_POOL_MAX * replicas stays below the Postgres max_connections limit.
|
|
||||||
const pool = new pg.Pool({
|
const pool = new pg.Pool({
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
|
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
|
||||||
max: Number(process.env.DB_POOL_MAX || 10),
|
|
||||||
});
|
});
|
||||||
db = drizzlePg(pool, { schema });
|
db = drizzlePg(pool, { schema });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { uniqueSlug } from '../lib/slugify.js';
|
|||||||
|
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
console.log(`Database type: ${dbType}`);
|
console.log(`Database type: ${dbType}`);
|
||||||
// Do not log DATABASE_URL: it may contain credentials.
|
console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`);
|
||||||
|
|
||||||
async function migrate() {
|
async function migrate() {
|
||||||
console.log('Running migrations...');
|
console.log('Running migrations...');
|
||||||
@@ -43,9 +43,6 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
|
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
try {
|
|
||||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
// Magic link tokens table
|
// Magic link tokens table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
@@ -262,10 +259,6 @@ async function migrate() {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
tpago_link TEXT,
|
tpago_link TEXT,
|
||||||
tpago_link_2 TEXT,
|
|
||||||
tpago_link_3 TEXT,
|
|
||||||
tpago_link_4 TEXT,
|
|
||||||
tpago_link_5 TEXT,
|
|
||||||
tpago_instructions TEXT,
|
tpago_instructions TEXT,
|
||||||
tpago_instructions_es TEXT,
|
tpago_instructions_es TEXT,
|
||||||
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
|
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -290,13 +283,6 @@ async function migrate() {
|
|||||||
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
|
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Add per-quantity TPago link columns to payment_options if they don't exist
|
|
||||||
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} TEXT`));
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event payment overrides table
|
// Event payment overrides table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
||||||
@@ -304,10 +290,6 @@ async function migrate() {
|
|||||||
event_id TEXT NOT NULL REFERENCES events(id),
|
event_id TEXT NOT NULL REFERENCES events(id),
|
||||||
tpago_enabled INTEGER,
|
tpago_enabled INTEGER,
|
||||||
tpago_link TEXT,
|
tpago_link TEXT,
|
||||||
tpago_link_2 TEXT,
|
|
||||||
tpago_link_3 TEXT,
|
|
||||||
tpago_link_4 TEXT,
|
|
||||||
tpago_link_5 TEXT,
|
|
||||||
tpago_instructions TEXT,
|
tpago_instructions TEXT,
|
||||||
tpago_instructions_es TEXT,
|
tpago_instructions_es TEXT,
|
||||||
bank_transfer_enabled INTEGER,
|
bank_transfer_enabled INTEGER,
|
||||||
@@ -327,13 +309,6 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Add per-quantity TPago link columns to event_payment_overrides if they don't exist
|
|
||||||
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
|
|
||||||
try {
|
|
||||||
await (db as any).run(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} TEXT`));
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -432,18 +407,6 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await (db as any).run(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS email_queue (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
params TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
processed_at TEXT
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Site settings table
|
// Site settings table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS site_settings (
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
@@ -556,9 +519,6 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`);
|
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
try {
|
|
||||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
|
|
||||||
// Magic link tokens table
|
// Magic link tokens table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -742,10 +702,6 @@ async function migrate() {
|
|||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
tpago_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
tpago_link VARCHAR(500),
|
tpago_link VARCHAR(500),
|
||||||
tpago_link_2 VARCHAR(500),
|
|
||||||
tpago_link_3 VARCHAR(500),
|
|
||||||
tpago_link_4 VARCHAR(500),
|
|
||||||
tpago_link_5 VARCHAR(500),
|
|
||||||
tpago_instructions TEXT,
|
tpago_instructions TEXT,
|
||||||
tpago_instructions_es TEXT,
|
tpago_instructions_es TEXT,
|
||||||
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
|
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -770,23 +726,12 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
|
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Add per-quantity TPago link columns to payment_options if they don't exist
|
|
||||||
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} VARCHAR(500)`));
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
CREATE TABLE IF NOT EXISTS event_payment_overrides (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
event_id UUID NOT NULL REFERENCES events(id),
|
event_id UUID NOT NULL REFERENCES events(id),
|
||||||
tpago_enabled INTEGER,
|
tpago_enabled INTEGER,
|
||||||
tpago_link VARCHAR(500),
|
tpago_link VARCHAR(500),
|
||||||
tpago_link_2 VARCHAR(500),
|
|
||||||
tpago_link_3 VARCHAR(500),
|
|
||||||
tpago_link_4 VARCHAR(500),
|
|
||||||
tpago_link_5 VARCHAR(500),
|
|
||||||
tpago_instructions TEXT,
|
tpago_instructions TEXT,
|
||||||
tpago_instructions_es TEXT,
|
tpago_instructions_es TEXT,
|
||||||
bank_transfer_enabled INTEGER,
|
bank_transfer_enabled INTEGER,
|
||||||
@@ -806,13 +751,6 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Add per-quantity TPago link columns to event_payment_overrides if they don't exist
|
|
||||||
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
|
|
||||||
try {
|
|
||||||
await (db as any).execute(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} VARCHAR(500)`));
|
|
||||||
} catch (e) { /* column may already exist */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -911,18 +849,6 @@ async function migrate() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS email_queue (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
params TEXT NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL,
|
|
||||||
processed_at TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Site settings table
|
// Site settings table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS site_settings (
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
@@ -1004,27 +930,6 @@ async function migrate() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
|
||||||
const indexStatements = [
|
|
||||||
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS tickets_user_id_idx ON tickets(user_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS tickets_booking_id_idx ON tickets(booking_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
|
|
||||||
];
|
|
||||||
for (const stmt of indexStatements) {
|
|
||||||
try {
|
|
||||||
if (dbType === 'sqlite') {
|
|
||||||
await (db as any).run(sql.raw(stmt));
|
|
||||||
} else {
|
|
||||||
await (db as any).execute(sql.raw(stmt));
|
|
||||||
}
|
|
||||||
} catch (e) { /* index may already exist */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
||||||
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
||||||
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ export const sqliteUsers = sqliteTable('users', {
|
|||||||
googleId: text('google_id'),
|
googleId: text('google_id'),
|
||||||
rucNumber: text('ruc_number'),
|
rucNumber: text('ruc_number'),
|
||||||
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
||||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
|
||||||
tokenVersion: integer('token_version').notNull().default(0),
|
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -137,10 +135,6 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
|||||||
// TPago configuration
|
// TPago configuration
|
||||||
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false),
|
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
tpagoLink: text('tpago_link'),
|
tpagoLink: text('tpago_link'),
|
||||||
tpagoLink2: text('tpago_link_2'),
|
|
||||||
tpagoLink3: text('tpago_link_3'),
|
|
||||||
tpagoLink4: text('tpago_link_4'),
|
|
||||||
tpagoLink5: text('tpago_link_5'),
|
|
||||||
tpagoInstructions: text('tpago_instructions'),
|
tpagoInstructions: text('tpago_instructions'),
|
||||||
tpagoInstructionsEs: text('tpago_instructions_es'),
|
tpagoInstructionsEs: text('tpago_instructions_es'),
|
||||||
// Bank Transfer configuration
|
// Bank Transfer configuration
|
||||||
@@ -172,10 +166,6 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
|
|||||||
// Override flags (null means use global)
|
// Override flags (null means use global)
|
||||||
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }),
|
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }),
|
||||||
tpagoLink: text('tpago_link'),
|
tpagoLink: text('tpago_link'),
|
||||||
tpagoLink2: text('tpago_link_2'),
|
|
||||||
tpagoLink3: text('tpago_link_3'),
|
|
||||||
tpagoLink4: text('tpago_link_4'),
|
|
||||||
tpagoLink5: text('tpago_link_5'),
|
|
||||||
tpagoInstructions: text('tpago_instructions'),
|
tpagoInstructions: text('tpago_instructions'),
|
||||||
tpagoInstructionsEs: text('tpago_instructions_es'),
|
tpagoInstructionsEs: text('tpago_instructions_es'),
|
||||||
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }),
|
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }),
|
||||||
@@ -273,18 +263,6 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
|
|||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Durable email queue. Jobs survive process restarts; a startup recovery step
|
|
||||||
// resets any 'processing' rows back to 'pending'.
|
|
||||||
export const sqliteEmailQueue = sqliteTable('email_queue', {
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
params: text('params').notNull(), // JSON-encoded TemplateEmailJobParams
|
|
||||||
status: text('status', { enum: ['pending', 'processing', 'sent', 'failed'] }).notNull().default('pending'),
|
|
||||||
attempts: integer('attempts').notNull().default(0),
|
|
||||||
lastError: text('last_error'),
|
|
||||||
createdAt: text('created_at').notNull(),
|
|
||||||
processedAt: text('processed_at'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Legal Pages table for admin-editable legal content
|
// Legal Pages table for admin-editable legal content
|
||||||
export const sqliteLegalPages = sqliteTable('legal_pages', {
|
export const sqliteLegalPages = sqliteTable('legal_pages', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -373,8 +351,6 @@ export const pgUsers = pgTable('users', {
|
|||||||
googleId: varchar('google_id', { length: 255 }),
|
googleId: varchar('google_id', { length: 255 }),
|
||||||
rucNumber: varchar('ruc_number', { length: 15 }),
|
rucNumber: varchar('ruc_number', { length: 15 }),
|
||||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
|
||||||
tokenVersion: pgInteger('token_version').notNull().default(0),
|
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -491,10 +467,6 @@ export const pgPaymentOptions = pgTable('payment_options', {
|
|||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0),
|
tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0),
|
||||||
tpagoLink: varchar('tpago_link', { length: 500 }),
|
tpagoLink: varchar('tpago_link', { length: 500 }),
|
||||||
tpagoLink2: varchar('tpago_link_2', { length: 500 }),
|
|
||||||
tpagoLink3: varchar('tpago_link_3', { length: 500 }),
|
|
||||||
tpagoLink4: varchar('tpago_link_4', { length: 500 }),
|
|
||||||
tpagoLink5: varchar('tpago_link_5', { length: 500 }),
|
|
||||||
tpagoInstructions: pgText('tpago_instructions'),
|
tpagoInstructions: pgText('tpago_instructions'),
|
||||||
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
||||||
bankTransferEnabled: pgInteger('bank_transfer_enabled').notNull().default(0),
|
bankTransferEnabled: pgInteger('bank_transfer_enabled').notNull().default(0),
|
||||||
@@ -520,10 +492,6 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
|
|||||||
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||||
tpagoEnabled: pgInteger('tpago_enabled'),
|
tpagoEnabled: pgInteger('tpago_enabled'),
|
||||||
tpagoLink: varchar('tpago_link', { length: 500 }),
|
tpagoLink: varchar('tpago_link', { length: 500 }),
|
||||||
tpagoLink2: varchar('tpago_link_2', { length: 500 }),
|
|
||||||
tpagoLink3: varchar('tpago_link_3', { length: 500 }),
|
|
||||||
tpagoLink4: varchar('tpago_link_4', { length: 500 }),
|
|
||||||
tpagoLink5: varchar('tpago_link_5', { length: 500 }),
|
|
||||||
tpagoInstructions: pgText('tpago_instructions'),
|
tpagoInstructions: pgText('tpago_instructions'),
|
||||||
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
tpagoInstructionsEs: pgText('tpago_instructions_es'),
|
||||||
bankTransferEnabled: pgInteger('bank_transfer_enabled'),
|
bankTransferEnabled: pgInteger('bank_transfer_enabled'),
|
||||||
@@ -620,18 +588,6 @@ export const pgEmailSettings = pgTable('email_settings', {
|
|||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Durable email queue. Jobs survive process restarts; a startup recovery step
|
|
||||||
// resets any 'processing' rows back to 'pending'.
|
|
||||||
export const pgEmailQueue = pgTable('email_queue', {
|
|
||||||
id: uuid('id').primaryKey(),
|
|
||||||
params: pgText('params').notNull(), // JSON-encoded TemplateEmailJobParams
|
|
||||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
|
||||||
attempts: pgInteger('attempts').notNull().default(0),
|
|
||||||
lastError: pgText('last_error'),
|
|
||||||
createdAt: timestamp('created_at').notNull(),
|
|
||||||
processedAt: timestamp('processed_at'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Legal Pages table for admin-editable legal content
|
// Legal Pages table for admin-editable legal content
|
||||||
export const pgLegalPages = pgTable('legal_pages', {
|
export const pgLegalPages = pgTable('legal_pages', {
|
||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
@@ -719,7 +675,6 @@ export const auditLogs = dbType === 'postgres' ? pgAuditLogs : sqliteAuditLogs;
|
|||||||
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
|
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
|
||||||
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
|
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
|
||||||
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
|
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
|
||||||
export const emailQueue = dbType === 'postgres' ? pgEmailQueue : sqliteEmailQueue;
|
|
||||||
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
|
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
|
||||||
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
|
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
|
||||||
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
|
||||||
|
|||||||
+15
-82
@@ -25,9 +25,6 @@ import legalSettingsRoutes from './routes/legal-settings.js';
|
|||||||
import faqRoutes from './routes/faq.js';
|
import faqRoutes from './routes/faq.js';
|
||||||
import emailService from './lib/email.js';
|
import emailService from './lib/email.js';
|
||||||
import { initEmailQueue } from './lib/emailQueue.js';
|
import { initEmailQueue } from './lib/emailQueue.js';
|
||||||
import { startBookingCleanup } from './lib/bookingCleanup.js';
|
|
||||||
import { getLock } from './lib/stores/lock.js';
|
|
||||||
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
|
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -59,19 +56,6 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Baseline security headers on every response.
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
app.use('*', async (c, next) => {
|
|
||||||
await next();
|
|
||||||
c.header('X-Content-Type-Options', 'nosniff');
|
|
||||||
c.header('X-Frame-Options', 'DENY');
|
|
||||||
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
||||||
c.header('X-XSS-Protection', '0');
|
|
||||||
if (isProduction) {
|
|
||||||
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// OpenAPI specification
|
// OpenAPI specification
|
||||||
const openApiSpec = {
|
const openApiSpec = {
|
||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
@@ -673,21 +657,11 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/events/next': {
|
|
||||||
get: {
|
|
||||||
tags: ['Events'],
|
|
||||||
summary: 'Get next upcoming event (chronological)',
|
|
||||||
description: 'Get the single earliest upcoming published event, ignoring featured promotion.',
|
|
||||||
responses: {
|
|
||||||
200: { description: 'Next event or null' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/api/events/next/upcoming': {
|
'/api/events/next/upcoming': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Events'],
|
tags: ['Events'],
|
||||||
summary: 'Get next upcoming event',
|
summary: 'Get next upcoming event',
|
||||||
description: 'Get the featured event if valid, otherwise the single next upcoming published event.',
|
description: 'Get the single next upcoming published event.',
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: 'Next event or null' },
|
200: { description: 'Next event or null' },
|
||||||
},
|
},
|
||||||
@@ -1853,46 +1827,20 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// API documentation is disabled in production to avoid exposing the full API
|
// OpenAPI JSON endpoint
|
||||||
// surface (and schema) to anonymous users. Enable locally / in non-prod only.
|
app.get('/openapi.json', (c) => {
|
||||||
if (!isProduction) {
|
return c.json(openApiSpec);
|
||||||
// OpenAPI JSON endpoint
|
|
||||||
app.get('/openapi.json', (c) => {
|
|
||||||
return c.json(openApiSpec);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Swagger UI
|
|
||||||
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
|
|
||||||
} else {
|
|
||||||
app.get('/openapi.json', (c) => c.json({ error: 'Not Found' }, 404));
|
|
||||||
app.get('/api-docs', (c) => c.json({ error: 'Not Found' }, 404));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static file serving for uploads.
|
|
||||||
// Uploads are validated as images at write time, but as defense-in-depth we force
|
|
||||||
// any non-image path to download as an opaque attachment so a stray/legacy
|
|
||||||
// .html/.svg can never be rendered (and therefore never execute script) in-origin.
|
|
||||||
app.use('/uploads/*', async (c, next) => {
|
|
||||||
await next();
|
|
||||||
const path = c.req.path.toLowerCase();
|
|
||||||
const isInlineImage = /\.(jpg|jpeg|png|gif|webp|avif)$/.test(path);
|
|
||||||
if (!isInlineImage) {
|
|
||||||
c.header('Content-Disposition', 'attachment');
|
|
||||||
c.header('Content-Type', 'application/octet-stream');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Swagger UI
|
||||||
|
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
|
||||||
|
|
||||||
|
// Static file serving for uploads
|
||||||
app.use('/uploads/*', serveStatic({ root: './' }));
|
app.use('/uploads/*', serveStatic({ root: './' }));
|
||||||
|
|
||||||
// Health check.
|
// Health check
|
||||||
// Always returns 200 so a transient Redis blip does not cause the load balancer
|
|
||||||
// to pull a node; Redis/subsystem status is reported in the body for monitoring.
|
|
||||||
app.get('/health', (c) => {
|
app.get('/health', (c) => {
|
||||||
return c.json({
|
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
redis: describeRedis(),
|
|
||||||
backends: describeBackends(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
@@ -1929,30 +1877,15 @@ const port = parseInt(process.env.PORT || '3001');
|
|||||||
// Initialize email queue with the email service reference
|
// Initialize email queue with the email service reference
|
||||||
initEmailQueue(emailService);
|
initEmailQueue(emailService);
|
||||||
|
|
||||||
// Periodically expire abandoned pending bookings so they stop holding seats.
|
// Initialize email templates on startup
|
||||||
startBookingCleanup();
|
emailService.seedDefaultTemplates().catch(err => {
|
||||||
|
console.error('[Email] Failed to seed templates:', err);
|
||||||
// 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.
|
|
||||||
getLock()
|
|
||||||
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
|
|
||||||
.then((result) => {
|
|
||||||
if (result === null) {
|
|
||||||
console.log('[Email] Template seeding skipped (another instance holds the lock)');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[Email] Failed to seed templates:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`🚀 Spanglish API server starting on port ${port}`);
|
console.log(`🚀 Spanglish API server starting on port ${port}`);
|
||||||
console.log(`📚 API docs available at http://localhost:${port}/api-docs`);
|
console.log(`📚 API docs available at http://localhost:${port}/api-docs`);
|
||||||
console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
|
console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
|
||||||
|
|
||||||
// Log which backend (memory/redis, local/s3) each subsystem selected.
|
|
||||||
logSelectedBackends();
|
|
||||||
|
|
||||||
serve({
|
serve({
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port,
|
port,
|
||||||
|
|||||||
+14
-126
@@ -4,21 +4,10 @@ import bcrypt from 'bcryptjs';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { Context } from 'hono';
|
import { Context } from 'hono';
|
||||||
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||||
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
|
import { eq, and, gt } from 'drizzle-orm';
|
||||||
import { generateId, getNow, toDbDate } from './utils.js';
|
import { generateId, getNow, toDbDate } from './utils.js';
|
||||||
|
|
||||||
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
|
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
|
||||||
const rawJwtSecret = process.env.JWT_SECRET;
|
|
||||||
|
|
||||||
// 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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
|
|
||||||
const JWT_ISSUER = 'spanglish';
|
const JWT_ISSUER = 'spanglish';
|
||||||
const JWT_AUDIENCE = 'spanglish-app';
|
const JWT_AUDIENCE = 'spanglish-app';
|
||||||
|
|
||||||
@@ -26,7 +15,6 @@ export interface JWTPayload {
|
|||||||
sub: string;
|
sub: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
tokenVersion?: number;
|
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
}
|
}
|
||||||
@@ -96,36 +84,23 @@ export async function verifyMagicLinkToken(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use a single generic error for all invalid states to avoid leaking token state
|
|
||||||
const genericError = 'Invalid or expired token';
|
|
||||||
|
|
||||||
if (!tokenRecord) {
|
if (!tokenRecord) {
|
||||||
return { valid: false, error: genericError };
|
return { valid: false, error: 'Invalid token' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenRecord.usedAt) {
|
if (tokenRecord.usedAt) {
|
||||||
return { valid: false, error: genericError };
|
return { valid: false, error: 'Token already used' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
||||||
return { valid: false, error: genericError };
|
return { valid: false, error: 'Token expired' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomically consume the token: only the request that flips used_at from NULL wins.
|
// Mark token as used
|
||||||
// This prevents a double-spend race where two concurrent requests both pass the
|
await (db as any)
|
||||||
// read-time "not used" check above.
|
|
||||||
const result: any = await (db as any)
|
|
||||||
.update(magicLinkTokens)
|
.update(magicLinkTokens)
|
||||||
.set({ usedAt: now })
|
.set({ usedAt: now })
|
||||||
.where(and(
|
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
|
||||||
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 };
|
return { valid: true, userId: tokenRecord.userId };
|
||||||
}
|
}
|
||||||
@@ -192,67 +167,26 @@ export async function invalidateAllUserSessions(userId: string): Promise<void> {
|
|||||||
.where(eq((userSessions as any).userId, userId));
|
.where(eq((userSessions as any).userId, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small blocklist of common/weak passwords (and obvious app-specific ones).
|
// Password validation (min 10 characters per spec)
|
||||||
// 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 } {
|
export function validatePassword(password: string): { valid: boolean; error?: string } {
|
||||||
if (password.length < 10) {
|
if (password.length < 10) {
|
||||||
return { valid: false, error: 'Password must be at least 10 characters long' };
|
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 };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
|
export async function createToken(userId: string, email: string, role: string): Promise<string> {
|
||||||
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
|
const token = await new jose.SignJWT({ sub: userId, email, role })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setIssuer(JWT_ISSUER)
|
.setIssuer(JWT_ISSUER)
|
||||||
.setAudience(JWT_AUDIENCE)
|
.setAudience(JWT_AUDIENCE)
|
||||||
.setExpirationTime('1d')
|
.setExpirationTime('7d')
|
||||||
.sign(JWT_SECRET);
|
.sign(JWT_SECRET);
|
||||||
|
|
||||||
return token;
|
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> {
|
export async function createRefreshToken(userId: string): Promise<string> {
|
||||||
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
@@ -289,44 +223,10 @@ export async function getAuthUser(c: Context): Promise<any | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never load the password hash into request context — it is only needed for
|
|
||||||
// explicit password-verification routes that query it separately.
|
|
||||||
const user = await dbGet<any>(
|
const user = await dbGet<any>(
|
||||||
(db as any)
|
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
|
||||||
.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))
|
|
||||||
);
|
);
|
||||||
|
return user || null;
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject tokens issued before a logout-everywhere / password change
|
|
||||||
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suspended/unclaimed accounts must not retain API access via an old JWT
|
|
||||||
if (user.accountStatus && user.accountStatus !== 'active') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireAuth(roles?: string[]) {
|
export function requireAuth(roles?: string[]) {
|
||||||
@@ -352,15 +252,3 @@ export async function isFirstUser(): Promise<boolean> {
|
|||||||
);
|
);
|
||||||
return !result || result.length === 0;
|
return !result || result.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch only the password hash column (never expose via getAuthUser). */
|
|
||||||
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))
|
|
||||||
);
|
|
||||||
const hash = row?.password;
|
|
||||||
return hash && String(hash).length > 0 ? String(hash) : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
// Reports which backend each scalable subsystem is using, for the health
|
|
||||||
// endpoint and startup logging.
|
|
||||||
|
|
||||||
import { isRedisEnabled, isRedisHealthy } 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 { getStorage } from './storage.js';
|
|
||||||
|
|
||||||
export function describeBackends() {
|
|
||||||
return {
|
|
||||||
cache: getCache().backend,
|
|
||||||
rateLimiter: getRateLimiter().backend,
|
|
||||||
pubsub: getPubSub().backend,
|
|
||||||
lock: getLock().backend,
|
|
||||||
storage: getStorage().backend,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeRedis() {
|
|
||||||
return { enabled: isRedisEnabled(), healthy: isRedisHealthy() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Log one line per subsystem at startup so the active backend is obvious. */
|
|
||||||
export function logSelectedBackends(): void {
|
|
||||||
const b = describeBackends();
|
|
||||||
const r = describeRedis();
|
|
||||||
console.log('[startup] Subsystem backends:');
|
|
||||||
console.log(` redis: ${r.enabled ? 'enabled' : 'disabled (in-memory fallback)'}`);
|
|
||||||
console.log(` cache: ${b.cache}`);
|
|
||||||
console.log(` rate limiter: ${b.rateLimiter}`);
|
|
||||||
console.log(` pub/sub: ${b.pubsub}`);
|
|
||||||
console.log(` lock: ${b.lock}`);
|
|
||||||
console.log(` storage: ${b.storage}`);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// Expire stale pending bookings.
|
|
||||||
//
|
|
||||||
// When a booking is started, its tickets are created with status 'pending' and
|
|
||||||
// a 'pending' payment. Pending tickets count toward an event's capacity, so an
|
|
||||||
// abandoned checkout would otherwise hold those seats forever. This job cancels
|
|
||||||
// pending tickets whose payment is still 'pending' (i.e. never paid and not
|
|
||||||
// awaiting admin approval) after a configurable TTL, freeing the seats.
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
function getTtlMs(): number {
|
|
||||||
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
|
|
||||||
return (Number.isFinite(minutes) && minutes > 0 ? minutes : 30) * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel stale pending bookings. Returns the number of tickets cancelled.
|
|
||||||
*
|
|
||||||
* A booking is considered stale when its payment is still 'pending' (not
|
|
||||||
* 'pending_approval', which means an admin is reviewing a manual transfer) and
|
|
||||||
* older than PENDING_BOOKING_TTL_MINUTES.
|
|
||||||
*/
|
|
||||||
export async function cleanupStalePendingBookings(): Promise<number> {
|
|
||||||
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
|
|
||||||
|
|
||||||
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
|
|
||||||
(db as any)
|
|
||||||
.select({
|
|
||||||
ticketId: (payments as any).ticketId,
|
|
||||||
paymentId: (payments as any).id,
|
|
||||||
})
|
|
||||||
.from(payments)
|
|
||||||
.where(and(
|
|
||||||
eq((payments as any).status, 'pending'),
|
|
||||||
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();
|
|
||||||
|
|
||||||
let cancelledTickets = 0;
|
|
||||||
if (ticketIds.length > 0) {
|
|
||||||
const result: any = await (db as any)
|
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'cancelled' })
|
|
||||||
.where(and(
|
|
||||||
inArray((tickets as any).id, ticketIds),
|
|
||||||
eq((tickets as any).status, 'pending')
|
|
||||||
));
|
|
||||||
cancelledTickets = result?.changes ?? result?.rowCount ?? ticketIds.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({ status: 'failed', updatedAt: now })
|
|
||||||
.where(inArray((payments as any).id, paymentIds));
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[BookingCleanup] Expired ${stale.length} stale pending payment(s); ` +
|
|
||||||
`cancelled ${cancelledTickets} ticket(s).`
|
|
||||||
);
|
|
||||||
return cancelledTickets;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a periodic cleanup of stale pending bookings. Each run is guarded by a
|
|
||||||
* distributed lock so that, across multiple replicas, only one instance does
|
|
||||||
* the work per interval.
|
|
||||||
*/
|
|
||||||
export function startBookingCleanup(): void {
|
|
||||||
const intervalMs = parseInt(process.env.PENDING_BOOKING_CLEANUP_INTERVAL_MS || '300000', 10); // 5 min
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
getLock()
|
|
||||||
.withLock('cleanup-pending-bookings', Math.min(intervalMs, 60_000), () =>
|
|
||||||
cleanupStalePendingBookings()
|
|
||||||
)
|
|
||||||
.catch((err) =>
|
|
||||||
console.error('[BookingCleanup] Run failed:', err?.message || err)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run shortly after startup, then on the interval.
|
|
||||||
setTimeout(run, 30_000).unref?.();
|
|
||||||
cleanupTimer = setInterval(run, intervalMs);
|
|
||||||
cleanupTimer.unref?.();
|
|
||||||
console.log(`[BookingCleanup] Scheduled every ${Math.round(intervalMs / 1000)}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopBookingCleanup(): void {
|
|
||||||
if (cleanupTimer) {
|
|
||||||
clearInterval(cleanupTimer);
|
|
||||||
cleanupTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1394
-56
File diff suppressed because it is too large
Load Diff
@@ -1,96 +0,0 @@
|
|||||||
// High-level booking confirmation email sender.
|
|
||||||
|
|
||||||
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { sendTemplateEmail } from './templateService.js';
|
|
||||||
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send booking confirmation email
|
|
||||||
* Supports multi-ticket bookings - includes all tickets in the booking
|
|
||||||
*/
|
|
||||||
export async function sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
// Get ticket with event info
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).id, ticketId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ticket) {
|
|
||||||
return { success: false, error: 'Ticket not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(events)
|
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return { success: false, error: 'Event not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all tickets in this booking (if multi-ticket)
|
|
||||||
let allTickets: any[] = [ticket];
|
|
||||||
if (ticket.bookingId) {
|
|
||||||
allTickets = await dbAll(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticketCount = allTickets.length;
|
|
||||||
const locale = ticket.preferredLanguage || 'en';
|
|
||||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
||||||
|
|
||||||
// Generate ticket PDF URL (primary ticket, or use combined endpoint for multi)
|
|
||||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
|
||||||
const ticketPdfUrl = ticketCount > 1 && ticket.bookingId
|
|
||||||
? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf`
|
|
||||||
: `${apiUrl}/api/tickets/${ticket.id}/pdf`;
|
|
||||||
|
|
||||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
||||||
|
|
||||||
// Build attendee list for multi-ticket emails
|
|
||||||
const attendeeNames = allTickets.map(t =>
|
|
||||||
`${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim()
|
|
||||||
).join(', ');
|
|
||||||
|
|
||||||
// Calculate total price for multi-ticket bookings
|
|
||||||
const totalPrice = event.price * ticketCount;
|
|
||||||
|
|
||||||
// Get site timezone for proper date/time formatting
|
|
||||||
const timezone = await getSiteTimezone();
|
|
||||||
|
|
||||||
return sendTemplateEmail({
|
|
||||||
templateSlug: 'booking-confirmation',
|
|
||||||
to: ticket.attendeeEmail,
|
|
||||||
toName: attendeeFullName,
|
|
||||||
locale,
|
|
||||||
eventId: event.id,
|
|
||||||
variables: {
|
|
||||||
attendeeName: attendeeFullName,
|
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
|
||||||
ticketId: ticket.id,
|
|
||||||
bookingId: ticket.bookingId || ticket.id,
|
|
||||||
qrCode: ticket.qrCode || '',
|
|
||||||
ticketPdfUrl,
|
|
||||||
eventTitle,
|
|
||||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
|
||||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
|
||||||
eventLocation: event.location,
|
|
||||||
eventLocationUrl: event.locationUrl || '',
|
|
||||||
eventPrice: formatCurrency(event.price, event.currency),
|
|
||||||
// Multi-ticket specific variables
|
|
||||||
ticketCount: ticketCount.toString(),
|
|
||||||
totalPrice: formatCurrency(totalPrice, event.currency),
|
|
||||||
attendeeNames,
|
|
||||||
isMultiTicket: ticketCount > 1 ? 'true' : 'false',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// Event-wide bulk email sending via the background queue.
|
|
||||||
|
|
||||||
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
|
|
||||||
import { eq, and } from 'drizzle-orm';
|
|
||||||
import { enqueueBulkEmails, type TemplateEmailJobParams } from '../emailQueue.js';
|
|
||||||
import { getTemplate } from './templateService.js';
|
|
||||||
import { formatDate, formatTime, getSiteTimezone } from './formatting.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue emails for event attendees (non-blocking).
|
|
||||||
* Adds all matching recipients to the background email queue and returns immediately.
|
|
||||||
* Rate limiting and actual sending is handled by the email queue.
|
|
||||||
*/
|
|
||||||
export async function queueEventEmails(params: {
|
|
||||||
eventId: string;
|
|
||||||
templateSlug: string;
|
|
||||||
customVariables?: Record<string, any>;
|
|
||||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
|
||||||
sentBy: string;
|
|
||||||
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
|
|
||||||
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
|
||||||
|
|
||||||
// Validate event exists
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(events)
|
|
||||||
.where(eq((events as any).id, eventId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return { success: false, queuedCount: 0, error: 'Event not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate template exists
|
|
||||||
const template = await getTemplate(templateSlug);
|
|
||||||
if (!template) {
|
|
||||||
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get tickets based on filter
|
|
||||||
let ticketQuery = (db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).eventId, eventId));
|
|
||||||
|
|
||||||
if (recipientFilter !== 'all') {
|
|
||||||
ticketQuery = ticketQuery.where(
|
|
||||||
and(
|
|
||||||
eq((tickets as any).eventId, eventId),
|
|
||||||
eq((tickets as any).status, recipientFilter)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTickets = await dbAll<any>(ticketQuery);
|
|
||||||
|
|
||||||
if (eventTickets.length === 0) {
|
|
||||||
return { success: true, queuedCount: 0, error: 'No recipients found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get site timezone for proper date/time formatting
|
|
||||||
const timezone = await getSiteTimezone();
|
|
||||||
|
|
||||||
// Build individual email jobs for the queue
|
|
||||||
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
|
|
||||||
const locale = ticket.preferredLanguage || 'en';
|
|
||||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
||||||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
||||||
|
|
||||||
return {
|
|
||||||
templateSlug,
|
|
||||||
to: ticket.attendeeEmail,
|
|
||||||
toName: fullName,
|
|
||||||
locale,
|
|
||||||
eventId: event.id,
|
|
||||||
sentBy,
|
|
||||||
variables: {
|
|
||||||
attendeeName: fullName,
|
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
|
||||||
ticketId: ticket.id,
|
|
||||||
eventTitle,
|
|
||||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
|
||||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
|
||||||
eventLocation: event.location,
|
|
||||||
eventLocationUrl: event.locationUrl || '',
|
|
||||||
...customVariables,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enqueue all emails for background processing
|
|
||||||
enqueueBulkEmails(jobs);
|
|
||||||
|
|
||||||
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
queuedCount: jobs.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// Shared formatting helpers and common template variables for emails.
|
|
||||||
|
|
||||||
import { db, dbGet, siteSettings } from '../../db/index.js';
|
|
||||||
import { getCache } from '../stores/cache.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get common variables for all emails
|
|
||||||
*/
|
|
||||||
export function getCommonVariables(): Record<string, string> {
|
|
||||||
return {
|
|
||||||
siteName: 'Spanglish',
|
|
||||||
siteUrl: process.env.FRONTEND_URL || 'https://spanglish.com',
|
|
||||||
currentYear: new Date().getFullYear().toString(),
|
|
||||||
supportEmail: process.env.EMAIL_FROM || 'hello@spanglish.com',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the site timezone from settings (cached for performance).
|
|
||||||
* Cached for a short TTL via the cache abstraction (in-memory or Redis).
|
|
||||||
*/
|
|
||||||
export async function getSiteTimezone(): Promise<string> {
|
|
||||||
const cached = await getCache().get<string>('site:timezone');
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const settings = await dbGet<any>(
|
|
||||||
(db as any).select().from(siteSettings).limit(1)
|
|
||||||
);
|
|
||||||
const timezone = settings?.timezone || 'America/Asuncion';
|
|
||||||
await getCache().set('site:timezone', timezone, 60);
|
|
||||||
return timezone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date for emails using site timezone
|
|
||||||
*/
|
|
||||||
export function formatDate(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
timeZone: timezone,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format time for emails using site timezone
|
|
||||||
*/
|
|
||||||
export function formatTime(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
timeZone: timezone,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format currency for emails. Kept distinct from lib/utils.ts formatCurrency
|
|
||||||
* because the email output format ("12.345 PYG" / "$10.00 USD") must not change.
|
|
||||||
*/
|
|
||||||
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
|
||||||
if (currency === 'PYG') {
|
|
||||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
|
||||||
}
|
|
||||||
return `$${amount.toFixed(2)} ${currency}`;
|
|
||||||
}
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
// High-level payment-related email senders and payment config resolution.
|
|
||||||
|
|
||||||
import { db, dbGet, dbAll, events, tickets, payments, paymentOptions, eventPaymentOverrides } from '../../db/index.js';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { sendTemplateEmail } from './templateService.js';
|
|
||||||
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send payment receipt email
|
|
||||||
*/
|
|
||||||
export async function sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
// Get payment with ticket and event info
|
|
||||||
const payment = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(payments)
|
|
||||||
.where(eq((payments as any).id, paymentId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!payment) {
|
|
||||||
return { success: false, error: 'Payment not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ticket) {
|
|
||||||
return { success: false, error: 'Ticket not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(events)
|
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return { success: false, error: 'Event not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total amount for multi-ticket bookings
|
|
||||||
let totalAmount = payment.amount;
|
|
||||||
let ticketCount = 1;
|
|
||||||
|
|
||||||
if (ticket.bookingId) {
|
|
||||||
// Get all payments for this booking
|
|
||||||
const bookingTickets = await dbAll<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
||||||
);
|
|
||||||
|
|
||||||
ticketCount = bookingTickets.length;
|
|
||||||
|
|
||||||
// Sum up all payment amounts for the booking
|
|
||||||
const bookingPayments = await Promise.all(
|
|
||||||
bookingTickets.map((t: any) =>
|
|
||||||
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
totalAmount = bookingPayments
|
|
||||||
.filter((p: any) => p)
|
|
||||||
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = ticket.preferredLanguage || 'en';
|
|
||||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
||||||
|
|
||||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
|
||||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
|
|
||||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
||||||
|
|
||||||
// Format amount with ticket count info for multi-ticket bookings
|
|
||||||
const amountDisplay = ticketCount > 1
|
|
||||||
? `${formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
|
|
||||||
: formatCurrency(totalAmount, payment.currency);
|
|
||||||
|
|
||||||
// Get site timezone for proper date/time formatting
|
|
||||||
const timezone = await getSiteTimezone();
|
|
||||||
|
|
||||||
return sendTemplateEmail({
|
|
||||||
templateSlug: 'payment-receipt',
|
|
||||||
to: ticket.attendeeEmail,
|
|
||||||
toName: receiptFullName,
|
|
||||||
locale,
|
|
||||||
eventId: event.id,
|
|
||||||
variables: {
|
|
||||||
attendeeName: receiptFullName,
|
|
||||||
ticketId: ticket.bookingId || ticket.id,
|
|
||||||
eventTitle,
|
|
||||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
|
||||||
paymentAmount: amountDisplay,
|
|
||||||
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
|
|
||||||
paymentReference: payment.reference || payment.id,
|
|
||||||
paymentDate: formatDate(payment.paidAt || payment.createdAt, locale, timezone),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get merged payment configuration for an event (global + overrides)
|
|
||||||
*/
|
|
||||||
export async function getPaymentConfig(eventId: string): Promise<Record<string, any>> {
|
|
||||||
// Get global options
|
|
||||||
const globalOptions = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(paymentOptions)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get event overrides
|
|
||||||
const overrides = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(eventPaymentOverrides)
|
|
||||||
.where(eq((eventPaymentOverrides as any).eventId, eventId))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Defaults
|
|
||||||
const defaults = {
|
|
||||||
tpagoEnabled: false,
|
|
||||||
tpagoLink: null,
|
|
||||||
tpagoLink2: null,
|
|
||||||
tpagoLink3: null,
|
|
||||||
tpagoLink4: null,
|
|
||||||
tpagoLink5: null,
|
|
||||||
tpagoInstructions: null,
|
|
||||||
tpagoInstructionsEs: null,
|
|
||||||
bankTransferEnabled: false,
|
|
||||||
bankName: null,
|
|
||||||
bankAccountHolder: null,
|
|
||||||
bankAccountNumber: null,
|
|
||||||
bankAlias: null,
|
|
||||||
bankPhone: null,
|
|
||||||
bankNotes: null,
|
|
||||||
bankNotesEs: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const global = globalOptions || defaults;
|
|
||||||
|
|
||||||
// Merge: override values take precedence if they're not null/undefined
|
|
||||||
return {
|
|
||||||
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
|
||||||
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
|
||||||
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
|
|
||||||
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
|
|
||||||
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
|
|
||||||
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
|
|
||||||
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
|
||||||
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
|
||||||
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
|
||||||
bankName: overrides?.bankName ?? global.bankName,
|
|
||||||
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
|
|
||||||
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
|
|
||||||
bankAlias: overrides?.bankAlias ?? global.bankAlias,
|
|
||||||
bankPhone: overrides?.bankPhone ?? global.bankPhone,
|
|
||||||
bankNotes: overrides?.bankNotes ?? global.bankNotes,
|
|
||||||
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send payment instructions email (for TPago or Bank Transfer)
|
|
||||||
* This email is sent immediately after user clicks "Continue to Payment"
|
|
||||||
*/
|
|
||||||
export async function sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
// Get ticket
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).id, ticketId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ticket) {
|
|
||||||
return { success: false, error: 'Ticket not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get event
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(events)
|
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return { success: false, error: 'Event not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get payment
|
|
||||||
const payment = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(payments)
|
|
||||||
.where(eq((payments as any).ticketId, ticketId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!payment) {
|
|
||||||
return { success: false, error: 'Payment not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only send for manual payment methods
|
|
||||||
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
|
|
||||||
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get merged payment config for this event
|
|
||||||
const paymentConfig = await getPaymentConfig(event.id);
|
|
||||||
|
|
||||||
const locale = ticket.preferredLanguage || 'en';
|
|
||||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
||||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
||||||
|
|
||||||
// Calculate total price for multi-ticket bookings
|
|
||||||
let totalPrice = event.price;
|
|
||||||
let ticketCount = 1;
|
|
||||||
|
|
||||||
if (ticket.bookingId) {
|
|
||||||
// Count all tickets in this booking
|
|
||||||
const bookingTickets = await dbAll<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
||||||
);
|
|
||||||
ticketCount = bookingTickets.length;
|
|
||||||
totalPrice = event.price * ticketCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a payment reference using booking ID or ticket ID
|
|
||||||
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
|
|
||||||
|
|
||||||
// Generate the booking URL for returning to payment page
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
|
||||||
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
|
||||||
|
|
||||||
// Determine which template to use
|
|
||||||
const templateSlug = payment.provider === 'tpago'
|
|
||||||
? 'payment-instructions-tpago'
|
|
||||||
: 'payment-instructions-bank-transfer';
|
|
||||||
|
|
||||||
// Format amount with ticket count info for multi-ticket bookings
|
|
||||||
const amountDisplay = ticketCount > 1
|
|
||||||
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
|
||||||
: formatCurrency(totalPrice, event.currency);
|
|
||||||
|
|
||||||
// Get site timezone for proper date/time formatting
|
|
||||||
const timezone = await getSiteTimezone();
|
|
||||||
|
|
||||||
// Build variables based on payment method
|
|
||||||
const variables: Record<string, any> = {
|
|
||||||
attendeeName: attendeeFullName,
|
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
|
||||||
ticketId: ticket.bookingId || ticket.id,
|
|
||||||
eventTitle,
|
|
||||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
|
||||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
|
||||||
eventLocation: event.location,
|
|
||||||
eventLocationUrl: event.locationUrl || '',
|
|
||||||
paymentAmount: amountDisplay,
|
|
||||||
paymentReference,
|
|
||||||
bookingUrl,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add payment-method specific variables
|
|
||||||
if (payment.provider === 'tpago') {
|
|
||||||
// Select the TPago link matching the number of tickets (1-5), falling back to the base link
|
|
||||||
const tpagoLinkKey = ticketCount <= 1 ? 'tpagoLink' : `tpagoLink${Math.min(ticketCount, 5)}`;
|
|
||||||
variables.tpagoLink = paymentConfig[tpagoLinkKey] || paymentConfig.tpagoLink || '';
|
|
||||||
} else {
|
|
||||||
// Bank transfer
|
|
||||||
variables.bankName = paymentConfig.bankName || '';
|
|
||||||
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
|
|
||||||
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
|
|
||||||
variables.bankAlias = paymentConfig.bankAlias || '';
|
|
||||||
variables.bankPhone = paymentConfig.bankPhone || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
|
|
||||||
|
|
||||||
return sendTemplateEmail({
|
|
||||||
templateSlug,
|
|
||||||
to: ticket.attendeeEmail,
|
|
||||||
toName: attendeeFullName,
|
|
||||||
locale,
|
|
||||||
eventId: event.id,
|
|
||||||
variables,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send payment rejection email
|
|
||||||
* This email is sent when admin rejects a TPago or Bank Transfer payment
|
|
||||||
*/
|
|
||||||
export async function sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
// Get payment
|
|
||||||
const payment = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(payments)
|
|
||||||
.where(eq((payments as any).id, paymentId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!payment) {
|
|
||||||
return { success: false, error: 'Payment not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get ticket
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ticket) {
|
|
||||||
return { success: false, error: 'Ticket not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get event
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(events)
|
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return { success: false, error: 'Event not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = ticket.preferredLanguage || 'en';
|
|
||||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
||||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
||||||
|
|
||||||
// Generate a new booking URL for the event
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
|
||||||
const newBookingUrl = `${frontendUrl}/book/${event.id}`;
|
|
||||||
|
|
||||||
// Get site timezone for proper date/time formatting
|
|
||||||
const timezone = await getSiteTimezone();
|
|
||||||
|
|
||||||
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
|
|
||||||
|
|
||||||
return sendTemplateEmail({
|
|
||||||
templateSlug: 'payment-rejected',
|
|
||||||
to: ticket.attendeeEmail,
|
|
||||||
toName: attendeeFullName,
|
|
||||||
locale,
|
|
||||||
eventId: event.id,
|
|
||||||
variables: {
|
|
||||||
attendeeName: attendeeFullName,
|
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
|
||||||
ticketId: ticket.id,
|
|
||||||
eventTitle,
|
|
||||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
|
||||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
|
||||||
eventLocation: event.location,
|
|
||||||
eventLocationUrl: event.locationUrl || '',
|
|
||||||
newBookingUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send payment reminder email
|
|
||||||
* This email is sent when admin wants to remind attendee about pending payment
|
|
||||||
*/
|
|
||||||
export async function sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
// Get payment
|
|
||||||
const payment = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(payments)
|
|
||||||
.where(eq((payments as any).id, paymentId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!payment) {
|
|
||||||
return { success: false, error: 'Payment not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only send for pending/pending_approval payments
|
|
||||||
if (!['pending', 'pending_approval'].includes(payment.status)) {
|
|
||||||
return { success: false, error: 'Payment reminder can only be sent for pending payments' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get ticket
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).id, payment.ticketId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!ticket) {
|
|
||||||
return { success: false, error: 'Ticket not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get event
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(events)
|
|
||||||
.where(eq((events as any).id, ticket.eventId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return { success: false, error: 'Event not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = ticket.preferredLanguage || 'en';
|
|
||||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
|
||||||
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
|
||||||
|
|
||||||
// Calculate total price for multi-ticket bookings
|
|
||||||
let totalPrice = event.price;
|
|
||||||
let ticketCount = 1;
|
|
||||||
|
|
||||||
if (ticket.bookingId) {
|
|
||||||
const bookingTickets = await dbAll<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(tickets)
|
|
||||||
.where(eq((tickets as any).bookingId, ticket.bookingId))
|
|
||||||
);
|
|
||||||
ticketCount = bookingTickets.length;
|
|
||||||
totalPrice = event.price * ticketCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the booking URL for returning to payment page
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
|
||||||
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
|
|
||||||
|
|
||||||
// Format amount with ticket count info for multi-ticket bookings
|
|
||||||
const amountDisplay = ticketCount > 1
|
|
||||||
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
|
|
||||||
: formatCurrency(totalPrice, event.currency);
|
|
||||||
|
|
||||||
// Get site timezone for proper date/time formatting
|
|
||||||
const timezone = await getSiteTimezone();
|
|
||||||
|
|
||||||
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
|
|
||||||
|
|
||||||
return sendTemplateEmail({
|
|
||||||
templateSlug: 'payment-reminder',
|
|
||||||
to: ticket.attendeeEmail,
|
|
||||||
toName: attendeeFullName,
|
|
||||||
locale,
|
|
||||||
eventId: event.id,
|
|
||||||
variables: {
|
|
||||||
attendeeName: attendeeFullName,
|
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
|
||||||
ticketId: ticket.bookingId || ticket.id,
|
|
||||||
eventTitle,
|
|
||||||
eventDate: formatDate(event.startDatetime, locale, timezone),
|
|
||||||
eventTime: formatTime(event.startDatetime, locale, timezone),
|
|
||||||
eventLocation: event.location,
|
|
||||||
eventLocationUrl: event.locationUrl || '',
|
|
||||||
paymentAmount: amountDisplay,
|
|
||||||
bookingUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
// Template DB access, seeding, and the core template/custom send + logging logic.
|
|
||||||
|
|
||||||
import { db, dbGet, emailTemplates, emailLogs } from '../../db/index.js';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { getNow, generateId } from '../utils.js';
|
|
||||||
import { replaceTemplateVariables, wrapInBaseTemplate, defaultTemplates } from '../emailTemplates.js';
|
|
||||||
import { sendEmail } from './transport.js';
|
|
||||||
import { getCommonVariables } from './formatting.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a template by slug
|
|
||||||
*/
|
|
||||||
export async function getTemplate(slug: string): Promise<any | null> {
|
|
||||||
const template = await dbGet(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(emailTemplates)
|
|
||||||
.where(eq((emailTemplates as any).slug, slug))
|
|
||||||
);
|
|
||||||
|
|
||||||
return template || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed default templates if they don't exist, and update system templates with latest content
|
|
||||||
*/
|
|
||||||
export async function seedDefaultTemplates(): Promise<void> {
|
|
||||||
console.log('[Email] Checking for default templates...');
|
|
||||||
|
|
||||||
for (const template of defaultTemplates) {
|
|
||||||
const existing = await getTemplate(template.slug);
|
|
||||||
const now = getNow();
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
console.log(`[Email] Creating template: ${template.name}`);
|
|
||||||
|
|
||||||
await (db as any).insert(emailTemplates).values({
|
|
||||||
id: generateId(),
|
|
||||||
name: template.name,
|
|
||||||
slug: template.slug,
|
|
||||||
subject: template.subject,
|
|
||||||
subjectEs: template.subjectEs,
|
|
||||||
bodyHtml: template.bodyHtml,
|
|
||||||
bodyHtmlEs: template.bodyHtmlEs,
|
|
||||||
bodyText: template.bodyText,
|
|
||||||
bodyTextEs: template.bodyTextEs,
|
|
||||||
description: template.description,
|
|
||||||
variables: JSON.stringify(template.variables),
|
|
||||||
isSystem: template.isSystem ? 1 : 0,
|
|
||||||
isActive: 1,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
} else if (existing.isSystem) {
|
|
||||||
// Update system templates with latest content from defaults
|
|
||||||
console.log(`[Email] Updating system template: ${template.name}`);
|
|
||||||
|
|
||||||
await (db as any)
|
|
||||||
.update(emailTemplates)
|
|
||||||
.set({
|
|
||||||
subject: template.subject,
|
|
||||||
subjectEs: template.subjectEs,
|
|
||||||
bodyHtml: template.bodyHtml,
|
|
||||||
bodyHtmlEs: template.bodyHtmlEs,
|
|
||||||
bodyText: template.bodyText,
|
|
||||||
bodyTextEs: template.bodyTextEs,
|
|
||||||
description: template.description,
|
|
||||||
variables: JSON.stringify(template.variables),
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((emailTemplates as any).slug, template.slug));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Email] Default templates check complete');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using a template
|
|
||||||
*/
|
|
||||||
export async function sendTemplateEmail(params: {
|
|
||||||
templateSlug: string;
|
|
||||||
to: string;
|
|
||||||
toName?: string;
|
|
||||||
variables: Record<string, any>;
|
|
||||||
locale?: string;
|
|
||||||
eventId?: string;
|
|
||||||
sentBy?: string;
|
|
||||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
|
||||||
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
|
|
||||||
|
|
||||||
// Get template
|
|
||||||
const template = await getTemplate(templateSlug);
|
|
||||||
if (!template) {
|
|
||||||
return { success: false, error: `Template "${templateSlug}" not found` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build variables
|
|
||||||
const allVariables = {
|
|
||||||
...getCommonVariables(),
|
|
||||||
lang: locale,
|
|
||||||
...variables,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get localized content
|
|
||||||
const subject = locale === 'es' && template.subjectEs
|
|
||||||
? template.subjectEs
|
|
||||||
: template.subject;
|
|
||||||
const bodyHtml = locale === 'es' && template.bodyHtmlEs
|
|
||||||
? template.bodyHtmlEs
|
|
||||||
: template.bodyHtml;
|
|
||||||
const bodyText = locale === 'es' && template.bodyTextEs
|
|
||||||
? template.bodyTextEs
|
|
||||||
: template.bodyText;
|
|
||||||
|
|
||||||
// Replace variables
|
|
||||||
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
|
||||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
|
|
||||||
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
|
||||||
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
|
|
||||||
|
|
||||||
// Create log entry
|
|
||||||
const logId = generateId();
|
|
||||||
const now = getNow();
|
|
||||||
|
|
||||||
await (db as any).insert(emailLogs).values({
|
|
||||||
id: logId,
|
|
||||||
templateId: template.id,
|
|
||||||
eventId: eventId || null,
|
|
||||||
recipientEmail: to,
|
|
||||||
recipientName: toName || null,
|
|
||||||
subject: finalSubject,
|
|
||||||
bodyHtml: finalBodyHtml,
|
|
||||||
status: 'pending',
|
|
||||||
sentBy: sentBy || null,
|
|
||||||
createdAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send email
|
|
||||||
const result = await sendEmail({
|
|
||||||
to,
|
|
||||||
subject: finalSubject,
|
|
||||||
html: finalBodyHtml,
|
|
||||||
text: finalBodyText,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update log with result
|
|
||||||
if (result.success) {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailLogs)
|
|
||||||
.set({
|
|
||||||
status: 'sent',
|
|
||||||
sentAt: getNow(),
|
|
||||||
})
|
|
||||||
.where(eq((emailLogs as any).id, logId));
|
|
||||||
} else {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailLogs)
|
|
||||||
.set({
|
|
||||||
status: 'failed',
|
|
||||||
errorMessage: result.error,
|
|
||||||
})
|
|
||||||
.where(eq((emailLogs as any).id, logId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: result.success,
|
|
||||||
logId,
|
|
||||||
error: result.error
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a custom email (not from template)
|
|
||||||
*/
|
|
||||||
export async function sendCustomEmail(params: {
|
|
||||||
to: string;
|
|
||||||
toName?: string;
|
|
||||||
subject: string;
|
|
||||||
bodyHtml: string;
|
|
||||||
bodyText?: string;
|
|
||||||
replyTo?: string;
|
|
||||||
eventId?: string;
|
|
||||||
sentBy?: string | null;
|
|
||||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
|
||||||
const { to: rawTo, toName, subject: rawSubject, bodyHtml, bodyText, replyTo: rawReplyTo, eventId, sentBy = null } = params;
|
|
||||||
|
|
||||||
// Strip CR/LF from header-bound values to prevent email header injection
|
|
||||||
// (e.g. an attacker-supplied subject/replyTo smuggling extra headers/recipients).
|
|
||||||
const stripHeader = (v?: string) => (v ? v.replace(/[\r\n]+/g, ' ').trim() : v);
|
|
||||||
const to = stripHeader(rawTo) as string;
|
|
||||||
const subject = stripHeader(rawSubject) as string;
|
|
||||||
const replyTo = stripHeader(rawReplyTo);
|
|
||||||
|
|
||||||
const allVariables = {
|
|
||||||
...getCommonVariables(),
|
|
||||||
subject,
|
|
||||||
};
|
|
||||||
|
|
||||||
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
|
|
||||||
|
|
||||||
// Create log entry
|
|
||||||
const logId = generateId();
|
|
||||||
const now = getNow();
|
|
||||||
|
|
||||||
await (db as any).insert(emailLogs).values({
|
|
||||||
id: logId,
|
|
||||||
templateId: null,
|
|
||||||
eventId: eventId || null,
|
|
||||||
recipientEmail: to,
|
|
||||||
recipientName: toName || null,
|
|
||||||
subject,
|
|
||||||
bodyHtml: finalBodyHtml,
|
|
||||||
status: 'pending',
|
|
||||||
sentBy: sentBy || null,
|
|
||||||
createdAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send email
|
|
||||||
const result = await sendEmail({
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
html: finalBodyHtml,
|
|
||||||
text: bodyText,
|
|
||||||
replyTo,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update log
|
|
||||||
if (result.success) {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailLogs)
|
|
||||||
.set({
|
|
||||||
status: 'sent',
|
|
||||||
sentAt: getNow(),
|
|
||||||
})
|
|
||||||
.where(eq((emailLogs as any).id, logId));
|
|
||||||
} else {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailLogs)
|
|
||||||
.set({
|
|
||||||
status: 'failed',
|
|
||||||
errorMessage: result.error,
|
|
||||||
})
|
|
||||||
.where(eq((emailLogs as any).id, logId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: result.success,
|
|
||||||
logId,
|
|
||||||
error: result.error
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resend an email from an existing log entry
|
|
||||||
*/
|
|
||||||
export async function resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const log = await dbGet<any>(
|
|
||||||
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!log) {
|
|
||||||
return { success: false, error: 'Email log not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
|
|
||||||
return { success: false, error: 'Email log missing required data to resend' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await sendEmail({
|
|
||||||
to: log.recipientEmail,
|
|
||||||
subject: log.subject,
|
|
||||||
html: log.bodyHtml,
|
|
||||||
text: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = getNow();
|
|
||||||
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailLogs)
|
|
||||||
.set({
|
|
||||||
status: 'sent',
|
|
||||||
sentAt: now,
|
|
||||||
errorMessage: null,
|
|
||||||
resendAttempts: currentResendAttempts,
|
|
||||||
lastResentAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((emailLogs as any).id, logId));
|
|
||||||
} else {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailLogs)
|
|
||||||
.set({
|
|
||||||
status: 'failed',
|
|
||||||
errorMessage: result.error,
|
|
||||||
resendAttempts: currentResendAttempts,
|
|
||||||
lastResentAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((emailLogs as any).id, logId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: result.success,
|
|
||||||
error: result.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
// Email transport layer: provider configuration, SMTP setup, and the low-level
|
|
||||||
// sendEmail router. No template rendering or DB logging happens here.
|
|
||||||
|
|
||||||
import nodemailer from 'nodemailer';
|
|
||||||
import type { Transporter } from 'nodemailer';
|
|
||||||
|
|
||||||
// ==================== Types ====================
|
|
||||||
|
|
||||||
export interface SendEmailOptions {
|
|
||||||
to: string | string[];
|
|
||||||
subject: string;
|
|
||||||
html: string;
|
|
||||||
text?: string;
|
|
||||||
replyTo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendEmailResult {
|
|
||||||
success: boolean;
|
|
||||||
messageId?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EmailProvider = 'resend' | 'smtp' | 'console';
|
|
||||||
|
|
||||||
// ==================== Provider Configuration ====================
|
|
||||||
|
|
||||||
function getEmailProvider(): EmailProvider {
|
|
||||||
const provider = (process.env.EMAIL_PROVIDER || 'console').toLowerCase();
|
|
||||||
if (provider === 'resend' || provider === 'smtp' || provider === 'console') {
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
console.warn(`[Email] Unknown provider "${provider}", falling back to console`);
|
|
||||||
return 'console';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFromEmail(): string {
|
|
||||||
return process.env.EMAIL_FROM || 'noreply@spanglish.com';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFromName(): string {
|
|
||||||
return process.env.EMAIL_FROM_NAME || 'Spanglish';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Provider info for diagnostics endpoints. */
|
|
||||||
export function getProviderInfo(): { provider: EmailProvider; configured: boolean } {
|
|
||||||
const provider = getEmailProvider();
|
|
||||||
let configured = false;
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case 'resend':
|
|
||||||
configured = !!(process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY);
|
|
||||||
break;
|
|
||||||
case 'smtp':
|
|
||||||
configured = !!process.env.SMTP_HOST;
|
|
||||||
break;
|
|
||||||
case 'console':
|
|
||||||
configured = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { provider, configured };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== SMTP Configuration ====================
|
|
||||||
|
|
||||||
interface SMTPConfig {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
secure: boolean;
|
|
||||||
auth?: {
|
|
||||||
user: string;
|
|
||||||
pass: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSMTPConfig(): SMTPConfig | null {
|
|
||||||
const host = process.env.SMTP_HOST;
|
|
||||||
const port = parseInt(process.env.SMTP_PORT || '587');
|
|
||||||
const user = process.env.SMTP_USER;
|
|
||||||
const pass = process.env.SMTP_PASS;
|
|
||||||
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
|
|
||||||
|
|
||||||
if (!host) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: SMTPConfig = {
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
secure,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user && pass) {
|
|
||||||
config.auth = { user, pass };
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached SMTP transporter
|
|
||||||
let smtpTransporter: Transporter | null = null;
|
|
||||||
|
|
||||||
function getSMTPTransporter(): Transporter | null {
|
|
||||||
if (smtpTransporter) {
|
|
||||||
return smtpTransporter;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = getSMTPConfig();
|
|
||||||
if (!config) {
|
|
||||||
console.error('[Email] SMTP configuration missing');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
smtpTransporter = nodemailer.createTransport({
|
|
||||||
host: config.host,
|
|
||||||
port: config.port,
|
|
||||||
secure: config.secure,
|
|
||||||
auth: config.auth,
|
|
||||||
// Additional options for better deliverability
|
|
||||||
pool: true,
|
|
||||||
maxConnections: 5,
|
|
||||||
maxMessages: 100,
|
|
||||||
// TLS options
|
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify connection configuration
|
|
||||||
smtpTransporter.verify((error, success) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('[Email] SMTP connection verification failed:', error.message);
|
|
||||||
} else {
|
|
||||||
console.log('[Email] SMTP server is ready to send emails');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return smtpTransporter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Email Providers ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send email using Resend API
|
|
||||||
*/
|
|
||||||
async function sendWithResend(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
||||||
const apiKey = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY;
|
|
||||||
const fromEmail = getFromEmail();
|
|
||||||
const fromName = getFromName();
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
console.error('[Email] Resend API key not configured');
|
|
||||||
return { success: false, error: 'Resend API key not configured' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('https://api.resend.com/emails', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: `${fromName} <${fromEmail}>`,
|
|
||||||
to: Array.isArray(options.to) ? options.to : [options.to],
|
|
||||||
subject: options.subject,
|
|
||||||
html: options.html,
|
|
||||||
text: options.text,
|
|
||||||
reply_to: options.replyTo,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('[Email] Resend API error:', data);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: data.message || data.error || 'Failed to send email'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Email] Email sent via Resend:', data.id);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
messageId: data.id
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[Email] Resend error:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message || 'Failed to send email via Resend'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send email using SMTP (Nodemailer)
|
|
||||||
*/
|
|
||||||
async function sendWithSMTP(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
||||||
const transporter = getSMTPTransporter();
|
|
||||||
|
|
||||||
if (!transporter) {
|
|
||||||
return { success: false, error: 'SMTP not configured' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromEmail = getFromEmail();
|
|
||||||
const fromName = getFromName();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const info = await transporter.sendMail({
|
|
||||||
from: `"${fromName}" <${fromEmail}>`,
|
|
||||||
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
|
||||||
replyTo: options.replyTo,
|
|
||||||
subject: options.subject,
|
|
||||||
html: options.html,
|
|
||||||
text: options.text,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[Email] Email sent via SMTP:', info.messageId);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
messageId: info.messageId
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[Email] SMTP error:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.message || 'Failed to send email via SMTP'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Console logger for development/testing (no actual email sent)
|
|
||||||
*/
|
|
||||||
async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
||||||
const to = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
|
||||||
|
|
||||||
console.log('\n========================================');
|
|
||||||
console.log('[Email] Console Mode - Email Preview');
|
|
||||||
console.log('========================================');
|
|
||||||
console.log(`To: ${to}`);
|
|
||||||
console.log(`Subject: ${options.subject}`);
|
|
||||||
console.log(`Reply-To: ${options.replyTo || 'N/A'}`);
|
|
||||||
console.log('----------------------------------------');
|
|
||||||
console.log('HTML Body (truncated):');
|
|
||||||
console.log(options.html?.substring(0, 500) + '...');
|
|
||||||
console.log('========================================\n');
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
messageId: `console-${Date.now()}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask an email address for logs: keep first char + domain (e.g. j***@example.com).
|
|
||||||
function maskEmail(email: string): string {
|
|
||||||
const [local, domain] = String(email).split('@');
|
|
||||||
if (!domain) return '***';
|
|
||||||
const head = local.slice(0, 1);
|
|
||||||
return `${head}***@${domain}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main send function that routes to the appropriate provider
|
|
||||||
*/
|
|
||||||
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
||||||
const provider = getEmailProvider();
|
|
||||||
|
|
||||||
const recipientCount = Array.isArray(options.to) ? options.to.length : 1;
|
|
||||||
const sample = Array.isArray(options.to) ? options.to[0] : options.to;
|
|
||||||
console.log(`[Email] Sending email via ${provider} to ${maskEmail(sample)}${recipientCount > 1 ? ` (+${recipientCount - 1} more)` : ''}`);
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case 'resend':
|
|
||||||
return sendWithResend(options);
|
|
||||||
case 'smtp':
|
|
||||||
return sendWithSMTP(options);
|
|
||||||
case 'console':
|
|
||||||
default:
|
|
||||||
return sendWithConsole(options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test email configuration by sending a test email
|
|
||||||
*/
|
|
||||||
export async function testConnection(to: string): Promise<SendEmailResult> {
|
|
||||||
const { provider, configured } = getProviderInfo();
|
|
||||||
|
|
||||||
if (!configured) {
|
|
||||||
return { success: false, error: `Email provider "${provider}" is not configured` };
|
|
||||||
}
|
|
||||||
|
|
||||||
return sendEmail({
|
|
||||||
to,
|
|
||||||
subject: 'Spanglish - Email Test',
|
|
||||||
html: `
|
|
||||||
<h2>Email Configuration Test</h2>
|
|
||||||
<p>This is a test email from your Spanglish platform.</p>
|
|
||||||
<p><strong>Provider:</strong> ${provider}</p>
|
|
||||||
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
|
|
||||||
<p>If you received this email, your email configuration is working correctly!</p>
|
|
||||||
`,
|
|
||||||
text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+50
-161
@@ -1,16 +1,17 @@
|
|||||||
// Durable email queue with rate limiting.
|
// In-memory email queue with rate limiting
|
||||||
// Jobs are persisted in the `email_queue` DB table so they survive process
|
// Processes emails asynchronously in the background without blocking the request thread
|
||||||
// restarts. Emails are processed asynchronously in the background without
|
|
||||||
// blocking the request thread.
|
|
||||||
|
|
||||||
import { eq, and, asc, sql } from 'drizzle-orm';
|
import { generateId } from './utils.js';
|
||||||
import { db, dbGet, emailQueue } from '../db/index.js';
|
|
||||||
import { generateId, getNow } from './utils.js';
|
|
||||||
import { isRedisEnabled } from './redis.js';
|
|
||||||
import { getRateLimiter } from './stores/rateLimiter.js';
|
|
||||||
|
|
||||||
// ==================== Types ====================
|
// ==================== Types ====================
|
||||||
|
|
||||||
|
export interface EmailJob {
|
||||||
|
id: string;
|
||||||
|
type: 'template';
|
||||||
|
params: TemplateEmailJobParams;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TemplateEmailJobParams {
|
export interface TemplateEmailJobParams {
|
||||||
templateSlug: string;
|
templateSlug: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -21,11 +22,6 @@ export interface TemplateEmailJobParams {
|
|||||||
sentBy?: string;
|
sentBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimedJob {
|
|
||||||
id: string;
|
|
||||||
params: TemplateEmailJobParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueueStatus {
|
export interface QueueStatus {
|
||||||
queued: number;
|
queued: number;
|
||||||
processing: boolean;
|
processing: boolean;
|
||||||
@@ -35,8 +31,7 @@ export interface QueueStatus {
|
|||||||
|
|
||||||
// ==================== Queue State ====================
|
// ==================== Queue State ====================
|
||||||
|
|
||||||
// Tracks send timestamps for the per-process (non-Redis) sliding-window rate
|
const queue: EmailJob[] = [];
|
||||||
// limit. The job backlog itself lives in the database, not in memory.
|
|
||||||
const sentTimestamps: number[] = [];
|
const sentTimestamps: number[] = [];
|
||||||
let processing = false;
|
let processing = false;
|
||||||
let processTimer: ReturnType<typeof setTimeout> | null = null;
|
let processTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -46,6 +41,7 @@ let _emailService: any = null;
|
|||||||
|
|
||||||
function getEmailService() {
|
function getEmailService() {
|
||||||
if (!_emailService) {
|
if (!_emailService) {
|
||||||
|
// Dynamic import to avoid circular dependency
|
||||||
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
|
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
|
||||||
}
|
}
|
||||||
return _emailService;
|
return _emailService;
|
||||||
@@ -54,31 +50,12 @@ function getEmailService() {
|
|||||||
/**
|
/**
|
||||||
* Initialize the email queue with a reference to the email service.
|
* Initialize the email queue with a reference to the email service.
|
||||||
* Must be called once at startup.
|
* Must be called once at startup.
|
||||||
*
|
|
||||||
* Also performs crash recovery: any jobs left in the 'processing' state by a
|
|
||||||
* previous (crashed) process are reset to 'pending' so they get retried.
|
|
||||||
*/
|
*/
|
||||||
export function initEmailQueue(emailService: any): void {
|
export function initEmailQueue(emailService: any): void {
|
||||||
_emailService = emailService;
|
_emailService = emailService;
|
||||||
recoverProcessingJobs()
|
|
||||||
.then((recovered) => {
|
|
||||||
if (recovered > 0) {
|
|
||||||
console.log(`[EmailQueue] Recovered ${recovered} in-flight job(s) after restart`);
|
|
||||||
}
|
|
||||||
scheduleProcessing();
|
|
||||||
})
|
|
||||||
.catch((err) => console.error('[EmailQueue] Recovery failed:', err?.message || err));
|
|
||||||
console.log('[EmailQueue] Initialized');
|
console.log('[EmailQueue] Initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recoverProcessingJobs(): Promise<number> {
|
|
||||||
const result: any = await (db as any)
|
|
||||||
.update(emailQueue)
|
|
||||||
.set({ status: 'pending' })
|
|
||||||
.where(eq((emailQueue as any).status, 'processing'));
|
|
||||||
return result?.changes ?? result?.rowCount ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Rate Limiting ====================
|
// ==================== Rate Limiting ====================
|
||||||
|
|
||||||
function getMaxPerHour(): number {
|
function getMaxPerHour(): number {
|
||||||
@@ -97,26 +74,19 @@ function cleanOldTimestamps(): void {
|
|||||||
|
|
||||||
// ==================== Queue Operations ====================
|
// ==================== Queue Operations ====================
|
||||||
|
|
||||||
async function insertJob(id: string, params: TemplateEmailJobParams): Promise<void> {
|
|
||||||
await (db as any).insert(emailQueue).values({
|
|
||||||
id,
|
|
||||||
params: JSON.stringify(params),
|
|
||||||
status: 'pending',
|
|
||||||
attempts: 0,
|
|
||||||
createdAt: getNow(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a single email job to the queue.
|
* Add a single email job to the queue.
|
||||||
* Returns the job ID. Persistence happens asynchronously so the caller is not
|
* Returns the job ID.
|
||||||
* blocked; processing is scheduled once the row is written.
|
|
||||||
*/
|
*/
|
||||||
export function enqueueEmail(params: TemplateEmailJobParams): string {
|
export function enqueueEmail(params: TemplateEmailJobParams): string {
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
insertJob(id, params)
|
queue.push({
|
||||||
.then(() => scheduleProcessing())
|
id,
|
||||||
.catch((err) => console.error('[EmailQueue] Failed to enqueue email:', err?.message || err));
|
type: 'template',
|
||||||
|
params,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
});
|
||||||
|
scheduleProcessing();
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,32 +95,31 @@ export function enqueueEmail(params: TemplateEmailJobParams): string {
|
|||||||
* Returns array of job IDs.
|
* Returns array of job IDs.
|
||||||
*/
|
*/
|
||||||
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
|
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
|
||||||
const ids = paramsList.map(() => generateId());
|
const ids: string[] = [];
|
||||||
if (ids.length === 0) return ids;
|
for (const params of paramsList) {
|
||||||
|
const id = generateId();
|
||||||
Promise.all(paramsList.map((params, i) => insertJob(ids[i], params)))
|
queue.push({
|
||||||
.then(() => {
|
id,
|
||||||
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
|
type: 'template',
|
||||||
scheduleProcessing();
|
params,
|
||||||
})
|
addedAt: Date.now(),
|
||||||
.catch((err) => console.error('[EmailQueue] Failed to enqueue bulk emails:', err?.message || err));
|
});
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
if (ids.length > 0) {
|
||||||
|
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
|
||||||
|
scheduleProcessing();
|
||||||
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current queue status
|
* Get current queue status
|
||||||
*/
|
*/
|
||||||
export async function getQueueStatus(): Promise<QueueStatus> {
|
export function getQueueStatus(): QueueStatus {
|
||||||
cleanOldTimestamps();
|
cleanOldTimestamps();
|
||||||
const row = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(emailQueue)
|
|
||||||
.where(eq((emailQueue as any).status, 'pending'))
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
queued: Number(row?.count || 0),
|
queued: queue.length,
|
||||||
processing,
|
processing,
|
||||||
sentInLastHour: sentTimestamps.length,
|
sentInLastHour: sentTimestamps.length,
|
||||||
maxPerHour: getMaxPerHour(),
|
maxPerHour: getMaxPerHour(),
|
||||||
@@ -166,125 +135,46 @@ function scheduleProcessing(): void {
|
|||||||
setImmediate(() => processNext());
|
setImmediate(() => processNext());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically claim the oldest pending job by flipping its status to
|
|
||||||
* 'processing'. Returns null if there is nothing to do. The conditional update
|
|
||||||
* (WHERE status='pending') guards against two workers claiming the same row.
|
|
||||||
*/
|
|
||||||
async function claimNextJob(): Promise<ClaimedJob | null> {
|
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
|
||||||
const row = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ id: (emailQueue as any).id, params: (emailQueue as any).params })
|
|
||||||
.from(emailQueue)
|
|
||||||
.where(eq((emailQueue as any).status, 'pending'))
|
|
||||||
.orderBy(asc((emailQueue as any).createdAt))
|
|
||||||
.limit(1)
|
|
||||||
);
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
const result: any = await (db as any)
|
|
||||||
.update(emailQueue)
|
|
||||||
.set({ status: 'processing' })
|
|
||||||
.where(and(
|
|
||||||
eq((emailQueue as any).id, row.id),
|
|
||||||
eq((emailQueue as any).status, 'pending')
|
|
||||||
));
|
|
||||||
|
|
||||||
const affected = result?.changes ?? result?.rowCount ?? 0;
|
|
||||||
if (affected > 0) {
|
|
||||||
try {
|
|
||||||
return { id: row.id, params: JSON.parse(row.params) };
|
|
||||||
} catch {
|
|
||||||
// Corrupt params: mark failed and move on rather than crash-looping.
|
|
||||||
await markJob(row.id, 'failed', 'Invalid job params (JSON parse failed)');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Lost the race for this row; try the next pending one.
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markJob(id: string, status: 'sent' | 'failed', error?: string | null): Promise<void> {
|
|
||||||
const update: any = { status, processedAt: getNow() };
|
|
||||||
if (status === 'failed') {
|
|
||||||
update.attempts = sql`${(emailQueue as any).attempts} + 1`;
|
|
||||||
if (error) update.lastError = error.slice(0, 1000);
|
|
||||||
}
|
|
||||||
await (db as any).update(emailQueue).set(update).where(eq((emailQueue as any).id, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function releaseJob(id: string): Promise<void> {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailQueue)
|
|
||||||
.set({ status: 'pending' })
|
|
||||||
.where(eq((emailQueue as any).id, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processNext(): Promise<void> {
|
async function processNext(): Promise<void> {
|
||||||
let job: ClaimedJob | null;
|
if (queue.length === 0) {
|
||||||
try {
|
|
||||||
job = await claimNextJob();
|
|
||||||
} catch (error: any) {
|
|
||||||
// Database error while claiming: back off and retry rather than stop.
|
|
||||||
console.error('[EmailQueue] Failed to claim next job:', error?.message || error);
|
|
||||||
processTimer = setTimeout(() => processNext(), 5_000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!job) {
|
|
||||||
processing = false;
|
processing = false;
|
||||||
console.log('[EmailQueue] Queue empty. Processing stopped.');
|
console.log('[EmailQueue] Queue empty. Processing stopped.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit check.
|
// Rate limit check
|
||||||
// - Without Redis: per-process sliding window.
|
|
||||||
// - With Redis: a shared hourly counter so the cap applies across all
|
|
||||||
// instances rather than once per replica.
|
|
||||||
cleanOldTimestamps();
|
cleanOldTimestamps();
|
||||||
const maxPerHour = getMaxPerHour();
|
const maxPerHour = getMaxPerHour();
|
||||||
let waitMs = 0;
|
|
||||||
|
|
||||||
if (isRedisEnabled()) {
|
if (sentTimestamps.length >= maxPerHour) {
|
||||||
const result = await getRateLimiter().consume('email:hourly', maxPerHour, 3_600_000);
|
|
||||||
if (!result.allowed) {
|
|
||||||
waitMs = (result.retryAfter ?? 60) * 1000 + 500; // 500ms buffer
|
|
||||||
}
|
|
||||||
} else if (sentTimestamps.length >= maxPerHour) {
|
|
||||||
// Calculate when the oldest timestamp in the window expires
|
// Calculate when the oldest timestamp in the window expires
|
||||||
waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
|
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
|
||||||
}
|
|
||||||
|
|
||||||
if (waitMs > 0) {
|
|
||||||
// Put the claimed job back so it is retried after the cooldown.
|
|
||||||
await releaseJob(job.id);
|
|
||||||
console.log(
|
console.log(
|
||||||
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
|
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
|
||||||
`Pausing for ${Math.ceil(waitMs / 1000)}s.`
|
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
|
||||||
);
|
);
|
||||||
processTimer = setTimeout(() => processNext(), waitMs);
|
processTimer = setTimeout(() => processNext(), waitMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dequeue and process
|
||||||
|
const job = queue.shift()!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const emailService = getEmailService();
|
const emailService = getEmailService();
|
||||||
await emailService.sendTemplateEmail(job.params);
|
await emailService.sendTemplateEmail(job.params);
|
||||||
sentTimestamps.push(Date.now());
|
sentTimestamps.push(Date.now());
|
||||||
await markJob(job.id, 'sent');
|
|
||||||
console.log(
|
console.log(
|
||||||
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
|
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
|
||||||
`Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await markJob(job.id, 'failed', error?.message || String(error));
|
|
||||||
console.error(
|
console.error(
|
||||||
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
|
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
|
||||||
error?.message || error
|
error?.message || error
|
||||||
);
|
);
|
||||||
// The sendTemplateEmail method already logs the failure in the email_logs
|
// The sendTemplateEmail method already logs the failure in the email_logs table,
|
||||||
// table, so we just record it on the queue row and move on.
|
// so we don't need to retry here. The error is logged and we move on.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay between sends to be gentle on the email server
|
// Small delay between sends to be gentle on the email server
|
||||||
@@ -292,8 +182,7 @@ async function processNext(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop processing (for graceful shutdown). In-flight and pending jobs remain
|
* Stop processing (for graceful shutdown)
|
||||||
* persisted in the database and resume on the next startup.
|
|
||||||
*/
|
*/
|
||||||
export function stopQueue(): void {
|
export function stopQueue(): void {
|
||||||
if (processTimer) {
|
if (processTimer) {
|
||||||
@@ -301,5 +190,5 @@ export function stopQueue(): void {
|
|||||||
processTimer = null;
|
processTimer = null;
|
||||||
}
|
}
|
||||||
processing = false;
|
processing = false;
|
||||||
console.log('[EmailQueue] Stopped. Pending jobs remain persisted in the database.');
|
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1216,26 +1216,8 @@ Spanglish`,
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Escape HTML-significant characters so substituted variable values can't inject markup.
|
// Helper function to replace template variables
|
||||||
function escapeHtmlValue(value: string): string {
|
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to replace template variables.
|
|
||||||
// When `escapeHtml` is true (HTML rendering contexts), substituted *values* are
|
|
||||||
// HTML-escaped to prevent stored-XSS via attacker-influenced variables (e.g. event
|
|
||||||
// location, attendee name). The template markup itself is never escaped. Subjects and
|
|
||||||
// plain-text bodies pass `escapeHtml=false` so they don't show literal entities.
|
|
||||||
export function replaceTemplateVariables(
|
|
||||||
template: string,
|
|
||||||
variables: Record<string, any>,
|
|
||||||
escapeHtml: boolean = false
|
|
||||||
): string {
|
|
||||||
let result = template;
|
let result = template;
|
||||||
|
|
||||||
// Handle conditional blocks {{#if variable}}...{{/if}}
|
// Handle conditional blocks {{#if variable}}...{{/if}}
|
||||||
@@ -1247,20 +1229,16 @@ export function replaceTemplateVariables(
|
|||||||
// Replace simple variables {{variable}}
|
// Replace simple variables {{variable}}
|
||||||
const variableRegex = /\{\{(\w+)\}\}/g;
|
const variableRegex = /\{\{(\w+)\}\}/g;
|
||||||
result = result.replace(variableRegex, (match, varName) => {
|
result = result.replace(variableRegex, (match, varName) => {
|
||||||
if (variables[varName] === undefined) return match;
|
return variables[varName] !== undefined ? String(variables[varName]) : match;
|
||||||
const raw = String(variables[varName]);
|
|
||||||
return escapeHtml ? escapeHtmlValue(raw) : raw;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to wrap content in the base template.
|
// Helper to wrap content in the base template
|
||||||
// `content` is treated as trusted HTML (it is the already-rendered body). The wrapper's
|
|
||||||
// own variables (subject, year, etc.) are HTML-escaped on substitution.
|
|
||||||
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
|
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
|
||||||
const wrappedContent = baseEmailWrapper.replace('{{content}}', () => content);
|
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
|
||||||
return replaceTemplateVariables(wrappedContent, variables, true);
|
return replaceTemplateVariables(wrappedContent, variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all available variables for a template by slug
|
// Get all available variables for a template by slug
|
||||||
|
|||||||
@@ -80,8 +80,11 @@ export async function createInvoice(params: CreateInvoiceParams): Promise<LNbits
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('Creating LNbits invoice:', {
|
console.log('Creating LNbits invoice:', {
|
||||||
|
url: `${config.url}${apiEndpoint}`,
|
||||||
amount: params.amount,
|
amount: params.amount,
|
||||||
unit: payload.unit,
|
unit: payload.unit,
|
||||||
|
memo: params.memo,
|
||||||
|
webhook: params.webhookUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Context } from 'hono';
|
|
||||||
import { getRateLimiter } from './stores/rateLimiter.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting helpers.
|
|
||||||
*
|
|
||||||
* The actual counting is delegated to a pluggable rate limiter (in-memory by
|
|
||||||
* default, Redis-backed when REDIS_URL is set) so limits are enforced either
|
|
||||||
* per instance (single-instance deployments) or across all instances
|
|
||||||
* (horizontal scaling). See lib/stores/rateLimiter.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume one unit against a key. Returns whether the request is allowed and,
|
|
||||||
* when blocked, how many seconds until the window resets.
|
|
||||||
*/
|
|
||||||
export function consumeRateLimit(
|
|
||||||
key: string,
|
|
||||||
max: number,
|
|
||||||
windowMs: number
|
|
||||||
): Promise<{ allowed: boolean; retryAfter?: number }> {
|
|
||||||
return getRateLimiter().consume(key, max, windowMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hono middleware factory that rate-limits by client IP.
|
|
||||||
* Use a distinct `prefix` per endpoint group so unrelated routes don't share a bucket.
|
|
||||||
*/
|
|
||||||
export function rateLimitMiddleware(opts: { max: number; windowMs: number; prefix: string }) {
|
|
||||||
return async (c: Context, next: () => Promise<void>) => {
|
|
||||||
const ip = getClientIp(c);
|
|
||||||
const result = await consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs);
|
|
||||||
if (!result.allowed) {
|
|
||||||
return c.json(
|
|
||||||
{ error: 'Too many requests. Please try again later.', retryAfter: result.retryAfter },
|
|
||||||
429
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// Optional Redis connection manager.
|
|
||||||
//
|
|
||||||
// Redis is entirely optional. When REDIS_URL is unset the app runs exactly as
|
|
||||||
// before with in-memory backends. When set, this module owns a single shared
|
|
||||||
// command connection plus a dedicated subscriber connection (a connection in
|
|
||||||
// subscribe mode cannot run normal commands), with auto-reconnect, capped
|
|
||||||
// backoff, and a health flag that callers and the health endpoint can read.
|
|
||||||
|
|
||||||
import Redis from 'ioredis';
|
|
||||||
|
|
||||||
let client: Redis | null = null;
|
|
||||||
let subscriber: Redis | null = null;
|
|
||||||
let healthy = false;
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
/** Whether Redis is configured via REDIS_URL. */
|
|
||||||
export function isRedisEnabled(): boolean {
|
|
||||||
return !!process.env.REDIS_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the Redis connection is currently usable. */
|
|
||||||
export function isRedisHealthy(): boolean {
|
|
||||||
return isRedisEnabled() && healthy;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
maxRetriesPerRequest: 1,
|
|
||||||
enableOfflineQueue: false,
|
|
||||||
lazyConnect: false,
|
|
||||||
retryStrategy(times) {
|
|
||||||
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
|
|
||||||
const delay = Math.min(times * 200, 5000);
|
|
||||||
return delay;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
instance.on('connect', () => {
|
|
||||||
console.log(`[redis] (${label}) connecting`);
|
|
||||||
});
|
|
||||||
instance.on('ready', () => {
|
|
||||||
healthy = true;
|
|
||||||
console.log(`[redis] (${label}) ready`);
|
|
||||||
});
|
|
||||||
instance.on('error', (err) => {
|
|
||||||
healthy = false;
|
|
||||||
console.error(`[redis] (${label}) error:`, err?.message || err);
|
|
||||||
});
|
|
||||||
instance.on('reconnecting', () => {
|
|
||||||
healthy = false;
|
|
||||||
console.warn(`[redis] (${label}) reconnecting`);
|
|
||||||
});
|
|
||||||
instance.on('end', () => {
|
|
||||||
healthy = false;
|
|
||||||
console.warn(`[redis] (${label}) connection closed`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureInit(): void {
|
|
||||||
if (initialized || !isRedisEnabled()) return;
|
|
||||||
initialized = true;
|
|
||||||
client = buildClient('commands');
|
|
||||||
subscriber = buildClient('subscriber');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shared command connection, or null when Redis is not configured. */
|
|
||||||
export function getRedis(): Redis | null {
|
|
||||||
ensureInit();
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dedicated subscriber connection, or null when Redis is not configured. */
|
|
||||||
export function getSubscriber(): Redis | null {
|
|
||||||
ensureInit();
|
|
||||||
return subscriber;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Close connections (used for graceful shutdown). */
|
|
||||||
export async function closeRedis(): Promise<void> {
|
|
||||||
const tasks: Promise<unknown>[] = [];
|
|
||||||
if (client) tasks.push(client.quit().catch(() => undefined));
|
|
||||||
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
|
|
||||||
await Promise.all(tasks);
|
|
||||||
client = null;
|
|
||||||
subscriber = null;
|
|
||||||
initialized = false;
|
|
||||||
healthy = false;
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
// Media storage abstraction with two implementations:
|
|
||||||
// - local: writes to the ./uploads directory and serves via /uploads/* (the
|
|
||||||
// original behavior, and the zero-config default)
|
|
||||||
// - s3: stores objects in an S3-compatible bucket (e.g. Garage), so uploads are
|
|
||||||
// shared across instances instead of living on one container's local disk
|
|
||||||
//
|
|
||||||
// S3 is enabled only when S3_ENDPOINT and S3_BUCKET are set. With it unset the
|
|
||||||
// app behaves exactly as before. A "key" is the object name (e.g. "abc123.jpg").
|
|
||||||
|
|
||||||
import { writeFile, mkdir, unlink } from 'fs/promises';
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
const UPLOAD_DIR = './uploads';
|
|
||||||
|
|
||||||
export interface Storage {
|
|
||||||
readonly backend: 'local' | 's3';
|
|
||||||
put(key: string, buffer: Buffer, contentType: string): Promise<void>;
|
|
||||||
delete(key: string): Promise<void>;
|
|
||||||
// Public URL to persist as the media record's fileUrl.
|
|
||||||
publicUrl(key: string): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether S3-compatible storage is configured. */
|
|
||||||
export function isS3Enabled(): boolean {
|
|
||||||
return !!(process.env.S3_ENDPOINT && process.env.S3_BUCKET);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extract the storage key (object name) from a stored fileUrl. */
|
|
||||||
export function keyFromUrl(fileUrl: string): string {
|
|
||||||
return fileUrl.split('/').pop() || fileUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Local implementation ====================
|
|
||||||
|
|
||||||
class LocalStorage implements Storage {
|
|
||||||
readonly backend = 'local' as const;
|
|
||||||
|
|
||||||
private async ensureDir(): Promise<void> {
|
|
||||||
if (!existsSync(UPLOAD_DIR)) {
|
|
||||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(key: string, buffer: Buffer): Promise<void> {
|
|
||||||
await this.ensureDir();
|
|
||||||
await writeFile(join(UPLOAD_DIR, key), buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
const filepath = join(UPLOAD_DIR, key);
|
|
||||||
if (existsSync(filepath)) {
|
|
||||||
await unlink(filepath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
publicUrl(key: string): string {
|
|
||||||
return `/uploads/${key}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== S3 implementation ====================
|
|
||||||
|
|
||||||
// Imported lazily so the AWS SDK is only loaded when S3 is actually configured.
|
|
||||||
type S3ClientType = import('@aws-sdk/client-s3').S3Client;
|
|
||||||
|
|
||||||
class S3Storage implements Storage {
|
|
||||||
readonly backend = 's3' as const;
|
|
||||||
private client: S3ClientType | null = null;
|
|
||||||
private bucket = process.env.S3_BUCKET as string;
|
|
||||||
|
|
||||||
private async getClient(): Promise<S3ClientType> {
|
|
||||||
if (this.client) return this.client;
|
|
||||||
const { S3Client } = await import('@aws-sdk/client-s3');
|
|
||||||
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE || 'true') !== 'false';
|
|
||||||
this.client = new S3Client({
|
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
|
||||||
region: process.env.S3_REGION || 'us-east-1',
|
|
||||||
forcePathStyle,
|
|
||||||
credentials:
|
|
||||||
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
|
|
||||||
? {
|
|
||||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
return this.client;
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(key: string, buffer: Buffer, contentType: string): Promise<void> {
|
|
||||||
const client = await this.getClient();
|
|
||||||
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
||||||
await client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: this.bucket,
|
|
||||||
Key: key,
|
|
||||||
Body: buffer,
|
|
||||||
ContentType: contentType,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
const client = await this.getClient();
|
|
||||||
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
|
||||||
await client.send(
|
|
||||||
new DeleteObjectCommand({
|
|
||||||
Bucket: this.bucket,
|
|
||||||
Key: key,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
publicUrl(key: string): string {
|
|
||||||
// Prefer an explicit public base URL (e.g. a CDN or Garage web endpoint).
|
|
||||||
const base = process.env.S3_PUBLIC_URL;
|
|
||||||
if (base) {
|
|
||||||
return `${base.replace(/\/$/, '')}/${key}`;
|
|
||||||
}
|
|
||||||
// Fall back to a path-style URL against the configured endpoint.
|
|
||||||
const endpoint = (process.env.S3_ENDPOINT || '').replace(/\/$/, '');
|
|
||||||
return `${endpoint}/${this.bucket}/${key}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Selection ====================
|
|
||||||
|
|
||||||
let instance: Storage | null = null;
|
|
||||||
|
|
||||||
export function getStorage(): Storage {
|
|
||||||
if (!instance) {
|
|
||||||
instance = isS3Enabled() ? new S3Storage() : new LocalStorage();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
// Cache abstraction with two implementations:
|
|
||||||
// - memory: per-process Map with TTL expiry (single instance)
|
|
||||||
// - redis: shared GET / SETEX / DEL with JSON values (all instances)
|
|
||||||
//
|
|
||||||
// Values are JSON-serialized. Selection happens once based on REDIS_URL. On any
|
|
||||||
// Redis error the cache behaves as a miss so callers fall back to their source.
|
|
||||||
|
|
||||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
|
||||||
|
|
||||||
export interface Cache {
|
|
||||||
readonly backend: 'memory' | 'redis';
|
|
||||||
get<T>(key: string): Promise<T | null>;
|
|
||||||
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
|
|
||||||
del(key: string): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Memory implementation ====================
|
|
||||||
|
|
||||||
interface Entry {
|
|
||||||
value: unknown;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MemoryCache implements Cache {
|
|
||||||
readonly backend = 'memory' as const;
|
|
||||||
private store = new Map<string, Entry>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const cleanup = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, entry] of this.store) {
|
|
||||||
if (now > entry.expiresAt) this.store.delete(key);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
(cleanup as any).unref?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
|
||||||
const entry = this.store.get(key);
|
|
||||||
if (!entry) return null;
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
this.store.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return entry.value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
|
||||||
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async del(key: string): Promise<void> {
|
|
||||||
this.store.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Redis implementation ====================
|
|
||||||
|
|
||||||
class RedisCache implements Cache {
|
|
||||||
readonly backend = 'redis' as const;
|
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return null;
|
|
||||||
try {
|
|
||||||
const raw = await redis.get(`cache:${key}`);
|
|
||||||
if (raw === null) return null;
|
|
||||||
return JSON.parse(raw) as T;
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[cache] redis get error:', err?.message || err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return;
|
|
||||||
try {
|
|
||||||
await redis.set(`cache:${key}`, JSON.stringify(value), 'EX', ttlSeconds);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[cache] redis set error:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async del(key: string): Promise<void> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return;
|
|
||||||
try {
|
|
||||||
await redis.del(`cache:${key}`);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[cache] redis del error:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Selection ====================
|
|
||||||
|
|
||||||
let instance: Cache | null = null;
|
|
||||||
|
|
||||||
export function getCache(): Cache {
|
|
||||||
if (!instance) {
|
|
||||||
instance = isRedisEnabled() ? new RedisCache() : new MemoryCache();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
// Distributed lock abstraction with two implementations:
|
|
||||||
// - memory: per-process key set with TTL (only meaningful within one instance)
|
|
||||||
// - redis: SET key token NX PX ttl, released with a compare-and-delete Lua
|
|
||||||
// script so only the holder can release it
|
|
||||||
//
|
|
||||||
// Use acquire/release for long-lived ownership (e.g. a background poller) and
|
|
||||||
// withLock for a one-shot critical section. Selection is based on REDIS_URL.
|
|
||||||
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { getRedis, isRedisEnabled } from '../redis.js';
|
|
||||||
|
|
||||||
export interface Lock {
|
|
||||||
readonly backend: 'memory' | 'redis';
|
|
||||||
// Returns a token when the lock was acquired, or null when already held.
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Memory implementation ====================
|
|
||||||
|
|
||||||
class MemoryLock implements Lock {
|
|
||||||
readonly backend = 'memory' as const;
|
|
||||||
private held = new Map<string, { token: string; expiresAt: number }>();
|
|
||||||
|
|
||||||
async acquire(key: string, ttlMs: number): Promise<string | null> {
|
|
||||||
const existing = this.held.get(key);
|
|
||||||
const now = Date.now();
|
|
||||||
if (existing && existing.expiresAt > now) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const token = randomUUID();
|
|
||||||
this.held.set(key, { token, expiresAt: now + ttlMs });
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async release(key: string, token: string): Promise<void> {
|
|
||||||
const existing = this.held.get(key);
|
|
||||||
if (existing && existing.token === token) {
|
|
||||||
this.held.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
|
|
||||||
const token = await this.acquire(key, ttlMs);
|
|
||||||
if (!token) return null;
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
await this.release(key, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Redis implementation ====================
|
|
||||||
|
|
||||||
const RELEASE_SCRIPT =
|
|
||||||
'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async release(key: string, token: string): Promise<void> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return;
|
|
||||||
try {
|
|
||||||
await redis.eval(RELEASE_SCRIPT, 1, `lock:${key}`, token);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[lock] redis release error:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
|
|
||||||
const token = await this.acquire(key, ttlMs);
|
|
||||||
if (!token) return null;
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
await this.release(key, token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Selection ====================
|
|
||||||
|
|
||||||
let instance: Lock | null = null;
|
|
||||||
|
|
||||||
export function getLock(): Lock {
|
|
||||||
if (!instance) {
|
|
||||||
instance = isRedisEnabled() ? new RedisLock() : new MemoryLock();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
// Pub/Sub abstraction with two implementations:
|
|
||||||
// - memory: in-process EventEmitter (single instance only)
|
|
||||||
// - redis: PUBLISH / SUBSCRIBE so a message published on one instance reaches
|
|
||||||
// subscribers on every instance
|
|
||||||
//
|
|
||||||
// Messages are JSON-serialized. Selection happens once based on REDIS_URL.
|
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { getRedis, getSubscriber, isRedisEnabled } from '../redis.js';
|
|
||||||
|
|
||||||
export type PubSubHandler = (message: any) => void;
|
|
||||||
|
|
||||||
export interface PubSub {
|
|
||||||
readonly backend: 'memory' | 'redis';
|
|
||||||
publish(channel: string, message: any): Promise<void>;
|
|
||||||
// Returns an unsubscribe function for this specific handler.
|
|
||||||
subscribe(channel: string, handler: PubSubHandler): Promise<() => void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Memory implementation ====================
|
|
||||||
|
|
||||||
class MemoryPubSub implements PubSub {
|
|
||||||
readonly backend = 'memory' as const;
|
|
||||||
private emitter = new EventEmitter();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// SSE fan-out can attach many listeners to the same channel; lift the cap.
|
|
||||||
this.emitter.setMaxListeners(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async publish(channel: string, message: any): Promise<void> {
|
|
||||||
this.emitter.emit(channel, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
|
|
||||||
this.emitter.on(channel, handler);
|
|
||||||
return () => this.emitter.off(channel, handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Redis implementation ====================
|
|
||||||
|
|
||||||
class RedisPubSub implements PubSub {
|
|
||||||
readonly backend = 'redis' as const;
|
|
||||||
// Per-channel handler sets so a single Redis subscription fans out locally.
|
|
||||||
private handlers = new Map<string, Set<PubSubHandler>>();
|
|
||||||
private wired = false;
|
|
||||||
|
|
||||||
private ensureWired(): void {
|
|
||||||
if (this.wired) return;
|
|
||||||
const sub = getSubscriber();
|
|
||||||
if (!sub) return;
|
|
||||||
this.wired = true;
|
|
||||||
sub.on('message', (channel: string, payload: string) => {
|
|
||||||
const set = this.handlers.get(channel);
|
|
||||||
if (!set || set.size === 0) return;
|
|
||||||
let parsed: any = payload;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(payload);
|
|
||||||
} catch {
|
|
||||||
// Leave as raw string if it was not JSON.
|
|
||||||
}
|
|
||||||
for (const handler of set) {
|
|
||||||
try {
|
|
||||||
handler(parsed);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[pubsub] handler error:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async publish(channel: string, message: any): Promise<void> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (!redis) return;
|
|
||||||
try {
|
|
||||||
await redis.publish(channel, JSON.stringify(message));
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[pubsub] publish error:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
|
|
||||||
this.ensureWired();
|
|
||||||
const sub = getSubscriber();
|
|
||||||
if (!sub) return () => undefined;
|
|
||||||
|
|
||||||
let set = this.handlers.get(channel);
|
|
||||||
if (!set) {
|
|
||||||
set = new Set();
|
|
||||||
this.handlers.set(channel, set);
|
|
||||||
try {
|
|
||||||
await sub.subscribe(channel);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[pubsub] subscribe error:', err?.message || err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set.add(handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const current = this.handlers.get(channel);
|
|
||||||
if (!current) return;
|
|
||||||
current.delete(handler);
|
|
||||||
if (current.size === 0) {
|
|
||||||
this.handlers.delete(channel);
|
|
||||||
sub.unsubscribe(channel).catch(() => undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Selection ====================
|
|
||||||
|
|
||||||
let instance: PubSub | null = null;
|
|
||||||
|
|
||||||
export function getPubSub(): PubSub {
|
|
||||||
if (!instance) {
|
|
||||||
instance = isRedisEnabled() ? new RedisPubSub() : new MemoryPubSub();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// Rate limiter abstraction with two implementations:
|
|
||||||
// - memory: per-process fixed window (the original behavior)
|
|
||||||
// - redis: shared fixed window across all instances (INCR + PEXPIRE)
|
|
||||||
//
|
|
||||||
// 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 { getRedis, isRedisEnabled } from '../redis.js';
|
|
||||||
|
|
||||||
export interface RateLimitResult {
|
|
||||||
allowed: boolean;
|
|
||||||
retryAfter?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RateLimiter {
|
|
||||||
readonly backend: 'memory' | 'redis';
|
|
||||||
consume(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Memory implementation ====================
|
|
||||||
|
|
||||||
interface Bucket {
|
|
||||||
count: number;
|
|
||||||
resetAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MemoryRateLimiter implements RateLimiter {
|
|
||||||
readonly backend = 'memory' as const;
|
|
||||||
private buckets = new Map<string, Bucket>();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Periodically drop expired buckets so the Map does not grow unbounded.
|
|
||||||
const cleanup = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, bucket] of this.buckets) {
|
|
||||||
if (now > bucket.resetAt) this.buckets.delete(key);
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
(cleanup as any).unref?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
|
|
||||||
const now = Date.now();
|
|
||||||
const bucket = this.buckets.get(key);
|
|
||||||
|
|
||||||
if (!bucket || now > bucket.resetAt) {
|
|
||||||
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket.count++;
|
|
||||||
if (bucket.count > max) {
|
|
||||||
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
|
|
||||||
}
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Redis implementation ====================
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
return { allowed: true };
|
|
||||||
} catch (err: any) {
|
|
||||||
// Fail open: never block traffic because Redis is unavailable.
|
|
||||||
console.error('[rateLimiter] redis error, allowing request:', err?.message || err);
|
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Selection ====================
|
|
||||||
|
|
||||||
let instance: RateLimiter | null = null;
|
|
||||||
|
|
||||||
export function getRateLimiter(): RateLimiter {
|
|
||||||
if (!instance) {
|
|
||||||
instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
+95
-76
@@ -6,16 +6,6 @@ import { getNow } from '../lib/utils.js';
|
|||||||
|
|
||||||
const adminRouter = new Hono();
|
const adminRouter = new Hono();
|
||||||
|
|
||||||
// Escape a value for inclusion in a CSV cell (RFC 4180 quoting).
|
|
||||||
const csvEscape = (value: string) => {
|
|
||||||
if (value == null) return '';
|
|
||||||
const str = String(value);
|
|
||||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
||||||
return '"' + str.replace(/"/g, '""') + '"';
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Dashboard overview stats (admin)
|
// Dashboard overview stats (admin)
|
||||||
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
@@ -77,14 +67,14 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
.where(eq((payments as any).status, 'pending'))
|
.where(eq((payments as any).status, 'pending'))
|
||||||
);
|
);
|
||||||
|
|
||||||
const revenueRow = await dbGet<any>(
|
const paidPayments = await dbAll<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` })
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq((payments as any).status, 'paid'))
|
.where(eq((payments as any).status, 'paid'))
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalRevenue = Number(revenueRow?.total || 0);
|
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
|
||||||
|
|
||||||
const newContacts = await dbGet<any>(
|
const newContacts = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -120,52 +110,56 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
|
|
||||||
// Get analytics data (admin)
|
// Get analytics data (admin)
|
||||||
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
||||||
// Get events with ticket counts using grouped aggregates (avoids 3 queries per event).
|
// Get events with ticket counts
|
||||||
const allEvents = await dbAll<any>((db as any).select().from(events));
|
const allEvents = await dbAll<any>((db as any).select().from(events));
|
||||||
|
|
||||||
const totalRows = await dbAll<any>(
|
const eventStats = await Promise.all(
|
||||||
(db as any)
|
allEvents.map(async (event: any) => {
|
||||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
const ticketCount = await dbGet<any>(
|
||||||
.from(tickets)
|
(db as any)
|
||||||
.groupBy((tickets as any).eventId)
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).eventId, event.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmedCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, event.id),
|
||||||
|
eq((tickets as any).status, 'confirmed'),
|
||||||
|
ne((tickets as any).isGuest, 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkedInCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, event.id),
|
||||||
|
eq((tickets as any).status, 'checked_in'),
|
||||||
|
ne((tickets as any).isGuest, 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
date: event.startDatetime,
|
||||||
|
capacity: event.capacity,
|
||||||
|
totalBookings: ticketCount?.count || 0,
|
||||||
|
confirmedBookings: confirmedCount?.count || 0,
|
||||||
|
checkedIn: checkedInCount?.count || 0,
|
||||||
|
revenue: (confirmedCount?.count || 0) * event.price,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const confirmedRows = await dbAll<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(and(eq((tickets as any).status, 'confirmed'), ne((tickets as any).isGuest, 1)))
|
|
||||||
.groupBy((tickets as any).eventId)
|
|
||||||
);
|
|
||||||
const checkedInRows = await dbAll<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
|
|
||||||
.from(tickets)
|
|
||||||
.where(and(eq((tickets as any).status, 'checked_in'), ne((tickets as any).isGuest, 1)))
|
|
||||||
.groupBy((tickets as any).eventId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toMap = (rows: any[]) => {
|
|
||||||
const m = new Map<string, number>();
|
|
||||||
for (const r of rows) m.set(r.eventId, Number(r.count) || 0);
|
|
||||||
return m;
|
|
||||||
};
|
|
||||||
const totalMap = toMap(totalRows);
|
|
||||||
const confirmedMap = toMap(confirmedRows);
|
|
||||||
const checkedInMap = toMap(checkedInRows);
|
|
||||||
|
|
||||||
const eventStats = allEvents.map((event: any) => {
|
|
||||||
const confirmedBookings = confirmedMap.get(event.id) || 0;
|
|
||||||
return {
|
|
||||||
id: event.id,
|
|
||||||
title: event.title,
|
|
||||||
date: event.startDatetime,
|
|
||||||
capacity: event.capacity,
|
|
||||||
totalBookings: totalMap.get(event.id) || 0,
|
|
||||||
confirmedBookings,
|
|
||||||
checkedIn: checkedInMap.get(event.id) || 0,
|
|
||||||
revenue: confirmedBookings * event.price,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
analytics: {
|
analytics: {
|
||||||
@@ -185,25 +179,30 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ticketList = await dbAll<any>(query);
|
const ticketList = await dbAll<any>(query);
|
||||||
|
|
||||||
const userIds = [...new Set(ticketList.map((t: any) => t.userId).filter(Boolean))];
|
|
||||||
const eventIds = [...new Set(ticketList.map((t: any) => t.eventId).filter(Boolean))];
|
|
||||||
const ticketIds = ticketList.map((t: any) => t.id);
|
|
||||||
|
|
||||||
const [userRows, eventRows, paymentRows] = await Promise.all([
|
|
||||||
userIds.length ? dbAll<any>((db as any).select().from(users).where(inArray((users as any).id, userIds))) : Promise.resolve([]),
|
|
||||||
eventIds.length ? dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds))) : Promise.resolve([]),
|
|
||||||
ticketIds.length ? dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))) : Promise.resolve([]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const usersById = new Map(userRows.map((u: any) => [u.id, u]));
|
|
||||||
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
|
|
||||||
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
|
|
||||||
|
|
||||||
const enrichedTickets = ticketList.map((ticket: any) => {
|
// Get user and event details for each ticket
|
||||||
const user = usersById.get(ticket.userId);
|
const enrichedTickets = await Promise.all(
|
||||||
const event = eventsById.get(ticket.eventId);
|
ticketList.map(async (ticket: any) => {
|
||||||
const payment = paymentsByTicketId.get(ticket.id);
|
const user = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq((users as any).id, ticket.userId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const payment = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ticketId: ticket.id,
|
ticketId: ticket.id,
|
||||||
@@ -219,7 +218,8 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
|||||||
paymentAmount: payment?.amount,
|
paymentAmount: payment?.amount,
|
||||||
createdAt: ticket.createdAt,
|
createdAt: ticket.createdAt,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ tickets: enrichedTickets });
|
return c.json({ tickets: enrichedTickets });
|
||||||
});
|
});
|
||||||
@@ -301,6 +301,16 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Generate CSV
|
||||||
|
const csvEscape = (value: string) => {
|
||||||
|
if (value == null) return '';
|
||||||
|
const str = String(value);
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||||
|
return '"' + str.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
'Ticket ID', 'Full Name', 'Email', 'Phone',
|
||||||
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
|
||||||
@@ -380,6 +390,15 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const csvEscape = (value: string) => {
|
||||||
|
if (value == null) return '';
|
||||||
|
const str = String(value);
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||||
|
return '"' + str.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
|
||||||
|
|
||||||
const rows = ticketList.map((ticket: any) => ({
|
const rows = ticketList.map((ticket: any) => ({
|
||||||
|
|||||||
+46
-93
@@ -14,17 +14,10 @@ import {
|
|||||||
createMagicLinkToken,
|
createMagicLinkToken,
|
||||||
verifyMagicLinkToken,
|
verifyMagicLinkToken,
|
||||||
invalidateAllUserSessions,
|
invalidateAllUserSessions,
|
||||||
bumpTokenVersion,
|
|
||||||
requireAuth,
|
requireAuth,
|
||||||
getUserPasswordHash,
|
|
||||||
} from '../lib/auth.js';
|
} from '../lib/auth.js';
|
||||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||||
import { sendEmail } from '../lib/email.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)
|
// User type that includes all fields (some added in schema updates)
|
||||||
type AuthUser = User & {
|
type AuthUser = User & {
|
||||||
@@ -104,7 +97,8 @@ const passwordResetSchema = z.object({
|
|||||||
|
|
||||||
const claimAccountSchema = z.object({
|
const claimAccountSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
password: z.string().min(10, 'Password must be at least 10 characters'),
|
password: z.string().min(10, 'Password must be at least 10 characters').optional(),
|
||||||
|
googleId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const changePasswordSchema = z.object({
|
const changePasswordSchema = z.object({
|
||||||
@@ -117,7 +111,7 @@ const googleAuthSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => {
|
auth.post('/register', zValidator('json', registerSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
// Validate password strength
|
// Validate password strength
|
||||||
@@ -167,7 +161,7 @@ auth.post('/register', authRateLimit, zValidator('json', registerSchema), async
|
|||||||
|
|
||||||
await (db as any).insert(users).values(newUser);
|
await (db as any).insert(users).values(newUser);
|
||||||
|
|
||||||
const token = await createToken(id, data.email, newUser.role, 0);
|
const token = await createToken(id, data.email, newUser.role);
|
||||||
const refreshToken = await createRefreshToken(id);
|
const refreshToken = await createRefreshToken(id);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -185,7 +179,7 @@ auth.post('/register', authRateLimit, zValidator('json', registerSchema), async
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Login with email/password
|
// Login with email/password
|
||||||
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
|
auth.post('/login', zValidator('json', loginSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
@@ -229,23 +223,8 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
|
|||||||
|
|
||||||
// Clear failed attempts on successful login
|
// Clear failed attempts on successful login
|
||||||
clearFailedAttempts(data.email);
|
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 token = await createToken(user.id, user.email, user.role);
|
||||||
const refreshToken = await createRefreshToken(user.id);
|
const refreshToken = await createRefreshToken(user.id);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -265,7 +244,7 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Request magic link login
|
// Request magic link login
|
||||||
auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
|
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
|
||||||
const { email } = c.req.valid('json');
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
const user = await dbGet<any>(
|
const user = await dbGet<any>(
|
||||||
@@ -306,7 +285,7 @@ auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequ
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Verify magic link and login
|
// Verify magic link and login
|
||||||
auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => {
|
auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => {
|
||||||
const { token } = c.req.valid('json');
|
const { token } = c.req.valid('json');
|
||||||
|
|
||||||
const verification = await verifyMagicLinkToken(token, 'login');
|
const verification = await verifyMagicLinkToken(token, 'login');
|
||||||
@@ -323,7 +302,7 @@ auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerif
|
|||||||
return c.json({ error: 'Invalid token' }, 400);
|
return c.json({ error: 'Invalid token' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
const authToken = await createToken(user.id, user.email, user.role);
|
||||||
const refreshToken = await createRefreshToken(user.id);
|
const refreshToken = await createRefreshToken(user.id);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -343,7 +322,7 @@ auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerif
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Request password reset
|
// Request password reset
|
||||||
auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => {
|
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
|
||||||
const { email } = c.req.valid('json');
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
const user = await dbGet<any>(
|
const user = await dbGet<any>(
|
||||||
@@ -384,7 +363,7 @@ auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordR
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reset password
|
// Reset password
|
||||||
auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => {
|
auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => {
|
||||||
const { token, password } = c.req.valid('json');
|
const { token, password } = c.req.valid('json');
|
||||||
|
|
||||||
// Validate password strength
|
// Validate password strength
|
||||||
@@ -410,15 +389,14 @@ auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordR
|
|||||||
})
|
})
|
||||||
.where(eq((users as any).id, verification.userId));
|
.where(eq((users as any).id, verification.userId));
|
||||||
|
|
||||||
// Invalidate all existing sessions/JWTs for security
|
// Invalidate all existing sessions for security
|
||||||
await invalidateAllUserSessions(verification.userId!);
|
await invalidateAllUserSessions(verification.userId!);
|
||||||
await bumpTokenVersion(verification.userId!);
|
|
||||||
|
|
||||||
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
|
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Claim unclaimed account
|
// Claim unclaimed account
|
||||||
auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
|
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
|
||||||
const { email } = c.req.valid('json');
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
const user = await dbGet<any>(
|
const user = await dbGet<any>(
|
||||||
@@ -433,8 +411,8 @@ auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkR
|
|||||||
return c.json({ error: 'Account is already claimed' }, 400);
|
return c.json({ error: 'Account is already claimed' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create claim token (expires in 1 hour)
|
// Create claim token (expires in 24 hours)
|
||||||
const token = await createMagicLinkToken(user.id, 'claim_account', 60);
|
const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60);
|
||||||
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
|
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
|
||||||
|
|
||||||
// Send email
|
// Send email
|
||||||
@@ -447,7 +425,7 @@ auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkR
|
|||||||
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
|
<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><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>Or copy this link: ${claimLink}</p>
|
||||||
<p>This link expires in 1 hour.</p>
|
<p>This link expires in 24 hours.</p>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -458,8 +436,12 @@ auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkR
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Complete account claim
|
// Complete account claim
|
||||||
auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => {
|
||||||
const { token, password } = c.req.valid('json');
|
const { token, password, googleId } = c.req.valid('json');
|
||||||
|
|
||||||
|
if (!password && !googleId) {
|
||||||
|
return c.json({ error: 'Please provide either a password or link a Google account' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
const verification = await verifyMagicLinkToken(token, 'claim_account');
|
const verification = await verifyMagicLinkToken(token, 'claim_account');
|
||||||
|
|
||||||
@@ -467,21 +449,25 @@ auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccou
|
|||||||
return c.json({ error: verification.error }, 400);
|
return c.json({ error: verification.error }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordValidation = validatePassword(password);
|
|
||||||
if (!passwordValidation.valid) {
|
|
||||||
return c.json({ error: passwordValidation.error }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
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> = {
|
const updates: Record<string, any> = {
|
||||||
isClaimed: toDbBool(true),
|
isClaimed: toDbBool(true),
|
||||||
accountStatus: 'active',
|
accountStatus: 'active',
|
||||||
password: await hashPassword(password),
|
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const passwordValidation = validatePassword(password);
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return c.json({ error: passwordValidation.error }, 400);
|
||||||
|
}
|
||||||
|
updates.password = await hashPassword(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (googleId) {
|
||||||
|
updates.googleId = googleId;
|
||||||
|
}
|
||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(users)
|
.update(users)
|
||||||
.set(updates)
|
.set(updates)
|
||||||
@@ -491,7 +477,7 @@ auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccou
|
|||||||
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
(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 authToken = await createToken(user.id, user.email, user.role);
|
||||||
const refreshToken = await createRefreshToken(user.id);
|
const refreshToken = await createRefreshToken(user.id);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -512,14 +498,13 @@ auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccou
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Google OAuth login/register
|
// Google OAuth login/register
|
||||||
auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => {
|
auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
|
||||||
const { credential } = c.req.valid('json');
|
const { credential } = c.req.valid('json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify the Google ID token. Google's tokeninfo endpoint validates the
|
// Verify Google token
|
||||||
// signature and expiry server-side; we additionally enforce the audience so a
|
// In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
|
||||||
// token minted for a different OAuth client cannot be replayed against us.
|
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
|
||||||
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return c.json({ error: 'Invalid Google token' }, 400);
|
return c.json({ error: 'Invalid Google token' }, 400);
|
||||||
@@ -530,29 +515,11 @@ auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
email_verified: string;
|
email_verified: string;
|
||||||
aud?: string;
|
|
||||||
exp?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// email_verified can be returned as boolean true or string "true"
|
if (googleData.email_verified !== 'true') {
|
||||||
if (String(googleData.email_verified) !== 'true') {
|
|
||||||
return c.json({ error: 'Google email not verified' }, 400);
|
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;
|
const { sub: googleId, email, name } = googleData;
|
||||||
|
|
||||||
@@ -617,7 +584,7 @@ auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async
|
|||||||
user = newUser;
|
user = newUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
const authToken = await createToken(user.id, user.email, user.role);
|
||||||
const refreshToken = await createRefreshToken(user.id);
|
const refreshToken = await createRefreshToken(user.id);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -676,9 +643,8 @@ auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify current password if user has one
|
// Verify current password if user has one
|
||||||
const existingHash = await getUserPasswordHash(user.id);
|
if (user.password) {
|
||||||
if (existingHash) {
|
const validPassword = await verifyPassword(currentPassword, user.password);
|
||||||
const validPassword = await verifyPassword(currentPassword, existingHash);
|
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return c.json({ error: 'Current password is incorrect' }, 400);
|
return c.json({ error: 'Current password is incorrect' }, 400);
|
||||||
}
|
}
|
||||||
@@ -694,25 +660,12 @@ auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSc
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(eq((users as any).id, user.id));
|
.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 });
|
return c.json({ message: 'Password changed successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout - invalidate all previously issued JWTs for this user (logout everywhere)
|
// Logout (client-side token removal, but we can log the action)
|
||||||
auth.post('/logout', async (c) => {
|
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' });
|
return c.json({ message: 'Logged out successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,26 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
|
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow, sanitizeHtml } from '../lib/utils.js';
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
import { emailService } from '../lib/email.js';
|
import { emailService } from '../lib/email.js';
|
||||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
|
||||||
|
|
||||||
const contactsRouter = new Hono();
|
const contactsRouter = new Hono();
|
||||||
|
|
||||||
// Per-IP rate limit for public, unauthenticated write endpoints (contact form,
|
|
||||||
// newsletter subscribe/unsubscribe) to prevent spam and email flooding.
|
|
||||||
const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000, prefix: 'contacts' });
|
|
||||||
|
|
||||||
// ==================== Sanitization Helpers ====================
|
// ==================== Sanitization Helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string to prevent HTML injection
|
||||||
|
* Escapes HTML special characters
|
||||||
|
*/
|
||||||
|
function sanitizeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize email header values to prevent email header injection
|
* Sanitize email header values to prevent email header injection
|
||||||
* Strips newlines and carriage returns that could be used to inject headers
|
* Strips newlines and carriage returns that could be used to inject headers
|
||||||
@@ -25,14 +33,14 @@ function sanitizeHeaderValue(str: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createContactSchema = z.object({
|
const createContactSchema = z.object({
|
||||||
name: z.string().min(2).max(200),
|
name: z.string().min(2),
|
||||||
email: z.string().email().max(254),
|
email: z.string().email(),
|
||||||
message: z.string().min(10).max(5000),
|
message: z.string().min(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscribeSchema = z.object({
|
const subscribeSchema = z.object({
|
||||||
email: z.string().email().max(254),
|
email: z.string().email(),
|
||||||
name: z.string().max(200).optional(),
|
name: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateContactSchema = z.object({
|
const updateContactSchema = z.object({
|
||||||
@@ -40,7 +48,7 @@ const updateContactSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Submit contact form (public)
|
// Submit contact form (public)
|
||||||
contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema), async (c) => {
|
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
@@ -117,7 +125,7 @@ contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to newsletter (public)
|
// Subscribe to newsletter (public)
|
||||||
contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeSchema), async (c) => {
|
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
// Check if already subscribed
|
// Check if already subscribed
|
||||||
@@ -158,30 +166,28 @@ contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeS
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Unsubscribe from newsletter (public)
|
// Unsubscribe from newsletter (public)
|
||||||
contactsRouter.post('/unsubscribe', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), async (c) => {
|
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
|
||||||
const { email } = c.req.valid('json');
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
const existing = await dbGet<any>(
|
const existing = await dbGet<any>(
|
||||||
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
|
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always return the same response whether or not the address exists, to avoid
|
if (!existing) {
|
||||||
// leaking which emails are subscribed (enumeration).
|
return c.json({ error: 'Email not found' }, 404);
|
||||||
if (existing && existing.status !== 'unsubscribed') {
|
|
||||||
await (db as any)
|
|
||||||
.update(emailSubscribers)
|
|
||||||
.set({ status: 'unsubscribed' })
|
|
||||||
.where(eq((emailSubscribers as any).id, existing.id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ message: 'If this email was subscribed, it has been unsubscribed.' });
|
await (db as any)
|
||||||
|
.update(emailSubscribers)
|
||||||
|
.set({ status: 'unsubscribed' })
|
||||||
|
.where(eq((emailSubscribers as any).id, existing.id));
|
||||||
|
|
||||||
|
return c.json({ message: 'Successfully unsubscribed' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all contacts (admin)
|
// Get all contacts (admin)
|
||||||
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 500);
|
|
||||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
|
||||||
|
|
||||||
let query = (db as any).select().from(contacts);
|
let query = (db as any).select().from(contacts);
|
||||||
|
|
||||||
@@ -189,9 +195,7 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
query = query.where(eq((contacts as any).status, status));
|
query = query.where(eq((contacts as any).status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(
|
const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
|
||||||
query.orderBy(desc((contacts as any).createdAt)).limit(limit).offset(offset)
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ contacts: result });
|
return c.json({ contacts: result });
|
||||||
});
|
});
|
||||||
@@ -251,8 +255,6 @@ contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
|||||||
// Get all subscribers (admin)
|
// Get all subscribers (admin)
|
||||||
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
|
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 1000);
|
|
||||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
|
||||||
|
|
||||||
let query = (db as any).select().from(emailSubscribers);
|
let query = (db as any).select().from(emailSubscribers);
|
||||||
|
|
||||||
@@ -260,9 +262,7 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy
|
|||||||
query = query.where(eq((emailSubscribers as any).status, status));
|
query = query.where(eq((emailSubscribers as any).status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(
|
const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
|
||||||
query.orderBy(desc((emailSubscribers as any).createdAt)).limit(limit).offset(offset)
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ subscribers: result });
|
return c.json({ subscribers: result });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
|
||||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
import { eq, desc, and, gt, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
|
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
|
||||||
import { generateId, getNow } from '../lib/utils.js';
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
// User type that includes all fields (some added in schema updates)
|
// User type that includes all fields (some added in schema updates)
|
||||||
@@ -37,8 +37,6 @@ dashboard.get('/profile', async (c) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
const hasPassword = !!(await getUserPasswordHash(user.id));
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
profile: {
|
profile: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -49,7 +47,7 @@ dashboard.get('/profile', async (c) => {
|
|||||||
rucNumber: user.rucNumber,
|
rucNumber: user.rucNumber,
|
||||||
isClaimed: user.isClaimed,
|
isClaimed: user.isClaimed,
|
||||||
accountStatus: user.accountStatus,
|
accountStatus: user.accountStatus,
|
||||||
hasPassword,
|
hasPassword: !!user.password,
|
||||||
hasGoogleLinked: !!user.googleId,
|
hasGoogleLinked: !!user.googleId,
|
||||||
memberSince: user.createdAt,
|
memberSince: user.createdAt,
|
||||||
membershipDays,
|
membershipDays,
|
||||||
@@ -105,31 +103,34 @@ dashboard.get('/tickets', async (c) => {
|
|||||||
.where(eq((tickets as any).userId, user.id))
|
.where(eq((tickets as any).userId, user.id))
|
||||||
.orderBy(desc((tickets as any).createdAt))
|
.orderBy(desc((tickets as any).createdAt))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Batch-fetch related events, payments, and invoices (avoids N+1 per ticket).
|
|
||||||
const eventIds = [...new Set(userTickets.map((t: any) => t.eventId).filter(Boolean))];
|
|
||||||
const ticketIds = userTickets.map((t: any) => t.id);
|
|
||||||
|
|
||||||
const eventRows = eventIds.length
|
|
||||||
? await dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds)))
|
|
||||||
: [];
|
|
||||||
const paymentRows = ticketIds.length
|
|
||||||
? await dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds)))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
|
|
||||||
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
|
|
||||||
|
|
||||||
const paidPaymentIds = paymentRows.filter((p: any) => p.status === 'paid').map((p: any) => p.id);
|
|
||||||
const invoiceRows = paidPaymentIds.length
|
|
||||||
? await dbAll<any>((db as any).select().from(invoices).where(inArray((invoices as any).paymentId, paidPaymentIds)))
|
|
||||||
: [];
|
|
||||||
const invoicesByPaymentId = new Map(invoiceRows.map((inv: any) => [inv.paymentId, inv]));
|
|
||||||
|
|
||||||
const ticketsWithEvents = userTickets.map((ticket: any) => {
|
// Get event details for each ticket
|
||||||
const event = eventsById.get(ticket.eventId);
|
const ticketsWithEvents = await Promise.all(
|
||||||
const payment = paymentsByTicketId.get(ticket.id);
|
userTickets.map(async (ticket: any) => {
|
||||||
const invoice = payment && payment.status === 'paid' ? invoicesByPaymentId.get(payment.id) : null;
|
const event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq((events as any).id, ticket.eventId))
|
||||||
|
);
|
||||||
|
|
||||||
|
const payment = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for invoice
|
||||||
|
let invoice: any = null;
|
||||||
|
if (payment && payment.status === 'paid') {
|
||||||
|
invoice = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq((invoices as any).paymentId, payment.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...ticket,
|
...ticket,
|
||||||
@@ -161,7 +162,8 @@ dashboard.get('/tickets', async (c) => {
|
|||||||
createdAt: invoice.createdAt,
|
createdAt: invoice.createdAt,
|
||||||
} : null,
|
} : null,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ tickets: ticketsWithEvents });
|
return c.json({ tickets: ticketsWithEvents });
|
||||||
});
|
});
|
||||||
@@ -450,22 +452,13 @@ dashboard.delete('/sessions/:id', async (c) => {
|
|||||||
return c.json({ message: 'Session revoked' });
|
return c.json({ message: 'Session revoked' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
|
// Revoke all sessions (logout everywhere)
|
||||||
// every previously issued JWT for this user, which is the actual enforcement
|
|
||||||
// mechanism (auth is stateless JWT, not DB-session based).
|
|
||||||
dashboard.post('/sessions/revoke-all', async (c) => {
|
dashboard.post('/sessions/revoke-all', async (c) => {
|
||||||
const user = (c as any).get('user') as AuthUser;
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
|
||||||
await invalidateAllUserSessions(user.id);
|
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 });
|
return c.json({ message: 'All sessions revoked. Please log in again.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set password (for users without one)
|
// Set password (for users without one)
|
||||||
@@ -478,7 +471,7 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
|
|||||||
const { password } = c.req.valid('json');
|
const { password } = c.req.valid('json');
|
||||||
|
|
||||||
// Check if user already has a password
|
// Check if user already has a password
|
||||||
if (await getUserPasswordHash(user.id)) {
|
if (user.password) {
|
||||||
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +502,7 @@ dashboard.post('/unlink-google', async (c) => {
|
|||||||
return c.json({ error: 'Google account not linked' }, 400);
|
return c.json({ error: 'Google account not linked' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await getUserPasswordHash(user.id))) {
|
if (!user.password) {
|
||||||
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
||||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
@@ -11,50 +9,6 @@ import { getQueueStatus } from '../lib/emailQueue.js';
|
|||||||
|
|
||||||
const emailsRouter = new Hono();
|
const emailsRouter = new Hono();
|
||||||
|
|
||||||
const slugPattern = /^[a-z0-9-]+$/;
|
|
||||||
|
|
||||||
const createTemplateSchema = z.object({
|
|
||||||
name: z.string().min(1).max(255),
|
|
||||||
slug: z.string().min(1).max(100).regex(slugPattern),
|
|
||||||
subject: z.string().min(1).max(500),
|
|
||||||
subjectEs: z.string().max(500).optional().nullable(),
|
|
||||||
bodyHtml: z.string().min(1).max(200_000),
|
|
||||||
bodyHtmlEs: z.string().max(200_000).optional().nullable(),
|
|
||||||
bodyText: z.string().max(200_000).optional().nullable(),
|
|
||||||
bodyTextEs: z.string().max(200_000).optional().nullable(),
|
|
||||||
description: z.string().max(2000).optional().nullable(),
|
|
||||||
variables: z.array(z.any()).max(100).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateTemplateSchema = createTemplateSchema.partial().extend({
|
|
||||||
isActive: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendCustomEmailSchema = z.object({
|
|
||||||
to: z.string().email().max(254),
|
|
||||||
toName: z.string().max(200).optional(),
|
|
||||||
subject: z.string().min(1).max(500),
|
|
||||||
bodyHtml: z.string().min(1).max(200_000),
|
|
||||||
bodyText: z.string().max(200_000).optional(),
|
|
||||||
eventId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const emailLogsQuerySchema = z.object({
|
|
||||||
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
|
|
||||||
offset: z.coerce.number().int().min(0).optional().default(0),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Safely parse a stored JSON variables column; a corrupt row must not 500 the route.
|
|
||||||
function safeParseVariables(raw: any): any[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Template Routes ====================
|
// ==================== Template Routes ====================
|
||||||
|
|
||||||
// Get all email templates
|
// Get all email templates
|
||||||
@@ -66,7 +20,7 @@ emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
// Parse variables JSON for each template
|
// Parse variables JSON for each template
|
||||||
const parsedTemplates = templates.map((t: any) => ({
|
const parsedTemplates = templates.map((t: any) => ({
|
||||||
...t,
|
...t,
|
||||||
variables: safeParseVariables(t.variables),
|
variables: t.variables ? JSON.parse(t.variables) : [],
|
||||||
isSystem: Boolean(t.isSystem),
|
isSystem: Boolean(t.isSystem),
|
||||||
isActive: Boolean(t.isActive),
|
isActive: Boolean(t.isActive),
|
||||||
}));
|
}));
|
||||||
@@ -92,7 +46,7 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
|
|||||||
return c.json({
|
return c.json({
|
||||||
template: {
|
template: {
|
||||||
...template,
|
...template,
|
||||||
variables: safeParseVariables(template.variables),
|
variables: template.variables ? JSON.parse(template.variables) : [],
|
||||||
isSystem: Boolean(template.isSystem),
|
isSystem: Boolean(template.isSystem),
|
||||||
isActive: Boolean(template.isActive),
|
isActive: Boolean(template.isActive),
|
||||||
}
|
}
|
||||||
@@ -100,10 +54,14 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create new email template
|
// Create new email template
|
||||||
emailsRouter.post('/templates', requireAuth(['admin']), zValidator('json', createTemplateSchema), async (c) => {
|
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
|
||||||
const body = c.req.valid('json');
|
const body = await c.req.json();
|
||||||
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
|
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
|
||||||
|
|
||||||
|
if (!name || !slug || !subject || !bodyHtml) {
|
||||||
|
return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if slug already exists
|
// Check if slug already exists
|
||||||
const existing = await dbGet<any>(
|
const existing = await dbGet<any>(
|
||||||
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
|
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
|
||||||
@@ -146,9 +104,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), zValidator('json', creat
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update email template
|
// Update email template
|
||||||
emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', updateTemplateSchema), async (c) => {
|
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
const body = c.req.valid('json');
|
const body = await c.req.json();
|
||||||
|
|
||||||
const existing = await dbGet<any>(
|
const existing = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
@@ -163,22 +121,22 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', up
|
|||||||
|
|
||||||
const updateData: any = { updatedAt: getNow() };
|
const updateData: any = { updatedAt: getNow() };
|
||||||
|
|
||||||
// System templates cannot have their slug or isSystem flag changed; only the
|
// Only allow updating certain fields for system templates
|
||||||
// editable fields below are applied.
|
const systemProtectedFields = ['slug', 'isSystem'];
|
||||||
|
|
||||||
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
|
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
|
||||||
if (!existing.isSystem) {
|
if (!existing.isSystem) {
|
||||||
allowedFields.push('slug');
|
allowedFields.push('slug');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const field of allowedFields) {
|
for (const field of allowedFields) {
|
||||||
const value = (body as Record<string, unknown>)[field];
|
if (body[field] !== undefined) {
|
||||||
if (value !== undefined) {
|
|
||||||
if (field === 'variables') {
|
if (field === 'variables') {
|
||||||
updateData[field] = JSON.stringify(value);
|
updateData[field] = JSON.stringify(body[field]);
|
||||||
} else if (field === 'isActive') {
|
} else if (field === 'isActive') {
|
||||||
updateData[field] = value ? 1 : 0;
|
updateData[field] = body[field] ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
updateData[field] = value;
|
updateData[field] = body[field];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +156,7 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', up
|
|||||||
return c.json({
|
return c.json({
|
||||||
template: {
|
template: {
|
||||||
...updated,
|
...updated,
|
||||||
variables: safeParseVariables(updated.variables),
|
variables: updated.variables ? JSON.parse(updated.variables) : [],
|
||||||
isSystem: Boolean(updated.isSystem),
|
isSystem: Boolean(updated.isSystem),
|
||||||
isActive: Boolean(updated.isActive),
|
isActive: Boolean(updated.isActive),
|
||||||
},
|
},
|
||||||
@@ -245,22 +203,16 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
|
|||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { templateSlug, customVariables, recipientFilter } = body;
|
const { templateSlug, customVariables, recipientFilter } = body;
|
||||||
|
|
||||||
if (!templateSlug || typeof templateSlug !== 'string') {
|
if (!templateSlug) {
|
||||||
return c.json({ error: 'Template slug is required' }, 400);
|
return c.json({ error: 'Template slug is required' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedFilters = ['confirmed', 'pending', 'all', 'checked_in'];
|
|
||||||
const filter = recipientFilter || 'confirmed';
|
|
||||||
if (!allowedFilters.includes(filter)) {
|
|
||||||
return c.json({ error: `Invalid recipientFilter. Allowed: ${allowedFilters.join(', ')}` }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queue emails for background processing instead of sending synchronously
|
// Queue emails for background processing instead of sending synchronously
|
||||||
const result = await emailService.queueEventEmails({
|
const result = await emailService.queueEventEmails({
|
||||||
eventId,
|
eventId,
|
||||||
templateSlug,
|
templateSlug,
|
||||||
customVariables,
|
customVariables,
|
||||||
recipientFilter: filter,
|
recipientFilter: recipientFilter || 'confirmed',
|
||||||
sentBy: user?.id,
|
sentBy: user?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,9 +220,14 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send custom email to specific recipients
|
// Send custom email to specific recipients
|
||||||
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidator('json', sendCustomEmailSchema), async (c) => {
|
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const user = (c as any).get('user');
|
const user = (c as any).get('user');
|
||||||
const { to, toName, subject, bodyHtml, bodyText, eventId } = c.req.valid('json');
|
const body = await c.req.json();
|
||||||
|
const { to, toName, subject, bodyHtml, bodyText, eventId } = body;
|
||||||
|
|
||||||
|
if (!to || !subject || !bodyHtml) {
|
||||||
|
return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await emailService.sendCustomEmail({
|
const result = await emailService.sendCustomEmail({
|
||||||
to,
|
to,
|
||||||
@@ -315,7 +272,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
: template.bodyHtml;
|
: template.bodyHtml;
|
||||||
|
|
||||||
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
||||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
|
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
||||||
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -331,9 +288,8 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const search = c.req.query('search');
|
const search = c.req.query('search');
|
||||||
// Clamp pagination so a NaN / out-of-range value can't produce undefined query behaviour.
|
const limit = parseInt(c.req.query('limit') || '50');
|
||||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1), 200);
|
const offset = parseInt(c.req.query('offset') || '0');
|
||||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
|
||||||
|
|
||||||
let query = (db as any).select().from(emailLogs);
|
let query = (db as any).select().from(emailLogs);
|
||||||
|
|
||||||
@@ -485,7 +441,7 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
|||||||
|
|
||||||
// Get email queue status
|
// Get email queue status
|
||||||
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
|
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
|
||||||
const status = await getQueueStatus();
|
const status = getQueueStatus();
|
||||||
return c.json({ status });
|
return c.json({ status });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+68
-108
@@ -127,13 +127,8 @@ const baseEventSchema = z.object({
|
|||||||
currency: z.string().default('PYG'),
|
currency: z.string().default('PYG'),
|
||||||
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
||||||
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||||
// Accept relative paths (/uploads/...) or http(s) URLs only — reject schemes like
|
// Accept relative paths (/uploads/...) or full URLs
|
||||||
// javascript:/data: that could be reflected into an href/src on the frontend.
|
bannerUrl: z.string().optional().nullable().or(z.literal('')),
|
||||||
bannerUrl: z.string()
|
|
||||||
.refine((v) => v === '' || v.startsWith('/') || /^https?:\/\//i.test(v), {
|
|
||||||
message: 'Banner URL must be a relative path or an http(s) URL',
|
|
||||||
})
|
|
||||||
.optional().nullable().or(z.literal('')),
|
|
||||||
// External booking support - accept boolean or number (0/1 from DB)
|
// External booking support - accept boolean or number (0/1 from DB)
|
||||||
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
||||||
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
@@ -171,59 +166,51 @@ const updateEventSchema = baseEventSchema.partial().refine(
|
|||||||
eventsRouter.get('/', async (c) => {
|
eventsRouter.get('/', async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const upcoming = c.req.query('upcoming');
|
const upcoming = c.req.query('upcoming');
|
||||||
|
|
||||||
// Only privileged users may see non-public events (drafts, archived, etc.).
|
|
||||||
// Anonymous/regular callers are restricted to published events regardless of
|
|
||||||
// any client-supplied status filter, so drafts cannot leak.
|
|
||||||
const authUser: any = await getAuthUser(c);
|
|
||||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
|
||||||
|
|
||||||
const conditions: any[] = [];
|
|
||||||
|
|
||||||
if (upcoming === 'true') {
|
|
||||||
// Upcoming feed is always published + future-dated, for everyone.
|
|
||||||
conditions.push(eq((events as any).status, 'published'));
|
|
||||||
conditions.push(gte((events as any).startDatetime, getNow()));
|
|
||||||
} else if (isPrivileged) {
|
|
||||||
// Admins/staff may filter by any status (or list everything when unset).
|
|
||||||
if (status) {
|
|
||||||
conditions.push(eq((events as any).status, status));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Public listing: published events only, regardless of any status param.
|
|
||||||
conditions.push(eq((events as any).status, 'published'));
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = (db as any).select().from(events);
|
let query = (db as any).select().from(events);
|
||||||
if (conditions.length > 0) {
|
|
||||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
if (status) {
|
||||||
|
query = query.where(eq((events as any).status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
|
if (upcoming === 'true') {
|
||||||
|
const now = getNow();
|
||||||
// Single grouped query for booked counts across all events (avoids N+1: previously
|
query = query.where(
|
||||||
// this ran one COUNT query per event).
|
and(
|
||||||
const countRows = await dbAll<any>(
|
eq((events as any).status, 'published'),
|
||||||
(db as any)
|
gte((events as any).startDatetime, now)
|
||||||
.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>();
|
|
||||||
for (const row of countRows) {
|
|
||||||
countByEvent.set(row.eventId, Number(row.count) || 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsWithCounts = result.map((event: any) => {
|
const result = await dbAll(query.orderBy(desc((events as any).startDatetime)));
|
||||||
const normalized = normalizeEvent(event);
|
|
||||||
const bookedCount = countByEvent.get(event.id) || 0;
|
// Get ticket counts for each event
|
||||||
return {
|
const eventsWithCounts = await Promise.all(
|
||||||
...normalized,
|
result.map(async (event: any) => {
|
||||||
bookedCount,
|
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
// This ensures check-in doesn't affect capacity/spots_left
|
||||||
};
|
const ticketCount = await dbGet<any>(
|
||||||
});
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tickets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((tickets as any).eventId, event.id),
|
||||||
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalized = normalizeEvent(event);
|
||||||
|
const bookedCount = ticketCount?.count || 0;
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
bookedCount,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ events: eventsWithCounts });
|
return c.json({ events: eventsWithCounts });
|
||||||
});
|
});
|
||||||
@@ -236,15 +223,6 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draft events are only visible to privileged users (admin preview); hide from public.
|
|
||||||
if ((event as any).status === 'draft') {
|
|
||||||
const authUser: any = await getAuthUser(c);
|
|
||||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
|
||||||
if (!isPrivileged) {
|
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
|
||||||
// This ensures check-in doesn't affect capacity/spots_left
|
// This ensures check-in doesn't affect capacity/spots_left
|
||||||
@@ -294,45 +272,6 @@ async function getEventTicketCount(eventId: string): Promise<number> {
|
|||||||
return ticketCount?.count || 0;
|
return ticketCount?.count || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
|
|
||||||
async function getNextChronologicalUpcoming(): Promise<any | null> {
|
|
||||||
const now = getNow();
|
|
||||||
const event = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select()
|
|
||||||
.from(events)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((events as any).status, 'published'),
|
|
||||||
gte((events as any).startDatetime, now)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy((events as any).startDatetime)
|
|
||||||
.limit(1)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookedCount = await getEventTicketCount(event.id);
|
|
||||||
const normalized = normalizeEvent(event);
|
|
||||||
return {
|
|
||||||
...normalized,
|
|
||||||
bookedCount,
|
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
|
|
||||||
eventsRouter.get('/next', async (c) => {
|
|
||||||
const event = await getNextChronologicalUpcoming();
|
|
||||||
if (!event) {
|
|
||||||
return c.json({ event: null });
|
|
||||||
}
|
|
||||||
return c.json({ event: { ...event, isFeatured: false } });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
|
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
|
||||||
eventsRouter.get('/next/upcoming', async (c) => {
|
eventsRouter.get('/next/upcoming', async (c) => {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
@@ -396,13 +335,34 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: get the next upcoming published event
|
// Fallback: get the next upcoming published event
|
||||||
const event = await getNextChronologicalUpcoming();
|
const event = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((events as any).status, 'published'),
|
||||||
|
gte((events as any).startDatetime, now)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy((events as any).startDatetime)
|
||||||
|
.limit(1)
|
||||||
|
);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ event: null });
|
return c.json({ event: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ event: { ...event, isFeatured: false } });
|
const bookedCount = await getEventTicketCount(event.id);
|
||||||
|
const normalized = normalizeEvent(event);
|
||||||
|
return c.json({
|
||||||
|
event: {
|
||||||
|
...normalized,
|
||||||
|
bookedCount,
|
||||||
|
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
|
||||||
|
isFeatured: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create event (admin/organizer only)
|
// Create event (admin/organizer only)
|
||||||
|
|||||||
@@ -6,22 +6,6 @@ import { getNow, generateId } from '../lib/utils.js';
|
|||||||
|
|
||||||
const faqRouter = new Hono();
|
const faqRouter = new Hono();
|
||||||
|
|
||||||
// Upper bounds for admin-supplied FAQ content (guards against accidental/abusive huge payloads)
|
|
||||||
const MAX_QUESTION_LEN = 1000;
|
|
||||||
const MAX_ANSWER_LEN = 20000;
|
|
||||||
|
|
||||||
// Returns an error message if any provided field exceeds its limit, else null.
|
|
||||||
function faqLengthError(fields: { question?: any; questionEs?: any; answer?: any; answerEs?: any }): string | null {
|
|
||||||
const check = (v: any, max: number, label: string) =>
|
|
||||||
typeof v === 'string' && v.length > max ? `${label} must be at most ${max} characters` : null;
|
|
||||||
return (
|
|
||||||
check(fields.question, MAX_QUESTION_LEN, 'Question') ||
|
|
||||||
check(fields.questionEs, MAX_QUESTION_LEN, 'Question (ES)') ||
|
|
||||||
check(fields.answer, MAX_ANSWER_LEN, 'Answer') ||
|
|
||||||
check(fields.answerEs, MAX_ANSWER_LEN, 'Answer (ES)')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Public Routes ====================
|
// ==================== Public Routes ====================
|
||||||
|
|
||||||
// Get FAQ list for public (only enabled; optional filter for homepage)
|
// Get FAQ list for public (only enabled; optional filter for homepage)
|
||||||
@@ -114,11 +98,6 @@ faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json({ error: 'Question and answer (EN) are required' }, 400);
|
return c.json({ error: 'Question and answer (EN) are required' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lengthError = faqLengthError({ question, questionEs, answer, answerEs });
|
|
||||||
if (lengthError) {
|
|
||||||
return c.json({ error: lengthError }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
|
|
||||||
@@ -178,11 +157,6 @@ faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json({ error: 'FAQ not found' }, 404);
|
return c.json({ error: 'FAQ not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lengthError = faqLengthError({ question, questionEs, answer, answerEs });
|
|
||||||
if (lengthError) {
|
|
||||||
return c.json({ error: lengthError }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {
|
const updateData: Record<string, unknown> = {
|
||||||
updatedAt: getNow(),
|
updatedAt: getNow(),
|
||||||
};
|
};
|
||||||
@@ -235,15 +209,6 @@ faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json({ error: 'ids array is required' }, 400);
|
return c.json({ error: 'ids array is required' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify every id exists before applying ranks (prevents silent no-ops on bad input).
|
|
||||||
const existingRows = await dbAll<any>(
|
|
||||||
(db as any).select({ id: (faqQuestions as any).id }).from(faqQuestions)
|
|
||||||
);
|
|
||||||
const existingIds = new Set(existingRows.map((r: any) => r.id));
|
|
||||||
if (ids.some((id: string) => !existingIds.has(id))) {
|
|
||||||
return c.json({ error: 'One or more FAQ ids are invalid' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
for (let i = 0; i < ids.length; i++) {
|
for (let i = 0; i < ids.length; i++) {
|
||||||
await (db as any)
|
await (db as any)
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ import path from 'path';
|
|||||||
|
|
||||||
const legalPagesRouter = new Hono();
|
const legalPagesRouter = new Hono();
|
||||||
|
|
||||||
|
// Helper: Convert plain text to simple markdown
|
||||||
|
// Preserves paragraphs and line breaks, nothing fancy
|
||||||
|
function textToMarkdown(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Split into paragraphs (double newlines)
|
||||||
|
const paragraphs = text.split(/\n\s*\n/);
|
||||||
|
|
||||||
|
// Process each paragraph
|
||||||
|
const processed = paragraphs.map(para => {
|
||||||
|
// Replace single newlines with double spaces + newline for markdown line breaks
|
||||||
|
return para.trim().replace(/\n/g, ' \n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join paragraphs with double newlines
|
||||||
|
return processed.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: Convert markdown to plain text for editing
|
// Helper: Convert markdown to plain text for editing
|
||||||
function markdownToText(markdown: string): string {
|
function markdownToText(markdown: string): string {
|
||||||
if (!markdown) return '';
|
if (!markdown) return '';
|
||||||
@@ -144,13 +162,6 @@ legalPagesRouter.get('/', async (c) => {
|
|||||||
legalPagesRouter.get('/:slug', async (c) => {
|
legalPagesRouter.get('/:slug', async (c) => {
|
||||||
const { slug } = c.req.param();
|
const { slug } = c.req.param();
|
||||||
const locale = c.req.query('locale') || 'en';
|
const locale = c.req.query('locale') || 'en';
|
||||||
|
|
||||||
// Reject anything that isn't a simple slug. The filesystem fallback below builds a
|
|
||||||
// path from this value, so an unconstrained slug (e.g. "../../etc/passwd") would
|
|
||||||
// allow path traversal / arbitrary file reads.
|
|
||||||
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
||||||
return c.json({ error: 'Legal page not found' }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// First try to get from database
|
// First try to get from database
|
||||||
const page = await dbGet<any>(
|
const page = await dbGet<any>(
|
||||||
@@ -264,17 +275,6 @@ legalPagesRouter.put('/admin/:slug', requireAuth(['admin']), async (c) => {
|
|||||||
if (!enContent && !esContent) {
|
if (!enContent && !esContent) {
|
||||||
return c.json({ error: 'At least one language content is required' }, 400);
|
return c.json({ error: 'At least one language content is required' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bound the sizes of admin-supplied content to avoid unbounded payloads.
|
|
||||||
const MAX_CONTENT_LEN = 200000; // ~200 KB of markdown per language
|
|
||||||
const MAX_TITLE_LEN = 255;
|
|
||||||
const tooLong = (v: any, max: number) => typeof v === 'string' && v.length > max;
|
|
||||||
if (tooLong(enContent, MAX_CONTENT_LEN) || tooLong(esContent, MAX_CONTENT_LEN)) {
|
|
||||||
return c.json({ error: `Content must be at most ${MAX_CONTENT_LEN} characters` }, 400);
|
|
||||||
}
|
|
||||||
if (tooLong(title, MAX_TITLE_LEN) || tooLong(titleEs, MAX_TITLE_LEN)) {
|
|
||||||
return c.json({ error: `Title must be at most ${MAX_TITLE_LEN} characters` }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await dbGet(
|
const existing = await dbGet(
|
||||||
(db as any)
|
(db as any)
|
||||||
|
|||||||
+44
-132
@@ -1,30 +1,19 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { streamSSE } from 'hono/streaming';
|
import { streamSSE } from 'hono/streaming';
|
||||||
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { getPubSub } from '../lib/stores/pubsub.js';
|
|
||||||
import { getLock } from '../lib/stores/lock.js';
|
|
||||||
|
|
||||||
const lnbitsRouter = new Hono();
|
const lnbitsRouter = new Hono();
|
||||||
|
|
||||||
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
|
// Store for active SSE connections (ticketId -> Set of response writers)
|
||||||
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
|
const activeConnections = new Map<string, Set<(data: any) => void>>();
|
||||||
const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
|
|
||||||
|
|
||||||
// Pub/sub unsubscribe handles per ticket (one local subscription per ticket).
|
|
||||||
const channelUnsubs = new Map<string, () => void>();
|
|
||||||
|
|
||||||
// Store for active background checkers (ticketId -> intervalId)
|
// Store for active background checkers (ticketId -> intervalId)
|
||||||
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
/** Pub/sub channel that carries payment events for a ticket. */
|
|
||||||
function paymentChannel(ticketId: string): string {
|
|
||||||
return `payment:${ticketId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LNbits webhook payload structure
|
* LNbits webhook payload structure
|
||||||
*/
|
*/
|
||||||
@@ -43,71 +32,32 @@ interface LNbitsWebhookPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify every client for a ticket across all instances.
|
* Notify all connected clients for a ticket
|
||||||
*
|
|
||||||
* Publishes to the ticket's pub/sub channel. In single-instance / in-memory
|
|
||||||
* mode this is an in-process broadcast; with Redis it reaches whichever
|
|
||||||
* instance(s) actually hold the SSE socket(s) for this ticket.
|
|
||||||
*/
|
*/
|
||||||
async function notifyClients(ticketId: string, data: any) {
|
function notifyClients(ticketId: string, data: any) {
|
||||||
await getPubSub().publish(paymentChannel(ticketId), data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deliver an event to the SSE sockets held by THIS process for a ticket.
|
|
||||||
* Invoked by the pub/sub subscription handler.
|
|
||||||
*/
|
|
||||||
async function deliverLocal(ticketId: string, data: any) {
|
|
||||||
const connections = activeConnections.get(ticketId);
|
const connections = activeConnections.get(ticketId);
|
||||||
if (connections) {
|
if (connections) {
|
||||||
await Promise.all(
|
connections.forEach(send => {
|
||||||
Array.from(connections).map(async (send) => {
|
try {
|
||||||
try {
|
send(data);
|
||||||
await send(data);
|
} catch (e) {
|
||||||
} catch (e) {
|
// Connection might be closed
|
||||||
// Connection might be closed
|
}
|
||||||
}
|
});
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distributed lock tokens for the per-ticket poller (ticketId -> token).
|
|
||||||
const checkerLockTokens = new Map<string, string>();
|
|
||||||
|
|
||||||
/** Release the per-ticket poller lock if this process holds it. */
|
|
||||||
function releaseCheckerLock(ticketId: string) {
|
|
||||||
const token = checkerLockTokens.get(ticketId);
|
|
||||||
if (token) {
|
|
||||||
checkerLockTokens.delete(ticketId);
|
|
||||||
void getLock().release(`checker:${ticketId}`, token);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start background payment checking for a ticket.
|
* Start background payment checking for a ticket
|
||||||
*
|
|
||||||
* Only one instance should poll LNbits per ticket, so we take a distributed
|
|
||||||
* lock for the lifetime of the poll. Other instances skip polling and instead
|
|
||||||
* receive the result via pub/sub. With no Redis configured the lock is a local
|
|
||||||
* no-op and behavior matches the original single-instance polling.
|
|
||||||
*/
|
*/
|
||||||
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
||||||
// Don't start if already checking on this instance
|
// Don't start if already checking
|
||||||
if (activeCheckers.has(ticketId)) {
|
if (activeCheckers.has(ticketId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiryMs = expirySeconds * 1000;
|
|
||||||
|
|
||||||
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
|
||||||
if (!lockToken) {
|
|
||||||
// Another instance is already polling this ticket.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
checkerLockTokens.set(ticketId, lockToken);
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const expiryMs = expirySeconds * 1000;
|
||||||
let checkCount = 0;
|
let checkCount = 0;
|
||||||
|
|
||||||
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
|
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
|
||||||
@@ -121,8 +71,7 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
|
|||||||
console.log(`Invoice expired for ticket ${ticketId}`);
|
console.log(`Invoice expired for ticket ${ticketId}`);
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
activeCheckers.delete(ticketId);
|
activeCheckers.delete(ticketId);
|
||||||
releaseCheckerLock(ticketId);
|
notifyClients(ticketId, { type: 'expired', ticketId });
|
||||||
await notifyClients(ticketId, { type: 'expired', ticketId });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +82,9 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp
|
|||||||
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
|
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
activeCheckers.delete(ticketId);
|
activeCheckers.delete(ticketId);
|
||||||
releaseCheckerLock(ticketId);
|
|
||||||
|
|
||||||
await handlePaymentComplete(ticketId, paymentHash);
|
await handlePaymentComplete(ticketId, paymentHash);
|
||||||
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error checking payment for ticket ${ticketId}:`, error);
|
console.error(`Error checking payment for ticket ${ticketId}:`, error);
|
||||||
@@ -154,7 +102,6 @@ function stopBackgroundChecker(ticketId: string) {
|
|||||||
if (interval) {
|
if (interval) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
activeCheckers.delete(ticketId);
|
activeCheckers.delete(ticketId);
|
||||||
releaseCheckerLock(ticketId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,23 +111,13 @@ function stopBackgroundChecker(ticketId: string) {
|
|||||||
*/
|
*/
|
||||||
lnbitsRouter.post('/webhook', async (c) => {
|
lnbitsRouter.post('/webhook', async (c) => {
|
||||||
try {
|
try {
|
||||||
// Optional shared-secret gate: if LNBITS_WEBHOOK_SECRET is configured, the
|
|
||||||
// webhook URL must carry a matching ?token=... (set when the invoice is created).
|
|
||||||
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
|
||||||
if (webhookSecret) {
|
|
||||||
const provided = c.req.query('token') || c.req.header('x-webhook-secret') || '';
|
|
||||||
if (provided !== webhookSecret) {
|
|
||||||
console.warn('LNbits webhook rejected: invalid or missing secret');
|
|
||||||
return c.json({ received: true, processed: false }, 401);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: LNbitsWebhookPayload = await c.req.json();
|
const payload: LNbitsWebhookPayload = await c.req.json();
|
||||||
|
|
||||||
// Log identifiers only (no full payload / PII)
|
|
||||||
console.log('LNbits webhook received:', {
|
console.log('LNbits webhook received:', {
|
||||||
paymentHash: payload.payment_hash,
|
paymentHash: payload.payment_hash,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
|
amount: payload.amount,
|
||||||
|
extra: payload.extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify the payment is actually complete by checking with LNbits
|
// Verify the payment is actually complete by checking with LNbits
|
||||||
@@ -198,27 +135,13 @@ lnbitsRouter.post('/webhook', async (c) => {
|
|||||||
return c.json({ received: true, processed: false }, 200);
|
return c.json({ received: true, processed: false }, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: bind the paid hash to this ticket's own invoice. Without this, a
|
|
||||||
// valid paid hash from any other invoice could be replayed with an arbitrary
|
|
||||||
// ticketId to confirm tickets for free.
|
|
||||||
const ticketPayment = await dbGet<any>(
|
|
||||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
|
||||||
);
|
|
||||||
if (!ticketPayment || ticketPayment.reference !== payload.payment_hash) {
|
|
||||||
console.warn('LNbits webhook rejected: payment hash does not match the ticket invoice', {
|
|
||||||
ticketId,
|
|
||||||
paymentHash: payload.payment_hash,
|
|
||||||
});
|
|
||||||
return c.json({ received: true, processed: false }, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop background checker since webhook confirmed payment
|
// Stop background checker since webhook confirmed payment
|
||||||
stopBackgroundChecker(ticketId);
|
stopBackgroundChecker(ticketId);
|
||||||
|
|
||||||
await handlePaymentComplete(ticketId, payload.payment_hash);
|
await handlePaymentComplete(ticketId, payload.payment_hash);
|
||||||
|
|
||||||
// Notify connected clients via SSE
|
// Notify connected clients via SSE
|
||||||
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
|
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
|
||||||
|
|
||||||
return c.json({ received: true, processed: true }, 200);
|
return c.json({ received: true, processed: true }, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -263,13 +186,15 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
|||||||
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
|
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
|
// Confirm all tickets in the booking
|
||||||
for (const ticket of ticketsToConfirm) {
|
for (const ticket of ticketsToConfirm) {
|
||||||
|
// Update ticket status to confirmed
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(tickets)
|
.update(tickets)
|
||||||
.set({ status: 'confirmed' })
|
.set({ status: 'confirmed' })
|
||||||
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
|
.where(eq((tickets as any).id, ticket.id));
|
||||||
|
|
||||||
|
// Update payment status to paid
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(payments)
|
.update(payments)
|
||||||
.set({
|
.set({
|
||||||
@@ -278,7 +203,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
|
|||||||
paidAt: now,
|
paidAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
|
.where(eq((payments as any).ticketId, ticket.id));
|
||||||
|
|
||||||
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
|
||||||
}
|
}
|
||||||
@@ -317,45 +242,38 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
|||||||
return c.json({ error: 'Ticket not found' }, 404);
|
return c.json({ error: 'Ticket not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If already paid, return immediately
|
||||||
|
if (ticket.status === 'confirmed') {
|
||||||
|
return c.json({ type: 'already_paid', ticketId }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
// Get payment to start background checker
|
// Get payment to start background checker
|
||||||
const payment = await dbGet<any>(
|
const payment = await dbGet<any>(
|
||||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start background checker if not already running (only while still pending)
|
// Start background checker if not already running
|
||||||
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
|
if (payment?.reference && !activeCheckers.has(ticketId)) {
|
||||||
await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
|
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent proxies/CDNs from buffering the event stream so events flush immediately.
|
|
||||||
c.header('Cache-Control', 'no-cache, no-transform');
|
|
||||||
c.header('X-Accel-Buffering', 'no');
|
|
||||||
|
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
const sendEvent = async (data: any) => {
|
// Register this connection
|
||||||
await stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// If already paid, notify over SSE and close (EventSource can parse this).
|
|
||||||
if (ticket.status === 'confirmed') {
|
|
||||||
await sendEvent({ type: 'already_paid', ticketId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register this connection. The first local connection for a ticket also
|
|
||||||
// subscribes to the ticket's pub/sub channel so events published by any
|
|
||||||
// instance (webhook or background checker) are delivered to these sockets.
|
|
||||||
if (!activeConnections.has(ticketId)) {
|
if (!activeConnections.has(ticketId)) {
|
||||||
activeConnections.set(ticketId, new Set());
|
activeConnections.set(ticketId, new Set());
|
||||||
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
|
|
||||||
void deliverLocal(ticketId, data);
|
|
||||||
});
|
|
||||||
channelUnsubs.set(ticketId, unsub);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendEvent = (data: any) => {
|
||||||
|
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
|
||||||
|
};
|
||||||
|
|
||||||
activeConnections.get(ticketId)!.add(sendEvent);
|
activeConnections.get(ticketId)!.add(sendEvent);
|
||||||
|
|
||||||
// Send initial status
|
// Send initial status
|
||||||
await sendEvent({ type: 'connected', ticketId });
|
await stream.writeSSE({
|
||||||
|
data: JSON.stringify({ type: 'connected', ticketId }),
|
||||||
|
event: 'payment'
|
||||||
|
});
|
||||||
|
|
||||||
// Keep connection alive with heartbeat
|
// Keep connection alive with heartbeat
|
||||||
const heartbeat = setInterval(async () => {
|
const heartbeat = setInterval(async () => {
|
||||||
@@ -374,12 +292,6 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
|||||||
connections.delete(sendEvent);
|
connections.delete(sendEvent);
|
||||||
if (connections.size === 0) {
|
if (connections.size === 0) {
|
||||||
activeConnections.delete(ticketId);
|
activeConnections.delete(ticketId);
|
||||||
// Drop the pub/sub subscription once no local sockets remain.
|
|
||||||
const unsub = channelUnsubs.get(ticketId);
|
|
||||||
if (unsub) {
|
|
||||||
unsub();
|
|
||||||
channelUnsubs.delete(ticketId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+38
-65
@@ -1,51 +1,24 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { db, dbGet, dbAll, media } from '../db/index.js';
|
import { db, dbGet, dbAll, media } from '../db/index.js';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow } from '../lib/utils.js';
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
import { getStorage, keyFromUrl } from '../lib/storage.js';
|
import { writeFile, mkdir, unlink } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join, extname } from 'path';
|
||||||
|
|
||||||
const mediaRouter = new Hono();
|
const mediaRouter = new Hono();
|
||||||
|
|
||||||
|
const UPLOAD_DIR = './uploads';
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
|
||||||
const MAX_FILE_SIZE =
|
const MAX_FILE_SIZE =
|
||||||
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
|
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
|
||||||
|
|
||||||
/**
|
// Ensure upload directory exists
|
||||||
* Detect a real image type from the file's magic bytes (content sniffing).
|
async function ensureUploadDir() {
|
||||||
* Returns the canonical mime + extension, or null if the content is not an
|
if (!existsSync(UPLOAD_DIR)) {
|
||||||
* allowed image. We deliberately ignore the client-supplied filename and
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
* Content-Type so an attacker cannot store e.g. an .html/.svg payload.
|
|
||||||
*/
|
|
||||||
function detectImageType(buf: Buffer): { mime: string; ext: string } | null {
|
|
||||||
if (buf.length < 12) return null;
|
|
||||||
|
|
||||||
// JPEG: FF D8 FF
|
|
||||||
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
||||||
return { mime: 'image/jpeg', ext: '.jpg' };
|
|
||||||
}
|
}
|
||||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
||||||
if (
|
|
||||||
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
|
|
||||||
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
|
|
||||||
) {
|
|
||||||
return { mime: 'image/png', ext: '.png' };
|
|
||||||
}
|
|
||||||
// GIF: "GIF87a" / "GIF89a"
|
|
||||||
if (buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a') {
|
|
||||||
return { mime: 'image/gif', ext: '.gif' };
|
|
||||||
}
|
|
||||||
// WEBP: "RIFF"...."WEBP"
|
|
||||||
if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
|
|
||||||
return { mime: 'image/webp', ext: '.webp' };
|
|
||||||
}
|
|
||||||
// AVIF / HEIF: "....ftyp" with an avif/heic brand
|
|
||||||
if (buf.toString('ascii', 4, 8) === 'ftyp') {
|
|
||||||
const brand = buf.toString('ascii', 8, 12);
|
|
||||||
if (brand === 'avif' || brand === 'avis') {
|
|
||||||
return { mime: 'image/avif', ext: '.avif' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload image
|
// Upload image
|
||||||
@@ -58,28 +31,28 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
return c.json({ error: 'No file provided' }, 400);
|
return c.json({ error: 'No file provided' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size (cheap check before reading the whole buffer)
|
// Validate file type
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
|
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
|
||||||
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
|
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the bytes and validate the *content* (not the client-provided type/name)
|
await ensureUploadDir();
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
|
|
||||||
const detected = detectImageType(buffer);
|
|
||||||
if (!detected) {
|
|
||||||
return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename using the *detected* extension (ignore client filename)
|
// Generate unique filename
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
const filename = `${id}${detected.ext}`;
|
const ext = extname(file.name) || '.jpg';
|
||||||
|
const filename = `${id}${ext}`;
|
||||||
// Persist via the storage backend (local disk or S3-compatible object store).
|
const filepath = join(UPLOAD_DIR, filename);
|
||||||
const storage = getStorage();
|
|
||||||
await storage.put(filename, buffer, detected.mime);
|
// Write file
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
await writeFile(filepath, Buffer.from(arrayBuffer));
|
||||||
|
|
||||||
// Get related info from form data
|
// Get related info from form data
|
||||||
const relatedId = body['relatedId'] as string | undefined;
|
const relatedId = body['relatedId'] as string | undefined;
|
||||||
@@ -89,7 +62,7 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
const now = getNow();
|
const now = getNow();
|
||||||
const mediaRecord = {
|
const mediaRecord = {
|
||||||
id,
|
id,
|
||||||
fileUrl: storage.publicUrl(filename),
|
fileUrl: `/uploads/${filename}`,
|
||||||
type: 'image' as const,
|
type: 'image' as const,
|
||||||
relatedId: relatedId || null,
|
relatedId: relatedId || null,
|
||||||
relatedType: relatedType || null,
|
relatedType: relatedType || null,
|
||||||
@@ -135,9 +108,12 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
return c.json({ error: 'Media not found' }, 404);
|
return c.json({ error: 'Media not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the underlying object from the storage backend.
|
// Delete file from disk
|
||||||
try {
|
try {
|
||||||
await getStorage().delete(keyFromUrl(mediaRecord.fileUrl));
|
const filepath = join('.', mediaRecord.fileUrl);
|
||||||
|
if (existsSync(filepath)) {
|
||||||
|
await unlink(filepath);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete file:', error);
|
console.error('Failed to delete file:', error);
|
||||||
}
|
}
|
||||||
@@ -152,20 +128,17 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const relatedType = c.req.query('relatedType');
|
const relatedType = c.req.query('relatedType');
|
||||||
const relatedId = c.req.query('relatedId');
|
const relatedId = c.req.query('relatedId');
|
||||||
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '200', 10) || 200, 1), 500);
|
|
||||||
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
|
|
||||||
|
|
||||||
// Combine filters into a single where() — chaining .where() replaces the prior condition in Drizzle.
|
|
||||||
const conditions: any[] = [];
|
|
||||||
if (relatedType) conditions.push(eq((media as any).relatedType, relatedType));
|
|
||||||
if (relatedId) conditions.push(eq((media as any).relatedId, relatedId));
|
|
||||||
|
|
||||||
let query = (db as any).select().from(media);
|
let query = (db as any).select().from(media);
|
||||||
if (conditions.length > 0) {
|
|
||||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
if (relatedType) {
|
||||||
|
query = query.where(eq((media as any).relatedType, relatedType));
|
||||||
|
}
|
||||||
|
if (relatedId) {
|
||||||
|
query = query.where(eq((media as any).relatedId, relatedId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll(query.limit(limit).offset(offset));
|
const result = await dbAll(query);
|
||||||
|
|
||||||
return c.json({ media: result });
|
return c.json({ media: result });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
|
import { db, dbGet, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
||||||
|
|
||||||
const paymentOptionsRouter = new Hono();
|
const paymentOptionsRouter = new Hono();
|
||||||
@@ -18,10 +18,6 @@ const booleanOrNumber = z.union([z.boolean(), z.number()]).transform((val) => {
|
|||||||
const updatePaymentOptionsSchema = z.object({
|
const updatePaymentOptionsSchema = z.object({
|
||||||
tpagoEnabled: booleanOrNumber.optional(),
|
tpagoEnabled: booleanOrNumber.optional(),
|
||||||
tpagoLink: z.string().optional().nullable(),
|
tpagoLink: z.string().optional().nullable(),
|
||||||
tpagoLink2: z.string().optional().nullable(),
|
|
||||||
tpagoLink3: z.string().optional().nullable(),
|
|
||||||
tpagoLink4: z.string().optional().nullable(),
|
|
||||||
tpagoLink5: z.string().optional().nullable(),
|
|
||||||
tpagoInstructions: z.string().optional().nullable(),
|
tpagoInstructions: z.string().optional().nullable(),
|
||||||
tpagoInstructionsEs: z.string().optional().nullable(),
|
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||||
bankTransferEnabled: booleanOrNumber.optional(),
|
bankTransferEnabled: booleanOrNumber.optional(),
|
||||||
@@ -40,31 +36,10 @@ const updatePaymentOptionsSchema = z.object({
|
|||||||
allowDuplicateBookings: booleanOrNumber.optional(),
|
allowDuplicateBookings: booleanOrNumber.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Strip bank account numbers from payment options for anonymous callers. */
|
|
||||||
function publicPaymentOptions(merged: Record<string, any>) {
|
|
||||||
return {
|
|
||||||
...merged,
|
|
||||||
bankName: null,
|
|
||||||
bankAccountHolder: null,
|
|
||||||
bankAccountNumber: null,
|
|
||||||
bankAlias: null,
|
|
||||||
bankPhone: null,
|
|
||||||
tpagoLink: null,
|
|
||||||
tpagoLink2: null,
|
|
||||||
tpagoLink3: null,
|
|
||||||
tpagoLink4: null,
|
|
||||||
tpagoLink5: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schema for event-level overrides
|
// Schema for event-level overrides
|
||||||
const updateEventOverridesSchema = z.object({
|
const updateEventOverridesSchema = z.object({
|
||||||
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
tpagoEnabled: booleanOrNumber.optional().nullable(),
|
||||||
tpagoLink: z.string().optional().nullable(),
|
tpagoLink: z.string().optional().nullable(),
|
||||||
tpagoLink2: z.string().optional().nullable(),
|
|
||||||
tpagoLink3: z.string().optional().nullable(),
|
|
||||||
tpagoLink4: z.string().optional().nullable(),
|
|
||||||
tpagoLink5: z.string().optional().nullable(),
|
|
||||||
tpagoInstructions: z.string().optional().nullable(),
|
tpagoInstructions: z.string().optional().nullable(),
|
||||||
tpagoInstructionsEs: z.string().optional().nullable(),
|
tpagoInstructionsEs: z.string().optional().nullable(),
|
||||||
bankTransferEnabled: booleanOrNumber.optional().nullable(),
|
bankTransferEnabled: booleanOrNumber.optional().nullable(),
|
||||||
@@ -93,10 +68,6 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
paymentOptions: {
|
paymentOptions: {
|
||||||
tpagoEnabled: false,
|
tpagoEnabled: false,
|
||||||
tpagoLink: null,
|
tpagoLink: null,
|
||||||
tpagoLink2: null,
|
|
||||||
tpagoLink3: null,
|
|
||||||
tpagoLink4: null,
|
|
||||||
tpagoLink5: null,
|
|
||||||
tpagoInstructions: null,
|
tpagoInstructions: null,
|
||||||
tpagoInstructionsEs: null,
|
tpagoInstructionsEs: null,
|
||||||
bankTransferEnabled: false,
|
bankTransferEnabled: false,
|
||||||
@@ -168,7 +139,6 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
|
|||||||
// Get payment options for a specific event (merged with global)
|
// Get payment options for a specific event (merged with global)
|
||||||
paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
||||||
const eventId = c.req.param('eventId');
|
const eventId = c.req.param('eventId');
|
||||||
const ticketId = c.req.query('ticketId');
|
|
||||||
|
|
||||||
// Get the event first to verify it exists
|
// Get the event first to verify it exists
|
||||||
const event = await dbGet(
|
const event = await dbGet(
|
||||||
@@ -201,10 +171,6 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
const defaults = {
|
const defaults = {
|
||||||
tpagoEnabled: false,
|
tpagoEnabled: false,
|
||||||
tpagoLink: null,
|
tpagoLink: null,
|
||||||
tpagoLink2: null,
|
|
||||||
tpagoLink3: null,
|
|
||||||
tpagoLink4: null,
|
|
||||||
tpagoLink5: null,
|
|
||||||
tpagoInstructions: null,
|
tpagoInstructions: null,
|
||||||
tpagoInstructionsEs: null,
|
tpagoInstructionsEs: null,
|
||||||
bankTransferEnabled: false,
|
bankTransferEnabled: false,
|
||||||
@@ -227,10 +193,6 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
const merged = {
|
const merged = {
|
||||||
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
|
||||||
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
|
||||||
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
|
|
||||||
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
|
|
||||||
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
|
|
||||||
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
|
|
||||||
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
|
||||||
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
|
||||||
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
|
||||||
@@ -247,24 +209,8 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Full bank/TPago credentials are only returned when the caller proves they hold
|
|
||||||
// a valid ticket for this event (the ticket UUID is the booking capability token),
|
|
||||||
// or when an authenticated admin/organizer requests them.
|
|
||||||
let revealSensitive = false;
|
|
||||||
const authUser: any = await getAuthUser(c);
|
|
||||||
if (authUser && ['admin', 'organizer'].includes(authUser.role)) {
|
|
||||||
revealSensitive = true;
|
|
||||||
} else if (ticketId) {
|
|
||||||
const ticket = await dbGet<any>(
|
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
|
|
||||||
);
|
|
||||||
if (ticket && ticket.eventId === eventId && ticket.status !== 'cancelled') {
|
|
||||||
revealSensitive = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
paymentOptions: revealSensitive ? merged : publicPaymentOptions(merged),
|
paymentOptions: merged,
|
||||||
hasOverrides: !!overrides,
|
hasOverrides: !!overrides,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -162,29 +162,6 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
|
|||||||
return c.json({ payments: enrichedPayments });
|
return c.json({ payments: enrichedPayments });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
|
|
||||||
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
|
||||||
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, 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({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
stats: {
|
|
||||||
total: Number(totalRow?.count || 0),
|
|
||||||
pending: Number(pendingRow?.count || 0),
|
|
||||||
paid: Number(paidRow?.count || 0),
|
|
||||||
refunded: Number(refundedRow?.count || 0),
|
|
||||||
failed: Number(failedRow?.count || 0),
|
|
||||||
totalRevenue: Number(revenueRow?.total || 0),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get payment by ID (admin)
|
// Get payment by ID (admin)
|
||||||
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
@@ -410,40 +387,26 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Determine all tickets in this booking (multi-ticket bookings must be rejected together)
|
// Update payment status to failed
|
||||||
const rejectTicket = await dbGet<any>(
|
await (db as any)
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
.update(payments)
|
||||||
);
|
.set({
|
||||||
let ticketsToReject: any[] = rejectTicket ? [rejectTicket] : [];
|
status: 'failed',
|
||||||
if (rejectTicket?.bookingId) {
|
paidByAdminId: user.id,
|
||||||
ticketsToReject = await dbAll<any>(
|
adminNote: adminNote || payment.adminNote,
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, rejectTicket.bookingId))
|
updatedAt: now,
|
||||||
);
|
})
|
||||||
console.log(`[Payment] Rejecting multi-ticket booking: ${rejectTicket.bookingId}, ${ticketsToReject.length} tickets`);
|
.where(eq((payments as any).id, id));
|
||||||
}
|
|
||||||
|
// Cancel the ticket - booking is no longer valid after rejection
|
||||||
for (const t of ticketsToReject) {
|
await (db as any)
|
||||||
// Fail the payment for each ticket in the booking
|
.update(tickets)
|
||||||
await (db as any)
|
.set({
|
||||||
.update(payments)
|
status: 'cancelled',
|
||||||
.set({
|
updatedAt: now,
|
||||||
status: 'failed',
|
})
|
||||||
paidByAdminId: user.id,
|
.where(eq((tickets as any).id, payment.ticketId));
|
||||||
adminNote: adminNote || payment.adminNote,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((payments as any).ticketId, (t as any).id));
|
|
||||||
|
|
||||||
// Cancel the ticket - booking is no longer valid after rejection
|
|
||||||
await (db as any)
|
|
||||||
.update(tickets)
|
|
||||||
.set({
|
|
||||||
status: 'cancelled',
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(eq((tickets as any).id, (t as any).id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
|
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
|
||||||
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
|
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
|
||||||
@@ -566,42 +529,56 @@ paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Refund all tickets/payments in the booking (multi-ticket bookings refund together)
|
// Update payment status
|
||||||
const refundTicket = await dbGet<any>(
|
await (db as any)
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
|
.update(payments)
|
||||||
);
|
.set({ status: 'refunded', updatedAt: now })
|
||||||
let ticketsToRefund: any[] = refundTicket ? [refundTicket] : [];
|
.where(eq((payments as any).id, id));
|
||||||
if (refundTicket?.bookingId) {
|
|
||||||
ticketsToRefund = await dbAll<any>(
|
// Cancel associated ticket
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, refundTicket.bookingId))
|
await (db as any)
|
||||||
);
|
.update(tickets)
|
||||||
console.log(`[Payment] Refunding multi-ticket booking: ${refundTicket.bookingId}, ${ticketsToRefund.length} tickets`);
|
.set({ status: 'cancelled' })
|
||||||
}
|
.where(eq((tickets as any).id, payment.ticketId));
|
||||||
|
|
||||||
for (const t of ticketsToRefund) {
|
|
||||||
// Only refund payments that were actually paid; leave others untouched
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({ status: 'refunded', updatedAt: now })
|
|
||||||
.where(and(eq((payments as any).ticketId, (t as any).id), eq((payments as any).status, 'paid')));
|
|
||||||
|
|
||||||
await (db as any)
|
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'cancelled' })
|
|
||||||
.where(eq((tickets as any).id, (t as any).id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ message: 'Refund processed successfully' });
|
return c.json({ message: 'Refund processed successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Payment webhook (for Stripe/MercadoPago)
|
// Payment webhook (for Stripe/MercadoPago)
|
||||||
// Not implemented: there is deliberately NO status mutation here. Until provider
|
|
||||||
// signature verification is implemented, accepting webhooks would let anyone forge
|
|
||||||
// a "paid" status. Returns 501 and never updates payments/tickets.
|
|
||||||
paymentsRouter.post('/webhook', async (c) => {
|
paymentsRouter.post('/webhook', async (c) => {
|
||||||
console.warn('Payment webhook received but provider webhooks are not implemented (no signature verification).');
|
// This would handle webhook notifications from payment providers
|
||||||
return c.json({ error: 'Webhook handling is not implemented' }, 501);
|
// Implementation depends on which provider is used
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
// Log webhook for debugging
|
||||||
|
console.log('Payment webhook received:', body);
|
||||||
|
|
||||||
|
// TODO: Implement provider-specific webhook handling
|
||||||
|
// - Verify webhook signature
|
||||||
|
// - Update payment status
|
||||||
|
// - Update ticket status
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get payment statistics (admin)
|
||||||
|
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||||
|
const allPayments = await dbAll<any>((db as any).select().from(payments));
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: allPayments.length,
|
||||||
|
pending: allPayments.filter((p: any) => p.status === 'pending').length,
|
||||||
|
paid: allPayments.filter((p: any) => p.status === 'paid').length,
|
||||||
|
refunded: allPayments.filter((p: any) => p.status === 'refunded').length,
|
||||||
|
failed: allPayments.filter((p: any) => p.status === 'failed').length,
|
||||||
|
totalRevenue: allPayments
|
||||||
|
.filter((p: any) => p.status === 'paid')
|
||||||
|
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({ stats });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default paymentsRouter;
|
export default paymentsRouter;
|
||||||
|
|||||||
@@ -17,20 +17,9 @@ interface UserContext {
|
|||||||
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||||
|
|
||||||
// Validation schema for updating site settings
|
// Validation schema for updating site settings
|
||||||
// Validate against the runtime's IANA timezone database (rejects arbitrary strings
|
|
||||||
// that later get fed into Intl.DateTimeFormat on the frontend).
|
|
||||||
const isValidTimezone = (tz: string): boolean => {
|
|
||||||
try {
|
|
||||||
Intl.DateTimeFormat('en-US', { timeZone: tz });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSiteSettingsSchema = z.object({
|
const updateSiteSettingsSchema = z.object({
|
||||||
timezone: z.string().refine(isValidTimezone, { message: 'Invalid timezone' }).optional(),
|
timezone: z.string().optional(),
|
||||||
siteName: z.string().max(255).optional(),
|
siteName: z.string().optional(),
|
||||||
siteDescription: z.string().optional().nullable(),
|
siteDescription: z.string().optional().nullable(),
|
||||||
siteDescriptionEs: z.string().optional().nullable(),
|
siteDescriptionEs: z.string().optional().nullable(),
|
||||||
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
|
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
|
||||||
|
|||||||
+83
-277
@@ -1,12 +1,11 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||||
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||||
|
|
||||||
@@ -18,9 +17,6 @@ const attendeeSchema = z.object({
|
|||||||
lastName: z.string().min(2).optional().or(z.literal('')),
|
lastName: z.string().min(2).optional().or(z.literal('')),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Maximum tickets a single buyer can book at once (enforced server-side)
|
|
||||||
const MAX_TICKETS_PER_BOOKING = 5;
|
|
||||||
|
|
||||||
const createTicketSchema = z.object({
|
const createTicketSchema = z.object({
|
||||||
eventId: z.string(),
|
eventId: z.string(),
|
||||||
firstName: z.string().min(2),
|
firstName: z.string().min(2),
|
||||||
@@ -28,30 +24,12 @@ const createTicketSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
phone: z.string().min(6).optional().or(z.literal('')),
|
phone: z.string().min(6).optional().or(z.literal('')),
|
||||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||||
// 'bancard' intentionally excluded: no checkout integration exists for it
|
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||||
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
|
||||||
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||||
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
|
// Optional: array of attendees for multi-ticket booking
|
||||||
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
|
attendees: z.array(attendeeSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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;
|
|
||||||
switch (method) {
|
|
||||||
case 'tpago':
|
|
||||||
return truthy(merged.tpagoEnabled);
|
|
||||||
case 'bank_transfer':
|
|
||||||
return truthy(merged.bankTransferEnabled);
|
|
||||||
case 'lightning':
|
|
||||||
return truthy(merged.lightningEnabled);
|
|
||||||
case 'cash':
|
|
||||||
return truthy(merged.cashEnabled);
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTicketSchema = z.object({
|
const updateTicketSchema = z.object({
|
||||||
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
|
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
|
||||||
adminNote: z.string().optional(),
|
adminNote: z.string().optional(),
|
||||||
@@ -82,11 +60,6 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
: [{ firstName: data.firstName, lastName: data.lastName }];
|
: [{ firstName: data.firstName, lastName: data.lastName }];
|
||||||
|
|
||||||
const ticketCount = attendeesList.length;
|
const ticketCount = attendeesList.length;
|
||||||
|
|
||||||
// Enforce the per-booking ticket cap server-side (UI also caps, but the API is authoritative)
|
|
||||||
if (ticketCount < 1 || ticketCount > MAX_TICKETS_PER_BOOKING) {
|
|
||||||
return c.json({ error: `You can book between 1 and ${MAX_TICKETS_PER_BOOKING} tickets per order.` }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get event
|
// Get event
|
||||||
const event = await dbGet<any>(
|
const event = await dbGet<any>(
|
||||||
@@ -99,28 +72,9 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
if (!['published', 'unlisted'].includes(event.status)) {
|
if (!['published', 'unlisted'].includes(event.status)) {
|
||||||
return c.json({ error: 'Event is not available for booking' }, 400);
|
return c.json({ error: 'Event is not available for booking' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the requested payment method is actually enabled for this event
|
|
||||||
// (merge global options with any event-level overrides; override wins when not null)
|
|
||||||
const globalPaymentOptions = await dbGet<any>(
|
|
||||||
(db as any).select().from(paymentOptions)
|
|
||||||
);
|
|
||||||
const eventOverrides = await dbGet<any>(
|
|
||||||
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, data.eventId))
|
|
||||||
);
|
|
||||||
const mergedPaymentOptions: Record<string, any> = {
|
|
||||||
tpagoEnabled: eventOverrides?.tpagoEnabled ?? globalPaymentOptions?.tpagoEnabled ?? false,
|
|
||||||
bankTransferEnabled: eventOverrides?.bankTransferEnabled ?? globalPaymentOptions?.bankTransferEnabled ?? false,
|
|
||||||
lightningEnabled: eventOverrides?.lightningEnabled ?? globalPaymentOptions?.lightningEnabled ?? true,
|
|
||||||
cashEnabled: eventOverrides?.cashEnabled ?? globalPaymentOptions?.cashEnabled ?? true,
|
|
||||||
};
|
|
||||||
if (!isPaymentMethodEnabled(data.paymentMethod, mergedPaymentOptions)) {
|
|
||||||
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check capacity - count pending, confirmed AND checked_in tickets.
|
// Check capacity - count confirmed AND checked_in tickets
|
||||||
// Pending reservations must hold seats to prevent overselling via unpaid bookings
|
// (checked_in were previously confirmed, check-in doesn't affect capacity)
|
||||||
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
|
|
||||||
const existingTicketCount = await dbGet<any>(
|
const existingTicketCount = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -128,7 +82,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq((tickets as any).eventId, data.eventId),
|
eq((tickets as any).eventId, data.eventId),
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -174,7 +128,13 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||||
const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false;
|
const globalOptions = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(paymentOptions)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
|
||||||
|
|
||||||
if (!allowDuplicateBookings) {
|
if (!allowDuplicateBookings) {
|
||||||
const existingTicket = await dbGet<any>(
|
const existingTicket = await dbGet<any>(
|
||||||
@@ -196,151 +156,52 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
|
|
||||||
// Generate booking ID to group multiple tickets
|
// Generate booking ID to group multiple tickets
|
||||||
const bookingId = generateId();
|
const bookingId = generateId();
|
||||||
|
|
||||||
// Atomically re-check capacity and insert tickets/payments inside a transaction
|
// Create tickets for each attendee
|
||||||
// so concurrent bookings cannot oversell the same seats (TOCTOU race).
|
const createdTickets: any[] = [];
|
||||||
class BookingCapacityError extends Error {
|
const createdPayments: any[] = [];
|
||||||
constructor(public code: 'SOLD_OUT' | 'NOT_ENOUGH', public available?: number) {
|
|
||||||
super(code);
|
for (let i = 0; i < attendeesList.length; i++) {
|
||||||
}
|
const attendee = attendeesList[i];
|
||||||
}
|
const ticketId = generateId();
|
||||||
|
const qrCode = generateTicketCode();
|
||||||
let createdTickets: any[] = [];
|
|
||||||
let createdPayments: any[] = [];
|
const newTicket = {
|
||||||
|
id: ticketId,
|
||||||
try {
|
bookingId: ticketCount > 1 ? bookingId : null, // Only set bookingId for multi-ticket bookings
|
||||||
if (isSqlite()) {
|
userId: user.id,
|
||||||
(db as any).transaction((tx: any) => {
|
eventId: data.eventId,
|
||||||
const countRow = tx
|
attendeeFirstName: attendee.firstName,
|
||||||
.select({ count: sql<number>`count(*)` })
|
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||||
.from(tickets)
|
attendeeEmail: data.email, // Buyer's email for all tickets
|
||||||
.where(
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
and(
|
attendeeRuc: data.ruc || null,
|
||||||
eq((tickets as any).eventId, data.eventId),
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
status: 'pending',
|
||||||
)
|
qrCode,
|
||||||
)
|
checkinAt: null,
|
||||||
.get();
|
createdAt: now,
|
||||||
const reserved = Number(countRow?.count || 0);
|
};
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
|
||||||
throw new BookingCapacityError('SOLD_OUT');
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
}
|
createdTickets.push(newTicket);
|
||||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
|
||||||
if (ticketCount > seatsLeft) {
|
// Create payment record for each ticket
|
||||||
throw new BookingCapacityError('NOT_ENOUGH', seatsLeft);
|
const paymentId = generateId();
|
||||||
}
|
const newPayment = {
|
||||||
|
id: paymentId,
|
||||||
for (let i = 0; i < attendeesList.length; i++) {
|
ticketId,
|
||||||
const attendee = attendeesList[i];
|
provider: data.paymentMethod,
|
||||||
const ticketId = generateId();
|
amount: event.price,
|
||||||
const qrCode = generateTicketCode();
|
currency: event.currency,
|
||||||
const newTicket = {
|
status: 'pending',
|
||||||
id: ticketId,
|
reference: null,
|
||||||
bookingId: ticketCount > 1 ? bookingId : null,
|
createdAt: now,
|
||||||
userId: user.id,
|
updatedAt: now,
|
||||||
eventId: data.eventId,
|
};
|
||||||
attendeeFirstName: attendee.firstName,
|
|
||||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
await (db as any).insert(payments).values(newPayment);
|
||||||
attendeeEmail: data.email,
|
createdPayments.push(newPayment);
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
|
||||||
attendeeRuc: data.ruc || null,
|
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
|
||||||
status: 'pending',
|
|
||||||
qrCode,
|
|
||||||
checkinAt: null,
|
|
||||||
createdAt: now,
|
|
||||||
};
|
|
||||||
tx.insert(tickets).values(newTicket).run();
|
|
||||||
createdTickets.push(newTicket);
|
|
||||||
|
|
||||||
const paymentId = generateId();
|
|
||||||
const newPayment = {
|
|
||||||
id: paymentId,
|
|
||||||
ticketId,
|
|
||||||
provider: data.paymentMethod,
|
|
||||||
amount: event.price,
|
|
||||||
currency: event.currency,
|
|
||||||
status: 'pending',
|
|
||||||
reference: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
tx.insert(payments).values(newPayment).run();
|
|
||||||
createdPayments.push(newPayment);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} 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 reserved = Number(countRow?.count || 0);
|
|
||||||
if (isEventSoldOut(event.capacity, reserved)) {
|
|
||||||
throw new BookingCapacityError('SOLD_OUT');
|
|
||||||
}
|
|
||||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
|
||||||
if (ticketCount > seatsLeft) {
|
|
||||||
throw new BookingCapacityError('NOT_ENOUGH', seatsLeft);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < attendeesList.length; i++) {
|
|
||||||
const attendee = attendeesList[i];
|
|
||||||
const ticketId = generateId();
|
|
||||||
const qrCode = generateTicketCode();
|
|
||||||
const newTicket = {
|
|
||||||
id: ticketId,
|
|
||||||
bookingId: ticketCount > 1 ? bookingId : null,
|
|
||||||
userId: user.id,
|
|
||||||
eventId: data.eventId,
|
|
||||||
attendeeFirstName: attendee.firstName,
|
|
||||||
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,
|
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
|
||||||
status: 'pending',
|
|
||||||
qrCode,
|
|
||||||
checkinAt: null,
|
|
||||||
createdAt: now,
|
|
||||||
};
|
|
||||||
await tx.insert(tickets).values(newTicket);
|
|
||||||
createdTickets.push(newTicket);
|
|
||||||
|
|
||||||
const paymentId = generateId();
|
|
||||||
const newPayment = {
|
|
||||||
id: paymentId,
|
|
||||||
ticketId,
|
|
||||||
provider: data.paymentMethod,
|
|
||||||
amount: event.price,
|
|
||||||
currency: event.currency,
|
|
||||||
status: 'pending',
|
|
||||||
reference: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
await tx.insert(payments).values(newPayment);
|
|
||||||
createdPayments.push(newPayment);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err instanceof BookingCapacityError) {
|
|
||||||
if (err.code === 'SOLD_OUT') {
|
|
||||||
return c.json({ error: 'Event is sold out' }, 400);
|
|
||||||
}
|
|
||||||
return c.json({
|
|
||||||
error: `Not enough seats available. Only ${err.available} spot(s) remaining.`,
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryTicket = createdTickets[0];
|
const primaryTicket = createdTickets[0];
|
||||||
@@ -360,26 +221,11 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If Lightning payment, create LNbits invoice (skip for free events — confirm immediately)
|
// If Lightning payment, create LNbits invoice
|
||||||
let lnbitsInvoice = null;
|
let lnbitsInvoice = null;
|
||||||
const totalPrice = event.price * ticketCount;
|
const totalPrice = event.price * ticketCount;
|
||||||
|
|
||||||
// Free events: no payment step required — confirm tickets immediately
|
if (data.paymentMethod === 'lightning' && totalPrice > 0) {
|
||||||
if (totalPrice === 0) {
|
|
||||||
for (const t of createdTickets) {
|
|
||||||
await (db as any)
|
|
||||||
.update(tickets)
|
|
||||||
.set({ status: 'confirmed' })
|
|
||||||
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
|
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({ status: 'paid', paidAt: now, updatedAt: now })
|
|
||||||
.where(and(eq((payments as any).ticketId, t.id), eq((payments as any).status, 'pending')));
|
|
||||||
}
|
|
||||||
emailService.sendBookingConfirmation(primaryTicket.id).catch(err => {
|
|
||||||
console.error('[Email] Failed to send free-booking confirmation:', err);
|
|
||||||
});
|
|
||||||
} else if (data.paymentMethod === 'lightning') {
|
|
||||||
if (!isLNbitsConfigured()) {
|
if (!isLNbitsConfigured()) {
|
||||||
// Delete the tickets and payments we just created
|
// Delete the tickets and payments we just created
|
||||||
for (const payment of createdPayments) {
|
for (const payment of createdPayments) {
|
||||||
@@ -395,11 +241,6 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||||
// Include the webhook secret (if configured) so the callback can be authenticated
|
|
||||||
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
|
||||||
const webhookUrl = webhookSecret
|
|
||||||
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
|
|
||||||
: `${apiUrl}/api/lnbits/webhook`;
|
|
||||||
|
|
||||||
// Pass the fiat currency directly to LNbits - it handles conversion automatically
|
// Pass the fiat currency directly to LNbits - it handles conversion automatically
|
||||||
// For multi-ticket, use total price
|
// For multi-ticket, use total price
|
||||||
@@ -407,7 +248,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
amount: totalPrice,
|
amount: totalPrice,
|
||||||
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
|
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
|
||||||
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
|
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
|
||||||
webhookUrl,
|
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
|
||||||
expiry: 900, // 15 minutes expiry for faster UX
|
expiry: 900, // 15 minutes expiry for faster UX
|
||||||
extra: {
|
extra: {
|
||||||
ticketId: primaryTicket.id,
|
ticketId: primaryTicket.id,
|
||||||
@@ -769,9 +610,6 @@ ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get ticket by ID
|
// Get ticket by ID
|
||||||
// Capability-based access: the unguessable ticket UUID acts as the access token for
|
|
||||||
// guest bookings (no account required). For anonymous callers we withhold attendee PII
|
|
||||||
// (email/phone/RUC); the full record is only returned to the owner or admin/staff.
|
|
||||||
ticketsRouter.get('/:id', async (c) => {
|
ticketsRouter.get('/:id', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
|
|
||||||
@@ -793,30 +631,13 @@ ticketsRouter.get('/:id', async (c) => {
|
|||||||
(db as any).select().from(payments).where(eq((payments as any).ticketId, id))
|
(db as any).select().from(payments).where(eq((payments as any).ticketId, id))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Count how many tickets belong to this booking (for per-quantity payment links)
|
return c.json({
|
||||||
let bookingTicketCount = 1;
|
ticket: {
|
||||||
if (ticket.bookingId) {
|
...ticket,
|
||||||
const bookingTickets = await dbAll<any>(
|
event,
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
payment,
|
||||||
);
|
},
|
||||||
bookingTicketCount = bookingTickets.length || 1;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Determine whether the requester is the owner or an admin/staff member
|
|
||||||
const authUser: any = await getAuthUser(c);
|
|
||||||
const isPrivileged = !!authUser && (
|
|
||||||
['admin', 'organizer', 'staff'].includes(authUser.role) || authUser.id === ticket.userId
|
|
||||||
);
|
|
||||||
|
|
||||||
const ticketPayload: any = { ...ticket, event, payment, bookingTicketCount };
|
|
||||||
if (!isPrivileged) {
|
|
||||||
// Strip attendee PII for anonymous capability-based access
|
|
||||||
delete ticketPayload.attendeeEmail;
|
|
||||||
delete ticketPayload.attendeePhone;
|
|
||||||
delete ticketPayload.attendeeRuc;
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ ticket: ticketPayload });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update ticket status (admin/organizer)
|
// Update ticket status (admin/organizer)
|
||||||
@@ -1154,7 +975,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
|||||||
|
|
||||||
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
|
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
|
||||||
// This sets status to "pending_approval" and notifies admin
|
// This sets status to "pending_approval" and notifies admin
|
||||||
ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, windowMs: 10 * 60 * 1000, prefix: 'mark-payment-sent' }), async (c) => {
|
ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
const { payerName } = body;
|
const { payerName } = body;
|
||||||
@@ -1208,33 +1029,18 @@ ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, wind
|
|||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|
||||||
// Update payment status to pending_approval for this ticket and any siblings
|
// Update payment status to pending_approval
|
||||||
// in a multi-ticket booking (mirrors the approve flow's bookingId fan-out).
|
await (db as any)
|
||||||
let ticketsToMark: any[] = [ticket];
|
.update(payments)
|
||||||
if (ticket.bookingId) {
|
.set({
|
||||||
ticketsToMark = await dbAll<any>(
|
status: 'pending_approval',
|
||||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
userMarkedPaidAt: now,
|
||||||
);
|
payerName: payerName?.trim() || null,
|
||||||
}
|
updatedAt: now,
|
||||||
|
})
|
||||||
for (const t of ticketsToMark) {
|
.where(eq((payments as any).id, payment.id));
|
||||||
await (db as any)
|
|
||||||
.update(payments)
|
|
||||||
.set({
|
|
||||||
status: 'pending_approval',
|
|
||||||
userMarkedPaidAt: now,
|
|
||||||
payerName: payerName?.trim() || null,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq((payments as any).ticketId, (t as any).id),
|
|
||||||
eq((payments as any).status, 'pending')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get updated payment for the requested ticket
|
// Get updated payment
|
||||||
const updatedPayment = await dbGet(
|
const updatedPayment = await dbGet(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
+23
-23
@@ -50,29 +50,6 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
return c.json({ users: result });
|
return c.json({ users: result });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
|
|
||||||
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
|
||||||
const totalUsers = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(users)
|
|
||||||
);
|
|
||||||
|
|
||||||
const adminCount = await dbGet<any>(
|
|
||||||
(db as any)
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(users)
|
|
||||||
.where(eq((users as any).role, 'admin'))
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
stats: {
|
|
||||||
total: totalUsers?.count || 0,
|
|
||||||
admins: adminCount?.count || 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get user by ID (admin or self)
|
// Get user by ID (admin or self)
|
||||||
usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
|
usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
@@ -309,4 +286,27 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get user statistics (admin)
|
||||||
|
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
|
||||||
|
const totalUsers = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users)
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminCount = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(users)
|
||||||
|
.where(eq((users as any).role, 'admin'))
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
stats: {
|
||||||
|
total: totalUsers?.count || 0,
|
||||||
|
admins: adminCount?.count || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export default usersRouter;
|
export default usersRouter;
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
# Example docker-compose for running the Spanglish API as multiple replicas
|
|
||||||
# behind nginx, with Redis for shared state and Postgres as the database.
|
|
||||||
#
|
|
||||||
# This is a starting point, not a turnkey production setup. It expects a
|
|
||||||
# Dockerfile at backend/Dockerfile that builds the API and runs it on PORT.
|
|
||||||
#
|
|
||||||
# Bring it up with N API replicas:
|
|
||||||
# docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
|
|
||||||
#
|
|
||||||
# No em dashes are used in this file by design.
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: spanglish
|
|
||||||
POSTGRES_PASSWORD: spanglish
|
|
||||||
POSTGRES_DB: spanglish
|
|
||||||
# Raise max_connections if DB_POOL_MAX * replicas approaches the default 100.
|
|
||||||
command: ["postgres", "-c", "max_connections=200"]
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U spanglish"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
|
||||||
volumes:
|
|
||||||
- redisdata:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: ../backend
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: "3001"
|
|
||||||
DB_TYPE: postgres
|
|
||||||
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
|
|
||||||
DB_POOL_MAX: "15"
|
|
||||||
REDIS_URL: redis://redis:6379
|
|
||||||
JWT_SECRET: change-me-to-a-strong-secret
|
|
||||||
FRONTEND_URL: http://localhost:8080
|
|
||||||
# Optional S3-compatible storage so uploads are shared across replicas.
|
|
||||||
# If you omit these, mount a shared volume at /app/uploads on every replica.
|
|
||||||
# S3_ENDPOINT: http://garage:3900
|
|
||||||
# S3_REGION: garage
|
|
||||||
# S3_BUCKET: spanglish-media
|
|
||||||
# S3_ACCESS_KEY_ID: ""
|
|
||||||
# S3_SECRET_ACCESS_KEY: ""
|
|
||||||
# S3_PUBLIC_URL: http://localhost:8080/media
|
|
||||||
expose:
|
|
||||||
- "3001"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
# Load balancer across the scaled api replicas. nginx resolves the "api"
|
|
||||||
# service name via Docker's embedded DNS, which round-robins across replicas.
|
|
||||||
lb:
|
|
||||||
image: nginx:alpine
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
volumes:
|
|
||||||
- ./nginx.scale.conf:/etc/nginx/conf.d/default.conf:ro
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
redisdata:
|
|
||||||
@@ -43,25 +43,6 @@ server {
|
|||||||
access_log /var/log/nginx/spanglish_frontend_access.log;
|
access_log /var/log/nginx/spanglish_frontend_access.log;
|
||||||
error_log /var/log/nginx/spanglish_frontend_error.log;
|
error_log /var/log/nginx/spanglish_frontend_error.log;
|
||||||
|
|
||||||
# LNbits payment SSE stream - must not be buffered or events won't flush in
|
|
||||||
# real time. Regex location takes precedence over the /api prefix below.
|
|
||||||
location ~ ^/api/lnbits/stream/ {
|
|
||||||
proxy_pass http://spanglish_backend;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Connection '';
|
|
||||||
|
|
||||||
# Disable buffering/caching for Server-Sent Events
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy /api to backend
|
# Proxy /api to backend
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://spanglish_backend;
|
proxy_pass http://spanglish_backend;
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# nginx load balancer for the scaled "api" service in docker-compose.scale.yml.
|
|
||||||
# Uses Docker's embedded DNS resolver so newly scaled replicas are discovered
|
|
||||||
# without editing a static upstream list.
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
# Docker embedded DNS. valid=10s re-resolves so scaling up/down is picked up.
|
|
||||||
resolver 127.0.0.11 valid=10s;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
# Use a variable so nginx defers resolution to request time (round-robin
|
|
||||||
# across all replicas of the "api" service).
|
|
||||||
set $api_upstream http://api:3001;
|
|
||||||
proxy_pass $api_upstream;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# Server-Sent Events: do not buffer the payment status stream.
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_read_timeout 1h;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
-34
@@ -1,51 +1,28 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
// Backend origin for API/upload proxying. Configurable per environment instead of
|
|
||||||
// being hardcoded to localhost.
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
|
|
||||||
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
|
|
||||||
.split(',')
|
|
||||||
.map((h) => h.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((hostname) => ({ protocol: 'https', hostname }));
|
|
||||||
|
|
||||||
const securityHeaders = [
|
|
||||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
||||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
|
||||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
||||||
{ key: 'X-XSS-Protection', value: '0' },
|
|
||||||
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
images: {
|
images: {
|
||||||
// Restrict remote image sources to a known allowlist instead of allowing any
|
domains: ['localhost', 'images.unsplash.com'],
|
||||||
// https host (which let the Next image optimizer be used as an open proxy).
|
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
|
||||||
{ protocol: 'http', hostname: 'localhost', port: '3001' },
|
|
||||||
...extraImageHosts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
protocol: 'https',
|
||||||
headers: securityHeaders,
|
hostname: '**',
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: '3001',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${BACKEND_URL}/api/:path*`,
|
destination: 'http://localhost:3001/api/:path*',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/uploads/:path*',
|
source: '/uploads/:path*',
|
||||||
destination: `${BACKEND_URL}/uploads/:path*`,
|
destination: 'http://localhost:3001/uploads/:path*',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"swr": "^2.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^20.14.9",
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { ticketsApi } from '@/lib/api';
|
|
||||||
import type { BookingStep } from '../_types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch for Lightning payment confirmation while on the paying step.
|
|
||||||
* SSE gives instant updates; a 3s poll runs in parallel as a safety net so a
|
|
||||||
* buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI.
|
|
||||||
*/
|
|
||||||
export function useLightningWatcher(
|
|
||||||
step: BookingStep,
|
|
||||||
ticketId: string | undefined,
|
|
||||||
locale: string,
|
|
||||||
setPaymentPending: (value: boolean) => void,
|
|
||||||
setStep: (value: BookingStep) => void
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (step !== 'paying' || !ticketId) return;
|
|
||||||
|
|
||||||
let settled = false;
|
|
||||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const confirmPaid = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
|
|
||||||
setPaymentPending(false);
|
|
||||||
setStep('success');
|
|
||||||
};
|
|
||||||
|
|
||||||
const expire = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
|
|
||||||
setPaymentPending(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Always same-origin so the streaming proxy route handler is used (it
|
|
||||||
// bypasses the rewrite, which buffers SSE).
|
|
||||||
const eventSource = new EventSource(`/api/lnbits/stream/${ticketId}`);
|
|
||||||
|
|
||||||
eventSource.addEventListener('payment', (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse((event as MessageEvent).data);
|
|
||||||
if (data.type === 'paid' || data.type === 'already_paid') {
|
|
||||||
confirmPaid();
|
|
||||||
} else if (data.type === 'expired') {
|
|
||||||
expire();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing payment event:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
// SSE failed or was closed; the poll below remains the source of truth.
|
|
||||||
eventSource.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const status = await ticketsApi.checkPaymentStatus(ticketId);
|
|
||||||
if (status.isPaid) {
|
|
||||||
confirmPaid();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking payment status:', error);
|
|
||||||
}
|
|
||||||
if (!settled) {
|
|
||||||
pollTimer = setTimeout(poll, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
pollTimer = setTimeout(poll, 3000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
settled = true;
|
|
||||||
eventSource.close();
|
|
||||||
if (pollTimer) clearTimeout(pollTimer);
|
|
||||||
};
|
|
||||||
}, [step, ticketId, locale]);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import toast from 'react-hot-toast';
|
|
||||||
import { PaymentOptionsConfig } from '@/lib/api';
|
|
||||||
import {
|
|
||||||
CreditCardIcon,
|
|
||||||
BanknotesIcon,
|
|
||||||
BoltIcon,
|
|
||||||
BuildingLibraryIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import type { PaymentMethod, BookingResult } from '../_types';
|
|
||||||
|
|
||||||
export const rucPattern = /^\d{6,10}$/;
|
|
||||||
|
|
||||||
/** Format RUC input: digits only, max 10. */
|
|
||||||
export function formatRuc(value: string): string {
|
|
||||||
return value.replace(/\D/g, '').slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Truncate a long invoice string for display. */
|
|
||||||
export function truncateInvoice(invoice: string, chars: number = 20): string {
|
|
||||||
if (invoice.length <= chars * 2) return invoice;
|
|
||||||
return `${invoice.slice(0, chars)}...${invoice.slice(-chars)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Copy a Lightning invoice to the clipboard with localized feedback. */
|
|
||||||
export function copyInvoiceToClipboard(invoice: string, locale: string): void {
|
|
||||||
navigator.clipboard.writeText(invoice).then(() => {
|
|
||||||
toast.success(locale === 'es' ? '¡Copiado!' : 'Copied!');
|
|
||||||
}).catch(() => {
|
|
||||||
toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentMethodOption {
|
|
||||||
id: PaymentMethod;
|
|
||||||
icon: typeof CreditCardIcon;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
badge?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the list of selectable payment methods from the event config. */
|
|
||||||
export function buildPaymentMethods(
|
|
||||||
paymentConfig: PaymentOptionsConfig | null,
|
|
||||||
locale: string
|
|
||||||
): PaymentMethodOption[] {
|
|
||||||
const paymentMethods: PaymentMethodOption[] = [];
|
|
||||||
|
|
||||||
if (paymentConfig?.lightningEnabled) {
|
|
||||||
paymentMethods.push({
|
|
||||||
id: 'lightning',
|
|
||||||
icon: BoltIcon,
|
|
||||||
label: 'Bitcoin Lightning',
|
|
||||||
description: locale === 'es' ? 'Pago instantáneo con Bitcoin' : 'Instant payment with Bitcoin',
|
|
||||||
badge: locale === 'es' ? 'Instantáneo' : 'Instant',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paymentConfig?.tpagoEnabled) {
|
|
||||||
paymentMethods.push({
|
|
||||||
id: 'tpago',
|
|
||||||
icon: CreditCardIcon,
|
|
||||||
label: locale === 'es' ? 'TPago / Tarjetas de Crédito' : 'TPago / Credit Cards',
|
|
||||||
description: locale === 'es' ? 'Pagá con tarjetas de crédito locales o internacionales' : 'Pay with local or international credit cards',
|
|
||||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paymentConfig?.bankTransferEnabled) {
|
|
||||||
paymentMethods.push({
|
|
||||||
id: 'bank_transfer',
|
|
||||||
icon: BuildingLibraryIcon,
|
|
||||||
label: locale === 'es' ? 'Transferencia Bancaria Local' : 'Local Bank Transfer',
|
|
||||||
description: locale === 'es' ? 'Pago por transferencia bancaria en Paraguay' : 'Pay via Paraguayan bank transfer',
|
|
||||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paymentConfig?.cashEnabled) {
|
|
||||||
paymentMethods.push({
|
|
||||||
id: 'cash',
|
|
||||||
icon: BanknotesIcon,
|
|
||||||
label: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
|
|
||||||
description: locale === 'es' ? 'Paga cuando llegues al evento' : 'Pay when you arrive at the event',
|
|
||||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return paymentMethods;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuccessContent {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
iconColor: string;
|
|
||||||
iconTextColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve the success-screen copy based on the payment method used. */
|
|
||||||
export function getSuccessContent(
|
|
||||||
bookingResult: BookingResult | null,
|
|
||||||
locale: string,
|
|
||||||
t: (key: string) => string
|
|
||||||
): SuccessContent {
|
|
||||||
if (bookingResult?.paymentMethod === 'cash') {
|
|
||||||
return {
|
|
||||||
title: locale === 'es' ? '¡Reserva Recibida!' : 'Reservation Received!',
|
|
||||||
description: locale === 'es'
|
|
||||||
? 'Tu lugar está reservado. El pago se realizará en el evento.'
|
|
||||||
: 'Your spot is reserved. Payment will be collected at the event.',
|
|
||||||
iconColor: 'bg-yellow-100',
|
|
||||||
iconTextColor: 'text-yellow-600',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (bookingResult?.paymentMethod === 'lightning') {
|
|
||||||
// For Lightning, if we're on success step, payment was confirmed
|
|
||||||
return {
|
|
||||||
title: locale === 'es' ? '¡Pago Confirmado!' : 'Payment Confirmed!',
|
|
||||||
description: locale === 'es'
|
|
||||||
? '¡Tu reserva está confirmada! Te esperamos en el evento.'
|
|
||||||
: 'Your booking is confirmed! See you at the event.',
|
|
||||||
iconColor: 'bg-green-100',
|
|
||||||
iconTextColor: 'text-green-600',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
title: t('booking.success.title'),
|
|
||||||
description: t('booking.success.description'),
|
|
||||||
iconColor: 'bg-green-100',
|
|
||||||
iconTextColor: 'text-green-600',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import Input from '@/components/ui/Input';
|
|
||||||
import {
|
|
||||||
CalendarIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
CurrencyDollarIcon,
|
|
||||||
ArrowLeftIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
UserIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import { Event } from '@/lib/api';
|
|
||||||
import { formatPrice } from '@/lib/utils';
|
|
||||||
import type { AttendeeInfo, BookingFormData } from '../_types';
|
|
||||||
import type { PaymentMethodOption } from '../_logic/booking';
|
|
||||||
|
|
||||||
interface BookingFormStepProps {
|
|
||||||
event: Event;
|
|
||||||
locale: string;
|
|
||||||
t: (key: string) => string;
|
|
||||||
spotsLeft: number;
|
|
||||||
isSoldOut: boolean;
|
|
||||||
ticketQuantity: number;
|
|
||||||
formData: BookingFormData;
|
|
||||||
setFormData: React.Dispatch<React.SetStateAction<BookingFormData>>;
|
|
||||||
errors: Partial<Record<keyof BookingFormData, string>>;
|
|
||||||
attendees: AttendeeInfo[];
|
|
||||||
setAttendees: React.Dispatch<React.SetStateAction<AttendeeInfo[]>>;
|
|
||||||
attendeeErrors: { [key: number]: string };
|
|
||||||
setAttendeeErrors: React.Dispatch<React.SetStateAction<{ [key: number]: string }>>;
|
|
||||||
handleRucChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
handleRucBlur: () => void;
|
|
||||||
paymentMethods: PaymentMethodOption[];
|
|
||||||
agreedToTerms: boolean;
|
|
||||||
setAgreedToTerms: (value: boolean) => void;
|
|
||||||
termsError: string | null;
|
|
||||||
submitting: boolean;
|
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
|
||||||
formatDate: (dateStr: string) => string;
|
|
||||||
fmtTime: (dateStr: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BookingFormStep({
|
|
||||||
event,
|
|
||||||
locale,
|
|
||||||
t,
|
|
||||||
spotsLeft,
|
|
||||||
isSoldOut,
|
|
||||||
ticketQuantity,
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
errors,
|
|
||||||
attendees,
|
|
||||||
setAttendees,
|
|
||||||
attendeeErrors,
|
|
||||||
setAttendeeErrors,
|
|
||||||
handleRucChange,
|
|
||||||
handleRucBlur,
|
|
||||||
paymentMethods,
|
|
||||||
agreedToTerms,
|
|
||||||
setAgreedToTerms,
|
|
||||||
termsError,
|
|
||||||
submitting,
|
|
||||||
onSubmit,
|
|
||||||
formatDate,
|
|
||||||
fmtTime,
|
|
||||||
}: BookingFormStepProps) {
|
|
||||||
return (
|
|
||||||
<div className="section-padding bg-secondary-gray min-h-screen">
|
|
||||||
<div className="container-page max-w-2xl">
|
|
||||||
<Link
|
|
||||||
href={`/events/${event.slug}`}
|
|
||||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-4 h-4" />
|
|
||||||
{t('common.back')}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Event Summary - Always Visible */}
|
|
||||||
<Card className="mb-6 overflow-hidden">
|
|
||||||
<div className="bg-primary-yellow/20 p-4 border-b border-primary-yellow/30">
|
|
||||||
<h2 className="font-bold text-lg text-primary-dark">
|
|
||||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-2 text-sm">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
|
||||||
<span>{formatDate(event.startDatetime)} • {fmtTime(event.startDatetime)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
|
||||||
<span>{event.location}</span>
|
|
||||||
</div>
|
|
||||||
{!event.externalBookingEnabled && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
|
||||||
<span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
|
||||||
<span className="font-bold text-lg">
|
|
||||||
{event.price === 0
|
|
||||||
? t('events.details.free')
|
|
||||||
: formatPrice(event.price, event.currency)}
|
|
||||||
</span>
|
|
||||||
{event.price > 0 && (
|
|
||||||
<span className="text-gray-400 text-sm">
|
|
||||||
{locale === 'es' ? 'por persona' : 'per person'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Ticket quantity and total */}
|
|
||||||
{ticketQuantity > 1 && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-secondary-light-gray">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-gray-600">
|
|
||||||
{locale === 'es' ? 'Tickets' : 'Tickets'}: <span className="font-semibold">{ticketQuantity}</span>
|
|
||||||
</span>
|
|
||||||
<span className="font-bold text-lg text-primary-dark">
|
|
||||||
{locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{isSoldOut ? (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<UserGroupIcon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
|
||||||
<h2 className="text-xl font-bold text-gray-700">{t('events.details.soldOut')}</h2>
|
|
||||||
<p className="text-gray-500 mt-2">{t('booking.form.soldOutMessage')}</p>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
{/* User Information Section */}
|
|
||||||
<Card className="mb-6 p-6">
|
|
||||||
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
|
|
||||||
{attendees.length > 0 && (
|
|
||||||
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
|
|
||||||
1
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{t('booking.form.personalInfo')}
|
|
||||||
{attendees.length > 0 && (
|
|
||||||
<span className="text-sm font-normal text-gray-500">
|
|
||||||
({locale === 'es' ? 'Asistente principal' : 'Primary attendee'})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label={t('booking.form.firstName')}
|
|
||||||
value={formData.firstName}
|
|
||||||
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
|
||||||
placeholder={t('booking.form.firstNamePlaceholder')}
|
|
||||||
error={errors.firstName}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('booking.form.lastName')}
|
|
||||||
</label>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={formData.lastName}
|
|
||||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
|
||||||
placeholder={t('booking.form.lastNamePlaceholder')}
|
|
||||||
error={errors.lastName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
label={t('booking.form.email')}
|
|
||||||
type="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
||||||
placeholder={t('booking.form.emailPlaceholder')}
|
|
||||||
error={errors.email}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('booking.form.phone')}
|
|
||||||
</label>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="tel"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
|
||||||
placeholder={t('booking.form.phonePlaceholder')}
|
|
||||||
error={errors.phone}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('booking.form.ruc')}
|
|
||||||
</label>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{t('booking.form.rucOptional')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={formData.ruc}
|
|
||||||
onChange={handleRucChange}
|
|
||||||
onBlur={handleRucBlur}
|
|
||||||
placeholder={t('booking.form.rucPlaceholder')}
|
|
||||||
error={errors.ruc}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Additional Attendees Section (for multi-ticket bookings) */}
|
|
||||||
{attendees.length > 0 && (
|
|
||||||
<Card className="mb-6 p-6">
|
|
||||||
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
|
|
||||||
<UserIcon className="w-5 h-5 text-primary-yellow" />
|
|
||||||
{locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Ingresa el nombre de cada asistente adicional. Cada persona recibirá su propio ticket.'
|
|
||||||
: 'Enter the name for each additional attendee. Each person will receive their own ticket.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{attendees.map((attendee, index) => (
|
|
||||||
<div key={index} className="p-4 bg-gray-50 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
|
|
||||||
{index + 2}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-gray-700">
|
|
||||||
{locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label={t('booking.form.firstName')}
|
|
||||||
value={attendee.firstName}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newAttendees = [...attendees];
|
|
||||||
newAttendees[index].firstName = e.target.value;
|
|
||||||
setAttendees(newAttendees);
|
|
||||||
if (attendeeErrors[index]) {
|
|
||||||
const newErrors = { ...attendeeErrors };
|
|
||||||
delete newErrors[index];
|
|
||||||
setAttendeeErrors(newErrors);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t('booking.form.firstNamePlaceholder')}
|
|
||||||
error={attendeeErrors[index]}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('booking.form.lastName')}
|
|
||||||
</label>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={attendee.lastName}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newAttendees = [...attendees];
|
|
||||||
newAttendees[index].lastName = e.target.value;
|
|
||||||
setAttendees(newAttendees);
|
|
||||||
}}
|
|
||||||
placeholder={t('booking.form.lastNamePlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Payment Selection Section */}
|
|
||||||
<Card className="mb-6 p-6">
|
|
||||||
<h3 className="font-bold text-lg mb-4 text-primary-dark">
|
|
||||||
{t('booking.form.paymentMethod')}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{paymentMethods.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'No hay métodos de pago disponibles para este evento.'
|
|
||||||
: 'No payment methods available for this event.'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{paymentMethods.map((method) => (
|
|
||||||
<button
|
|
||||||
key={method.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
|
|
||||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
|
|
||||||
formData.paymentMethod === method.id
|
|
||||||
? 'border-primary-yellow bg-primary-yellow/10'
|
|
||||||
: 'border-secondary-light-gray hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
|
||||||
formData.paymentMethod === method.id
|
|
||||||
? 'bg-primary-yellow'
|
|
||||||
: 'bg-gray-100'
|
|
||||||
}`}>
|
|
||||||
<method.icon className={`w-5 h-5 ${
|
|
||||||
formData.paymentMethod === method.id
|
|
||||||
? 'text-primary-dark'
|
|
||||||
: 'text-gray-500'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="font-medium text-primary-dark">{method.label}</p>
|
|
||||||
{method.badge && (
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
||||||
method.badge === 'Instant' || method.badge === 'Instantáneo'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{method.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500">{method.description}</p>
|
|
||||||
</div>
|
|
||||||
{formData.paymentMethod === method.id && (
|
|
||||||
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Terms & Privacy agreement */}
|
|
||||||
<Card className="mb-6 p-6">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
id="booking-terms-agree"
|
|
||||||
type="checkbox"
|
|
||||||
checked={agreedToTerms}
|
|
||||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
|
||||||
aria-required="true"
|
|
||||||
aria-invalid={termsError ? true : undefined}
|
|
||||||
aria-describedby={termsError ? 'booking-terms-error' : undefined}
|
|
||||||
className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="booking-terms-agree"
|
|
||||||
className="text-sm text-gray-500 leading-relaxed cursor-pointer select-none"
|
|
||||||
>
|
|
||||||
{t('booking.form.termsAgreePart1')}
|
|
||||||
<Link
|
|
||||||
href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-secondary-blue hover:text-brand-navy underline"
|
|
||||||
>
|
|
||||||
{t('booking.form.termsOfService')}
|
|
||||||
</Link>
|
|
||||||
{t('booking.form.termsAgreePart2')}
|
|
||||||
<Link
|
|
||||||
href={`/legal/privacy-policy${locale === 'es' ? '?locale=es' : ''}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-secondary-blue hover:text-brand-navy underline"
|
|
||||||
>
|
|
||||||
{t('booking.form.privacyPolicy')}
|
|
||||||
</Link>
|
|
||||||
{t('booking.form.termsAgreePart3')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{termsError && (
|
|
||||||
<p id="booking-terms-error" className="mt-1.5 text-sm text-red-600">
|
|
||||||
{termsError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
isLoading={submitting}
|
|
||||||
disabled={paymentMethods.length === 0 || !agreedToTerms}
|
|
||||||
>
|
|
||||||
{formData.paymentMethod === 'cash'
|
|
||||||
? t('booking.form.reserveSpot')
|
|
||||||
: formData.paymentMethod === 'lightning'
|
|
||||||
? t('booking.form.proceedPayment')
|
|
||||||
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import Input from '@/components/ui/Input';
|
|
||||||
import {
|
|
||||||
CreditCardIcon,
|
|
||||||
BuildingLibraryIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ArrowTopRightOnSquareIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import { Event, PaymentOptionsConfig } from '@/lib/api';
|
|
||||||
import { formatPrice, getTpagoLink } from '@/lib/utils';
|
|
||||||
import type { BookingResult } from '../_types';
|
|
||||||
|
|
||||||
interface ManualPaymentStepProps {
|
|
||||||
bookingResult: BookingResult;
|
|
||||||
event: Event;
|
|
||||||
paymentConfig: PaymentOptionsConfig;
|
|
||||||
locale: string;
|
|
||||||
paidUnderDifferentName: boolean;
|
|
||||||
setPaidUnderDifferentName: (value: boolean) => void;
|
|
||||||
payerName: string;
|
|
||||||
setPayerName: (value: string) => void;
|
|
||||||
markingPaid: boolean;
|
|
||||||
onMarkPaymentSent: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ManualPaymentStep({
|
|
||||||
bookingResult,
|
|
||||||
event,
|
|
||||||
paymentConfig,
|
|
||||||
locale,
|
|
||||||
paidUnderDifferentName,
|
|
||||||
setPaidUnderDifferentName,
|
|
||||||
payerName,
|
|
||||||
setPayerName,
|
|
||||||
markingPaid,
|
|
||||||
onMarkPaymentSent,
|
|
||||||
}: ManualPaymentStepProps) {
|
|
||||||
const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer';
|
|
||||||
const isTpago = bookingResult.paymentMethod === 'tpago';
|
|
||||||
const ticketCount = bookingResult.ticketCount || 1;
|
|
||||||
const totalAmount = (event?.price || 0) * ticketCount;
|
|
||||||
const tpagoLink = getTpagoLink(paymentConfig, ticketCount);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="section-padding">
|
|
||||||
<div className="container-page max-w-xl">
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<div className={`w-16 h-16 rounded-full ${isBankTransfer ? 'bg-green-100' : 'bg-blue-100'} flex items-center justify-center mx-auto mb-4`}>
|
|
||||||
{isBankTransfer ? (
|
|
||||||
<BuildingLibraryIcon className="w-8 h-8 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<CreditCardIcon className="w-8 h-8 text-blue-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-xl font-bold text-primary-dark mb-2">
|
|
||||||
{locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Sigue las instrucciones para completar tu pago'
|
|
||||||
: 'Follow the instructions to complete your payment'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount to pay */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-center">
|
|
||||||
<p className="text-sm text-gray-500 mb-1">
|
|
||||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-primary-dark">
|
|
||||||
{event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}
|
|
||||||
</p>
|
|
||||||
{ticketCount > 1 && (
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bank Transfer Details */}
|
|
||||||
{isBankTransfer && (
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
<h3 className="font-semibold text-gray-900">
|
|
||||||
{locale === 'es' ? 'Datos Bancarios' : 'Bank Details'}
|
|
||||||
</h3>
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
|
|
||||||
{paymentConfig.bankName && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">{locale === 'es' ? 'Banco' : 'Bank'}:</span>
|
|
||||||
<span className="font-medium">{paymentConfig.bankName}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{paymentConfig.bankAccountHolder && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">{locale === 'es' ? 'Titular' : 'Account Holder'}:</span>
|
|
||||||
<span className="font-medium">{paymentConfig.bankAccountHolder}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{paymentConfig.bankAccountNumber && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">{locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}:</span>
|
|
||||||
<span className="font-medium font-mono">{paymentConfig.bankAccountNumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{paymentConfig.bankAlias && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Alias:</span>
|
|
||||||
<span className="font-medium">{paymentConfig.bankAlias}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{paymentConfig.bankPhone && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">{locale === 'es' ? 'Teléfono' : 'Phone'}:</span>
|
|
||||||
<span className="font-medium">{paymentConfig.bankPhone}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && (
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TPago Link */}
|
|
||||||
{isTpago && (
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
<h3 className="font-semibold text-gray-900">
|
|
||||||
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
|
||||||
</h3>
|
|
||||||
{tpagoLink && (
|
|
||||||
<a
|
|
||||||
href={tpagoLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
<ArrowTopRightOnSquareIcon className="w-5 h-5" />
|
|
||||||
{locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && (
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reference */}
|
|
||||||
<div className="bg-gray-100 rounded-lg p-3 mb-6">
|
|
||||||
<p className="text-xs text-gray-500 mb-1">
|
|
||||||
{locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'}
|
|
||||||
</p>
|
|
||||||
<p className="font-mono font-bold text-lg">{bookingResult.qrCode}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Manual verification notice */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-blue-800">
|
|
||||||
<p className="font-medium mb-1">
|
|
||||||
{locale === 'es' ? 'Verificación manual' : 'Manual verification'}
|
|
||||||
</p>
|
|
||||||
<p className="text-blue-700">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.'
|
|
||||||
: 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Paid under different name option */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={paidUnderDifferentName}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPaidUnderDifferentName(e.target.checked);
|
|
||||||
if (!e.target.checked) setPayerName('');
|
|
||||||
}}
|
|
||||||
className="mt-1 w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-700">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'El pago está a nombre de otra persona'
|
|
||||||
: 'The payment is under another person\'s name'}
|
|
||||||
</span>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Marcá esta opción si el pago fue realizado por un familiar o tercero.'
|
|
||||||
: 'Check this option if the payment was made by a family member or a third party.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{paidUnderDifferentName && (
|
|
||||||
<div className="mt-3 pl-7">
|
|
||||||
<Input
|
|
||||||
label={locale === 'es' ? 'Nombre del pagador' : 'Payer name'}
|
|
||||||
value={payerName}
|
|
||||||
onChange={(e) => setPayerName(e.target.value)}
|
|
||||||
placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Warning before I Have Paid button */}
|
|
||||||
<p className="text-sm text-center text-amber-700 font-medium mb-3">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Solo haz clic aquí después de haber completado el pago.'
|
|
||||||
: 'Only click this after you have actually completed the payment.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* I Have Paid Button */}
|
|
||||||
<Button
|
|
||||||
onClick={onMarkPaymentSent}
|
|
||||||
isLoading={markingPaid}
|
|
||||||
size="lg"
|
|
||||||
className="w-full"
|
|
||||||
disabled={paidUnderDifferentName && !payerName.trim()}
|
|
||||||
>
|
|
||||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
|
||||||
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="text-xs text-center text-gray-500 mt-4">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Tu reserva será confirmada una vez que verifiquemos el pago'
|
|
||||||
: 'Your booking will be confirmed once we verify the payment'}
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { QRCodeSVG } from 'qrcode.react';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import { BoltIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { copyInvoiceToClipboard, truncateInvoice } from '../_logic/booking';
|
|
||||||
import type { LightningInvoice } from '../_types';
|
|
||||||
|
|
||||||
interface PayingStepProps {
|
|
||||||
invoice: LightningInvoice;
|
|
||||||
qrCode: string;
|
|
||||||
locale: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PayingStep({ invoice, qrCode, locale }: PayingStepProps) {
|
|
||||||
return (
|
|
||||||
<div className="section-padding">
|
|
||||||
<div className="container-page max-w-md">
|
|
||||||
<Card className="p-6 text-center">
|
|
||||||
{/* Amount - prominent at top */}
|
|
||||||
<div className="mb-4">
|
|
||||||
{invoice.fiatAmount && invoice.fiatCurrency && (
|
|
||||||
<p className="text-2xl font-bold text-primary-dark">
|
|
||||||
{invoice.fiatAmount.toLocaleString()} {invoice.fiatCurrency}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-orange-600 font-medium">
|
|
||||||
≈ {invoice.amount.toLocaleString()} sats
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR Code - clickable to copy */}
|
|
||||||
<div
|
|
||||||
className="bg-white p-4 rounded-lg shadow-inner inline-block mb-4 cursor-pointer hover:shadow-md transition-shadow"
|
|
||||||
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest, locale)}
|
|
||||||
title={locale === 'es' ? 'Clic para copiar' : 'Click to copy'}
|
|
||||||
>
|
|
||||||
<QRCodeSVG
|
|
||||||
value={invoice.paymentRequest.toUpperCase()}
|
|
||||||
size={200}
|
|
||||||
level="M"
|
|
||||||
includeMargin={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invoice string - truncated, clickable */}
|
|
||||||
<div
|
|
||||||
className="bg-secondary-gray rounded-lg p-3 mb-4 cursor-pointer hover:bg-gray-200 transition-colors"
|
|
||||||
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest, locale)}
|
|
||||||
>
|
|
||||||
<p className="font-mono text-xs text-gray-600 flex items-center justify-center gap-2">
|
|
||||||
<ClipboardDocumentIcon className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span className="truncate">{truncateInvoice(invoice.paymentRequest, 16)}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
|
||||||
{locale === 'es' ? 'Toca para copiar' : 'Tap to copy'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Open in Wallet - primary action */}
|
|
||||||
<a
|
|
||||||
href={`lightning:${invoice.paymentRequest}`}
|
|
||||||
className="inline-flex items-center justify-center gap-2 w-full px-6 py-3 bg-orange-500 text-white rounded-btn hover:bg-orange-600 transition-colors font-medium mb-4"
|
|
||||||
>
|
|
||||||
<BoltIcon className="w-5 h-5" />
|
|
||||||
{locale === 'es' ? 'Abrir en Billetera' : 'Open in Wallet'}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Status indicator */}
|
|
||||||
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
|
|
||||||
<div className="animate-spin w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full" />
|
|
||||||
<span>{locale === 'es' ? 'Esperando pago...' : 'Waiting for payment...'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ticket reference - small */}
|
|
||||||
<p className="text-xs text-gray-400 mt-3">
|
|
||||||
{locale === 'es' ? 'Ref' : 'Ref'}: {qrCode}
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import { ClockIcon, TicketIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { Event } from '@/lib/api';
|
|
||||||
import type { BookingResult } from '../_types';
|
|
||||||
|
|
||||||
interface PendingApprovalStepProps {
|
|
||||||
bookingResult: BookingResult;
|
|
||||||
event: Event | null;
|
|
||||||
locale: string;
|
|
||||||
t: (key: string) => string;
|
|
||||||
formatDate: (dateStr: string) => string;
|
|
||||||
fmtTime: (dateStr: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PendingApprovalStep({
|
|
||||||
bookingResult,
|
|
||||||
event,
|
|
||||||
locale,
|
|
||||||
t,
|
|
||||||
formatDate,
|
|
||||||
fmtTime,
|
|
||||||
}: PendingApprovalStepProps) {
|
|
||||||
return (
|
|
||||||
<div className="section-padding">
|
|
||||||
<div className="container-page max-w-xl">
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mx-auto mb-6">
|
|
||||||
<ClockIcon className="w-10 h-10 text-yellow-600" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
|
||||||
{locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Estamos verificando tu pago. Recibirás un email de confirmación una vez aprobado.'
|
|
||||||
: 'We are verifying your payment. You will receive a confirmation email once approved.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
|
||||||
<div className="flex items-center justify-center gap-2 mb-4">
|
|
||||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
|
||||||
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-600 space-y-2">
|
|
||||||
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
|
|
||||||
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
|
|
||||||
<p><strong>{t('booking.success.time')}:</strong> {event && fmtTime(event.startDatetime)}</p>
|
|
||||||
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
|
||||||
<p className="text-yellow-800 text-sm">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'La verificación del pago puede tomar hasta 24 horas hábiles. Por favor revisa tu email regularmente.'
|
|
||||||
: 'Payment verification may take up to 24 business hours. Please check your email regularly.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<Link href="/events">
|
|
||||||
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/">
|
|
||||||
<Button>{t('booking.success.backHome')}</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import {
|
|
||||||
CheckCircleIcon,
|
|
||||||
TicketIcon,
|
|
||||||
ArrowDownTrayIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import { Event } from '@/lib/api';
|
|
||||||
import { getSuccessContent } from '../_logic/booking';
|
|
||||||
import type { BookingResult } from '../_types';
|
|
||||||
|
|
||||||
interface SuccessStepProps {
|
|
||||||
bookingResult: BookingResult;
|
|
||||||
event: Event;
|
|
||||||
locale: string;
|
|
||||||
t: (key: string) => string;
|
|
||||||
formatDate: (dateStr: string) => string;
|
|
||||||
fmtTime: (dateStr: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SuccessStep({
|
|
||||||
bookingResult,
|
|
||||||
event,
|
|
||||||
locale,
|
|
||||||
t,
|
|
||||||
formatDate,
|
|
||||||
fmtTime,
|
|
||||||
}: SuccessStepProps) {
|
|
||||||
const successContent = getSuccessContent(bookingResult, locale, t);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="section-padding">
|
|
||||||
<div className="container-page max-w-2xl">
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<div className={`w-16 h-16 rounded-full ${successContent.iconColor} flex items-center justify-center mx-auto mb-6`}>
|
|
||||||
<CheckCircleIcon className={`w-10 h-10 ${successContent.iconTextColor}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
|
||||||
{successContent.title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
{successContent.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
|
||||||
{/* Multi-ticket indicator */}
|
|
||||||
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
|
|
||||||
<div className="mb-4 pb-4 border-b border-gray-300">
|
|
||||||
<p className="text-lg font-semibold text-primary-dark">
|
|
||||||
{locale === 'es'
|
|
||||||
? `${bookingResult.ticketCount} tickets reservados`
|
|
||||||
: `${bookingResult.ticketCount} tickets booked`}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Cada asistente recibirá su propio código QR'
|
|
||||||
: 'Each attendee will receive their own QR code'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2 mb-4">
|
|
||||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
|
||||||
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
|
|
||||||
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
|
|
||||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
|
|
||||||
+{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-600 space-y-2">
|
|
||||||
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
|
|
||||||
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
|
|
||||||
<p><strong>{t('booking.success.time')}:</strong> {fmtTime(event.startDatetime)}</p>
|
|
||||||
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{bookingResult.paymentMethod === 'cash' && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
|
||||||
<p className="text-yellow-800 text-sm">
|
|
||||||
<strong>{t('booking.success.cashNote')}:</strong> {t('booking.success.cashDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{bookingResult.paymentMethod === 'bancard' && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
||||||
<p className="text-blue-800 text-sm">
|
|
||||||
{t('booking.success.cardNote')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{bookingResult.paymentMethod === 'lightning' && (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
|
||||||
<p className="text-green-800 text-sm flex items-center gap-2">
|
|
||||||
<CheckCircleIcon className="w-5 h-5" />
|
|
||||||
{locale === 'es'
|
|
||||||
? '¡Pago con Bitcoin Lightning recibido exitosamente!'
|
|
||||||
: 'Bitcoin Lightning payment received successfully!'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
|
||||||
{t('booking.success.emailSent')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Download Ticket Button - only for instant confirmation (Lightning) */}
|
|
||||||
{bookingResult.paymentMethod === 'lightning' && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<a
|
|
||||||
href={bookingResult.bookingId
|
|
||||||
? `/api/tickets/booking/${bookingResult.bookingId}/pdf`
|
|
||||||
: `/api/tickets/${bookingResult.ticketId}/pdf`
|
|
||||||
}
|
|
||||||
download
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowDownTrayIcon className="w-5 h-5" />
|
|
||||||
{locale === 'es'
|
|
||||||
? (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Descargar Tickets' : 'Descargar Ticket')
|
|
||||||
: (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Download Tickets' : 'Download Ticket')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<Link href="/events">
|
|
||||||
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/">
|
|
||||||
<Button>{t('booking.success.backHome')}</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// Shared types for the booking flow.
|
|
||||||
|
|
||||||
export interface AttendeeInfo {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
|
||||||
|
|
||||||
export interface BookingFormData {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
preferredLanguage: 'en' | 'es';
|
|
||||||
paymentMethod: PaymentMethod;
|
|
||||||
ruc: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LightningInvoice {
|
|
||||||
paymentHash: string;
|
|
||||||
paymentRequest: string; // BOLT11 invoice
|
|
||||||
amount: number; // Amount in satoshis
|
|
||||||
fiatAmount?: number; // Original fiat amount
|
|
||||||
fiatCurrency?: string; // Original fiat currency
|
|
||||||
expiry?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingResult {
|
|
||||||
ticketId: string;
|
|
||||||
ticketIds?: string[]; // For multi-ticket bookings
|
|
||||||
bookingId?: string;
|
|
||||||
qrCode: string;
|
|
||||||
qrCodes?: string[]; // For multi-ticket bookings
|
|
||||||
paymentMethod: PaymentMethod;
|
|
||||||
lightningInvoice?: LightningInvoice;
|
|
||||||
ticketCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BookingStep = 'form' | 'paying' | 'manual_payment' | 'pending_approval' | 'success';
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +69,7 @@ export default function BookingPaymentPage() {
|
|||||||
|
|
||||||
// Get payment config for the event
|
// Get payment config for the event
|
||||||
if (ticketData.eventId) {
|
if (ticketData.eventId) {
|
||||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId, ticketData.id);
|
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId);
|
||||||
setPaymentConfig(paymentOptions);
|
setPaymentConfig(paymentOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +305,6 @@ export default function BookingPaymentPage() {
|
|||||||
if (step === 'manual_payment' && ticket && paymentConfig) {
|
if (step === 'manual_payment' && ticket && paymentConfig) {
|
||||||
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
|
||||||
const isTpago = ticket.payment?.provider === 'tpago';
|
const isTpago = ticket.payment?.provider === 'tpago';
|
||||||
const tpagoLink = getTpagoLink(paymentConfig, ticket.bookingTicketCount || 1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding">
|
<div className="section-padding">
|
||||||
@@ -419,9 +418,9 @@ export default function BookingPaymentPage() {
|
|||||||
<h3 className="font-semibold text-gray-900">
|
<h3 className="font-semibold text-gray-900">
|
||||||
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
||||||
</h3>
|
</h3>
|
||||||
{tpagoLink && (
|
{paymentConfig.tpagoLink && (
|
||||||
<a
|
<a
|
||||||
href={tpagoLink}
|
href={paymentConfig.tpagoLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
|
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
async function getFeaturedEventSlug(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const revalidateSeconds =
|
|
||||||
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
|
|
||||||
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
|
|
||||||
next: { tags: ['next-event'], revalidate: revalidateSeconds },
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const data = await response.json();
|
|
||||||
return data.event?.slug || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function FeaturedEventPage() {
|
|
||||||
const slug = await getFeaturedEventSlug();
|
|
||||||
redirect(slug ? `/events/${slug}` : '/events');
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import Button from '@/components/ui/Button';
|
|||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||||
import { authApi } from '@/lib/api';
|
import { authApi } from '@/lib/api';
|
||||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
function LoginContent() {
|
function LoginContent() {
|
||||||
@@ -26,8 +25,8 @@ function LoginContent() {
|
|||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for redirect after login (only same-origin relative paths are honoured)
|
// Check for redirect after login
|
||||||
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
|
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
async function getNextEventSlug(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const revalidateSeconds =
|
|
||||||
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
|
|
||||||
const response = await fetch(`${apiUrl}/api/events/next`, {
|
|
||||||
next: { tags: ['next-event'], revalidate: revalidateSeconds },
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const data = await response.json();
|
|
||||||
return data.event?.slug || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NextEventPage() {
|
|
||||||
const slug = await getNextEventSlug();
|
|
||||||
redirect(slug ? `/events/${slug}` : '/events');
|
|
||||||
}
|
|
||||||
@@ -1047,7 +1047,6 @@ export default function AdminEmailsPage() {
|
|||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<iframe
|
<iframe
|
||||||
srcDoc={previewHtml}
|
srcDoc={previewHtml}
|
||||||
sandbox=""
|
|
||||||
className="w-full h-full min-h-[500px]"
|
className="w-full h-full min-h-[500px]"
|
||||||
title="Email Preview"
|
title="Email Preview"
|
||||||
/>
|
/>
|
||||||
@@ -1101,7 +1100,6 @@ export default function AdminEmailsPage() {
|
|||||||
{selectedLog.bodyHtml ? (
|
{selectedLog.bodyHtml ? (
|
||||||
<iframe
|
<iframe
|
||||||
srcDoc={selectedLog.bodyHtml}
|
srcDoc={selectedLog.bodyHtml}
|
||||||
sandbox=""
|
|
||||||
className="w-full h-full min-h-[400px]"
|
className="w-full h-full min-h-[400px]"
|
||||||
title="Email Content"
|
title="Email Content"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
export function StatusBadge({ status, compact = false }: { status: string; compact?: boolean }) {
|
|
||||||
const styles: Record<string, string> = {
|
|
||||||
pending: 'bg-yellow-100 text-yellow-800',
|
|
||||||
confirmed: 'bg-green-100 text-green-800',
|
|
||||||
cancelled: 'bg-red-100 text-red-800',
|
|
||||||
checked_in: 'bg-blue-100 text-blue-800',
|
|
||||||
};
|
|
||||||
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[status] || 'bg-gray-100 text-gray-800'
|
|
||||||
)}>
|
|
||||||
{status.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the core data for the admin event detail page (event, tickets, active
|
|
||||||
* email templates) and exposes a reload function used after mutations.
|
|
||||||
*/
|
|
||||||
export function useEventDetailData(eventId: string) {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [event, setEvent] = useState<Event | null>(null);
|
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
|
||||||
|
|
||||||
const loadEventData = async () => {
|
|
||||||
try {
|
|
||||||
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
|
|
||||||
eventsApi.getById(eventId),
|
|
||||||
ticketsApi.getAll({ eventId }),
|
|
||||||
emailsApi.getTemplates(),
|
|
||||||
]);
|
|
||||||
setEvent(eventRes.event);
|
|
||||||
setTickets(ticketsRes.tickets);
|
|
||||||
setTemplates(templatesRes.templates.filter(t => t.isActive));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to load event data');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadEventData();
|
|
||||||
}, [eventId]);
|
|
||||||
|
|
||||||
return { loading, event, tickets, templates, loadEventData };
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the event-level payment override editor state: loading global +
|
|
||||||
* override config, computing effective values, editing, saving and resetting.
|
|
||||||
*/
|
|
||||||
export function usePaymentOverrides(eventId: string, locale: string) {
|
|
||||||
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
|
|
||||||
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
|
|
||||||
const [hasPaymentOverrides, setHasPaymentOverrides] = useState(false);
|
|
||||||
const [savingPayments, setSavingPayments] = useState(false);
|
|
||||||
const [loadingPayments, setLoadingPayments] = useState(false);
|
|
||||||
|
|
||||||
const loadPaymentOptions = async () => {
|
|
||||||
if (globalPaymentOptions) return;
|
|
||||||
setLoadingPayments(true);
|
|
||||||
try {
|
|
||||||
const [globalRes, overridesRes] = await Promise.all([
|
|
||||||
paymentOptionsApi.getGlobal(),
|
|
||||||
paymentOptionsApi.getEventOverrides(eventId),
|
|
||||||
]);
|
|
||||||
setGlobalPaymentOptions(globalRes.paymentOptions);
|
|
||||||
if (overridesRes.overrides) {
|
|
||||||
setPaymentOverrides(overridesRes.overrides);
|
|
||||||
setHasPaymentOverrides(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to load payment options');
|
|
||||||
} finally {
|
|
||||||
setLoadingPayments(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEffectivePaymentOption = <K extends keyof PaymentOptionsConfig>(key: K): PaymentOptionsConfig[K] => {
|
|
||||||
if (paymentOverrides[key] !== undefined && paymentOverrides[key] !== null) {
|
|
||||||
return paymentOverrides[key] as PaymentOptionsConfig[K];
|
|
||||||
}
|
|
||||||
return globalPaymentOptions?.[key] as PaymentOptionsConfig[K];
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePaymentOverride = <K extends keyof PaymentOptionsConfig>(
|
|
||||||
key: K,
|
|
||||||
value: PaymentOptionsConfig[K] | null
|
|
||||||
) => {
|
|
||||||
setPaymentOverrides((prev) => ({ ...prev, [key]: value }));
|
|
||||||
setHasPaymentOverrides(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSavePaymentOptions = async () => {
|
|
||||||
setSavingPayments(true);
|
|
||||||
try {
|
|
||||||
await paymentOptionsApi.updateEventOverrides(eventId, paymentOverrides);
|
|
||||||
toast.success(locale === 'es' ? 'Opciones de pago guardadas' : 'Payment options saved');
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to save payment options');
|
|
||||||
} finally {
|
|
||||||
setSavingPayments(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetToGlobal = async () => {
|
|
||||||
if (!confirm(locale === 'es'
|
|
||||||
? '¿Resetear a la configuración global? Se eliminarán todas las personalizaciones de este evento.'
|
|
||||||
: 'Reset to global settings? This will remove all customizations for this event.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingPayments(true);
|
|
||||||
try {
|
|
||||||
await paymentOptionsApi.deleteEventOverrides(eventId);
|
|
||||||
setPaymentOverrides({});
|
|
||||||
setHasPaymentOverrides(false);
|
|
||||||
toast.success(locale === 'es' ? 'Restablecido a configuración global' : 'Reset to global settings');
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to reset payment options');
|
|
||||||
} finally {
|
|
||||||
setSavingPayments(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
globalPaymentOptions,
|
|
||||||
paymentOverrides,
|
|
||||||
hasPaymentOverrides,
|
|
||||||
savingPayments,
|
|
||||||
loadingPayments,
|
|
||||||
loadPaymentOptions,
|
|
||||||
getEffectivePaymentOption,
|
|
||||||
updatePaymentOverride,
|
|
||||||
handleSavePaymentOptions,
|
|
||||||
handleResetToGlobal,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PaymentOverridesController = ReturnType<typeof usePaymentOverrides>;
|
|
||||||
@@ -1,521 +0,0 @@
|
|||||||
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 {
|
|
||||||
CheckCircleIcon,
|
|
||||||
EnvelopeIcon,
|
|
||||||
PlusIcon,
|
|
||||||
StarIcon,
|
|
||||||
XMarkIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
|
|
||||||
|
|
||||||
interface EventModalsProps {
|
|
||||||
// counts + filter
|
|
||||||
ticketsCount: number;
|
|
||||||
pendingCount: number;
|
|
||||||
confirmedCount: number;
|
|
||||||
checkedInCount: number;
|
|
||||||
cancelledCount: number;
|
|
||||||
statusFilter: AttendeeStatusFilter;
|
|
||||||
setStatusFilter: (value: AttendeeStatusFilter) => void;
|
|
||||||
// mobile filter sheet
|
|
||||||
mobileFilterOpen: boolean;
|
|
||||||
setMobileFilterOpen: (value: boolean) => void;
|
|
||||||
// add ticket sheet
|
|
||||||
showAddTicketSheet: boolean;
|
|
||||||
setShowAddTicketSheet: (value: boolean) => void;
|
|
||||||
// export sheets
|
|
||||||
showExportSheet: boolean;
|
|
||||||
setShowExportSheet: (value: boolean) => void;
|
|
||||||
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
|
||||||
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
|
|
||||||
showNoteModal: boolean;
|
|
||||||
setShowNoteModal: (value: boolean) => void;
|
|
||||||
selectedTicket: Ticket | null;
|
|
||||||
setSelectedTicket: (value: Ticket | null) => void;
|
|
||||||
noteText: string;
|
|
||||||
setNoteText: (value: string) => void;
|
|
||||||
handleSaveNote: () => void;
|
|
||||||
// preview modal
|
|
||||||
previewHtml: string | null;
|
|
||||||
setPreviewHtml: (value: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventModals(props: EventModalsProps) {
|
|
||||||
const {
|
|
||||||
ticketsCount,
|
|
||||||
pendingCount,
|
|
||||||
confirmedCount,
|
|
||||||
checkedInCount,
|
|
||||||
cancelledCount,
|
|
||||||
statusFilter,
|
|
||||||
setStatusFilter,
|
|
||||||
mobileFilterOpen,
|
|
||||||
setMobileFilterOpen,
|
|
||||||
showAddTicketSheet,
|
|
||||||
setShowAddTicketSheet,
|
|
||||||
showExportSheet,
|
|
||||||
setShowExportSheet,
|
|
||||||
handleExportAttendees,
|
|
||||||
showTicketExportSheet,
|
|
||||||
setShowTicketExportSheet,
|
|
||||||
handleExportTickets,
|
|
||||||
showAddAtDoorModal,
|
|
||||||
setShowAddAtDoorModal,
|
|
||||||
addAtDoorForm,
|
|
||||||
setAddAtDoorForm,
|
|
||||||
handleAddAtDoor,
|
|
||||||
showManualTicketModal,
|
|
||||||
setShowManualTicketModal,
|
|
||||||
manualTicketForm,
|
|
||||||
setManualTicketForm,
|
|
||||||
handleManualTicket,
|
|
||||||
showInviteGuestModal,
|
|
||||||
setShowInviteGuestModal,
|
|
||||||
inviteGuestForm,
|
|
||||||
setInviteGuestForm,
|
|
||||||
handleInviteGuest,
|
|
||||||
submitting,
|
|
||||||
showNoteModal,
|
|
||||||
setShowNoteModal,
|
|
||||||
selectedTicket,
|
|
||||||
setSelectedTicket,
|
|
||||||
noteText,
|
|
||||||
setNoteText,
|
|
||||||
handleSaveNote,
|
|
||||||
previewHtml,
|
|
||||||
setPreviewHtml,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Mobile filter bottom sheet */}
|
|
||||||
<BottomSheet
|
|
||||||
open={mobileFilterOpen}
|
|
||||||
onClose={() => setMobileFilterOpen(false)}
|
|
||||||
title="Filter by Status"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{[
|
|
||||||
{ value: 'all', label: `All (${ticketsCount})` },
|
|
||||||
{ value: 'pending', label: `Pending (${pendingCount})` },
|
|
||||||
{ value: 'confirmed', label: `Confirmed (${confirmedCount})` },
|
|
||||||
{ value: 'checked_in', label: `Checked In (${checkedInCount})` },
|
|
||||||
{ value: 'cancelled', label: `Cancelled (${cancelledCount})` },
|
|
||||||
].map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => { setStatusFilter(option.value as AttendeeStatusFilter); setMobileFilterOpen(false); }}
|
|
||||||
className={clsx(
|
|
||||||
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
|
||||||
statusFilter === option.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
{statusFilter === option.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</BottomSheet>
|
|
||||||
|
|
||||||
{/* Mobile FAB bottom sheet */}
|
|
||||||
<BottomSheet
|
|
||||||
open={showAddTicketSheet}
|
|
||||||
onClose={() => setShowAddTicketSheet(false)}
|
|
||||||
title="Add Ticket"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowManualTicketModal(true); 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>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowAddAtDoorModal(true); 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" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Add at Door</p>
|
|
||||||
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowInviteGuestModal(true); 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" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Invite Guest</p>
|
|
||||||
<p className="text-xs text-gray-500">Free ticket, not counted in revenue</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BottomSheet>
|
|
||||||
|
|
||||||
{/* Mobile export bottom sheet (attendees) */}
|
|
||||||
<BottomSheet
|
|
||||||
open={showExportSheet}
|
|
||||||
onClose={() => setShowExportSheet(false)}
|
|
||||||
title="Export Attendees"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{[
|
|
||||||
{ status: 'all' as const, label: 'Export All' },
|
|
||||||
{ status: 'confirmed' as const, label: 'Export Confirmed' },
|
|
||||||
{ status: 'checked_in' as const, label: 'Export Checked-in' },
|
|
||||||
{ status: 'confirmed_pending' as const, label: 'Confirmed & Pending' },
|
|
||||||
].map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.status}
|
|
||||||
onClick={() => { handleExportAttendees(opt.status); setShowExportSheet(false); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
|
|
||||||
</div>
|
|
||||||
</BottomSheet>
|
|
||||||
|
|
||||||
{/* Mobile export bottom sheet (tickets) */}
|
|
||||||
<BottomSheet
|
|
||||||
open={showTicketExportSheet}
|
|
||||||
onClose={() => setShowTicketExportSheet(false)}
|
|
||||||
title="Export Tickets"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{[
|
|
||||||
{ status: 'all' as const, label: 'Export All' },
|
|
||||||
{ status: 'confirmed' as const, label: 'Export Valid' },
|
|
||||||
{ status: 'checked_in' as const, label: 'Export Checked-in' },
|
|
||||||
].map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.status}
|
|
||||||
onClick={() => { handleExportTickets(opt.status); setShowTicketExportSheet(false); }}
|
|
||||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
|
|
||||||
</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">
|
|
||||||
<Card className="w-full md:max-w-md rounded-t-2xl md:rounded-card">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-bold">Admin Note</h2>
|
|
||||||
<p className="text-xs text-gray-500">{selectedTicket.attendeeFirstName} {selectedTicket.attendeeLastName || ''}</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }}
|
|
||||||
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>
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-1">Note</label>
|
|
||||||
<textarea value={noteText} onChange={(e) => setNoteText(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={4} placeholder="Add a private note..." maxLength={1000} />
|
|
||||||
<p className="text-[10px] text-gray-400 mt-1 text-right">{noteText.length}/1000</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button variant="outline" onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }} className="flex-1 min-h-[44px]">Cancel</Button>
|
|
||||||
<Button onClick={handleSaveNote} isLoading={submitting} className="flex-1 min-h-[44px]">Save Note</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview Modal */}
|
|
||||||
{previewHtml && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
|
||||||
<h2 className="text-base font-bold">Email Preview</h2>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)} className="min-h-[44px] md:min-h-0">Close</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<iframe srcDoc={previewHtml} sandbox="" className="w-full h-full min-h-[500px]" title="Email Preview" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
import { Ticket } from '@/lib/api';
|
|
||||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import {
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
ArrowDownTrayIcon,
|
|
||||||
PlusIcon,
|
|
||||||
EnvelopeIcon,
|
|
||||||
StarIcon,
|
|
||||||
FunnelIcon,
|
|
||||||
ChatBubbleLeftIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import { StatusBadge } from '../_components/StatusBadge';
|
|
||||||
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
|
|
||||||
|
|
||||||
interface AttendeesTabProps {
|
|
||||||
locale: string;
|
|
||||||
tickets: Ticket[];
|
|
||||||
filteredTickets: Ticket[];
|
|
||||||
searchQuery: string;
|
|
||||||
setSearchQuery: (value: string) => void;
|
|
||||||
statusFilter: AttendeeStatusFilter;
|
|
||||||
setStatusFilter: (value: AttendeeStatusFilter) => void;
|
|
||||||
pendingCount: number;
|
|
||||||
confirmedCount: number;
|
|
||||||
checkedInCount: number;
|
|
||||||
cancelledCount: number;
|
|
||||||
exporting: boolean;
|
|
||||||
showExportDropdown: boolean;
|
|
||||||
setShowExportDropdown: (value: boolean) => void;
|
|
||||||
showAddTicketDropdown: boolean;
|
|
||||||
setShowAddTicketDropdown: (value: boolean) => void;
|
|
||||||
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
|
||||||
setShowManualTicketModal: (value: boolean) => void;
|
|
||||||
setShowAddAtDoorModal: (value: boolean) => void;
|
|
||||||
setShowInviteGuestModal: (value: boolean) => void;
|
|
||||||
setMobileFilterOpen: (value: boolean) => void;
|
|
||||||
setShowExportSheet: (value: boolean) => void;
|
|
||||||
setShowAddTicketSheet: (value: boolean) => void;
|
|
||||||
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
|
||||||
handleOpenNoteModal: (ticket: Ticket) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AttendeesTab({
|
|
||||||
locale,
|
|
||||||
tickets,
|
|
||||||
filteredTickets,
|
|
||||||
searchQuery,
|
|
||||||
setSearchQuery,
|
|
||||||
statusFilter,
|
|
||||||
setStatusFilter,
|
|
||||||
pendingCount,
|
|
||||||
confirmedCount,
|
|
||||||
checkedInCount,
|
|
||||||
cancelledCount,
|
|
||||||
exporting,
|
|
||||||
showExportDropdown,
|
|
||||||
setShowExportDropdown,
|
|
||||||
showAddTicketDropdown,
|
|
||||||
setShowAddTicketDropdown,
|
|
||||||
handleExportAttendees,
|
|
||||||
setShowManualTicketModal,
|
|
||||||
setShowAddAtDoorModal,
|
|
||||||
setShowInviteGuestModal,
|
|
||||||
setMobileFilterOpen,
|
|
||||||
setShowExportSheet,
|
|
||||||
setShowAddTicketSheet,
|
|
||||||
getPrimaryAction,
|
|
||||||
handleOpenNoteModal,
|
|
||||||
}: AttendeesTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Desktop toolbar */}
|
|
||||||
<Card className="p-3 hidden md:block">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Left: Search + Status */}
|
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search name, email, phone..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value as AttendeeStatusFilter)}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
>
|
|
||||||
<option value="all">All ({tickets.length})</option>
|
|
||||||
<option value="pending">Pending ({pendingCount})</option>
|
|
||||||
<option value="confirmed">Confirmed ({confirmedCount})</option>
|
|
||||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
|
||||||
<option value="cancelled">Cancelled ({cancelledCount})</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{/* Right: Export + Add Ticket dropdown */}
|
|
||||||
<Dropdown
|
|
||||||
open={showExportDropdown}
|
|
||||||
onOpenChange={setShowExportDropdown}
|
|
||||||
trigger={
|
|
||||||
<Button variant="outline" size="sm" disabled={exporting}>
|
|
||||||
{exporting ? (
|
|
||||||
<div className="w-3.5 h-3.5 mr-1.5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
|
||||||
) : (
|
|
||||||
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
)}
|
|
||||||
Export
|
|
||||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownItem onClick={() => handleExportAttendees('all')}>Export All</DropdownItem>
|
|
||||||
<DropdownItem onClick={() => handleExportAttendees('confirmed')}>Export Confirmed</DropdownItem>
|
|
||||||
<DropdownItem onClick={() => handleExportAttendees('checked_in')}>Export Checked-in</DropdownItem>
|
|
||||||
<DropdownItem onClick={() => handleExportAttendees('confirmed_pending')}>Confirmed & Pending</DropdownItem>
|
|
||||||
<div className="border-t border-gray-100 mx-2" />
|
|
||||||
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
|
|
||||||
</Dropdown>
|
|
||||||
|
|
||||||
<Dropdown
|
|
||||||
open={showAddTicketDropdown}
|
|
||||||
onOpenChange={setShowAddTicketDropdown}
|
|
||||||
trigger={
|
|
||||||
<Button size="sm">
|
|
||||||
<PlusIcon className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
Add Ticket
|
|
||||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
|
|
||||||
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
|
|
||||||
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
|
|
||||||
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
|
||||||
</DropdownItem>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
{(searchQuery || statusFilter !== 'all') && (
|
|
||||||
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
|
||||||
<span>Showing {filteredTickets.length} of {tickets.length}</span>
|
|
||||||
<button onClick={() => { setSearchQuery(''); setStatusFilter('all'); }} className="text-primary-yellow hover:underline">
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mobile toolbar */}
|
|
||||||
<div className="md:hidden space-y-2">
|
|
||||||
<div className="relative">
|
|
||||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search name, email, phone..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileFilterOpen(true)}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
|
||||||
statusFilter !== 'all'
|
|
||||||
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
|
|
||||||
: 'border-secondary-light-gray text-gray-600'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FunnelIcon className="w-4 h-4" />
|
|
||||||
{statusFilter === 'all' ? 'Filter' : statusFilter.replace('_', ' ')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowExportSheet(true)}
|
|
||||||
disabled={exporting}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
|
|
||||||
>
|
|
||||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
{(searchQuery || statusFilter !== 'all') && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setSearchQuery(''); setStatusFilter('all'); }}
|
|
||||||
className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(searchQuery || statusFilter !== 'all') && (
|
|
||||||
<p className="text-xs text-gray-500">Showing {filteredTickets.length} of {tickets.length}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: Dense table */}
|
|
||||||
<Card className="overflow-hidden hidden md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<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">Contact</th>
|
|
||||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
||||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
|
|
||||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{filteredTickets.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-4 py-10 text-center text-gray-500 text-sm">
|
|
||||||
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredTickets.map((ticket) => {
|
|
||||||
const primary = getPrimaryAction(ticket);
|
|
||||||
return (
|
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
||||||
{ticket.bookingId && (
|
|
||||||
<span className="text-[10px] text-purple-600" title={`Booking: ${ticket.bookingId}`}>
|
|
||||||
Group booking
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p>
|
|
||||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
<StatusBadge status={ticket.status} compact />
|
|
||||||
{!!ticket.isGuest && (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{ticket.checkinAt && (
|
|
||||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
|
||||||
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
|
||||||
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
{primary && (
|
|
||||||
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2 py-1">
|
|
||||||
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
|
|
||||||
{primary.label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<MoreMenu>
|
|
||||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
|
||||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
|
||||||
</DropdownItem>
|
|
||||||
{ticket.adminNote && (
|
|
||||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 truncate max-w-[180px]">
|
|
||||||
Note: {ticket.adminNote}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
|
|
||||||
ID: {ticket.id.slice(0, 8)}...
|
|
||||||
</div>
|
|
||||||
</MoreMenu>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mobile: Card layout */}
|
|
||||||
<div className="md:hidden space-y-2">
|
|
||||||
{filteredTickets.length === 0 ? (
|
|
||||||
<div className="text-center py-10 text-gray-500 text-sm">
|
|
||||||
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTickets.map((ticket) => {
|
|
||||||
const primary = getPrimaryAction(ticket);
|
|
||||||
return (
|
|
||||||
<Card key={ticket.id} className="p-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
||||||
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
|
|
||||||
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
|
||||||
<StatusBadge status={ticket.status} compact />
|
|
||||||
{!!ticket.isGuest && (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
|
||||||
<p className="text-[10px] text-gray-400">
|
|
||||||
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
|
||||||
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{primary && (
|
|
||||||
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
|
||||||
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
|
|
||||||
{primary.label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<MoreMenu>
|
|
||||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
|
||||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
|
||||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
|
||||||
</DropdownItem>
|
|
||||||
</MoreMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile FAB */}
|
|
||||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddTicketSheet(true)}
|
|
||||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform"
|
|
||||||
>
|
|
||||||
<PlusIcon className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { EmailTemplate } from '@/lib/api';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import {
|
|
||||||
EyeIcon,
|
|
||||||
PaperAirplaneIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ClockIcon,
|
|
||||||
TicketIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
|
|
||||||
|
|
||||||
interface EmailTabProps {
|
|
||||||
templates: EmailTemplate[];
|
|
||||||
selectedTemplate: string;
|
|
||||||
setSelectedTemplate: (value: string) => void;
|
|
||||||
recipientFilter: RecipientFilter;
|
|
||||||
setRecipientFilter: (value: RecipientFilter) => void;
|
|
||||||
customMessage: string;
|
|
||||||
setCustomMessage: (value: string) => void;
|
|
||||||
sending: boolean;
|
|
||||||
handlePreviewEmail: () => void;
|
|
||||||
handleSendEmail: () => void;
|
|
||||||
getFilteredRecipientCount: () => number;
|
|
||||||
ticketsCount: number;
|
|
||||||
confirmedCount: number;
|
|
||||||
pendingCount: number;
|
|
||||||
checkedInCount: number;
|
|
||||||
cancelledCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmailTab({
|
|
||||||
templates,
|
|
||||||
selectedTemplate,
|
|
||||||
setSelectedTemplate,
|
|
||||||
recipientFilter,
|
|
||||||
setRecipientFilter,
|
|
||||||
customMessage,
|
|
||||||
setCustomMessage,
|
|
||||||
sending,
|
|
||||||
handlePreviewEmail,
|
|
||||||
handleSendEmail,
|
|
||||||
getFilteredRecipientCount,
|
|
||||||
ticketsCount,
|
|
||||||
confirmedCount,
|
|
||||||
pendingCount,
|
|
||||||
checkedInCount,
|
|
||||||
cancelledCount,
|
|
||||||
}: EmailTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<Card className="p-5">
|
|
||||||
<h3 className="font-semibold text-base mb-3">Send Email to Attendees</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Email Template</label>
|
|
||||||
<select
|
|
||||||
value={selectedTemplate}
|
|
||||||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
|
|
||||||
>
|
|
||||||
<option value="">Select a template...</option>
|
|
||||||
{templates.map((template) => (
|
|
||||||
<option key={template.id} value={template.slug}>
|
|
||||||
{template.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Recipients</label>
|
|
||||||
<select
|
|
||||||
value={recipientFilter}
|
|
||||||
onChange={(e) => setRecipientFilter(e.target.value as RecipientFilter)}
|
|
||||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">All Attendees ({ticketsCount})</option>
|
|
||||||
<option value="confirmed">Confirmed Only ({confirmedCount})</option>
|
|
||||||
<option value="pending">Pending Only ({pendingCount})</option>
|
|
||||||
<option value="checked_in">Checked In Only ({checkedInCount})</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Custom Message (optional)</label>
|
|
||||||
<textarea
|
|
||||||
value={customMessage}
|
|
||||||
onChange={(e) => setCustomMessage(e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
|
|
||||||
rows={3}
|
|
||||||
placeholder="Add a custom message that will be included in the email..."
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-gray-500 mt-1">
|
|
||||||
This message will replace the {`{{customMessage}}`} variable in the template.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 pt-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePreviewEmail}
|
|
||||||
disabled={!selectedTemplate}
|
|
||||||
className="min-h-[44px] md:min-h-0"
|
|
||||||
>
|
|
||||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSendEmail}
|
|
||||||
disabled={!selectedTemplate || getFilteredRecipientCount() === 0}
|
|
||||||
isLoading={sending}
|
|
||||||
className="min-h-[44px] md:min-h-0"
|
|
||||||
>
|
|
||||||
<PaperAirplaneIcon className="w-4 h-4 mr-1.5" />
|
|
||||||
Send to {getFilteredRecipientCount()} {getFilteredRecipientCount() === 1 ? 'person' : 'people'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-5">
|
|
||||||
<h3 className="font-semibold text-base mb-3">Recipient Summary</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[
|
|
||||||
{ label: 'Confirmed', count: confirmedCount, icon: CheckCircleIcon, color: 'text-green-500' },
|
|
||||||
{ label: 'Pending Payment', count: pendingCount, icon: ClockIcon, color: 'text-yellow-500' },
|
|
||||||
{ label: 'Checked In', count: checkedInCount, icon: TicketIcon, color: 'text-blue-500' },
|
|
||||||
{ label: 'Cancelled', count: cancelledCount, icon: XCircleIcon, color: 'text-red-500' },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.label} className="flex items-center justify-between p-2.5 bg-gray-50 rounded-btn">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<item.icon className={clsx('w-4 h-4', item.color)} />
|
|
||||||
<span className="text-sm">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-sm">{item.count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="border-t pt-2 mt-2">
|
|
||||||
<div className="flex items-center justify-between font-semibold text-sm">
|
|
||||||
<span>Total Bookings</span>
|
|
||||||
<span>{ticketsCount}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Event } from '@/lib/api';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
interface OverviewTabProps {
|
|
||||||
event: Event;
|
|
||||||
formatDate: (dateStr: string) => string;
|
|
||||||
fmtTime: (dateStr: string) => string;
|
|
||||||
formatCurrency: (amount: number, currency: string) => string;
|
|
||||||
confirmedCount: number;
|
|
||||||
checkedInCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount }: OverviewTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<Card className="p-5">
|
|
||||||
<h3 className="font-semibold text-base mb-3">Event Information</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">Date & Time</p>
|
|
||||||
<p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p>
|
|
||||||
<p className="text-sm text-gray-600">{fmtTime(event.startDatetime)}{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<MapPinIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">Location</p>
|
|
||||||
<p className="text-sm text-gray-600">{event.location}</p>
|
|
||||||
{event.locationUrl && (
|
|
||||||
<a href={event.locationUrl} target="_blank" rel="noopener" className="text-blue-600 text-xs hover:underline">
|
|
||||||
View on Map
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<CurrencyDollarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">Price</p>
|
|
||||||
<p className="text-sm text-gray-600">{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-sm">Capacity</p>
|
|
||||||
<p className="text-sm text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
|
|
||||||
<p className="text-xs text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-5">
|
|
||||||
<h3 className="font-semibold text-base mb-3">Description</h3>
|
|
||||||
<div className="prose prose-sm max-w-none">
|
|
||||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
|
||||||
{event.descriptionEs && (
|
|
||||||
<>
|
|
||||||
<p className="font-medium text-sm mt-3">Spanish:</p>
|
|
||||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.descriptionEs}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{event.bannerUrl && (
|
|
||||||
<Card className="p-5 lg:col-span-2">
|
|
||||||
<h3 className="font-semibold text-base mb-3">Event Banner</h3>
|
|
||||||
<img
|
|
||||||
src={event.bannerUrl}
|
|
||||||
alt={event.title}
|
|
||||||
className="w-full max-h-64 object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
import { PaymentOptionsConfig } from '@/lib/api';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import {
|
|
||||||
CreditCardIcon,
|
|
||||||
BuildingLibraryIcon,
|
|
||||||
BoltIcon,
|
|
||||||
BanknotesIcon,
|
|
||||||
ArrowPathIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
|
|
||||||
|
|
||||||
interface PaymentsTabProps {
|
|
||||||
locale: string;
|
|
||||||
payments: PaymentOverridesController;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
|
||||||
const {
|
|
||||||
loadingPayments,
|
|
||||||
hasPaymentOverrides,
|
|
||||||
savingPayments,
|
|
||||||
globalPaymentOptions,
|
|
||||||
paymentOverrides,
|
|
||||||
getEffectivePaymentOption,
|
|
||||||
updatePaymentOverride,
|
|
||||||
handleResetToGlobal,
|
|
||||||
handleSavePaymentOptions,
|
|
||||||
} = payments;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{loadingPayments ? (
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-base">
|
|
||||||
{locale === 'es' ? 'Métodos de Pago del Evento' : 'Event Payment Methods'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{hasPaymentOverrides
|
|
||||||
? (locale === 'es' ? 'Configuración personalizada' : 'Custom settings')
|
|
||||||
: (locale === 'es' ? 'Usando configuración global' : 'Using global settings')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{hasPaymentOverrides && (
|
|
||||||
<Button variant="outline" size="sm" onClick={handleResetToGlobal} disabled={savingPayments} className="min-h-[44px] md:min-h-0">
|
|
||||||
<ArrowPathIcon className="w-4 h-4 mr-1.5" />
|
|
||||||
{locale === 'es' ? 'Resetear' : 'Reset'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button size="sm" onClick={handleSavePaymentOptions} isLoading={savingPayments} className="min-h-[44px] md:min-h-0">
|
|
||||||
{locale === 'es' ? 'Guardar' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TPago */}
|
|
||||||
<Card>
|
|
||||||
<div className="p-4 md:p-5">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
||||||
<CreditCardIcon className="w-4 h-4 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-sm">
|
|
||||||
{locale === 'es' ? 'TPago / Tarjeta' : 'TPago / Card'}
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] text-gray-500">
|
|
||||||
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{globalPaymentOptions && !globalPaymentOptions.tpagoEnabled && (
|
|
||||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
|
||||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => updatePaymentOverride('tpagoEnabled', !getEffectivePaymentOption('tpagoEnabled'))}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
getEffectivePaymentOption('tpagoEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
getEffectivePaymentOption('tpagoEnabled') ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{getEffectivePaymentOption('tpagoEnabled') && (
|
|
||||||
<div className="space-y-3 pt-3 border-t">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
{locale === 'es' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
|
|
||||||
</label>
|
|
||||||
<p className="text-[10px] text-gray-500 mb-2">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Cada enlace tiene un monto fijo. Un enlace distinto por cantidad de tickets.'
|
|
||||||
: 'Each link has a fixed amount. One link per ticket quantity.'}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{([1, 2, 3, 4, 5] as const).map((qty) => {
|
|
||||||
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
|
|
||||||
return (
|
|
||||||
<div key={qty} className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-medium text-gray-600 w-20 flex-shrink-0">
|
|
||||||
{qty} {qty === 1 ? 'ticket' : 'tickets'}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={(paymentOverrides[key] as string | null) ?? ''}
|
|
||||||
onChange={(e) => updatePaymentOverride(key, (e.target.value || null) as any)}
|
|
||||||
placeholder={(globalPaymentOptions?.[key] as string | null) || 'https://www.tpago.com.py/links?alias=...'}
|
|
||||||
className="flex-1 px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
|
|
||||||
<textarea
|
|
||||||
value={paymentOverrides.tpagoInstructions ?? ''}
|
|
||||||
onChange={(e) => updatePaymentOverride('tpagoInstructions', e.target.value || null)}
|
|
||||||
rows={2}
|
|
||||||
placeholder={globalPaymentOptions?.tpagoInstructions || 'Instructions for users...'}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instrucciones (ES)</label>
|
|
||||||
<textarea
|
|
||||||
value={paymentOverrides.tpagoInstructionsEs ?? ''}
|
|
||||||
onChange={(e) => updatePaymentOverride('tpagoInstructionsEs', e.target.value || null)}
|
|
||||||
rows={2}
|
|
||||||
placeholder={globalPaymentOptions?.tpagoInstructionsEs || 'Instrucciones para usuarios...'}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400">
|
|
||||||
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Bank Transfer */}
|
|
||||||
<Card>
|
|
||||||
<div className="p-4 md:p-5">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
||||||
<BuildingLibraryIcon className="w-4 h-4 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-sm">
|
|
||||||
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] text-gray-500">
|
|
||||||
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{globalPaymentOptions && !globalPaymentOptions.bankTransferEnabled && (
|
|
||||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
|
||||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => updatePaymentOverride('bankTransferEnabled', !getEffectivePaymentOption('bankTransferEnabled'))}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
getEffectivePaymentOption('bankTransferEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
getEffectivePaymentOption('bankTransferEnabled') ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{getEffectivePaymentOption('bankTransferEnabled') && (
|
|
||||||
<div className="space-y-3 pt-3 border-t">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{[
|
|
||||||
{ label: locale === 'es' ? 'Banco' : 'Bank Name', key: 'bankName' as const, placeholder: 'e.g., Banco Itaú' },
|
|
||||||
{ label: locale === 'es' ? 'Titular' : 'Account Holder', key: 'bankAccountHolder' as const, placeholder: 'e.g., Juan Pérez' },
|
|
||||||
{ label: locale === 'es' ? 'N° Cuenta' : 'Account Number', key: 'bankAccountNumber' as const, placeholder: 'e.g., 1234567890' },
|
|
||||||
{ label: 'Alias', key: 'bankAlias' as const, placeholder: 'e.g., spanglish.pagos' },
|
|
||||||
{ label: locale === 'es' ? 'Teléfono' : 'Phone', key: 'bankPhone' as const, placeholder: '+595 981 123456' },
|
|
||||||
].map((field) => (
|
|
||||||
<div key={field.key}>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{field.label}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={(paymentOverrides as any)[field.key] ?? ''}
|
|
||||||
onChange={(e) => updatePaymentOverride(field.key, e.target.value || null)}
|
|
||||||
placeholder={(globalPaymentOptions as any)?.[field.key] || field.placeholder}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Notes (EN)</label>
|
|
||||||
<textarea
|
|
||||||
value={paymentOverrides.bankNotes ?? ''}
|
|
||||||
onChange={(e) => updatePaymentOverride('bankNotes', e.target.value || null)}
|
|
||||||
rows={2}
|
|
||||||
placeholder={globalPaymentOptions?.bankNotes || 'Additional notes...'}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Notas (ES)</label>
|
|
||||||
<textarea
|
|
||||||
value={paymentOverrides.bankNotesEs ?? ''}
|
|
||||||
onChange={(e) => updatePaymentOverride('bankNotesEs', e.target.value || null)}
|
|
||||||
rows={2}
|
|
||||||
placeholder={globalPaymentOptions?.bankNotesEs || 'Notas adicionales...'}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400">
|
|
||||||
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Bitcoin Lightning */}
|
|
||||||
<Card>
|
|
||||||
<div className="p-4 md:p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
||||||
<BoltIcon className="w-4 h-4 text-orange-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-sm">Bitcoin Lightning</h4>
|
|
||||||
<p className="text-[10px] text-gray-500">
|
|
||||||
{locale === 'es' ? 'Confirmación automática' : 'Auto confirmation'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{globalPaymentOptions && !globalPaymentOptions.lightningEnabled && (
|
|
||||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
|
||||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => updatePaymentOverride('lightningEnabled', !getEffectivePaymentOption('lightningEnabled'))}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
getEffectivePaymentOption('lightningEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
getEffectivePaymentOption('lightningEnabled') ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{getEffectivePaymentOption('lightningEnabled') && (
|
|
||||||
<div className="pt-3 border-t mt-3">
|
|
||||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-orange-800">
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Lightning configurado vía LNbits. No personalizable por evento.'
|
|
||||||
: 'Lightning is configured via LNbits. Cannot be customized per event.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Cash at Door */}
|
|
||||||
<Card>
|
|
||||||
<div className="p-4 md:p-5">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
||||||
<BanknotesIcon className="w-4 h-4 text-yellow-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-sm">
|
|
||||||
{locale === 'es' ? 'Efectivo' : 'Cash at Door'}
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] text-gray-500">
|
|
||||||
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{globalPaymentOptions && !globalPaymentOptions.cashEnabled && (
|
|
||||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
|
||||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => updatePaymentOverride('cashEnabled', !getEffectivePaymentOption('cashEnabled'))}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
getEffectivePaymentOption('cashEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
getEffectivePaymentOption('cashEnabled') ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{getEffectivePaymentOption('cashEnabled') && (
|
|
||||||
<div className="space-y-3 pt-3 border-t">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
|
|
||||||
<textarea
|
|
||||||
value={paymentOverrides.cashInstructions ?? ''}
|
|
||||||
onChange={(e) => updatePaymentOverride('cashInstructions', e.target.value || null)}
|
|
||||||
rows={2}
|
|
||||||
placeholder={globalPaymentOptions?.cashInstructions || 'Cash payment instructions...'}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instrucciones (ES)</label>
|
|
||||||
<textarea
|
|
||||||
value={paymentOverrides.cashInstructionsEs ?? ''}
|
|
||||||
onChange={(e) => updatePaymentOverride('cashInstructionsEs', e.target.value || null)}
|
|
||||||
rows={2}
|
|
||||||
placeholder={globalPaymentOptions?.cashInstructionsEs || 'Instrucciones de pago en efectivo...'}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-400">
|
|
||||||
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<Card>
|
|
||||||
<div className="p-4 md:p-5">
|
|
||||||
<h4 className="font-semibold text-sm mb-3">
|
|
||||||
{locale === 'es' ? 'Resumen' : 'Active Methods'}
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
{[
|
|
||||||
{ label: 'TPago', enabled: getEffectivePaymentOption('tpagoEnabled') },
|
|
||||||
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
|
|
||||||
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
|
|
||||||
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
|
|
||||||
].map((method) => (
|
|
||||||
<div key={method.label} className="flex items-center gap-1.5">
|
|
||||||
{method.enabled ? (
|
|
||||||
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon className="w-4 h-4 text-gray-300" />
|
|
||||||
)}
|
|
||||||
<span className={clsx('text-sm', method.enabled ? 'text-gray-900' : 'text-gray-400')}>
|
|
||||||
{method.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{hasPaymentOverrides && (
|
|
||||||
<p className="text-[10px] text-gray-500 mt-3 flex items-center gap-1">
|
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-primary-yellow rounded-full" />
|
|
||||||
{locale === 'es'
|
|
||||||
? 'Configuración personalizada activa'
|
|
||||||
: 'Custom settings override global defaults'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import { Ticket } from '@/lib/api';
|
|
||||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
|
||||||
import {
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
ArrowDownTrayIcon,
|
|
||||||
ArrowUturnLeftIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import type { TicketStatusFilter } from '../_types';
|
|
||||||
|
|
||||||
interface TicketsTabProps {
|
|
||||||
locale: string;
|
|
||||||
confirmedTickets: Ticket[];
|
|
||||||
filteredConfirmedTickets: Ticket[];
|
|
||||||
ticketSearchQuery: string;
|
|
||||||
setTicketSearchQuery: (value: string) => void;
|
|
||||||
ticketStatusFilter: TicketStatusFilter;
|
|
||||||
setTicketStatusFilter: (value: TicketStatusFilter) => void;
|
|
||||||
confirmedCount: number;
|
|
||||||
checkedInCount: number;
|
|
||||||
exporting: boolean;
|
|
||||||
showTicketExportDropdown: boolean;
|
|
||||||
setShowTicketExportDropdown: (value: boolean) => void;
|
|
||||||
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
|
||||||
handleCheckin: (ticketId: string) => void;
|
|
||||||
handleRemoveCheckin: (ticketId: string) => void;
|
|
||||||
setShowTicketExportSheet: (value: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketsTab({
|
|
||||||
locale,
|
|
||||||
confirmedTickets,
|
|
||||||
filteredConfirmedTickets,
|
|
||||||
ticketSearchQuery,
|
|
||||||
setTicketSearchQuery,
|
|
||||||
ticketStatusFilter,
|
|
||||||
setTicketStatusFilter,
|
|
||||||
confirmedCount,
|
|
||||||
checkedInCount,
|
|
||||||
exporting,
|
|
||||||
showTicketExportDropdown,
|
|
||||||
setShowTicketExportDropdown,
|
|
||||||
handleExportTickets,
|
|
||||||
handleCheckin,
|
|
||||||
handleRemoveCheckin,
|
|
||||||
setShowTicketExportSheet,
|
|
||||||
}: TicketsTabProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Desktop toolbar */}
|
|
||||||
<Card className="p-3 hidden md:block">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by name or ticket ID..."
|
|
||||||
value={ticketSearchQuery}
|
|
||||||
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={ticketStatusFilter}
|
|
||||||
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
>
|
|
||||||
<option value="all">All ({confirmedTickets.length})</option>
|
|
||||||
<option value="confirmed">Valid ({confirmedCount})</option>
|
|
||||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
|
||||||
</select>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<Dropdown
|
|
||||||
open={showTicketExportDropdown}
|
|
||||||
onOpenChange={setShowTicketExportDropdown}
|
|
||||||
trigger={
|
|
||||||
<Button variant="outline" size="sm" disabled={exporting}>
|
|
||||||
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
Export
|
|
||||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownItem onClick={() => handleExportTickets('all')}>Export All</DropdownItem>
|
|
||||||
<DropdownItem onClick={() => handleExportTickets('confirmed')}>Export Valid</DropdownItem>
|
|
||||||
<DropdownItem onClick={() => handleExportTickets('checked_in')}>Export Checked-in</DropdownItem>
|
|
||||||
<div className="border-t border-gray-100 mx-2" />
|
|
||||||
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
|
|
||||||
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
|
||||||
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length}</span>
|
|
||||||
<button onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }} className="text-primary-yellow hover:underline">Clear</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mobile toolbar */}
|
|
||||||
<div className="md:hidden space-y-2">
|
|
||||||
<div className="relative">
|
|
||||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by name or ticket ID..."
|
|
||||||
value={ticketSearchQuery}
|
|
||||||
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={ticketStatusFilter}
|
|
||||||
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
|
|
||||||
className="px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]"
|
|
||||||
>
|
|
||||||
<option value="all">All ({confirmedTickets.length})</option>
|
|
||||||
<option value="confirmed">Valid ({confirmedCount})</option>
|
|
||||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTicketExportSheet(true)}
|
|
||||||
disabled={exporting}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
|
|
||||||
>
|
|
||||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop: Dense table */}
|
|
||||||
<Card className="overflow-hidden hidden md:block">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<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">Status</th>
|
|
||||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Check-in</th>
|
|
||||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{filteredConfirmedTickets.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-4 py-10 text-center text-gray-500 text-sm">
|
|
||||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredConfirmedTickets.map((ticket) => (
|
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
||||||
{ticket.bookingId && (
|
|
||||||
<span className="text-[10px] text-purple-600">Group booking</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
{ticket.status === 'confirmed' ? (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium">Valid</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium">Checked In</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
|
||||||
{ticket.checkinAt ? (
|
|
||||||
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
||||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-300">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
{ticket.status === 'confirmed' && (
|
|
||||||
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2 py-1">
|
|
||||||
Check In
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{ticket.status === 'checked_in' && (
|
|
||||||
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2 py-1">
|
|
||||||
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
|
|
||||||
Undo
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<MoreMenu>
|
|
||||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
|
|
||||||
ID: {ticket.id.slice(0, 8)}...
|
|
||||||
</div>
|
|
||||||
{ticket.bookingId && (
|
|
||||||
<div className="px-4 py-1.5 text-[10px] text-purple-500 font-mono" title={ticket.bookingId}>
|
|
||||||
Booking: {ticket.bookingId.slice(0, 8)}...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</MoreMenu>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mobile: Card layout */}
|
|
||||||
<div className="md:hidden space-y-2">
|
|
||||||
{filteredConfirmedTickets.length === 0 ? (
|
|
||||||
<div className="text-center py-10 text-gray-500 text-sm">
|
|
||||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredConfirmedTickets.map((ticket) => (
|
|
||||||
<Card key={ticket.id} className="p-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
||||||
{ticket.bookingId && <p className="text-[10px] text-purple-600">Group booking</p>}
|
|
||||||
</div>
|
|
||||||
{ticket.status === 'confirmed' ? (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium flex-shrink-0">Valid</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium flex-shrink-0">Checked In</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
|
||||||
<p className="text-[10px] text-gray-400">
|
|
||||||
{ticket.checkinAt
|
|
||||||
? `Checked in ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`
|
|
||||||
: 'Not checked in'}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{ticket.status === 'confirmed' && (
|
|
||||||
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
|
||||||
Check In
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{ticket.status === 'checked_in' && (
|
|
||||||
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
|
||||||
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
|
|
||||||
Undo
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { ComponentType } from 'react';
|
|
||||||
|
|
||||||
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
|
||||||
|
|
||||||
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled';
|
|
||||||
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
|
|
||||||
export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
|
|
||||||
|
|
||||||
export interface PrimaryAction {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
variant: 'outline' | 'primary';
|
|
||||||
icon?: ComponentType<{ className?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AttendeeFormState {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
adminNote: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddAtDoorFormState extends AttendeeFormState {
|
|
||||||
autoCheckin: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// Pure formatting/IO helpers for the admin event detail page.
|
|
||||||
|
|
||||||
export function formatCurrency(amount: number, currency: string): string {
|
|
||||||
if (currency === 'PYG') {
|
|
||||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
|
||||||
}
|
|
||||||
return `$${amount.toFixed(2)} ${currency}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Trigger a browser download for a blob with the given filename. */
|
|
||||||
export function downloadBlob(blob: Blob, filename: string): void {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -26,10 +26,6 @@ export default function PaymentOptionsPage() {
|
|||||||
const [options, setOptions] = useState<PaymentOptionsConfig>({
|
const [options, setOptions] = useState<PaymentOptionsConfig>({
|
||||||
tpagoEnabled: false,
|
tpagoEnabled: false,
|
||||||
tpagoLink: null,
|
tpagoLink: null,
|
||||||
tpagoLink2: null,
|
|
||||||
tpagoLink3: null,
|
|
||||||
tpagoLink4: null,
|
|
||||||
tpagoLink5: null,
|
|
||||||
tpagoInstructions: null,
|
tpagoInstructions: null,
|
||||||
tpagoInstructionsEs: null,
|
tpagoInstructionsEs: null,
|
||||||
bankTransferEnabled: false,
|
bankTransferEnabled: false,
|
||||||
@@ -144,31 +140,13 @@ export default function PaymentOptionsPage() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{locale === 'es' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
|
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-gray-500 mb-2">
|
<Input
|
||||||
{locale === 'es'
|
value={options.tpagoLink || ''}
|
||||||
? 'Cada enlace tiene un monto fijo. Usá un enlace distinto para cada cantidad de tickets.'
|
onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
|
||||||
: 'Each link has a fixed amount. Use a different link for each ticket quantity.'}
|
placeholder="https://www.tpago.com.py/links?alias=..."
|
||||||
</p>
|
/>
|
||||||
<div className="space-y-2">
|
|
||||||
{([1, 2, 3, 4, 5] as const).map((qty) => {
|
|
||||||
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
|
|
||||||
return (
|
|
||||||
<div key={qty} className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-gray-600 w-24 flex-shrink-0">
|
|
||||||
{qty} {locale === 'es' ? (qty === 1 ? 'ticket' : 'tickets') : (qty === 1 ? 'ticket' : 'tickets')}
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
value={(options[key] as string | null) || ''}
|
|
||||||
onChange={(e) => updateOption(key, (e.target.value || null) as any)}
|
|
||||||
placeholder="https://www.tpago.com.py/links?alias=..."
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
// Streaming proxy for the backend LNbits SSE endpoint.
|
|
||||||
//
|
|
||||||
// Next.js `rewrites` buffer `text/event-stream` responses, so payment events
|
|
||||||
// never reach the browser in real time when going through the same-origin proxy.
|
|
||||||
// This route handler streams the backend response body straight through with
|
|
||||||
// anti-buffering headers, so EventSource on the booking page works in dev and
|
|
||||||
// proxyless deployments. A route handler takes precedence over the rewrite.
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: { ticketId: string } }
|
|
||||||
) {
|
|
||||||
const upstream = await fetch(
|
|
||||||
`${BACKEND_URL}/api/lnbits/stream/${params.ticketId}`,
|
|
||||||
{
|
|
||||||
headers: { Accept: 'text/event-stream' },
|
|
||||||
signal: request.signal,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the ticket is already confirmed the backend may answer with JSON instead
|
|
||||||
// of a stream; pass that through unchanged so the client can handle it.
|
|
||||||
const contentType = upstream.headers.get('content-type') || '';
|
|
||||||
if (!contentType.includes('text/event-stream')) {
|
|
||||||
return new Response(upstream.body, {
|
|
||||||
status: upstream.status,
|
|
||||||
headers: { 'Content-Type': contentType || 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(upstream.body, {
|
|
||||||
status: upstream.status,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
'X-Accel-Buffering': 'no',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,14 @@
|
|||||||
import { revalidateTag } from 'next/cache';
|
import { revalidateTag } from 'next/cache';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { timingSafeEqual } from 'crypto';
|
|
||||||
|
|
||||||
// Constant-time string comparison to avoid leaking the secret via response timing.
|
|
||||||
function secretsMatch(provided: unknown, expected: string): boolean {
|
|
||||||
if (typeof provided !== 'string' || provided.length === 0) return false;
|
|
||||||
const a = Buffer.from(provided);
|
|
||||||
const b = Buffer.from(expected);
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
return timingSafeEqual(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { secret, tag } = body;
|
const { secret, tag } = body;
|
||||||
|
|
||||||
// Validate the revalidation secret. Reject if it is unset or left at an insecure default.
|
// Validate the revalidation secret
|
||||||
const revalidateSecret = process.env.REVALIDATE_SECRET;
|
const revalidateSecret = process.env.REVALIDATE_SECRET;
|
||||||
if (!revalidateSecret || revalidateSecret === 'change-me' || revalidateSecret.length < 16) {
|
if (!revalidateSecret || secret !== revalidateSecret) {
|
||||||
return NextResponse.json({ error: 'Revalidation is not configured' }, { status: 503 });
|
|
||||||
}
|
|
||||||
if (!secretsMatch(secret, revalidateSecret)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
|
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,18 +58,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
changeFrequency: 'daily',
|
changeFrequency: 'daily',
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
url: `${siteUrl}/next`,
|
|
||||||
lastModified: now,
|
|
||||||
changeFrequency: 'daily',
|
|
||||||
priority: 0.8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${siteUrl}/featured`,
|
|
||||||
lastModified: now,
|
|
||||||
changeFrequency: 'daily',
|
|
||||||
priority: 0.8,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/community`,
|
url: `${siteUrl}/community`,
|
||||||
lastModified: now,
|
lastModified: now,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -83,9 +82,10 @@ export default function GoogleSignInButton({
|
|||||||
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
|
|
||||||
// Use window.location for navigation to ensure clean state.
|
// Use window.location for navigation to ensure clean state
|
||||||
// Constrain to a same-origin path to avoid open redirects.
|
if (redirectTo) {
|
||||||
window.location.href = safeInternalPath(redirectTo, '/dashboard');
|
window.location.href = redirectTo;
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
||||||
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
||||||
|
|||||||
@@ -21,17 +21,6 @@ function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string
|
|||||||
return match ? match[1].trim() : updatedAt;
|
return match ? match[1].trim() : updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only permit safe link schemes. Anything else (javascript:, data:, etc.) is dropped
|
|
||||||
// so a malicious markdown link can't execute script when clicked.
|
|
||||||
function sanitizeHref(href?: string): string | undefined {
|
|
||||||
if (!href) return undefined;
|
|
||||||
const trimmed = href.trim();
|
|
||||||
// Allow relative/anchor/protocol-relative-safe links
|
|
||||||
if (trimmed.startsWith('/') || trimmed.startsWith('#')) return trimmed;
|
|
||||||
if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return trimmed;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LegalPageLayout({
|
export default function LegalPageLayout({
|
||||||
slug,
|
slug,
|
||||||
initialLocale,
|
initialLocale,
|
||||||
@@ -152,20 +141,17 @@ export default function LegalPageLayout({
|
|||||||
{children}
|
{children}
|
||||||
</li>
|
</li>
|
||||||
),
|
),
|
||||||
// Style links (with scheme allowlist to block javascript:/data: URLs)
|
// Style links
|
||||||
a: ({ href, children }) => {
|
a: ({ href, children }) => (
|
||||||
const safeHref = sanitizeHref(href);
|
<a
|
||||||
return (
|
href={href}
|
||||||
<a
|
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
|
||||||
href={safeHref}
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||||
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
target={safeHref?.startsWith('http') ? '_blank' : undefined}
|
>
|
||||||
rel={safeHref?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
{children}
|
||||||
>
|
</a>
|
||||||
{children}
|
),
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Style horizontal rules
|
// Style horizontal rules
|
||||||
hr: () => (
|
hr: () => (
|
||||||
<hr className="my-8 border-gray-200" />
|
<hr className="my-8 border-gray-200" />
|
||||||
|
|||||||
@@ -14,18 +14,11 @@ interface RichTextEditorProps {
|
|||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape HTML-significant characters so any raw HTML embedded in the markdown source
|
|
||||||
// is neutralised before we layer our own generated tags on top (defense-in-depth;
|
|
||||||
// the public renderer escapes too, and TipTap sanitizes via its schema).
|
|
||||||
function escapeRawHtml(s: string): string {
|
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert markdown to HTML for TipTap
|
// Convert markdown to HTML for TipTap
|
||||||
function markdownToHtml(markdown: string): string {
|
function markdownToHtml(markdown: string): string {
|
||||||
if (!markdown) return '<p></p>';
|
if (!markdown) return '<p></p>';
|
||||||
|
|
||||||
let html = escapeRawHtml(markdown);
|
let html = markdown;
|
||||||
|
|
||||||
// Convert horizontal rules first (before other processing)
|
// Convert horizontal rules first (before other processing)
|
||||||
html = html.replace(/^---+$/gm, '<hr>');
|
html = html.replace(/^---+$/gm, '<hr>');
|
||||||
|
|||||||
@@ -44,17 +44,6 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
|
|
||||||
const TOKEN_KEY = 'spanglish-token';
|
const TOKEN_KEY = 'spanglish-token';
|
||||||
const USER_KEY = 'spanglish-user';
|
const USER_KEY = 'spanglish-user';
|
||||||
const AUTH_COOKIE = 'spanglish-auth';
|
|
||||||
|
|
||||||
function setAuthCookie() {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAuthCookie() {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
@@ -77,14 +66,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||||
setAuthCookie();
|
|
||||||
} else if (res.status === 401) {
|
} else if (res.status === 401) {
|
||||||
// Token is invalid, clear auth state
|
// Token is invalid, clear auth state
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
clearAuthCookie();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Network error, keep using cached data
|
// Network error, keep using cached data
|
||||||
@@ -98,24 +85,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const savedUser = localStorage.getItem(USER_KEY);
|
const savedUser = localStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
if (savedToken && savedUser) {
|
if (savedToken && savedUser) {
|
||||||
// Guard against corrupt/tampered localStorage so the whole app doesn't crash.
|
setToken(savedToken);
|
||||||
let parsedUser: User | null = null;
|
setUser(JSON.parse(savedUser));
|
||||||
try {
|
// Refresh user data from server to get latest role/permissions
|
||||||
parsedUser = JSON.parse(savedUser);
|
refreshUser().finally(() => setIsLoading(false));
|
||||||
} catch {
|
|
||||||
parsedUser = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedUser) {
|
|
||||||
setToken(savedToken);
|
|
||||||
setUser(parsedUser);
|
|
||||||
// Refresh user data from server to get latest role/permissions (source of truth)
|
|
||||||
refreshUser().finally(() => setIsLoading(false));
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -126,7 +99,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
localStorage.setItem(TOKEN_KEY, data.token);
|
localStorage.setItem(TOKEN_KEY, data.token);
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||||
setAuthCookie();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
@@ -194,21 +166,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused).
|
|
||||||
const currentToken = localStorage.getItem(TOKEN_KEY);
|
|
||||||
if (currentToken) {
|
|
||||||
fetch(`${API_BASE}/api/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${currentToken}` },
|
|
||||||
}).catch(() => {
|
|
||||||
// Ignore network errors; local state is cleared regardless.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(USER_KEY);
|
localStorage.removeItem(USER_KEY);
|
||||||
clearAuthCookie();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateUser = useCallback((updatedUser: User) => {
|
const updateUser = useCallback((updatedUser: User) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
|||||||
import { fetchApi, fetchBlob } from './client';
|
|
||||||
import type {
|
|
||||||
DashboardData,
|
|
||||||
AnalyticsData,
|
|
||||||
ExportedTicket,
|
|
||||||
ExportedPayment,
|
|
||||||
FinancialSummary,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export const adminApi = {
|
|
||||||
getDashboard: () => fetchApi<{ dashboard: DashboardData }>('/api/admin/dashboard'),
|
|
||||||
getAnalytics: () => fetchApi<{ analytics: AnalyticsData }>('/api/admin/analytics'),
|
|
||||||
exportTickets: (eventId?: string) => {
|
|
||||||
const query = eventId ? `?eventId=${eventId}` : '';
|
|
||||||
return fetchApi<{ tickets: ExportedTicket[] }>(`/api/admin/export/tickets${query}`);
|
|
||||||
},
|
|
||||||
exportFinancial: (params?: { startDate?: string; endDate?: string; eventId?: string }) => {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.startDate) query.set('startDate', params.startDate);
|
|
||||||
if (params?.endDate) query.set('endDate', params.endDate);
|
|
||||||
if (params?.eventId) query.set('eventId', params.eventId);
|
|
||||||
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
|
|
||||||
},
|
|
||||||
/** Download attendee export as a file (CSV). Returns a Blob. */
|
|
||||||
exportAttendees: (eventId: string, params?: { status?: string; format?: string; q?: string }) => {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.status) query.set('status', params.status);
|
|
||||||
if (params?.format) query.set('format', params.format);
|
|
||||||
if (params?.q) query.set('q', params.q);
|
|
||||||
return fetchBlob(
|
|
||||||
`/api/admin/events/${eventId}/attendees/export?${query}`,
|
|
||||||
`attendees-${new Date().toISOString().split('T')[0]}.csv`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
/** Download tickets export as CSV. Returns a Blob. */
|
|
||||||
exportTicketsCSV: (eventId: string, params?: { status?: string; q?: string }) => {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.status) query.set('status', params.status);
|
|
||||||
if (params?.q) query.set('q', params.q);
|
|
||||||
return fetchBlob(
|
|
||||||
`/api/admin/events/${eventId}/tickets/export?${query}`,
|
|
||||||
`tickets-${new Date().toISOString().split('T')[0]}.csv`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { User } from './types';
|
|
||||||
|
|
||||||
export const authApi = {
|
|
||||||
// Magic link
|
|
||||||
requestMagicLink: (email: string) =>
|
|
||||||
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
verifyMagicLink: (token: string) =>
|
|
||||||
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Password reset
|
|
||||||
requestPasswordReset: (email: string) =>
|
|
||||||
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
confirmPasswordReset: (token: string, password: string) =>
|
|
||||||
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ token, password }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Account claiming
|
|
||||||
requestClaimAccount: (email: string) =>
|
|
||||||
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
|
|
||||||
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
|
|
||||||
'/api/auth/claim-account/confirm',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ token, ...data }),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
// Google OAuth
|
|
||||||
googleAuth: (credential: string) =>
|
|
||||||
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ credential }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Change password
|
|
||||||
changePassword: (currentPassword: string, newPassword: string) =>
|
|
||||||
fetchApi<{ message: string }>('/api/auth/change-password', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Get current user
|
|
||||||
me: () => fetchApi<{ user: User }>('/api/auth/me'),
|
|
||||||
};
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
|
||||||
|
|
||||||
export interface ApiError {
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the stored auth token (browser only). */
|
|
||||||
export function getToken(): string | null {
|
|
||||||
return typeof window !== 'undefined' ? localStorage.getItem('spanglish-token') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchApi<T>(
|
|
||||||
endpoint: string,
|
|
||||||
options: RequestInit = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const token = getToken();
|
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
|
||||||
...options,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorData = await res.json().catch(() => ({ error: 'Request failed' }));
|
|
||||||
const errorMessage = typeof errorData.error === 'string'
|
|
||||||
? errorData.error
|
|
||||||
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a file download (CSV/blob) with auth, returning the blob and the
|
|
||||||
* filename parsed from the Content-Disposition header.
|
|
||||||
*/
|
|
||||||
export async function fetchBlob(
|
|
||||||
endpoint: string,
|
|
||||||
fallbackFilename: string
|
|
||||||
): Promise<{ blob: Blob; filename: string }> {
|
|
||||||
const token = getToken();
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}${endpoint}`, { headers });
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
|
|
||||||
throw new Error(errorData.error || 'Export failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const disposition = res.headers.get('Content-Disposition') || '';
|
|
||||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
|
||||||
const filename = filenameMatch ? filenameMatch[1] : fallbackFilename;
|
|
||||||
const blob = await res.blob();
|
|
||||||
return { blob, filename };
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { Contact } from './types';
|
|
||||||
|
|
||||||
export const contactsApi = {
|
|
||||||
submit: (data: { name: string; email: string; message: string }) =>
|
|
||||||
fetchApi<{ message: string }>('/api/contacts', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
subscribe: (email: string, name?: string) =>
|
|
||||||
fetchApi<{ message: string }>('/api/contacts/subscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ email, name }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
getAll: (status?: string) => {
|
|
||||||
const query = status ? `?status=${status}` : '';
|
|
||||||
return fetchApi<{ contacts: Contact[] }>(`/api/contacts${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateStatus: (id: string, status: string) =>
|
|
||||||
fetchApi<{ contact: Contact }>(`/api/contacts/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ status }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type {
|
|
||||||
DashboardSummary,
|
|
||||||
UserProfile,
|
|
||||||
UserTicket,
|
|
||||||
UserPayment,
|
|
||||||
UserInvoice,
|
|
||||||
UserSession,
|
|
||||||
NextEventInfo,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export const dashboardApi = {
|
|
||||||
// Summary
|
|
||||||
getSummary: () =>
|
|
||||||
fetchApi<{ summary: DashboardSummary }>('/api/dashboard/summary'),
|
|
||||||
|
|
||||||
// Profile
|
|
||||||
getProfile: () =>
|
|
||||||
fetchApi<{ profile: UserProfile }>('/api/dashboard/profile'),
|
|
||||||
|
|
||||||
updateProfile: (data: { name?: string; phone?: string; languagePreference?: string; rucNumber?: string }) =>
|
|
||||||
fetchApi<{ profile: UserProfile; message: string }>('/api/dashboard/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Tickets
|
|
||||||
getTickets: () =>
|
|
||||||
fetchApi<{ tickets: UserTicket[] }>('/api/dashboard/tickets'),
|
|
||||||
|
|
||||||
getTicket: (id: string) =>
|
|
||||||
fetchApi<{ ticket: UserTicket }>(`/api/dashboard/tickets/${id}`),
|
|
||||||
|
|
||||||
// Next event
|
|
||||||
getNextEvent: () =>
|
|
||||||
fetchApi<{ nextEvent: NextEventInfo | null }>('/api/dashboard/next-event'),
|
|
||||||
|
|
||||||
// Payments
|
|
||||||
getPayments: () =>
|
|
||||||
fetchApi<{ payments: UserPayment[] }>('/api/dashboard/payments'),
|
|
||||||
|
|
||||||
// Invoices
|
|
||||||
getInvoices: () =>
|
|
||||||
fetchApi<{ invoices: UserInvoice[] }>('/api/dashboard/invoices'),
|
|
||||||
|
|
||||||
// Sessions
|
|
||||||
getSessions: () =>
|
|
||||||
fetchApi<{ sessions: UserSession[] }>('/api/dashboard/sessions'),
|
|
||||||
|
|
||||||
revokeSession: (id: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/dashboard/sessions/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
revokeAllSessions: () =>
|
|
||||||
fetchApi<{ message: string }>('/api/dashboard/sessions/revoke-all', { method: 'POST' }),
|
|
||||||
|
|
||||||
// Security
|
|
||||||
setPassword: (password: string) =>
|
|
||||||
fetchApi<{ message: string }>('/api/dashboard/set-password', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
unlinkGoogle: () =>
|
|
||||||
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { EmailTemplate, EmailVariable, EmailLog, EmailStats, Pagination } from './types';
|
|
||||||
|
|
||||||
export const emailsApi = {
|
|
||||||
// Templates
|
|
||||||
getTemplates: () => fetchApi<{ templates: EmailTemplate[] }>('/api/emails/templates'),
|
|
||||||
|
|
||||||
getTemplate: (id: string) => fetchApi<{ template: EmailTemplate }>(`/api/emails/templates/${id}`),
|
|
||||||
|
|
||||||
createTemplate: (data: Partial<EmailTemplate>) =>
|
|
||||||
fetchApi<{ template: EmailTemplate; message: string }>('/api/emails/templates', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateTemplate: (id: string, data: Partial<EmailTemplate>) =>
|
|
||||||
fetchApi<{ template: EmailTemplate; message: string }>(`/api/emails/templates/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
deleteTemplate: (id: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/emails/templates/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
getTemplateVariables: (slug: string) =>
|
|
||||||
fetchApi<{ variables: EmailVariable[] }>(`/api/emails/templates/${slug}/variables`),
|
|
||||||
|
|
||||||
// Sending
|
|
||||||
sendToEvent: (eventId: string, data: {
|
|
||||||
templateSlug: string;
|
|
||||||
customVariables?: Record<string, any>;
|
|
||||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ success: boolean; queuedCount: number; error?: string }>(
|
|
||||||
`/api/emails/send/event/${eventId}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
sendCustom: (data: {
|
|
||||||
to: string;
|
|
||||||
toName?: string;
|
|
||||||
subject: string;
|
|
||||||
bodyHtml: string;
|
|
||||||
bodyText?: string;
|
|
||||||
eventId?: string;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ success: boolean; logId?: string; error?: string }>('/api/emails/send/custom', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
preview: (data: {
|
|
||||||
templateSlug: string;
|
|
||||||
variables?: Record<string, any>;
|
|
||||||
locale?: string;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ subject: string; bodyHtml: string }>('/api/emails/preview', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Logs
|
|
||||||
getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.eventId) query.set('eventId', params.eventId);
|
|
||||||
if (params?.status) query.set('status', params.status);
|
|
||||||
if (params?.search) query.set('search', params.search);
|
|
||||||
if (params?.limit) query.set('limit', params.limit.toString());
|
|
||||||
if (params?.offset) query.set('offset', params.offset.toString());
|
|
||||||
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
|
|
||||||
|
|
||||||
resendLog: (id: string) =>
|
|
||||||
fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, {
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
|
|
||||||
getStats: (eventId?: string) => {
|
|
||||||
const query = eventId ? `?eventId=${eventId}` : '';
|
|
||||||
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
seedTemplates: () =>
|
|
||||||
fetchApi<{ message: string }>('/api/emails/seed-templates', { method: 'POST' }),
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { Event } from './types';
|
|
||||||
|
|
||||||
export const eventsApi = {
|
|
||||||
getAll: (params?: { status?: string; upcoming?: boolean }) => {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.status) query.set('status', params.status);
|
|
||||||
if (params?.upcoming) query.set('upcoming', 'true');
|
|
||||||
return fetchApi<{ events: Event[] }>(`/api/events?${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
|
|
||||||
|
|
||||||
getNext: () => fetchApi<{ event: Event | null }>('/api/events/next'),
|
|
||||||
|
|
||||||
getNextUpcoming: () => fetchApi<{ event: Event | null }>('/api/events/next/upcoming'),
|
|
||||||
|
|
||||||
create: (data: Partial<Event>) =>
|
|
||||||
fetchApi<{ event: Event }>('/api/events', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
update: (id: string, data: Partial<Event>) =>
|
|
||||||
fetchApi<{ event: Event }>(`/api/events/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
delete: (id: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/events/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
duplicate: (id: string) =>
|
|
||||||
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
|
|
||||||
|
|
||||||
getSlugAliases: (id: string) =>
|
|
||||||
fetchApi<{ aliases: { slug: string; createdAt: string }[] }>(`/api/events/${id}/slug-aliases`),
|
|
||||||
|
|
||||||
deleteSlugAlias: (id: string, slug: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/events/${id}/slug-aliases/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
|
|
||||||
};
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { FaqItem, FaqItemAdmin } from './types';
|
|
||||||
|
|
||||||
export const faqApi = {
|
|
||||||
// Public
|
|
||||||
getList: (homepageOnly?: boolean) =>
|
|
||||||
fetchApi<{ faqs: FaqItem[] }>(`/api/faq${homepageOnly ? '?homepage=true' : ''}`),
|
|
||||||
|
|
||||||
// Admin
|
|
||||||
getAdminList: () =>
|
|
||||||
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/list'),
|
|
||||||
|
|
||||||
getById: (id: string) =>
|
|
||||||
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`),
|
|
||||||
|
|
||||||
create: (data: {
|
|
||||||
question: string;
|
|
||||||
questionEs?: string;
|
|
||||||
answer: string;
|
|
||||||
answerEs?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
showOnHomepage?: boolean;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ faq: FaqItemAdmin }>('/api/faq/admin', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
update: (id: string, data: {
|
|
||||||
question?: string;
|
|
||||||
questionEs?: string | null;
|
|
||||||
answer?: string;
|
|
||||||
answerEs?: string | null;
|
|
||||||
enabled?: boolean;
|
|
||||||
showOnHomepage?: boolean;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
delete: (id: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/faq/admin/${id}`, { method: 'DELETE' }),
|
|
||||||
|
|
||||||
reorder: (ids: string[]) =>
|
|
||||||
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/reorder', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ ids }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// Barrel for the frontend API client. Consumers import from `@/lib/api`.
|
|
||||||
export type { ApiError } from './client';
|
|
||||||
export * from './types';
|
|
||||||
|
|
||||||
export { eventsApi } from './events';
|
|
||||||
export { ticketsApi } from './tickets';
|
|
||||||
export { contactsApi } from './contacts';
|
|
||||||
export { usersApi } from './users';
|
|
||||||
export { paymentsApi } from './payments';
|
|
||||||
export { paymentOptionsApi } from './paymentOptions';
|
|
||||||
export { mediaApi } from './media';
|
|
||||||
export { adminApi } from './admin';
|
|
||||||
export { emailsApi } from './emails';
|
|
||||||
export { authApi } from './auth';
|
|
||||||
export { dashboardApi } from './dashboard';
|
|
||||||
export { siteSettingsApi } from './siteSettings';
|
|
||||||
export { legalSettingsApi } from './legalSettings';
|
|
||||||
export { legalPagesApi } from './legalPages';
|
|
||||||
export { faqApi } from './faq';
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { LegalPage, LegalPagePublic, LegalPageListItem } from './types';
|
|
||||||
|
|
||||||
export const legalPagesApi = {
|
|
||||||
// Public endpoints
|
|
||||||
getAll: (locale?: string) =>
|
|
||||||
fetchApi<{ pages: LegalPageListItem[] }>(`/api/legal-pages${locale ? `?locale=${locale}` : ''}`),
|
|
||||||
|
|
||||||
getBySlug: (slug: string, locale?: string) =>
|
|
||||||
fetchApi<{ page: LegalPagePublic }>(`/api/legal-pages/${slug}${locale ? `?locale=${locale}` : ''}`),
|
|
||||||
|
|
||||||
// Admin endpoints
|
|
||||||
getAdminList: () =>
|
|
||||||
fetchApi<{ pages: LegalPage[] }>('/api/legal-pages/admin/list'),
|
|
||||||
|
|
||||||
getAdminPage: (slug: string) =>
|
|
||||||
fetchApi<{ page: LegalPage }>(`/api/legal-pages/admin/${slug}`),
|
|
||||||
|
|
||||||
update: (slug: string, data: {
|
|
||||||
contentMarkdown?: string;
|
|
||||||
contentMarkdownEs?: string;
|
|
||||||
title?: string;
|
|
||||||
titleEs?: string;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ page: LegalPage; message: string }>(`/api/legal-pages/admin/${slug}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
seed: () =>
|
|
||||||
fetchApi<{ message: string; seeded: number; pages?: string[] }>('/api/legal-pages/admin/seed', {
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { LegalSettingsData } from './types';
|
|
||||||
|
|
||||||
export const legalSettingsApi = {
|
|
||||||
get: () => fetchApi<{ settings: LegalSettingsData }>('/api/legal-settings'),
|
|
||||||
|
|
||||||
update: (data: Partial<LegalSettingsData>) =>
|
|
||||||
fetchApi<{ settings: LegalSettingsData; message: string }>('/api/legal-settings', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { fetchApi, API_BASE, getToken } from './client';
|
|
||||||
import type { Media } from './types';
|
|
||||||
|
|
||||||
export const mediaApi = {
|
|
||||||
getAll: (relatedType?: string, relatedId?: string) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (relatedType) params.set('relatedType', relatedType);
|
|
||||||
if (relatedId) params.set('relatedId', relatedId);
|
|
||||||
const query = params.toString();
|
|
||||||
return fetchApi<{ media: Media[] }>(`/api/media${query ? `?${query}` : ''}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
upload: async (file: File, relatedId?: string, relatedType?: string) => {
|
|
||||||
const token = getToken();
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
if (relatedId) formData.append('relatedId', relatedId);
|
|
||||||
if (relatedType) formData.append('relatedType', relatedType);
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/api/media/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
|
|
||||||
throw new Error(errorData.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json() as Promise<{ media: Media; url: string }>;
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: (id: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/media/${id}`, { method: 'DELETE' }),
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { PaymentOptionsConfig } from './types';
|
|
||||||
|
|
||||||
export const paymentOptionsApi = {
|
|
||||||
// Global payment options
|
|
||||||
getGlobal: () =>
|
|
||||||
fetchApi<{ paymentOptions: PaymentOptionsConfig }>('/api/payment-options'),
|
|
||||||
|
|
||||||
updateGlobal: (data: Partial<PaymentOptionsConfig>) =>
|
|
||||||
fetchApi<{ paymentOptions: PaymentOptionsConfig; message: string }>('/api/payment-options', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Event-specific options (merged with global). Pass ticketId after booking to
|
|
||||||
// retrieve bank/TPago credentials gated behind the booking capability token.
|
|
||||||
getForEvent: (eventId: string, ticketId?: string) =>
|
|
||||||
fetchApi<{ paymentOptions: PaymentOptionsConfig; hasOverrides: boolean }>(
|
|
||||||
`/api/payment-options/event/${eventId}${ticketId ? `?ticketId=${encodeURIComponent(ticketId)}` : ''}`
|
|
||||||
),
|
|
||||||
|
|
||||||
// Event overrides (admin only)
|
|
||||||
getEventOverrides: (eventId: string) =>
|
|
||||||
fetchApi<{ overrides: Partial<PaymentOptionsConfig> | null }>(
|
|
||||||
`/api/payment-options/event/${eventId}/overrides`
|
|
||||||
),
|
|
||||||
|
|
||||||
updateEventOverrides: (eventId: string, data: Partial<PaymentOptionsConfig>) =>
|
|
||||||
fetchApi<{ overrides: Partial<PaymentOptionsConfig>; message: string }>(
|
|
||||||
`/api/payment-options/event/${eventId}/overrides`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
deleteEventOverrides: (eventId: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/payment-options/event/${eventId}/overrides`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { Payment, PaymentWithDetails } from './types';
|
|
||||||
|
|
||||||
export const paymentsApi = {
|
|
||||||
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.status) query.set('status', params.status);
|
|
||||||
if (params?.provider) query.set('provider', params.provider);
|
|
||||||
if (params?.pendingApproval) query.set('pendingApproval', 'true');
|
|
||||||
if (params?.eventId) query.set('eventId', params.eventId);
|
|
||||||
if (params?.eventIds && params.eventIds.length > 0) query.set('eventIds', params.eventIds.join(','));
|
|
||||||
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
getPendingApproval: () =>
|
|
||||||
fetchApi<{ payments: PaymentWithDetails[] }>('/api/payments/pending-approval'),
|
|
||||||
|
|
||||||
update: (id: string, data: { status: string; reference?: string; adminNote?: string }) =>
|
|
||||||
fetchApi<{ payment: Payment }>(`/api/payments/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
approve: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
|
||||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ adminNote, sendEmail }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
|
|
||||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reject`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ adminNote, sendEmail }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
sendReminder: (id: string) =>
|
|
||||||
fetchApi<{ message: string; reminderSentAt?: string }>(`/api/payments/${id}/send-reminder`, {
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateNote: (id: string, adminNote: string) =>
|
|
||||||
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/note`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ adminNote }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
refund: (id: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }),
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { SiteSettings, TimezoneOption } from './types';
|
|
||||||
|
|
||||||
export const siteSettingsApi = {
|
|
||||||
get: () => fetchApi<{ settings: SiteSettings }>('/api/site-settings'),
|
|
||||||
|
|
||||||
update: (data: Partial<SiteSettings>) =>
|
|
||||||
fetchApi<{ settings: SiteSettings; message: string }>('/api/site-settings', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
getTimezones: () =>
|
|
||||||
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
|
|
||||||
|
|
||||||
setFeaturedEvent: (eventId: string | null) =>
|
|
||||||
fetchApi<{ featuredEventId: string | null; message: string }>('/api/site-settings/featured-event', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ eventId }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { fetchApi, API_BASE } from './client';
|
|
||||||
import type {
|
|
||||||
Ticket,
|
|
||||||
Payment,
|
|
||||||
BookingData,
|
|
||||||
TicketValidationResult,
|
|
||||||
TicketSearchResult,
|
|
||||||
LiveSearchResult,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export const ticketsApi = {
|
|
||||||
book: (data: BookingData) =>
|
|
||||||
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
getById: (id: string) => fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`),
|
|
||||||
|
|
||||||
getAll: (params?: { eventId?: string; status?: string }) => {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.eventId) query.set('eventId', params.eventId);
|
|
||||||
if (params?.status) query.set('status', params.status);
|
|
||||||
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Validate ticket by QR code (for scanner)
|
|
||||||
validate: (code: string, eventId?: string) =>
|
|
||||||
fetchApi<TicketValidationResult>('/api/tickets/validate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ code, eventId }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Search tickets by name/email (for scanner manual search)
|
|
||||||
search: (query: string, eventId?: string) =>
|
|
||||||
fetchApi<{ tickets: TicketSearchResult[] }>('/api/tickets/search', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ query, eventId }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Get event check-in stats (for scanner header counter)
|
|
||||||
getCheckinStats: (eventId: string) =>
|
|
||||||
fetchApi<{ eventId: string; capacity: number; checkedIn: number; totalActive: number }>(
|
|
||||||
`/api/tickets/stats/checkin?eventId=${eventId}`
|
|
||||||
),
|
|
||||||
|
|
||||||
// Live search tickets (GET - for scanner live search with debounce)
|
|
||||||
searchLive: (q: string, eventId?: string) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('q', q);
|
|
||||||
if (eventId) params.set('eventId', eventId);
|
|
||||||
return fetchApi<{ tickets: LiveSearchResult[] }>(`/api/tickets/search?${params}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
checkin: (id: string) =>
|
|
||||||
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
|
|
||||||
removeCheckin: (id: string) =>
|
|
||||||
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/remove-checkin`, {
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
|
|
||||||
cancel: (id: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/tickets/${id}/cancel`, { method: 'POST' }),
|
|
||||||
|
|
||||||
updateStatus: (id: string, status: string) =>
|
|
||||||
fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ status }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateNote: (id: string, note: string) =>
|
|
||||||
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/note`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ note }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
markPaid: (id: string) =>
|
|
||||||
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/mark-paid`, {
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// For manual payment methods (bank_transfer, tpago) - user marks payment as sent
|
|
||||||
markPaymentSent: (id: string, payerName?: string) =>
|
|
||||||
fetchApi<{ payment: Payment; message: string }>(`/api/tickets/${id}/mark-payment-sent`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ payerName }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
adminCreate: (data: {
|
|
||||||
eventId: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
preferredLanguage?: 'en' | 'es';
|
|
||||||
autoCheckin?: boolean;
|
|
||||||
adminNote?: string;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/create', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
manualCreate: (data: {
|
|
||||||
eventId: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName?: string;
|
|
||||||
email: string;
|
|
||||||
phone?: string;
|
|
||||||
preferredLanguage?: 'en' | 'es';
|
|
||||||
adminNote?: string;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/manual', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
guestCreate: (data: {
|
|
||||||
eventId: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
preferredLanguage?: 'en' | 'es';
|
|
||||||
adminNote?: string;
|
|
||||||
}) =>
|
|
||||||
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/guest', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
checkPaymentStatus: (ticketId: string) =>
|
|
||||||
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
|
|
||||||
`/api/lnbits/status/${ticketId}`
|
|
||||||
),
|
|
||||||
|
|
||||||
// Get PDF download URL (returns the URL, not the PDF itself)
|
|
||||||
getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`,
|
|
||||||
};
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
export interface Event {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
titleEs?: string;
|
|
||||||
description: string;
|
|
||||||
descriptionEs?: string;
|
|
||||||
shortDescription?: string;
|
|
||||||
shortDescriptionEs?: string;
|
|
||||||
startDatetime: string;
|
|
||||||
endDatetime?: string;
|
|
||||||
location: string;
|
|
||||||
locationUrl?: string;
|
|
||||||
price: number;
|
|
||||||
currency: string;
|
|
||||||
capacity: number;
|
|
||||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
|
||||||
bannerUrl?: string;
|
|
||||||
externalBookingEnabled?: boolean;
|
|
||||||
externalBookingUrl?: string;
|
|
||||||
bookedCount?: number;
|
|
||||||
availableSeats?: number;
|
|
||||||
isFeatured?: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Ticket {
|
|
||||||
id: string;
|
|
||||||
bookingId?: string; // Groups multiple tickets from same booking
|
|
||||||
bookingTicketCount?: number; // Total tickets in the booking (for per-quantity payment links)
|
|
||||||
userId: string;
|
|
||||||
eventId: string;
|
|
||||||
attendeeFirstName: string;
|
|
||||||
attendeeLastName?: string;
|
|
||||||
attendeeEmail?: string;
|
|
||||||
attendeePhone?: string;
|
|
||||||
attendeeRuc?: string;
|
|
||||||
preferredLanguage?: string;
|
|
||||||
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
|
|
||||||
checkinAt?: string;
|
|
||||||
checkedInByAdminId?: string;
|
|
||||||
qrCode: string;
|
|
||||||
adminNote?: string;
|
|
||||||
isGuest?: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
event?: Event;
|
|
||||||
payment?: Payment;
|
|
||||||
user?: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketValidationResult {
|
|
||||||
valid: boolean;
|
|
||||||
status: 'valid' | 'already_checked_in' | 'pending_payment' | 'cancelled' | 'invalid' | 'wrong_event';
|
|
||||||
canCheckIn: boolean;
|
|
||||||
ticket?: {
|
|
||||||
id: string;
|
|
||||||
qrCode: string;
|
|
||||||
attendeeName: string;
|
|
||||||
attendeeEmail?: string;
|
|
||||||
attendeePhone?: string;
|
|
||||||
status: string;
|
|
||||||
checkinAt?: string;
|
|
||||||
checkedInBy?: string;
|
|
||||||
};
|
|
||||||
event?: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startDatetime: string;
|
|
||||||
location: string;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TicketSearchResult {
|
|
||||||
id: string;
|
|
||||||
qrCode: string;
|
|
||||||
attendeeName: string;
|
|
||||||
attendeeEmail?: string;
|
|
||||||
attendeePhone?: string;
|
|
||||||
status: string;
|
|
||||||
checkinAt?: string;
|
|
||||||
event?: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startDatetime: string;
|
|
||||||
location: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LiveSearchResult {
|
|
||||||
ticket_id: string;
|
|
||||||
name: string;
|
|
||||||
email?: string;
|
|
||||||
status: string;
|
|
||||||
checked_in: boolean;
|
|
||||||
checkinAt?: string;
|
|
||||||
event_id: string;
|
|
||||||
qrCode: string;
|
|
||||||
event?: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startDatetime: string;
|
|
||||||
location: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Payment {
|
|
||||||
id: string;
|
|
||||||
ticketId: string;
|
|
||||||
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed';
|
|
||||||
reference?: string;
|
|
||||||
userMarkedPaidAt?: string;
|
|
||||||
payerName?: string; // Name of payer if different from attendee
|
|
||||||
paidAt?: string;
|
|
||||||
paidByAdminId?: string;
|
|
||||||
adminNote?: string;
|
|
||||||
reminderSentAt?: string; // When payment reminder email was sent
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentWithDetails extends Payment {
|
|
||||||
ticket: {
|
|
||||||
id: string;
|
|
||||||
bookingId?: string;
|
|
||||||
attendeeFirstName: string;
|
|
||||||
attendeeLastName?: string;
|
|
||||||
attendeeEmail?: string;
|
|
||||||
attendeePhone?: string;
|
|
||||||
status: string;
|
|
||||||
} | null;
|
|
||||||
event: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startDatetime: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaymentOptionsConfig {
|
|
||||||
tpagoEnabled: boolean;
|
|
||||||
tpagoLink?: string | null;
|
|
||||||
tpagoLink2?: string | null;
|
|
||||||
tpagoLink3?: string | null;
|
|
||||||
tpagoLink4?: string | null;
|
|
||||||
tpagoLink5?: string | null;
|
|
||||||
tpagoInstructions?: string | null;
|
|
||||||
tpagoInstructionsEs?: string | null;
|
|
||||||
bankTransferEnabled: boolean;
|
|
||||||
bankName?: string | null;
|
|
||||||
bankAccountHolder?: string | null;
|
|
||||||
bankAccountNumber?: string | null;
|
|
||||||
bankAlias?: string | null;
|
|
||||||
bankPhone?: string | null;
|
|
||||||
bankNotes?: string | null;
|
|
||||||
bankNotesEs?: string | null;
|
|
||||||
lightningEnabled: boolean;
|
|
||||||
cashEnabled: boolean;
|
|
||||||
cashInstructions?: string | null;
|
|
||||||
cashInstructionsEs?: string | null;
|
|
||||||
// Booking settings
|
|
||||||
allowDuplicateBookings?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
phone?: string;
|
|
||||||
role: 'admin' | 'organizer' | 'staff' | 'marketing' | 'user';
|
|
||||||
languagePreference?: string;
|
|
||||||
isClaimed?: boolean;
|
|
||||||
rucNumber?: string;
|
|
||||||
accountStatus?: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Contact {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
message: string;
|
|
||||||
status: 'new' | 'read' | 'replied';
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AttendeeData {
|
|
||||||
firstName: string;
|
|
||||||
lastName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BookingData {
|
|
||||||
eventId: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
preferredLanguage?: 'en' | 'es';
|
|
||||||
paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
|
||||||
ruc?: string;
|
|
||||||
// For multi-ticket bookings
|
|
||||||
attendees?: AttendeeData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardData {
|
|
||||||
stats: {
|
|
||||||
totalUsers: number;
|
|
||||||
totalEvents: number;
|
|
||||||
totalTickets: number;
|
|
||||||
confirmedTickets: number;
|
|
||||||
pendingPayments: number;
|
|
||||||
totalRevenue: number;
|
|
||||||
newContacts: number;
|
|
||||||
totalSubscribers: number;
|
|
||||||
};
|
|
||||||
upcomingEvents: Event[];
|
|
||||||
recentTickets: Ticket[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnalyticsData {
|
|
||||||
events: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
date: string;
|
|
||||||
capacity: number;
|
|
||||||
totalBookings: number;
|
|
||||||
confirmedBookings: number;
|
|
||||||
checkedIn: number;
|
|
||||||
revenue: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Media {
|
|
||||||
id: string;
|
|
||||||
fileUrl: string;
|
|
||||||
type: 'image' | 'video' | 'document';
|
|
||||||
relatedId?: string;
|
|
||||||
relatedType?: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportedTicket {
|
|
||||||
ticketId: string;
|
|
||||||
ticketStatus: string;
|
|
||||||
qrCode: string;
|
|
||||||
checkinAt?: string;
|
|
||||||
userName: string;
|
|
||||||
userEmail: string;
|
|
||||||
userPhone?: string;
|
|
||||||
eventTitle: string;
|
|
||||||
eventDate: string;
|
|
||||||
paymentStatus: string;
|
|
||||||
paymentAmount: number;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailTemplate {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
subject: string;
|
|
||||||
subjectEs?: string;
|
|
||||||
bodyHtml: string;
|
|
||||||
bodyHtmlEs?: string;
|
|
||||||
bodyText?: string;
|
|
||||||
bodyTextEs?: string;
|
|
||||||
description?: string;
|
|
||||||
variables: EmailVariable[];
|
|
||||||
isSystem: boolean;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailVariable {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
example: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailLog {
|
|
||||||
id: string;
|
|
||||||
templateId?: string;
|
|
||||||
eventId?: string;
|
|
||||||
recipientEmail: string;
|
|
||||||
recipientName?: string;
|
|
||||||
subject: string;
|
|
||||||
bodyHtml?: string;
|
|
||||||
status: 'pending' | 'sent' | 'failed' | 'bounced';
|
|
||||||
errorMessage?: string;
|
|
||||||
sentAt?: string;
|
|
||||||
sentBy?: string;
|
|
||||||
createdAt: string;
|
|
||||||
resendAttempts?: number;
|
|
||||||
lastResentAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmailStats {
|
|
||||||
total: number;
|
|
||||||
sent: number;
|
|
||||||
failed: number;
|
|
||||||
pending: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Pagination {
|
|
||||||
total: number;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExportedPayment {
|
|
||||||
paymentId: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
provider: string;
|
|
||||||
status: string;
|
|
||||||
reference?: string;
|
|
||||||
paidAt?: string;
|
|
||||||
createdAt: string;
|
|
||||||
ticketId: string;
|
|
||||||
attendeeFirstName: string;
|
|
||||||
attendeeLastName?: string;
|
|
||||||
attendeeEmail?: string;
|
|
||||||
eventId: string;
|
|
||||||
eventTitle: string;
|
|
||||||
eventDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FinancialSummary {
|
|
||||||
totalPayments: number;
|
|
||||||
totalPaid: number;
|
|
||||||
totalPending: number;
|
|
||||||
totalRefunded: number;
|
|
||||||
byProvider: {
|
|
||||||
bancard: number;
|
|
||||||
lightning: number;
|
|
||||||
cash: number;
|
|
||||||
bank_transfer: number;
|
|
||||||
tpago: number;
|
|
||||||
};
|
|
||||||
paidCount: number;
|
|
||||||
pendingCount: number;
|
|
||||||
pendingApprovalCount: number;
|
|
||||||
refundedCount: number;
|
|
||||||
failedCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== User Dashboard Types ====================
|
|
||||||
|
|
||||||
export interface UserProfile {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
phone?: string;
|
|
||||||
languagePreference?: string;
|
|
||||||
rucNumber?: string;
|
|
||||||
isClaimed: boolean;
|
|
||||||
accountStatus: string;
|
|
||||||
hasPassword: boolean;
|
|
||||||
hasGoogleLinked: boolean;
|
|
||||||
memberSince: string;
|
|
||||||
membershipDays: number;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserTicket extends Ticket {
|
|
||||||
invoice?: {
|
|
||||||
id: string;
|
|
||||||
invoiceNumber: string;
|
|
||||||
pdfUrl?: string;
|
|
||||||
createdAt: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserPayment extends Payment {
|
|
||||||
ticket: {
|
|
||||||
id: string;
|
|
||||||
attendeeFirstName: string;
|
|
||||||
attendeeLastName?: string;
|
|
||||||
status: string;
|
|
||||||
} | null;
|
|
||||||
event: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
titleEs?: string;
|
|
||||||
startDatetime: string;
|
|
||||||
} | null;
|
|
||||||
invoice?: {
|
|
||||||
id: string;
|
|
||||||
invoiceNumber: string;
|
|
||||||
pdfUrl?: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInvoice {
|
|
||||||
id: string;
|
|
||||||
paymentId: string;
|
|
||||||
invoiceNumber: string;
|
|
||||||
rucNumber?: string;
|
|
||||||
legalName?: string;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
pdfUrl?: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
event?: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
titleEs?: string;
|
|
||||||
startDatetime: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSession {
|
|
||||||
id: string;
|
|
||||||
userAgent?: string;
|
|
||||||
ipAddress?: string;
|
|
||||||
lastActiveAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardSummary {
|
|
||||||
user: {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
accountStatus: string;
|
|
||||||
memberSince: string;
|
|
||||||
membershipDays: number;
|
|
||||||
};
|
|
||||||
stats: {
|
|
||||||
totalTickets: number;
|
|
||||||
confirmedTickets: number;
|
|
||||||
upcomingEvents: number;
|
|
||||||
pendingPayments: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NextEventInfo {
|
|
||||||
event: Event;
|
|
||||||
ticket: Ticket;
|
|
||||||
payment: Payment | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Site Settings Types ====================
|
|
||||||
|
|
||||||
export interface SiteSettings {
|
|
||||||
id?: string;
|
|
||||||
timezone: string;
|
|
||||||
siteName: string;
|
|
||||||
siteDescription?: string | null;
|
|
||||||
siteDescriptionEs?: string | null;
|
|
||||||
contactEmail?: string | null;
|
|
||||||
contactPhone?: string | null;
|
|
||||||
facebookUrl?: string | null;
|
|
||||||
instagramUrl?: string | null;
|
|
||||||
twitterUrl?: string | null;
|
|
||||||
linkedinUrl?: string | null;
|
|
||||||
featuredEventId?: string | null;
|
|
||||||
maintenanceMode: boolean;
|
|
||||||
maintenanceMessage?: string | null;
|
|
||||||
maintenanceMessageEs?: string | null;
|
|
||||||
updatedAt?: string;
|
|
||||||
updatedBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimezoneOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Legal Settings Types ====================
|
|
||||||
|
|
||||||
export interface LegalSettingsData {
|
|
||||||
id?: string;
|
|
||||||
companyName?: string | null;
|
|
||||||
legalEntityName?: string | null;
|
|
||||||
rucNumber?: string | null;
|
|
||||||
companyAddress?: string | null;
|
|
||||||
companyCity?: string | null;
|
|
||||||
companyCountry?: string | null;
|
|
||||||
supportEmail?: string | null;
|
|
||||||
legalEmail?: string | null;
|
|
||||||
governingLaw?: string | null;
|
|
||||||
jurisdictionCity?: string | null;
|
|
||||||
updatedAt?: string;
|
|
||||||
updatedBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Legal Pages Types ====================
|
|
||||||
|
|
||||||
export interface LegalPage {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
titleEs?: string | null;
|
|
||||||
contentText: string;
|
|
||||||
contentTextEs?: string | null;
|
|
||||||
contentMarkdown: string;
|
|
||||||
contentMarkdownEs?: string | null;
|
|
||||||
updatedAt: string;
|
|
||||||
updatedBy?: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
source?: 'database' | 'filesystem';
|
|
||||||
hasEnglish?: boolean;
|
|
||||||
hasSpanish?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegalPagePublic {
|
|
||||||
id?: string;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
contentMarkdown: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
source?: 'database' | 'filesystem';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegalPageListItem {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
updatedAt: string;
|
|
||||||
hasEnglish?: boolean;
|
|
||||||
hasSpanish?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== FAQ Types ====================
|
|
||||||
|
|
||||||
export interface FaqItem {
|
|
||||||
id: string;
|
|
||||||
question: string;
|
|
||||||
questionEs?: string | null;
|
|
||||||
answer: string;
|
|
||||||
answerEs?: string | null;
|
|
||||||
rank?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FaqItemAdmin extends FaqItem {
|
|
||||||
enabled: boolean;
|
|
||||||
showOnHomepage: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { fetchApi } from './client';
|
|
||||||
import type { User } from './types';
|
|
||||||
|
|
||||||
export const usersApi = {
|
|
||||||
getAll: (role?: string) => {
|
|
||||||
const query = role ? `?role=${role}` : '';
|
|
||||||
return fetchApi<{ users: User[] }>(`/api/users${query}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`),
|
|
||||||
|
|
||||||
update: (id: string, data: Partial<User>) =>
|
|
||||||
fetchApi<{ user: User }>(`/api/users/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
|
|
||||||
delete: (id: string) =>
|
|
||||||
fetchApi<{ message: string }>(`/api/users/${id}`, { method: 'DELETE' }),
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user