A ticket scanner app for BT Campground Events for Android and PWA for iPhone and others.
Find a file
Hank b36d63a6a4 Add check-in audit logging + in-app history view
Every successful check-in/undo writes a row to the "2026 Ticket Audit Logs"
NocoDB table (timestamp, people, code, action, name, remaining-after).
Non-fatal: audit failures never block a gate check-in. New GET /api/audit
endpoint (global or per-code). Admin panel gains a global "Recent check-ins"
panel and per-ticket history. Audit table is optional via NOCODB_AUDIT_TABLE_ID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 03:53:30 +00:00
.forgejo/workflows Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
app Add check-in audit logging + in-app history view 2026-07-08 03:53:30 +00:00
backend Add check-in audit logging + in-app history view 2026-07-08 03:53:30 +00:00
ci Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
runner Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
scripts Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
.dockerignore Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
.env.example Add check-in audit logging + in-app history view 2026-07-08 03:53:30 +00:00
.gitignore Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
docker-compose.yml Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
Dockerfile Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
INSTALL.md Initial Camp Scan ticketing system 2026-07-08 03:47:59 +00:00
README.md Add check-in audit logging + in-app history view 2026-07-08 03:53:30 +00:00

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 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 — 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):

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.

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

# 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

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