|
All checks were successful
Build Android APK / build-apk (push) Successful in 46m57s
Added "← Back to the scan app" on the crush33 unlock screen and a "← Scanner" link in the admin hub top bar (both -> /). v0.3.0 rolls up everything since v0.2.0: the /crush33 admin hub (sidebar, admin-only donor lookup, wipe/switch danger zone with confirm modals), removal of the /comp route, drawer no longer shows crush33, the customer_name / voucher-count / ticketless-order webhook fixes, ice bag fix, and the Adults/Youth/Kids gate panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| app | ||
| backend | ||
| ci | ||
| docs | ||
| runner | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| INSTALL.md | ||
| README.md | ||
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 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.
- 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 |
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.
The table MUST have an
Idprimary key. NocoDB's v2PATCH /recordswith no primary key updates every row in the table, so a PK-less table would make each scan rewrite all tickets. The backend now refuses to update a record with noId(fail-safe), but the table itself must have one. Tables cloned from the existing 2025 table already haveId; if you build one by hand via the API, include an{"title":"Id","uidt":"ID"}column. Column names are mapped inbackend/src/fields.ts— change them there if the real titles differ. Put the table's ID (right-click table → Copy Table ID) inNOCODB_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):
| 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.
CampTickets TESTandCampTickets Audit TESTtables already exist in NocoDB for testing. PointNOCODB_TABLE_ID/NOCODB_AUDIT_TABLE_IDat 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
# 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 |
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)
A Forgejo Actions runner builds a signed APK whenever a vX.Y.Z tag is pushed. Run the runner on the roomy server, not the app host — the Android SDK needs several GB the app host doesn't have. See runner/README.md for deploying it.
One-time setup:
- Generate a release keystore (PKCS12, uses openssl — no JDK needed) 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(for PKCS12 the keystore and key passwords are the same value). - 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 build runs jobs in a node:22-bookworm container and installs the Android SDK itself; the first run may need an SDK/Gradle tweak, so shake it out with a throwaway v0.0.1 tag first.
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 |
GET /test |
Sample QR codes for testing (only when ENABLE_TEST_PAGE=true) |
Test page
With ENABLE_TEST_PAGE=true, GET /test renders a page of scannable QR codes covering different attribute combinations (solo, family with ice + parking, a real donor for Banquet mode, ice-only, and a pre-exhausted ticket, plus an invalid code). It idempotently seeds these personas into the current NocoDB table, so only enable it against a TEST table — never in production.
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.