MichilisandClaude Opus 5 e296e80e48 Rebuild the Scanner page into a unified door check-in screen.
Most attendees arrive without their QR open, and taking money at the
door meant leaving the scanner for the event dashboard, where the Add
Ticket modal demanded an email and recorded no payment method. The
screen now leads with manual name search, keeps the camera one tap away
behind a fullscreen overlay, and creates and charges walk-ins inline.

Check-in and payment are one action: anything done here is born
confirmed, paid (or comp) and checked in through a single endpoint,
POST /api/events/:eventId/door-checkin. There are no confirm dialogs
anywhere, because they stall the queue; a ten-second Undo replaces
them, reversing exactly what the action changed via the undo state
recorded alongside its idempotency key. Writes fire in the background
with retries, so venue wifi never blocks the person at the door, and a
capacity limit only warns, since staff at the door are the authority.

Every write carries a client-generated idempotency key, inserted in the
same transaction as the writes it guards, so a double tap or a retry
after a timeout cannot produce a second ticket, payment or check-in.
Search runs entirely in memory over one preloaded list: names are
matched accent- and case-insensitively in both directions, per word,
prefix before substring, with a mostly-numeric query searching phone
digits so two people with the same name can be told apart.

Door money is recorded as payments.source 'door' plus payments.method
(cash, bitcoin, transfer or guest) while provider keeps its existing
value, so capacity counting, the stale-booking sweeps and the admin
payment lists are unaffected and revenue can still be split pre-sale
versus door. Bitcoin records the payment as made, on the same trust
model as cash, with no invoice generated; lib/doorPayments.ts is where
a real Lightning flow slots in later.

Also fixes the SQLite tickets DDL, which still created the pre-split
attendee_name column with NOT NULL email and phone. Only fresh
databases were affected -- existing ones were relaxed by later ALTERs
-- but on those, door walk-ins (and any other ticket) could not be
inserted at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 06:09:55 +00:00
2026-01-29 14:13:11 -03:00

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

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

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

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

S
Description
No description provided
Readme
7.4 MiB
2026-08-22 06:08:04 +00:00
Languages
TypeScript 91.3%
Go 8.3%
JavaScript 0.2%
CSS 0.1%