CampgroundTickets/README.md
Hank 3397e3e3ec Initial Camp Scan ticketing system
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>
2026-07-08 03:47:59 +00:00

167 lines
8.9 KiB
Markdown

# 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`.
> A `CampTickets TEST` table already exists in NocoDB for testing. Point `NOCODB_TABLE_ID` at 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
```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 <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.