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>
This commit is contained in:
Hank 2026-07-08 04:07:51 +00:00
parent b36d63a6a4
commit d293e53ee6
15 changed files with 587 additions and 125 deletions

View file

@ -3,7 +3,10 @@
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.
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.
@ -45,8 +48,10 @@ The app expects the **2026 Campground Tickets** table to be a clone of the 2025
| `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). 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`.
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"
@ -62,7 +67,19 @@ Every check-in and undo is recorded to a separate table so the crew can review w
| `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.
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.
@ -150,6 +167,7 @@ On the ticket form: **Settings & Integrations → Webhook → Add Webhook**.
| `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).
@ -174,8 +192,9 @@ The runner runs jobs in a `node:22-bookworm` container and installs the Android
|---|---|
| `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 |
| `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 |