Signing in showed the "Welcome back!" toast but never left /login. The session cookie was host-only on the API subdomain, so the Next middleware guard on the site origin saw no cookie and bounced /dashboard straight back to /login?redirect=/dashboard. - Add AUTH_COOKIE_DOMAIN, wiring Better Auth's crossSubDomainCookies so the cookie also reaches the site origin. Unset in dev, where localhost is single-host and must stay host-only. - Navigate after authentication with a full page load, via a shared authRedirect helper: only a top-level request carries the httpOnly cookie. Used by the login, register, magic-link and Google flows. - Show "Redirecting..." on the login and register pages and keep the submit button disabled until the browser replaces the page, instead of re-enabling it mid-navigation. - Guard against a redirect loop with a sessionStorage marker. A React ref cannot do this: the full page load resets component state. If the destination bounces back, explain it rather than navigating again. - Middleware: accept any *.session_token cookie so a cookiePrefix change cannot lock everyone out, and preserve the destination's query string. - Trust any loopback port in dev, so reaching the dev server through a forwarded port does not fail Better Auth's CSRF origin check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
288 lines
12 KiB
Markdown
288 lines
12 KiB
Markdown
# Spanglish — Language Exchange Event Platform
|
|
|
|
A full-stack web app for organizing and managing language exchange events (Asunción, Paraguay).
|
|
|
|
## Features
|
|
|
|
- **Public site**: events, booking, contact, community, **photo galleries**, legal pages, bilingual (EN/ES)
|
|
- Stable `/next` and `/featured` URLs that redirect to the current event
|
|
- Human-readable event URL slugs (with legacy-ID redirect support)
|
|
- **User dashboard**: overview tab, profile, tickets, payments, sessions/security
|
|
- Lightning invoice reuse and re-payment straight from the dashboard
|
|
- **Admin** (`/admin`): events, tickets/check-in, users/roles, payments, email templates, media uploads, **photo galleries**
|
|
- One unified ticket-creation modal with first-class payment status
|
|
- **Payments**: one automatic provider (**Lightning** via LNbits) plus manual providers (**TPago link**, bank transfer, card, cash), all defined in a central provider registry. Manual payments stay pending until an admin reconciles them (they are not auto-failed after the pending TTL).
|
|
- **Photo galleries** (standalone `photo-api` Go service): admins upload event photos, group them into galleries, and share them by visibility mode (public / private / share-link / ticket-holders). See [`photo-api/`](photo-api/README.md).
|
|
- **API**: Swagger UI at `/api-docs`, OpenAPI JSON at `/openapi.json`, health check at `/health`
|
|
|
|
## Tech stack
|
|
|
|
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
|
|
- **Photo service**: standalone Go module (`photo-api/`), its own binary/deploy unit, sharing the backend database and `JWT_SECRET`
|
|
- **Auth**: [Better Auth](https://better-auth.com) — httpOnly cookie sessions (DB-validated on every request for instant revocation), **Argon2id** password hashing (with legacy bcrypt verification for older hashes), magic links, Google sign-in, admin ban/suspend
|
|
- **Email**: `nodemailer` (SMTP) with optional provider config
|
|
- **Frontend**: Next.js (App Router), Tailwind CSS, Heroicons, skeleton loading states, custom error / global-error pages
|
|
|
|
## Local development
|
|
|
|
### Prerequisites
|
|
|
|
- Node.js 18+
|
|
- npm
|
|
- Go 1.26+ (for the `photo-api` service)
|
|
- Optional: `libvips-tools` (or `libheif-examples`) on the host to accept HEIC photo uploads
|
|
|
|
### Setup
|
|
|
|
```bash
|
|
npm install
|
|
cp backend/.env.example backend/.env
|
|
cp frontend/.env.example frontend/.env
|
|
cp photo-api/.env.example photo-api/.env # set JWT_SECRET + DATABASE_URL to match backend/.env
|
|
```
|
|
|
|
### Initialize database (SQLite by default)
|
|
|
|
```bash
|
|
npm run db:migrate # backend (Drizzle) tables
|
|
npm run migrate:photos # photo-api owns only the photos_* tables via its own migrations
|
|
```
|
|
|
|
### Run
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
`npm run dev` starts the backend, frontend, and photo-api together (via `concurrently`).
|
|
|
|
Default URLs:
|
|
|
|
- Frontend: `http://localhost:3002`
|
|
- Backend API: `http://localhost:3001`
|
|
- Photo API: `http://localhost:3003` (the Next dev server rewrites `/api/photos/*` to it)
|
|
- API docs: `http://localhost:3001/api-docs`
|
|
|
|
### First user becomes admin
|
|
|
|
The first user to register becomes the **admin**. Register at `/register`.
|
|
|
|
## Useful scripts (workspace)
|
|
|
|
Run these from the repo root:
|
|
|
|
```bash
|
|
npm run dev # backend + frontend + photo-api
|
|
npm run build # build all workspaces
|
|
npm run start
|
|
npm run db:generate
|
|
npm run db:migrate
|
|
npm run db:studio
|
|
npm run db:export # Backup database
|
|
npm run db:import # Restore from backup
|
|
|
|
# Photo service (Go)
|
|
npm run dev:photos # go run ./cmd/photo-api
|
|
npm run build:photos # go build -o bin/photo-api
|
|
npm run migrate:photos # apply photos_* migrations
|
|
npm run test:photos # go test ./...
|
|
```
|
|
|
|
You can also run per workspace:
|
|
|
|
```bash
|
|
npm run dev --workspace=backend
|
|
npm run dev --workspace=frontend
|
|
```
|
|
|
|
## Environment variables
|
|
|
|
### Backend (`backend/.env`)
|
|
|
|
Key settings (see `backend/.env.example` for the full list):
|
|
|
|
- **DB**: `DB_TYPE=sqlite|postgres`, `DATABASE_URL=./data/spanglish.db` (or Postgres URL)
|
|
- **Auth**: `BETTER_AUTH_SECRET` (required in production, 32+ chars), `BETTER_AUTH_URL` (public site origin; falls back to `FRONTEND_URL`), optional `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
|
|
- **Auth cookie scope**: `AUTH_COOKIE_DOMAIN` (optional, e.g. `.spanglishcommunity.com`) — set it only when the API is served from a different host than the site. The session cookie is host-only by default, so a cookie issued by `api.example.com` is invisible to the frontend's Next middleware on `example.com` and every `/dashboard`/`/admin` visit bounces back to `/login`. Leave empty in development.
|
|
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
|
|
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
|
|
- **Payments (optional)**: LNbits (Lightning) configuration for the automatic provider. Manual providers (TPago link, bank transfer, card, cash) need no API keys — the TPago pay link is configured and sent via an email template.
|
|
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
|
|
|
|
### Photo service (`photo-api/.env`)
|
|
|
|
Key settings (see `photo-api/.env.example`):
|
|
|
|
- **Port**: `PORT=3003` (dev)
|
|
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
|
|
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
|
|
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
|
|
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
|
|
|
|
### Frontend (`frontend/.env`)
|
|
|
|
Key settings (see `frontend/.env.example`):
|
|
|
|
- **Server port**: `PORT=3002`
|
|
- **API base URL**: `NEXT_PUBLIC_API_URL` (optional)
|
|
- Leave empty to use same-origin `/api` (recommended when running behind nginx). Requests become relative paths, so nginx maps them to the backend port in production and the Next.js rewrites do it in dev — the browser never needs to know the port.
|
|
- Inlined at build time: changing it requires a rebuild, not just a restart.
|
|
- Pointing it at a separate API host (e.g. `https://api.example.com`) puts the session cookie on that host, where the Next middleware guarding `/admin` and `/dashboard` cannot read it. If you do that, set `AUTH_COOKIE_DOMAIN` on the backend as well.
|
|
- **Server-side API hosts**: `PHOTO_API_URL` (server-rendered `/photos` pages and the sitemap) and `BACKEND_URL` — server components cannot use relative URLs, so these are needed even when `NEXT_PUBLIC_API_URL` is empty. In production point them at loopback (`http://127.0.0.1:3020` / `http://127.0.0.1:3018`).
|
|
- **Social links (optional)**: `NEXT_PUBLIC_WHATSAPP`, `NEXT_PUBLIC_INSTAGRAM`, etc.
|
|
|
|
## Database
|
|
|
|
### SQLite (default)
|
|
|
|
- DB file defaults to `backend/data/spanglish.db` (via `DATABASE_URL=./data/spanglish.db`)
|
|
- Run migrations with `npm run db:migrate`
|
|
|
|
### PostgreSQL
|
|
|
|
Set in `backend/.env`:
|
|
|
|
```env
|
|
DB_TYPE=postgres
|
|
DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
|
|
```
|
|
|
|
Then run:
|
|
|
|
```bash
|
|
npm run db:migrate
|
|
```
|
|
|
|
### Backups (export / import)
|
|
|
|
Create backups and restore if needed:
|
|
|
|
```bash
|
|
# Export (creates timestamped file in backend/data/backups/)
|
|
npm run db:export
|
|
|
|
# Export to custom path
|
|
npm run db:export -- -o ./my-backup.db # SQLite
|
|
npm run db:export -- -o ./my-backup.sql # PostgreSQL
|
|
|
|
# Import (stop the backend server first)
|
|
npm run db:import -- ./data/backups/spanglish-2025-03-07-143022.db
|
|
npm run db:import -- --yes ./data/backups/spanglish-2025-03-07.sql # Skip confirmation
|
|
```
|
|
|
|
**Note:** Stop the backend before importing so the database file is not locked.
|
|
|
|
## Production deployment (nginx + systemd)
|
|
|
|
This repo includes example configs in `deploy/`:
|
|
|
|
- **systemd**: `deploy/spanglish-backend.service`, `deploy/spanglish-frontend.service`, `deploy/spanglish-photos.service`
|
|
- Backend runs on **3018**, frontend on **3019**, photo-api on **3020** by default (see the unit files)
|
|
- Backend needs write access to `backend/data` and `backend/uploads`
|
|
- The photo service runs its own migrations on start (`photo-api migrate`) and needs write access to its `STORAGE_PATH` (or S3 config)
|
|
- **nginx**:
|
|
- `deploy/spanglish_upstreams.conf` defines upstreams for ports 3018/3019/3020
|
|
- `deploy/front-end_nginx.conf` proxies `/api/photos` to the photo service, `/api` and `/uploads` to the backend, and everything else to the frontend
|
|
- `deploy/back-end_nginx.conf` is a dedicated API vhost example with CORS handling
|
|
|
|
Typical production flow:
|
|
|
|
```bash
|
|
npm ci
|
|
npm run build
|
|
npm run build:photos
|
|
npm run db:migrate
|
|
npm run migrate:photos
|
|
```
|
|
|
|
Then install/enable the systemd services and nginx configs for your server.
|
|
|
|
## Horizontal scaling
|
|
|
|
The backend can run as a single instance with zero extra configuration (the
|
|
default), or as multiple replicas behind a load balancer. Scaling support is
|
|
fully optional and backward compatible: if you set none of the variables below,
|
|
the app behaves exactly as before with in-memory state and local-disk uploads.
|
|
|
|
### Requirements for multiple instances
|
|
|
|
- **Use PostgreSQL.** Set `DB_TYPE=postgres`. SQLite is a single local file and
|
|
cannot be shared safely across instances.
|
|
- **Set `REDIS_URL`.** This makes the following subsystems shared across
|
|
instances instead of per process:
|
|
- distributed cache
|
|
- rate limiting (shared sliding/fixed window)
|
|
- pub/sub for real-time payment events, so an SSE client connected to one
|
|
instance still receives an event when the LNbits webhook lands on another
|
|
- distributed locks (so only one instance seeds email templates per boot and
|
|
only one instance polls LNbits per pending ticket)
|
|
- the email hourly cap (`MAX_EMAILS_PER_HOUR`) becomes a global cap
|
|
- **Tune the DB pool.** `DB_POOL_MAX` is the max Postgres connections per
|
|
instance (default 10). Keep `DB_POOL_MAX * replicas` below the Postgres
|
|
`max_connections` setting (default 100). For example, 5 replicas at
|
|
`DB_POOL_MAX=15` uses up to 75 connections.
|
|
|
|
If Redis is configured but becomes unreachable at runtime, each subsystem
|
|
degrades gracefully (rate limiter fails open, cache misses fall through to the
|
|
DB, locks proceed) and the API keeps serving rather than crashing.
|
|
|
|
### Uploads across instances
|
|
|
|
Media uploads default to local disk (`./uploads`). With more than one instance
|
|
you must use shared storage so a file uploaded on one instance is readable on
|
|
the others. Two options:
|
|
|
|
- **S3-compatible storage (recommended):** set `S3_ENDPOINT`, `S3_BUCKET`,
|
|
`S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (and optionally `S3_PUBLIC_URL`,
|
|
`S3_REGION`, `S3_FORCE_PATH_STYLE`). Works with Garage, MinIO, or AWS S3.
|
|
- **Shared volume:** mount the same `./uploads` directory (e.g. NFS) into every
|
|
instance.
|
|
|
|
### Real-time payment SSE behind a load balancer
|
|
|
|
The payment status stream (`/api/lnbits/stream/:ticketId`) is a long-lived SSE
|
|
connection. With Redis pub/sub enabled, any instance can deliver the payment
|
|
event regardless of which instance holds the socket, so sticky sessions are not
|
|
strictly required. Enabling sticky sessions (IP hash) for the SSE path is still
|
|
a reasonable optimization.
|
|
|
|
### Health and observability
|
|
|
|
`GET /health` always returns 200 and reports Redis connectivity and which
|
|
backend each subsystem selected, for example:
|
|
|
|
```json
|
|
{
|
|
"status": "ok",
|
|
"redis": { "enabled": true, "healthy": true },
|
|
"backends": {
|
|
"cache": "redis",
|
|
"rateLimiter": "redis",
|
|
"pubsub": "redis",
|
|
"lock": "redis",
|
|
"storage": "s3"
|
|
}
|
|
}
|
|
```
|
|
|
|
The same selection is logged once at startup.
|
|
|
|
### docker-compose example (N replicas + Redis)
|
|
|
|
A ready-to-edit snippet lives at `deploy/docker-compose.scale.yml`. It runs
|
|
Postgres, Redis, and the API scaled to multiple replicas behind nginx. Bring it
|
|
up with:
|
|
|
|
```bash
|
|
docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
|
|
```
|
|
|
|
## Documentation
|
|
|
|
- **Specs / notes**: `about/`
|
|
- **Legal docs**: `frontend/legal/`
|
|
|
|
## License
|
|
|
|
MIT
|