# Beartaria Campgrounds 2026 — Ticketing & Gate Scanner End-to-end ticketing for the 2026 event: 1. **Purchase** — a FluentForms checkout on `tickets.beartariacampgrounds.com` POSTs a webhook to this backend, which writes a row to NocoDB, generates a unique ticket **QR code**, and emails it to the buyer via MailerSend (subject *"2026 Beartaria Campgrounds Tickets"*). 2. **Gate** — staff scan the QR with **Camp Scan** (Android app + iPhone PWA, one Expo codebase). It validates the code, lets staff check in however many people are arriving on that visit, decrements the remaining count in NocoDB, and flashes **green + chime** / **red + buzz** with the name, counts, extras (parking/ice), and DB-update confirmation. 3. **Admin** — a panel in the same app for manual lookup by name/email/code and button-based check-in/undo when scanning fails. One QR per purchase is **reusable across visits** until all its tickets are redeemed (e.g. a family arriving in two groups on one code). Children under 4 are free and not counted. See **[INSTALL.md](./INSTALL.md)** for installing the app on gate phones. ## Architecture A single container runs a Fastify server that serves both `/api/*` and the exported Expo web (PWA) build on the same origin. The host's existing **nginx** terminates TLS for `scan.beartariacampgrounds.com` and proxies to the container on `127.0.0.1` only. NocoDB and MailerSend are external. ``` FluentForms ──POST /webhook──▶ ┌─────────────── camptickets container ───────────────┐ │ Fastify: /api/* (auth, webhook, scan, redeem) │ Gate phones ──HTTPS──▶ nginx ─▶│ + static Expo web build (PWA) │─▶ NocoDB (REST) (PWA / APK) scan.beartaria │ 127.0.0.1:8091 ◀─ nginx proxy_pass │─▶ MailerSend (email) └──────────────────────────────────────────────────────┘ ``` ## Repo layout ``` backend/ Fastify + TypeScript API (NocoDB client, webhook, QR, MailerSend, scan/redeem) app/ Expo (React Native) app — Android APK + web PWA (login, scanner, admin) Dockerfile Multi-stage: build web → build backend → slim runtime that serves both docker-compose.yml App service, bound to 127.0.0.1 only runner/ Forgejo Actions runner (docker compose) that builds APKs .forgejo/workflows/build-apk.yml Tag-triggered signed APK build + release ci/ Gradle signing init-script + version-from-tag script scripts/gen-keystore.sh One-time release keystore generator ``` ## NocoDB setup The app expects the **2026 Campground Tickets** table to be a clone of the 2025 submission table (name in `Title`, `Email Address`, age-bracket number columns, `Car Parking` / `RV Parking` / `Ice Access` / `Is Donor` checkboxes) **plus four columns this system adds**: | Column | Type | |---|---| | `Ticket Code` | SingleLineText | | `Redeemed` | Number (default 0) | | `SubmissionKey` | SingleLineText | | `LastScanAt` | DateTime | Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Column names are mapped in [`backend/src/fields.ts`](./backend/src/fields.ts) — change them there if the real titles differ. Put the table's ID (right-click table → *Copy Table ID*) in `NOCODB_TABLE_ID`. ### Audit log table — "2026 Ticket Audit Logs" Every check-in and undo is recorded to a separate table so the crew can review what happened. Columns (titles mapped in [`backend/src/services/audit.ts`](./backend/src/services/audit.ts)): | Column | Type | |---|---| | `Summary` | SingleLineText (primary — e.g. `BC26-XXXX +2 (check-in)`) | | `At` | DateTime | | `Ticket Code` | SingleLineText | | `People` | Number (negative for an undo) | | `Action` | SingleLineText (`check-in` / `undo`) | | `Name` | SingleLineText | | `Remaining After` | Number | Put its table ID in `NOCODB_AUDIT_TABLE_ID`. Leave the var empty to disable audit logging (check-ins still work). The admin panel shows global recent activity and per-ticket history from this table. > `CampTickets TEST` and `CampTickets Audit TEST` tables already exist in NocoDB for testing. Point `NOCODB_TABLE_ID` / `NOCODB_AUDIT_TABLE_ID` at them for dry runs, then switch to the real 2026 tables for production. ## Configuration (`.env`) Copy `.env.example` → `.env` and fill in. `.env` is git-ignored. | Var | Meaning | |---|---| | `NOCODB_BASE_URL` | `https://nocodb.beartariacampgrounds.com` | | `NOCODB_API_TOKEN` | NocoDB API token (`xc-token`) | | `NOCODB_TABLE_ID` | Table ID of the 2026 (or TEST) table | | `MAILERSEND_API_TOKEN` | MailerSend send token | | `MAIL_FROM_EMAIL` / `MAIL_FROM_NAME` | Sender (domain must be verified in MailerSend — `beartariacampgrounds.com` already is) | | `WEBHOOK_SECRET` | Shared secret FluentForms sends as the `X-Webhook-Secret` header | | `EVENT_PIN` | Gate staff PIN | | `TOKEN_SECRET` | JWT signing secret (long random) | | `TOKEN_TTL` | Session length (default `30d`) | | `MAIL_TEST_RECIPIENTS` | Optional allow-list; while set, only these addresses receive mail (use for testing). Leave empty in production. | | `HOST_PORT` | Host port nginx proxies to (compose default `8091`) | Session note: staff enter the PIN once; the app stores the resulting token (localStorage on web, SecureStore on native) and stays signed in for `TOKEN_TTL`. ## Local development ```bash # Backend (watch mode) cd backend && npm install && npm run dev # uses ../backend/.env via --env-file in prod; for dev, export vars or use node --env-file npm test # unit + concurrency tests # App (web PWA in a browser) cd app && npm install && npm run web # http://localhost:8081 # Native (Android) with a dev build / Expo Go: npm run android ``` Run the whole stack as it deploys: ```bash cp backend/.env .env # compose reads ./.env docker compose up -d --build curl http://127.0.0.1:8091/api/health ``` ## Deployment 1. Clone to the server, create `.env` (see above) pointed at the **real** table. 2. `docker compose up -d --build` 3. Add the nginx vhost (TLS via your existing certbot/acme setup) and reload nginx: ```nginx server { server_name scan.beartariacampgrounds.com; listen 443 ssl; # ssl_certificate / ssl_certificate_key managed by your existing setup client_max_body_size 2m; location / { proxy_pass http://127.0.0.1:8091; 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; } } ``` The container listens on `127.0.0.1:8091` (host `8080` is already used on this host). Camera scanning requires HTTPS — the nginx TLS vhost provides it. ## FluentForms webhook On the ticket form: **Settings & Integrations → Webhook → Add Webhook**. - **Request URL:** `https://scan.beartariacampgrounds.com/webhook` - **Request Method:** `POST` - **Request Format:** `JSON` (form-encoded also works) - **Request Headers:** add `X-Webhook-Secret` = the value of `WEBHOOK_SECRET` - **Request Body:** map form fields to these keys: | Key | Value | |---|---| | `name` | purchaser name | | `email` | purchaser email | | `submission_id` | the entry/submission ID (for idempotency; content-hash fallback if omitted) | | `ages_0_3`, `ages_4_7`, `ages_8_12`, `ages_13_17`, `ages_18_25`, `ages_26_45`, `ages_46_64`, `ages_65` | headcount per bracket | | `car_parking`, `rv_parking`, `ice_access`, `is_donor` | yes/no or 1/0 | | `address`, `payment_method` | optional | On success the buyer receives the QR email. Re-sends of the same submission are idempotent (no duplicate rows/emails). If an email fails, the row is still created and returns HTTP 502 (visible in FluentForms' log); re-send later with `POST /api/tickets/{code}/resend-email` (staff-auth'd). ## APK builds (Forgejo CI) The runner in `runner/` is registered against `git.mowden.top` and builds a signed APK whenever a `vX.Y.Z` tag is pushed. **One-time setup:** 1. Generate a release keystore and print the secrets: `bash scripts/gen-keystore.sh` 2. In Forgejo → CampgroundTickets → *Settings → Actions → Secrets*, add: `ANDROID_KEYSTORE_B64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. 3. Keep the keystore file safe forever — Obtainium updates require the same signing key on every release. **Release:** `git tag v0.1.0 && git push origin v0.1.0` → the workflow builds `camp-scan-v0.1.0.apk` and attaches it to the Forgejo release. Obtainium picks it up (see INSTALL.md). The runner runs jobs in a `node:22-bookworm` container and installs the Android SDK itself. To (re)start it: `cd runner && cp .env.example .env` (set `REGISTRATION_TOKEN`, `DOCKER_GID`) `&& docker compose up -d`. ## API reference (staff endpoints require `Authorization: Bearer `) | Method & path | Purpose | |---|---| | `POST /api/auth/login` `{pin}` | Exchange PIN for a token | | `POST /webhook` (secret header) | FluentForms purchase → create ticket + email | | `POST /api/lookup` `{code}` | Read a ticket by code (no mutation) | | `POST /api/redeem` `{code, count}` | Check in `count` people (negative undoes); serialized per code | | `GET /api/tickets?q=` | Search by name/email or exact code | | `GET /api/audit?code=&limit=` | Recent check-in log (all, or one code) | | `POST /api/tickets/{code}/resend-email` | Re-send the QR email | | `GET /api/health` | Health + NocoDB probe | Concurrency is safe within the single instance: redeem operations serialize per ticket code, so two gates scanning the same code can't over-redeem. **Do not scale the service to multiple replicas** — the serialization is in-process.