- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed independently of ticket check-ins; grab all bags at once or some now. - Banquet mode: donor total (online + offline) looked up by the ticket's email via the Donors Master List, with a manual email override. New DonorService + POST /api/banquet. - Redeem generalized over a resource (tickets|ice); audit records ice actions. - App gains a mode selector; webhook maps ice_bags (defaults to ICE_BAGS_DEFAULT when only a boolean ice option is present). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
11 KiB
Markdown
203 lines
11 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 flashes **green + chime** / **red + buzz** with name, counts, extras, and DB-update confirmation. Three scan modes:
|
||
- **Check-in** — check in however many people are arriving on that visit; decrements the ticket count.
|
||
- **Ice** — hand out prepaid ice bags (all at once or some now); decrements a separate ice count.
|
||
- **Banquet** — show the scanned buyer's total donations (online + offline), looked up by their email; also supports a manual email lookup.
|
||
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 |
|
||
| `Ice Total` | Number (prepaid ice bags) |
|
||
| `Ice Redeemed` | Number (default 0) |
|
||
|
||
Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Ice bags remaining = `Ice Total − Ice Redeemed`. 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. Ice pickups are logged too (actions `ice` / `ice-undo`).
|
||
|
||
### Banquet mode — donor tables
|
||
|
||
Banquet mode reads existing donor tables (no new columns). Set these table IDs:
|
||
|
||
| Var | Table |
|
||
|---|---|
|
||
| `NOCODB_DONORS_TABLE_ID` | Donors Master List (authoritative `Total Donations` / `Total Online Donations` / `Total Offline Donations`, matched by `Email` or `Alternate Email`) |
|
||
| `NOCODB_DONOR_ONLINE_TABLE_ID` | Donor Online Transactions (fallback sum of `Donation Amount` by email) |
|
||
| `NOCODB_DONOR_OFFLINE_TABLE_ID` | Donor Offline Transactions (fallback) |
|
||
|
||
If `NOCODB_DONORS_TABLE_ID` is unset, banquet mode is disabled. Column names are mapped in [`backend/src/services/donors.ts`](./backend/src/services/donors.ts).
|
||
|
||
> `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 |
|
||
| `ice_bags` | prepaid ice bag count (optional; if omitted and `ice_access` is truthy, defaults to `ICE_BAGS_DEFAULT`) |
|
||
| `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 (includes ice counts; no mutation) |
|
||
| `POST /api/redeem` `{code, count, resource?}` | Redeem `count` of `resource` (`tickets` default, or `ice`); negative undoes; serialized per code+resource |
|
||
| `POST /api/banquet` `{code?\|email?}` | Donor total for a scanned ticket's email or a manual email |
|
||
| `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.
|