Backend (Fastify + TS): FluentForms webhook -> NocoDB row + QR + MailerSend email; PIN auth; scan/lookup/redeem with per-code serialization; reusable QR codes with count-based check-in; admin search. App (Expo, one codebase): Android APK + iPhone PWA. Login, camera scanner (native + web barcode-detector split), green/red overlay with sound + haptics, admin lookup/redeem. Session token persisted per device. Ops: multi-stage Dockerfile serving API + PWA same-origin, compose bound to 127.0.0.1; Forgejo Actions runner + tag-triggered signed APK build for Obtainium. Docs in README.md and INSTALL.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.9 KiB
Beartaria Campgrounds 2026 — Ticketing & Gate Scanner
End-to-end ticketing for the 2026 event:
- Purchase — a FluentForms checkout on
tickets.beartariacampgrounds.comPOSTs 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"). - 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.
- 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 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 — change them there if the real titles differ. Put the table's ID (right-click table → Copy Table ID) in NOCODB_TABLE_ID.
A
CampTickets TESTtable already exists in NocoDB for testing. PointNOCODB_TABLE_IDat it for dry runs, then switch to the real 2026 table 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
# 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:
cp backend/.env .env # compose reads ./.env
docker compose up -d --build
curl http://127.0.0.1:8091/api/health
Deployment
- Clone to the server, create
.env(see above) pointed at the real table. docker compose up -d --build- Add the nginx vhost (TLS via your existing certbot/acme setup) and reload 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 ofWEBHOOK_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:
- Generate a release keystore and print the secrets:
bash scripts/gen-keystore.sh - In Forgejo → CampgroundTickets → Settings → Actions → Secrets, add:
ANDROID_KEYSTORE_B64,ANDROID_KEYSTORE_PASSWORD,ANDROID_KEY_ALIAS,ANDROID_KEY_PASSWORD. - 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 <token>)
| 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 |
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.