Files
Spanglish/README.md
T
MichilisandClaude Opus 4.6 dafa3711f8 Fix post-login redirect when the session cookie is off-origin
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>
2026-07-29 20:45:05 +00:00

12 KiB

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/.
  • 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 — 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

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)

npm run db:migrate      # backend (Drizzle) tables
npm run migrate:photos  # photo-api owns only the photos_* tables via its own migrations

Run

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:

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:

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:

DB_TYPE=postgres
DATABASE_URL=postgresql://user:password@localhost:5432/spanglish

Then run:

npm run db:migrate

Backups (export / import)

Create backups and restore if needed:

# 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:

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:

{
  "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:

docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3

Documentation

  • Specs / notes: about/
  • Legal docs: frontend/legal/

License

MIT