CampgroundTickets/README.md
Hank d293e53ee6 Add Check-in / Ice / Banquet scan modes
- 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>
2026-07-08 04:07:51 +00:00

203 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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