# 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 ./...

# Move the photo library between storage backends (both must be configured
# in photo-api/.env; the source is left untouched, reruns skip what is there)
npm run sync:photos:to-s3      # local disk -> S3
npm run sync:photos:to-local   # S3 -> local disk
npm run sync:photos -- to-s3 --dry-run   # flags: --dry-run --overwrite
                                         #        --concurrency=N --gallery=<id>

# Uploads are deduplicated per gallery by content hash. Photos uploaded before
# that existed need hashing once (idempotent, deletes nothing):
npm run backfill:photos:checksums
```

After a sync, set `STORAGE_BACKEND=s3` (or `local`) in `photo-api/.env` and
restart the service to serve from the new backend. See
[`photo-api/README.md`](photo-api/README.md#move-the-library-between-backends).

You can also run per workspace:

```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
- **Storage switch**: `STORAGE_BACKEND=auto|local|s3` (`auto` = S3 when it is configured). Keep both sides configured and flip this one line to move between them; `npm run sync:photos:to-s3` / `:to-local` copies the existing library first
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)

### Frontend (`frontend/.env`)

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
