Initial Camp Scan ticketing system
Backend (Fastify + TS): FluentForms webhook -> NocoDB row + QR + MailerSend email; PIN auth; scan/lookup/redeem with per-code serialization; reusable QR codes with count-based check-in; admin search. App (Expo, one codebase): Android APK + iPhone PWA. Login, camera scanner (native + web barcode-detector split), green/red overlay with sound + haptics, admin lookup/redeem. Session token persisted per device. Ops: multi-stage Dockerfile serving API + PWA same-origin, compose bound to 127.0.0.1; Forgejo Actions runner + tag-triggered signed APK build for Obtainium. Docs in README.md and INSTALL.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.expo
|
||||||
|
**/web-build
|
||||||
|
app/android
|
||||||
|
app/ios
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.apk
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
runner/data
|
||||||
28
.env.example
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Copy to .env and fill in. NEVER commit .env.
|
||||||
|
|
||||||
|
# HTTP port the container listens on (nginx proxies scan.beartariacampgrounds.com -> 127.0.0.1:8080)
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Existing NocoDB instance
|
||||||
|
NOCODB_BASE_URL=https://nocodb.beartariacampgrounds.com
|
||||||
|
# API token (xc-token) created in NocoDB: Account Settings -> Tokens
|
||||||
|
NOCODB_API_TOKEN=
|
||||||
|
# Table ID of "2026 Campground Tickets" (right-click table -> Copy Table ID, looks like m1a2b3c4d5e6f7)
|
||||||
|
NOCODB_TABLE_ID=
|
||||||
|
|
||||||
|
# MailerSend
|
||||||
|
MAILERSEND_API_TOKEN=
|
||||||
|
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com
|
||||||
|
MAIL_FROM_NAME=Beartaria Campgrounds
|
||||||
|
|
||||||
|
# Shared secret FluentForms sends in the X-Webhook-Secret header (long random string)
|
||||||
|
WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Gate staff PIN for the scanner app
|
||||||
|
EVENT_PIN=
|
||||||
|
|
||||||
|
# JWT signing secret (long random string) and token lifetime
|
||||||
|
TOKEN_SECRET=
|
||||||
|
TOKEN_TTL=30d
|
||||||
|
|
||||||
|
LOG_LEVEL=info
|
||||||
87
.forgejo/workflows/build-apk.yml
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
name: Build Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-apk:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve version from tag
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME:-v0.0.0}"
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
node ci/set-version.mjs "$TAG"
|
||||||
|
|
||||||
|
- name: Install JDK 17 and tools
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends openjdk-17-jdk-headless unzip wget git
|
||||||
|
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Install Android SDK
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||||
|
cd /tmp
|
||||||
|
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip
|
||||||
|
unzip -q cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||||
|
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
|
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||||
|
yes | sdkmanager --licenses >/dev/null || true
|
||||||
|
sdkmanager --install "platform-tools" \
|
||||||
|
"platforms;android-36" "platforms;android-35" \
|
||||||
|
"build-tools;36.0.0" "build-tools;35.0.0" >/dev/null
|
||||||
|
echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH"
|
||||||
|
echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install app dependencies
|
||||||
|
working-directory: app
|
||||||
|
run: npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Expo prebuild (android)
|
||||||
|
working-directory: app
|
||||||
|
run: npx expo prebuild -p android --no-install
|
||||||
|
|
||||||
|
- name: Decode release keystore
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
working-directory: app/android
|
||||||
|
env:
|
||||||
|
CAMPSCAN_STORE_FILE: ${{ runner.temp }}/release.keystore
|
||||||
|
CAMPSCAN_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
CAMPSCAN_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
CAMPSCAN_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew assembleRelease --init-script ../../ci/signing.gradle --no-daemon
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/artifacts"
|
||||||
|
cp app/build/outputs/apk/release/app-release.apk \
|
||||||
|
"$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk"
|
||||||
|
|
||||||
|
- name: Publish Forgejo release with APK
|
||||||
|
uses: actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: https://git.mowden.top
|
||||||
|
repo: Beartaria/CampgroundTickets
|
||||||
|
tag: ${{ steps.ver.outputs.tag }}
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
release-dir: artifacts
|
||||||
|
release-notes: "Camp Scan ${{ steps.ver.outputs.tag }} — install/update via Obtainium."
|
||||||
|
override: true
|
||||||
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# secrets
|
||||||
|
.env
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
runner/data/
|
||||||
|
|
||||||
|
# node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# builds
|
||||||
|
backend/dist/
|
||||||
|
app/dist/
|
||||||
|
app/.expo/
|
||||||
|
app/web-build/
|
||||||
|
app/android/
|
||||||
|
app/ios/
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
43
Dockerfile
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- Stage 1: build the Expo web (PWA) bundle ----
|
||||||
|
FROM node:22-bookworm AS web
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app/package.json app/package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY app/ ./
|
||||||
|
# Native app calls the public host; web build is same-origin so this is unused there.
|
||||||
|
ENV EXPO_PUBLIC_API_URL=https://scan.beartariacampgrounds.com
|
||||||
|
RUN npx expo export --platform web && node scripts/inject-pwa.mjs dist
|
||||||
|
|
||||||
|
# ---- Stage 2: build the backend ----
|
||||||
|
FROM node:22-bookworm AS backend
|
||||||
|
WORKDIR /srv
|
||||||
|
COPY backend/package.json backend/package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY backend/tsconfig.json ./
|
||||||
|
COPY backend/src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Stage 3: production dependencies only ----
|
||||||
|
FROM node:22-bookworm-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /srv
|
||||||
|
COPY backend/package.json backend/package-lock.json* ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=backend /srv/dist ./dist
|
||||||
|
COPY --from=web /app/dist ./web
|
||||||
|
ENV WEB_DIR=/srv/web
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Run as the non-root node user shipped in the base image.
|
||||||
|
RUN chown -R node:node /srv
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://localhost:'+ (process.env.PORT||8080) +'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
57
INSTALL.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Installing Camp Scan on gate phones
|
||||||
|
|
||||||
|
Camp Scan runs two ways. Use whichever fits the device:
|
||||||
|
|
||||||
|
- **Android** → install the native app (best camera performance) via **Obtainium**, which also auto-updates it.
|
||||||
|
- **iPhone / iPad** → install the **PWA** from Safari (no App Store needed).
|
||||||
|
|
||||||
|
Either way, you enter the **gate PIN once** and the device stays signed in for the whole event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Android (Obtainium)
|
||||||
|
|
||||||
|
Obtainium installs and updates apps straight from our Forgejo releases — no Play Store.
|
||||||
|
|
||||||
|
### 1. Install Obtainium
|
||||||
|
Get it from [the Obtainium releases page](https://github.com/ImranR98/Obtainium/releases) or F-Droid, and install the APK (you may need to allow "install unknown apps" for your browser/file manager).
|
||||||
|
|
||||||
|
### 2. Add Camp Scan
|
||||||
|
Tap this on the phone:
|
||||||
|
|
||||||
|
### ➕ [Add Camp Scan to Obtainium](obtainium://add/https://git.mowden.top/Beartaria/CampgroundTickets)
|
||||||
|
|
||||||
|
If the button doesn't open Obtainium, add it manually:
|
||||||
|
1. Obtainium → **Add App**
|
||||||
|
2. **App source URL:** `https://git.mowden.top/Beartaria/CampgroundTickets`
|
||||||
|
3. Obtainium detects the **Forgejo** source automatically → **Add**.
|
||||||
|
4. Tap **Install**.
|
||||||
|
|
||||||
|
### 3. Updates
|
||||||
|
When a new version is released (a new `vX.Y.Z` tag builds an APK), Obtainium shows an update — tap to install over the top. Because every release is signed with the same key, updates install cleanly.
|
||||||
|
|
||||||
|
> **If the repository is private:** Obtainium can't read the releases without a token. Either make the repo (or a releases-only mirror) public, or in Obtainium set a Forgejo credential/token under the app's *Additional Settings*. A token can also be embedded in the add-link, but anyone who sees that link gets repo read access, so prefer public releases. Ask the maintainer which applies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## iPhone / iPad (PWA)
|
||||||
|
|
||||||
|
1. Open **Safari** (not Chrome) and go to **https://scan.beartariacampgrounds.com**
|
||||||
|
2. Tap the **Share** button → **Add to Home Screen** → **Add**.
|
||||||
|
3. Launch **Camp Scan** from the home screen. It runs full-screen like a native app.
|
||||||
|
4. Allow **camera access** when prompted (required to scan). Camera only works over HTTPS, which the site provides.
|
||||||
|
|
||||||
|
Android users can also "Add to Home Screen" from Chrome instead of using Obtainium if they prefer the web version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First run (all devices)
|
||||||
|
|
||||||
|
1. Open Camp Scan.
|
||||||
|
2. Enter the **gate PIN** (ask the event organizer). The first tap also enables scan sounds.
|
||||||
|
3. You're in. The device stays signed in until you tap **Sign out** or the session expires (~30 days). You won't re-enter the PIN each shift.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
- **Scan:** point the camera at a ticket QR. Pick how many people are entering now (a family can split arrivals across the day on one code), then **Check in**. Green + chime = success and the count is saved; red + buzz = a problem (already fully redeemed, not a valid ticket, or a network/database error — the reason is shown).
|
||||||
|
- **Admin** (top-right): if scanning won't work, search by **name, email, or ticket code**, then use the buttons to check people in or undo a mistaken check-in.
|
||||||
167
README.md
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
# 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](./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`](./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`.
|
||||||
|
|
||||||
|
> A `CampTickets TEST` table already exists in NocoDB for testing. Point `NOCODB_TABLE_ID` at it for dry runs, then switch to the real 2026 table 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 |
|
||||||
|
| `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 |
|
||||||
|
| `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.
|
||||||
48
app/app.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Camp Scan",
|
||||||
|
"slug": "camptickets",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scheme": "campscan",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"android": {
|
||||||
|
"package": "top.mowden.campscan",
|
||||||
|
"versionCode": 1,
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.VIBRATE"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "top.mowden.campscan",
|
||||||
|
"infoPlist": {
|
||||||
|
"NSCameraUsageDescription": "Camp Scan uses the camera to scan ticket QR codes at the gate."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "single",
|
||||||
|
"favicon": "./assets/icon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-audio",
|
||||||
|
"expo-status-bar",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Camp Scan uses the camera to scan ticket QR codes at the gate."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, ActivityIndicator } from "react-native";
|
||||||
|
import { Stack, useRouter, useSegments } from "expo-router";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { getToken } from "../lib/api";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [hasToken, setHasToken] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getToken().then((t) => {
|
||||||
|
setHasToken(!!t);
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
const onLogin = segments[0] === "login";
|
||||||
|
if (!hasToken && !onLogin) router.replace("/login");
|
||||||
|
if (hasToken && onLogin) router.replace("/");
|
||||||
|
}, [ready, hasToken, segments, router]);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: theme.bg, alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<ActivityIndicator color={theme.successBright} size="large" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: theme.bg },
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
app/app/admin.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { StyleSheet, View, Text, Pressable, TextInput, ScrollView, ActivityIndicator } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { searchTickets, redeem, type TicketView } from "../lib/api";
|
||||||
|
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
export default function AdminScreen() {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [results, setResults] = useState<TicketView[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async () => {
|
||||||
|
if (!q.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setNote("");
|
||||||
|
try {
|
||||||
|
const { results } = await searchTickets(q.trim());
|
||||||
|
setResults(results);
|
||||||
|
setSearched(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AuthError") return router.replace("/login");
|
||||||
|
setNote(e?.message ?? "Search failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const adjust = useCallback(async (t: TicketView, delta: number) => {
|
||||||
|
setNote("");
|
||||||
|
try {
|
||||||
|
const res = await redeem(t.code, delta);
|
||||||
|
if (!res.ok) {
|
||||||
|
feedbackError();
|
||||||
|
const msgs: Record<string, string> = {
|
||||||
|
insufficient: `Only ${res.ticket?.remaining ?? 0} remaining.`,
|
||||||
|
exhausted: "Already fully redeemed.",
|
||||||
|
not_found: "Ticket not found.",
|
||||||
|
db_error: `Database error: ${res.detail ?? ""}`,
|
||||||
|
};
|
||||||
|
setNote(msgs[res.reason] ?? "Update failed");
|
||||||
|
if (res.ticket) updateRow(res.ticket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feedbackSuccess();
|
||||||
|
updateRow(res.ticket);
|
||||||
|
setNote(`${delta > 0 ? "Checked in" : "Restored"} ${Math.abs(delta)} for ${res.ticket.name}. Database updated.`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AuthError") return router.replace("/login");
|
||||||
|
feedbackError();
|
||||||
|
setNote(e?.message ?? "Update failed");
|
||||||
|
}
|
||||||
|
function updateRow(updated: TicketView) {
|
||||||
|
setResults((rows) => rows.map((r) => (r.code === updated.code ? updated : r)));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
|
<View style={styles.topbar}>
|
||||||
|
<Pressable onPress={() => router.replace("/")} hitSlop={10}>
|
||||||
|
<Text style={styles.link}>‹ Scanner</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.brand}>Admin lookup</Text>
|
||||||
|
<View style={{ width: 60 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.searchRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Name, email, or ticket code"
|
||||||
|
placeholderTextColor={theme.textDim}
|
||||||
|
value={q}
|
||||||
|
onChangeText={setQ}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="search"
|
||||||
|
onSubmitEditing={doSearch}
|
||||||
|
/>
|
||||||
|
<Pressable style={styles.searchBtn} onPress={doSearch}>
|
||||||
|
<Text style={styles.searchBtnText}>Search</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!note && <Text style={styles.note}>{note}</Text>}
|
||||||
|
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color={theme.successBright} style={{ marginTop: 30 }} />
|
||||||
|
) : (
|
||||||
|
<ScrollView style={styles.list} contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
|
{searched && results.length === 0 && <Text style={styles.empty}>No matching tickets.</Text>}
|
||||||
|
{results.map((t) => (
|
||||||
|
<TicketCard key={t.code} ticket={t} onAdjust={adjust} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: TicketView, d: number) => void }) {
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (ticket.extras.carParking) tags.push("🚗 Car");
|
||||||
|
if (ticket.extras.rvParking) tags.push("🚐 RV");
|
||||||
|
if (ticket.extras.iceAccess) tags.push("🧊 Ice");
|
||||||
|
if (ticket.extras.isDonor) tags.push("⭐ Donor");
|
||||||
|
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.cardHead}>
|
||||||
|
<Text style={styles.cardName}>{ticket.name}</Text>
|
||||||
|
<Text style={styles.cardCode}>{ticket.code}</Text>
|
||||||
|
</View>
|
||||||
|
{!!ticket.email && <Text style={styles.cardEmail}>{ticket.email}</Text>}
|
||||||
|
<Text style={styles.cardCounts}>
|
||||||
|
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{ticket.remaining}</Text> remaining ·{" "}
|
||||||
|
{ticket.redeemed}/{ticket.total} redeemed
|
||||||
|
</Text>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<View style={styles.tags}>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<Text key={t} style={styles.tag}>
|
||||||
|
{t}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actBtn, styles.actUndo]}
|
||||||
|
onPress={() => onAdjust(ticket, -1)}
|
||||||
|
disabled={ticket.redeemed <= 0}
|
||||||
|
>
|
||||||
|
<Text style={styles.actText}>− Undo 1</Text>
|
||||||
|
</Pressable>
|
||||||
|
{[1, 2, 5].map((n) => (
|
||||||
|
<Pressable
|
||||||
|
key={n}
|
||||||
|
style={[styles.actBtn, styles.actRedeem, ticket.remaining < n && styles.actDisabled]}
|
||||||
|
onPress={() => onAdjust(ticket, n)}
|
||||||
|
disabled={ticket.remaining < n}
|
||||||
|
>
|
||||||
|
<Text style={styles.actText}>+ Check in {n}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: theme.bg },
|
||||||
|
topbar: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
||||||
|
link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 60 },
|
||||||
|
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: theme.text,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
searchBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 18, justifyContent: "center" },
|
||||||
|
searchBtnText: { color: "#fff", fontSize: 16, fontWeight: "700" },
|
||||||
|
note: { color: theme.text, backgroundColor: theme.card, marginHorizontal: 16, marginTop: 12, padding: 12, borderRadius: 10, fontSize: 14 },
|
||||||
|
list: { flex: 1, marginTop: 12, paddingHorizontal: 16 },
|
||||||
|
empty: { color: theme.textDim, textAlign: "center", marginTop: 30, fontSize: 16 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
cardHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 6 },
|
||||||
|
cardName: { color: theme.text, fontSize: 20, fontWeight: "700" },
|
||||||
|
cardCode: { color: theme.textDim, fontSize: 14, letterSpacing: 1 },
|
||||||
|
cardEmail: { color: theme.textDim, fontSize: 14, marginTop: 2 },
|
||||||
|
cardCounts: { color: theme.text, fontSize: 16, marginTop: 10 },
|
||||||
|
tags: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 10 },
|
||||||
|
tag: {
|
||||||
|
color: theme.text,
|
||||||
|
backgroundColor: theme.cardBorder,
|
||||||
|
paddingHorizontal: 9,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 14 },
|
||||||
|
actBtn: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10 },
|
||||||
|
actRedeem: { backgroundColor: theme.primary },
|
||||||
|
actUndo: { backgroundColor: theme.warn },
|
||||||
|
actDisabled: { opacity: 0.35 },
|
||||||
|
actText: { color: "#fff", fontSize: 14, fontWeight: "700" },
|
||||||
|
});
|
||||||
332
app/app/index.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { StyleSheet, View, Text, Pressable, ScrollView } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import QRScanner from "../components/QRScanner";
|
||||||
|
import ResultOverlay, { OverlayStatus } from "../components/ResultOverlay";
|
||||||
|
import { lookup, redeem, logout, type TicketView } from "../lib/api";
|
||||||
|
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
type Phase = "scanning" | "busy" | "confirm" | "success" | "error";
|
||||||
|
|
||||||
|
export default function ScannerScreen() {
|
||||||
|
const [phase, setPhase] = useState<Phase>("scanning");
|
||||||
|
const [ticket, setTicket] = useState<TicketView | null>(null);
|
||||||
|
const [count, setCount] = useState(1);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [checkedIn, setCheckedIn] = useState(0);
|
||||||
|
const resumeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current);
|
||||||
|
setTicket(null);
|
||||||
|
setMessage("");
|
||||||
|
setCheckedIn(0);
|
||||||
|
setCount(1);
|
||||||
|
setPhase("scanning");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showError = useCallback((msg: string) => {
|
||||||
|
feedbackError();
|
||||||
|
setMessage(msg);
|
||||||
|
setPhase("error");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScan = useCallback(
|
||||||
|
async (raw: string) => {
|
||||||
|
setPhase("busy");
|
||||||
|
try {
|
||||||
|
const res = await lookup(raw);
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(`Database error: ${res.detail}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.found) {
|
||||||
|
showError(`Not a valid ticket:\n${raw.slice(0, 40)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTicket(res.ticket);
|
||||||
|
setCount(Math.min(1, res.ticket.remaining));
|
||||||
|
setPhase("confirm");
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AuthError") {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(e?.message ?? "Lookup failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showError],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCheckIn = useCallback(async () => {
|
||||||
|
if (!ticket || count < 1) return;
|
||||||
|
setPhase("busy");
|
||||||
|
try {
|
||||||
|
const res = await redeem(ticket.code, count);
|
||||||
|
if (!res.ok) {
|
||||||
|
const reasons: Record<string, string> = {
|
||||||
|
exhausted: "All tickets on this code are already redeemed.",
|
||||||
|
insufficient: `Only ${res.ticket?.remaining ?? 0} left on this ticket.`,
|
||||||
|
not_found: "Ticket not found.",
|
||||||
|
db_error: `Database error: ${res.detail ?? ""}`,
|
||||||
|
};
|
||||||
|
showError(reasons[res.reason] ?? "Check-in failed");
|
||||||
|
if (res.ticket) setTicket(res.ticket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feedbackSuccess();
|
||||||
|
setTicket(res.ticket);
|
||||||
|
setCheckedIn(res.checkedIn);
|
||||||
|
setPhase("success");
|
||||||
|
resumeTimer.current = setTimeout(resume, 4000);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AuthError") {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(e?.message ?? "Check-in failed");
|
||||||
|
}
|
||||||
|
}, [ticket, count, resume, showError]);
|
||||||
|
|
||||||
|
const doLogout = useCallback(async () => {
|
||||||
|
await logout();
|
||||||
|
router.replace("/login");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
|
<View style={styles.topbar}>
|
||||||
|
<Text style={styles.brand}>🐻 Camp Scan</Text>
|
||||||
|
<View style={styles.topActions}>
|
||||||
|
<Pressable onPress={() => router.push("/admin")} hitSlop={10}>
|
||||||
|
<Text style={styles.link}>Admin</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={doLogout} hitSlop={10}>
|
||||||
|
<Text style={styles.link}>Sign out</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.scannerArea}>
|
||||||
|
<QRScanner onScan={handleScan} active={phase === "scanning"} />
|
||||||
|
{phase === "scanning" && (
|
||||||
|
<View pointerEvents="none" style={styles.reticle}>
|
||||||
|
<View style={styles.reticleBox} />
|
||||||
|
<Text style={styles.hint}>Point the camera at a ticket QR code</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "confirm" && ticket && (
|
||||||
|
<ResultOverlay status="neutral" onDismiss={undefined}>
|
||||||
|
<ConfirmCard
|
||||||
|
ticket={ticket}
|
||||||
|
count={count}
|
||||||
|
setCount={setCount}
|
||||||
|
onCheckIn={handleCheckIn}
|
||||||
|
onCancel={resume}
|
||||||
|
/>
|
||||||
|
</ResultOverlay>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "success" && ticket && (
|
||||||
|
<ResultOverlay status="success" onDismiss={resume}>
|
||||||
|
<Text style={styles.bigIcon}>✓</Text>
|
||||||
|
<Text style={styles.bigTitle}>Checked in {checkedIn}</Text>
|
||||||
|
<Text style={styles.name}>{ticket.name}</Text>
|
||||||
|
<Text style={styles.counts}>
|
||||||
|
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
|
||||||
|
</Text>
|
||||||
|
<ExtrasRow ticket={ticket} />
|
||||||
|
<Text style={styles.dbConfirm}>Database updated</Text>
|
||||||
|
<Text style={styles.tapHint}>Tap to scan the next ticket</Text>
|
||||||
|
</ResultOverlay>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "error" && (
|
||||||
|
<ResultOverlay status="error" onDismiss={resume}>
|
||||||
|
<Text style={styles.bigIcon}>✕</Text>
|
||||||
|
<Text style={styles.bigTitle}>Problem</Text>
|
||||||
|
<Text style={styles.errorMsg}>{message}</Text>
|
||||||
|
{ticket && (
|
||||||
|
<Text style={styles.counts}>
|
||||||
|
{ticket.name} · {ticket.remaining} remaining
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.tapHint}>Tap to try again</Text>
|
||||||
|
</ResultOverlay>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "busy" && (
|
||||||
|
<View style={styles.busy}>
|
||||||
|
<Text style={styles.busyText}>Working…</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (ticket.extras.carParking) tags.push("🚗 Car parking");
|
||||||
|
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
|
||||||
|
if (ticket.extras.iceAccess) tags.push("🧊 Ice access");
|
||||||
|
if (ticket.extras.isDonor) tags.push("⭐ Donor");
|
||||||
|
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
|
||||||
|
if (!tags.length) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.tags}>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<Text key={t} style={styles.tag}>
|
||||||
|
{t}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmCard({
|
||||||
|
ticket,
|
||||||
|
count,
|
||||||
|
setCount,
|
||||||
|
onCheckIn,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
ticket: TicketView;
|
||||||
|
count: number;
|
||||||
|
setCount: (n: number) => void;
|
||||||
|
onCheckIn: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const exhausted = ticket.remaining <= 0;
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.card} contentContainerStyle={styles.cardContent}>
|
||||||
|
<Text style={styles.cardName}>{ticket.name}</Text>
|
||||||
|
<Text style={styles.cardCode}>{ticket.code}</Text>
|
||||||
|
<Text style={styles.cardCounts}>
|
||||||
|
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{ticket.remaining}</Text> of{" "}
|
||||||
|
{ticket.total} remaining
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.cardSub}>{ticket.redeemed} already redeemed</Text>
|
||||||
|
<ExtrasRow ticket={ticket} />
|
||||||
|
|
||||||
|
{exhausted ? (
|
||||||
|
<Text style={styles.exhausted}>All tickets on this code are already redeemed.</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.stepperLabel}>How many are entering now?</Text>
|
||||||
|
<View style={styles.stepper}>
|
||||||
|
<StepBtn label="–" onPress={() => setCount(Math.max(1, count - 1))} disabled={count <= 1} />
|
||||||
|
<Text style={styles.stepValue}>{count}</Text>
|
||||||
|
<StepBtn
|
||||||
|
label="+"
|
||||||
|
onPress={() => setCount(Math.min(ticket.remaining, count + 1))}
|
||||||
|
disabled={count >= ticket.remaining}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Pressable style={styles.checkinBtn} onPress={onCheckIn}>
|
||||||
|
<Text style={styles.checkinText}>Check in {count}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Pressable style={styles.cancelBtn} onPress={onCancel}>
|
||||||
|
<Text style={styles.cancelText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepBtn({ label, onPress, disabled }: { label: string; onPress: () => void; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Pressable style={[styles.stepBtn, disabled && styles.stepBtnDisabled]} onPress={onPress} disabled={disabled}>
|
||||||
|
<Text style={styles.stepBtnText}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: theme.bg },
|
||||||
|
topbar: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
||||||
|
topActions: { flexDirection: "row", gap: 18 },
|
||||||
|
link: { color: theme.textDim, fontSize: 15, fontWeight: "600" },
|
||||||
|
scannerArea: { flex: 1, position: "relative", overflow: "hidden" },
|
||||||
|
reticle: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
|
||||||
|
reticleBox: {
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: "rgba(255,255,255,0.85)",
|
||||||
|
borderRadius: 24,
|
||||||
|
},
|
||||||
|
hint: { color: "#fff", marginTop: 20, fontSize: 15, textShadowColor: "#000", textShadowRadius: 4 },
|
||||||
|
busy: {
|
||||||
|
position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.4)",
|
||||||
|
},
|
||||||
|
busyText: { color: "#fff", fontSize: 18, fontWeight: "600" },
|
||||||
|
|
||||||
|
bigIcon: { color: "#fff", fontSize: 96, fontWeight: "900", lineHeight: 104 },
|
||||||
|
bigTitle: { color: "#fff", fontSize: 34, fontWeight: "800", marginTop: 4 },
|
||||||
|
name: { color: "#fff", fontSize: 24, fontWeight: "700", marginTop: 12, textAlign: "center" },
|
||||||
|
counts: { color: "rgba(255,255,255,0.95)", fontSize: 18, marginTop: 8, textAlign: "center" },
|
||||||
|
dbConfirm: { color: "#fff", fontSize: 15, marginTop: 16, fontWeight: "600" },
|
||||||
|
errorMsg: { color: "#fff", fontSize: 18, marginTop: 12, textAlign: "center", lineHeight: 24 },
|
||||||
|
tapHint: { color: "rgba(255,255,255,0.75)", fontSize: 14, marginTop: 24 },
|
||||||
|
|
||||||
|
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
|
||||||
|
tag: {
|
||||||
|
color: "#fff",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.18)",
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 13,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
|
||||||
|
card: { maxHeight: "100%", width: "100%" },
|
||||||
|
cardContent: { alignItems: "center", paddingVertical: 8 },
|
||||||
|
cardName: { color: theme.text, fontSize: 26, fontWeight: "800", textAlign: "center" },
|
||||||
|
cardCode: { color: theme.textDim, fontSize: 15, marginTop: 4, letterSpacing: 1 },
|
||||||
|
cardCounts: { color: theme.text, fontSize: 22, marginTop: 16 },
|
||||||
|
cardSub: { color: theme.textDim, fontSize: 14, marginTop: 4 },
|
||||||
|
exhausted: { color: theme.dangerBright, fontSize: 17, marginTop: 20, textAlign: "center", fontWeight: "600" },
|
||||||
|
stepperLabel: { color: theme.text, fontSize: 16, marginTop: 22 },
|
||||||
|
stepper: { flexDirection: "row", alignItems: "center", gap: 24, marginTop: 12 },
|
||||||
|
stepBtn: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: theme.primary,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
stepBtnDisabled: { backgroundColor: theme.cardBorder },
|
||||||
|
stepBtnText: { color: "#fff", fontSize: 32, fontWeight: "800", lineHeight: 36 },
|
||||||
|
stepValue: { color: theme.text, fontSize: 44, fontWeight: "800", minWidth: 64, textAlign: "center" },
|
||||||
|
checkinBtn: {
|
||||||
|
backgroundColor: theme.successBright,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
checkinText: { color: "#06210f", fontSize: 22, fontWeight: "800" },
|
||||||
|
cancelBtn: { marginTop: 16, padding: 10 },
|
||||||
|
cancelText: { color: theme.textDim, fontSize: 16 },
|
||||||
|
});
|
||||||
119
app/app/login.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { StyleSheet, View, Text, Pressable } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { login, AuthError } from "../lib/api";
|
||||||
|
import { primeFeedback } from "../lib/feedback";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const [pin, setPin] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function press(k: string) {
|
||||||
|
// First tap unlocks web audio playback.
|
||||||
|
primeFeedback();
|
||||||
|
setError("");
|
||||||
|
if (k === "clear") return setPin("");
|
||||||
|
if (k === "back") return setPin((p) => p.slice(0, -1));
|
||||||
|
const next = (pin + k).slice(0, 12);
|
||||||
|
setPin(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!pin) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await login(pin);
|
||||||
|
router.replace("/");
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e instanceof AuthError ? "Incorrect PIN" : (e?.message ?? "Login failed"));
|
||||||
|
setPin("");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.logo}>🐻</Text>
|
||||||
|
<Text style={styles.title}>Camp Scan</Text>
|
||||||
|
<Text style={styles.subtitle}>Enter the gate PIN</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{Array.from({ length: Math.max(4, pin.length) }).map((_, i) => (
|
||||||
|
<View key={i} style={[styles.dot, i < pin.length && styles.dotFilled]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
|
<View style={styles.pad}>
|
||||||
|
{KEYS.map((k) => (
|
||||||
|
<Pressable
|
||||||
|
key={k}
|
||||||
|
style={[styles.key, (k === "clear" || k === "back") && styles.keyAlt]}
|
||||||
|
onPress={() => press(k)}
|
||||||
|
>
|
||||||
|
<Text style={styles.keyText}>{k === "back" ? "⌫" : k === "clear" ? "C" : k}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.submit, (busy || !pin) && styles.submitDisabled]}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={busy || !pin}
|
||||||
|
>
|
||||||
|
<Text style={styles.submitText}>{busy ? "Signing in…" : "Sign in"}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: theme.bg, alignItems: "center", paddingHorizontal: 24 },
|
||||||
|
header: { alignItems: "center", marginTop: 48 },
|
||||||
|
logo: { fontSize: 56 },
|
||||||
|
title: { color: theme.text, fontSize: 30, fontWeight: "800", marginTop: 8 },
|
||||||
|
subtitle: { color: theme.textDim, fontSize: 16, marginTop: 6 },
|
||||||
|
dots: { flexDirection: "row", gap: 14, marginTop: 32, minHeight: 18 },
|
||||||
|
dot: { width: 14, height: 14, borderRadius: 7, backgroundColor: theme.cardBorder },
|
||||||
|
dotFilled: { backgroundColor: theme.successBright },
|
||||||
|
error: { color: theme.dangerBright, marginTop: 16, fontSize: 15, fontWeight: "600" },
|
||||||
|
pad: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 28,
|
||||||
|
maxWidth: 300,
|
||||||
|
},
|
||||||
|
key: {
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
borderRadius: 42,
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
keyAlt: { backgroundColor: "transparent" },
|
||||||
|
keyText: { color: theme.text, fontSize: 30, fontWeight: "600" },
|
||||||
|
submit: {
|
||||||
|
marginTop: 28,
|
||||||
|
backgroundColor: theme.successBright,
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
submitDisabled: { opacity: 0.4 },
|
||||||
|
submitText: { color: "#06210f", fontSize: 20, fontWeight: "800" },
|
||||||
|
});
|
||||||
BIN
app/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/assets/favicon.png
Normal file
|
After Width: | Height: | Size: 195 B |
BIN
app/assets/icon-192.png
Normal file
|
After Width: | Height: | Size: 773 B |
BIN
app/assets/icon-512.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/assets/icon.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/assets/sounds/error.wav
Normal file
BIN
app/assets/sounds/success.wav
Normal file
56
app/components/QRScanner.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { StyleSheet, View, Text, Pressable } from "react-native";
|
||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
export interface QRScannerProps {
|
||||||
|
onScan: (code: string) => void;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native (Android/iOS) scanner using expo-camera. */
|
||||||
|
export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return <View style={styles.fill} />;
|
||||||
|
}
|
||||||
|
if (!permission.granted) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.fill, styles.center]}>
|
||||||
|
<Text style={styles.msg}>Camera access is needed to scan tickets.</Text>
|
||||||
|
<Pressable style={styles.btn} onPress={requestPermission}>
|
||||||
|
<Text style={styles.btnText}>Grant camera permission</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CameraView
|
||||||
|
style={styles.fill}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
||||||
|
onBarcodeScanned={
|
||||||
|
active
|
||||||
|
? ({ data }) => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Debounce repeated frames of the same code.
|
||||||
|
if (data === lastScan.current.code && now - lastScan.current.at < 3000) return;
|
||||||
|
lastScan.current = { code: data, at: now };
|
||||||
|
onScan(data);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
fill: { flex: 1, width: "100%", height: "100%" },
|
||||||
|
center: { alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: theme.bg },
|
||||||
|
msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 20 },
|
||||||
|
btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 },
|
||||||
|
btnText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||||
|
});
|
||||||
121
app/components/QRScanner.web.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { StyleSheet, View, Text, Pressable } from "react-native";
|
||||||
|
import { BarcodeDetector } from "barcode-detector/ponyfill";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
import type { QRScannerProps } from "./QRScanner";
|
||||||
|
|
||||||
|
/** Web/PWA scanner using getUserMedia + the BarcodeDetector ponyfill (zxing-wasm). */
|
||||||
|
export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [starting, setStarting] = useState(true);
|
||||||
|
|
||||||
|
activeRef.current = active;
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
setError(null);
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: { ideal: "environment" } },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
streamRef.current = stream;
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) {
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.setAttribute("playsinline", "true");
|
||||||
|
await video.play().catch(() => {});
|
||||||
|
}
|
||||||
|
const detector = new BarcodeDetector({ formats: ["qr_code"] });
|
||||||
|
let busy = false;
|
||||||
|
const tick = async () => {
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v || v.readyState < 2 || busy || !activeRef.current) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const codes = await detector.detect(v);
|
||||||
|
if (codes && codes.length) {
|
||||||
|
const data = codes[0].rawValue;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!(data === lastScan.current.code && now - lastScan.current.at < 3000)) {
|
||||||
|
lastScan.current = { code: data, at: now };
|
||||||
|
onScan(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* transient decode error; keep scanning */
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
setStarting(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
setStarting(false);
|
||||||
|
setError(
|
||||||
|
e?.name === "NotAllowedError"
|
||||||
|
? "Camera permission was denied. Allow camera access and reload."
|
||||||
|
: "Could not open the camera. Make sure you're on HTTPS and no other app is using it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
start();
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.fill}>
|
||||||
|
{/* Raw DOM video element; react-dom renders it inside the RN-Web div tree. */}
|
||||||
|
<video
|
||||||
|
ref={videoRef as any}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
{starting && !error && (
|
||||||
|
<View style={styles.overlayMsg}>
|
||||||
|
<Text style={styles.msg}>Starting camera…</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<View style={styles.overlayMsg}>
|
||||||
|
<Text style={styles.msg}>{error}</Text>
|
||||||
|
<Pressable style={styles.btn} onPress={start}>
|
||||||
|
<Text style={styles.btnText}>Retry</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
fill: { flex: 1, width: "100%", height: "100%", backgroundColor: "#000" },
|
||||||
|
overlayMsg: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
backgroundColor: theme.bg,
|
||||||
|
},
|
||||||
|
msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 16 },
|
||||||
|
btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 },
|
||||||
|
btnText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||||
|
});
|
||||||
32
app/components/ResultOverlay.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { StyleSheet, View, Pressable } from "react-native";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
export type OverlayStatus = "success" | "error" | "neutral";
|
||||||
|
|
||||||
|
const BG: Record<OverlayStatus, string> = {
|
||||||
|
success: theme.success,
|
||||||
|
error: theme.danger,
|
||||||
|
neutral: theme.card,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResultOverlay({
|
||||||
|
status,
|
||||||
|
onDismiss,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
status: OverlayStatus;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable style={[styles.fill, { backgroundColor: BG[status] }]} onPress={onDismiss}>
|
||||||
|
<View style={styles.inner}>{children}</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
fill: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 10 },
|
||||||
|
inner: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||||
|
});
|
||||||
120
app/lib/api.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { loadToken, saveToken, clearToken } from "./storage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API base URL. On web the app is served from the same origin as the API, so we
|
||||||
|
* use a relative path. On native (the Android APK) it must point at the public
|
||||||
|
* HTTPS host, baked in at build time via EXPO_PUBLIC_API_URL.
|
||||||
|
*/
|
||||||
|
export const API_BASE =
|
||||||
|
Platform.OS === "web"
|
||||||
|
? ""
|
||||||
|
: (process.env.EXPO_PUBLIC_API_URL ?? "https://scan.beartariacampgrounds.com").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
export interface TicketView {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
total: number;
|
||||||
|
redeemed: number;
|
||||||
|
remaining: number;
|
||||||
|
extras: {
|
||||||
|
carParking: boolean;
|
||||||
|
rvParking: boolean;
|
||||||
|
iceAccess: boolean;
|
||||||
|
isDonor: boolean;
|
||||||
|
freeUnder4: number;
|
||||||
|
};
|
||||||
|
ages: { bracket: string; count: number; free: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthError extends Error {}
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
let cachedToken: string | null = null;
|
||||||
|
|
||||||
|
export async function getToken(): Promise<string | null> {
|
||||||
|
if (cachedToken) return cachedToken;
|
||||||
|
cachedToken = await loadToken();
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(pin: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ pin }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new AuthError("Incorrect PIN");
|
||||||
|
if (!res.ok) throw new ApiError(`Login failed (${res.status})`);
|
||||||
|
const { token } = (await res.json()) as { token: string };
|
||||||
|
cachedToken = token;
|
||||||
|
await saveToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
cachedToken = null;
|
||||||
|
await clearToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authed<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) throw new AuthError("Not logged in");
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
await logout();
|
||||||
|
throw new AuthError("Session expired");
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
let body: any = undefined;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(body?.error ?? body?.detail ?? `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LookupResult =
|
||||||
|
| { ok: true; found: true; ticket: TicketView }
|
||||||
|
| { ok: true; found: false }
|
||||||
|
| { ok: false; reason: "db_error"; detail: string };
|
||||||
|
|
||||||
|
export function lookup(code: string): Promise<LookupResult> {
|
||||||
|
return authed<LookupResult>("/api/lookup", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RedeemResult =
|
||||||
|
| { ok: true; ticket: TicketView; checkedIn: number }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
reason: "not_found" | "exhausted" | "insufficient" | "db_error";
|
||||||
|
ticket?: TicketView;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function redeem(code: string, count: number): Promise<RedeemResult> {
|
||||||
|
return authed<RedeemResult>("/api/redeem", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ code, count }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchTickets(q: string): Promise<{ results: TicketView[] }> {
|
||||||
|
return authed<{ results: TicketView[] }>(`/api/tickets?q=${encodeURIComponent(q)}`);
|
||||||
|
}
|
||||||
64
app/lib/feedback.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { createAudioPlayer, setAudioModeAsync, type AudioPlayer } from "expo-audio";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
|
// Preloaded one-shot players. Created on first prime() call, which must happen
|
||||||
|
// in response to a user gesture (the PIN login tap) so web autoplay policies
|
||||||
|
// allow later programmatic playback.
|
||||||
|
let successPlayer: AudioPlayer | null = null;
|
||||||
|
let errorPlayer: AudioPlayer | null = null;
|
||||||
|
let primed = false;
|
||||||
|
|
||||||
|
export async function primeFeedback(): Promise<void> {
|
||||||
|
if (primed) return;
|
||||||
|
primed = true;
|
||||||
|
try {
|
||||||
|
await setAudioModeAsync({ playsInSilentMode: true });
|
||||||
|
} catch {
|
||||||
|
/* not fatal */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
successPlayer = createAudioPlayer(require("../assets/sounds/success.wav"));
|
||||||
|
errorPlayer = createAudioPlayer(require("../assets/sounds/error.wav"));
|
||||||
|
// Nudge the web audio context alive with a muted play/pause.
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
for (const p of [successPlayer, errorPlayer]) {
|
||||||
|
try {
|
||||||
|
p.volume = 0;
|
||||||
|
p.play();
|
||||||
|
p.pause();
|
||||||
|
p.seekTo(0);
|
||||||
|
p.volume = 1;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* audio unavailable; feedback falls back to haptics/visual only */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replay(player: AudioPlayer | null): void {
|
||||||
|
if (!player) return;
|
||||||
|
try {
|
||||||
|
player.seekTo(0);
|
||||||
|
player.play();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feedbackSuccess(): void {
|
||||||
|
replay(successPlayer);
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feedbackError(): void {
|
||||||
|
replay(errorPlayer);
|
||||||
|
if (Platform.OS !== "web") {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/lib/storage.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
// Token persistence: localStorage on web, SecureStore on native.
|
||||||
|
const KEY = "campscan.token";
|
||||||
|
|
||||||
|
export async function saveToken(token: string): Promise<void> {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(KEY, token);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SecureStore = await import("expo-secure-store");
|
||||||
|
await SecureStore.setItemAsync(KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadToken(): Promise<string | null> {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const SecureStore = await import("expo-secure-store");
|
||||||
|
return SecureStore.getItemAsync(KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearToken(): Promise<void> {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SecureStore = await import("expo-secure-store");
|
||||||
|
await SecureStore.deleteItemAsync(KEY);
|
||||||
|
}
|
||||||
14
app/lib/theme.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
export const theme = {
|
||||||
|
bg: "#0f1a12",
|
||||||
|
card: "#16241a",
|
||||||
|
cardBorder: "#24382a",
|
||||||
|
text: "#eaf2ec",
|
||||||
|
textDim: "#9db3a4",
|
||||||
|
primary: "#2e7d32",
|
||||||
|
primaryDark: "#1b5e20",
|
||||||
|
success: "#1b7f3b",
|
||||||
|
successBright: "#25c05a",
|
||||||
|
danger: "#8f1d1d",
|
||||||
|
dangerBright: "#e04343",
|
||||||
|
warn: "#b8860b",
|
||||||
|
};
|
||||||
7902
app/package-lock.json
generated
Normal file
33
app/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "camptickets-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"export:web": "expo export --platform web && node scripts/inject-pwa.mjs dist",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prebuild": "expo prebuild"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "~57.0.3",
|
||||||
|
"@types/react": "~19.2.4",
|
||||||
|
"barcode-detector": "^3.2.0",
|
||||||
|
"expo": "~57.0.4",
|
||||||
|
"expo-audio": "~57.0.0",
|
||||||
|
"expo-camera": "~57.0.1",
|
||||||
|
"expo-constants": "~57.0.3",
|
||||||
|
"expo-haptics": "~57.0.0",
|
||||||
|
"expo-linking": "~57.0.2",
|
||||||
|
"expo-router": "~57.0.4",
|
||||||
|
"expo-secure-store": "~57.0.0",
|
||||||
|
"expo-status-bar": "~57.0.0",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "4.25.2",
|
||||||
|
"react-native-web": "^0.21.2",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/public/favicon.png
Normal file
|
After Width: | Height: | Size: 195 B |
BIN
app/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 773 B |
BIN
app/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
15
app/public/manifest.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "Camp Scan — Beartaria Campgrounds",
|
||||||
|
"short_name": "Camp Scan",
|
||||||
|
"description": "Scan and redeem 2026 Beartaria Campgrounds tickets at the gate.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#0f1a12",
|
||||||
|
"theme_color": "#0f1a12",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
31
app/scripts/inject-pwa.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Post-export step: inject PWA manifest link, theme color, and apple-touch meta
|
||||||
|
// into the SPA index.html. Expo's `single` web output does not use +html.tsx,
|
||||||
|
// so we patch the generated file directly. Idempotent.
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const dist = process.argv[2] || "dist";
|
||||||
|
const file = join(dist, "index.html");
|
||||||
|
|
||||||
|
const HEAD = `
|
||||||
|
<meta name="theme-color" content="#0f1a12" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Camp Scan" />`;
|
||||||
|
|
||||||
|
let html = readFileSync(file, "utf8");
|
||||||
|
|
||||||
|
if (!html.includes('rel="manifest"')) {
|
||||||
|
html = html.replace("</head>", `${HEAD}\n </head>`);
|
||||||
|
}
|
||||||
|
// Allow full-screen camera: disable user zoom, cover the notch.
|
||||||
|
html = html.replace(
|
||||||
|
/<meta name="viewport"[^>]*\/>/,
|
||||||
|
'<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />',
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(file, html);
|
||||||
|
console.log("inject-pwa: patched", file);
|
||||||
10
app/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||||
|
}
|
||||||
3509
backend/package-lock.json
generated
Normal file
29
backend/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "camptickets-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/formbody": "^8.0.2",
|
||||||
|
"@fastify/jwt": "^9.0.4",
|
||||||
|
"@fastify/rate-limit": "^10.2.2",
|
||||||
|
"@fastify/static": "^8.0.4",
|
||||||
|
"fastify": "^5.2.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/src/config.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
PORT: z.coerce.number().default(8080),
|
||||||
|
HOST: z.string().default("0.0.0.0"),
|
||||||
|
|
||||||
|
NOCODB_BASE_URL: z.string().url(),
|
||||||
|
NOCODB_API_TOKEN: z.string().min(1),
|
||||||
|
NOCODB_TABLE_ID: z.string().min(1),
|
||||||
|
|
||||||
|
MAILERSEND_API_TOKEN: z.string().min(1),
|
||||||
|
MAIL_FROM_EMAIL: z.string().email(),
|
||||||
|
MAIL_FROM_NAME: z.string().default("Beartaria Campgrounds"),
|
||||||
|
|
||||||
|
WEBHOOK_SECRET: z.string().min(1),
|
||||||
|
EVENT_PIN: z.string().min(1),
|
||||||
|
TOKEN_SECRET: z.string().min(16),
|
||||||
|
TOKEN_TTL: z.string().default("30d"),
|
||||||
|
|
||||||
|
LOG_LEVEL: z.string().default("info"),
|
||||||
|
|
||||||
|
// Directory of the exported Expo web build to serve statically. Optional in dev.
|
||||||
|
WEB_DIR: z.string().optional(),
|
||||||
|
|
||||||
|
// Comma-separated email addresses allowed to receive mail while MailerSend is
|
||||||
|
// still in trial mode. Leave empty in production once the domain is verified.
|
||||||
|
MAIL_TEST_RECIPIENTS: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
let cached: Config | null = null;
|
||||||
|
|
||||||
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||||
|
if (cached) return cached;
|
||||||
|
const parsed = schema.safeParse(env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const issues = parsed.error.issues
|
||||||
|
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||||
|
.join("\n");
|
||||||
|
throw new Error(`Invalid environment configuration:\n${issues}`);
|
||||||
|
}
|
||||||
|
cached = parsed.data;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tests: reset the memoized config.
|
||||||
|
export function resetConfig(): void {
|
||||||
|
cached = null;
|
||||||
|
}
|
||||||
35
backend/src/context.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { Config } from "./config.js";
|
||||||
|
import { NocoDBClient } from "./services/nocodb.js";
|
||||||
|
import { Mailer } from "./services/mailer.js";
|
||||||
|
import { RedeemQueue } from "./services/redeemQueue.js";
|
||||||
|
|
||||||
|
/** Shared services wired once at startup and hung off the Fastify instance. */
|
||||||
|
export interface AppContext {
|
||||||
|
config: Config;
|
||||||
|
nocodb: NocoDBClient;
|
||||||
|
mailer: Mailer;
|
||||||
|
queue: RedeemQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContext(config: Config): AppContext {
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
nocodb: new NocoDBClient(config),
|
||||||
|
mailer: new Mailer(config),
|
||||||
|
queue: new RedeemQueue(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ambient module augmentation so `fastify.ctx` and `request.user` are typed.
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
ctx: AppContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@fastify/jwt" {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: { role: "staff" };
|
||||||
|
user: { role: "staff" };
|
||||||
|
}
|
||||||
|
}
|
||||||
115
backend/src/fields.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Mapping between the NocoDB "2026 Campground Tickets" table columns, the
|
||||||
|
* webhook payload keys, and the view we return to the app.
|
||||||
|
*
|
||||||
|
* The 2026 table is a clone of the 2025 submission table (per-purchase record
|
||||||
|
* with age-bracket headcounts, parking/ice flags, donor flag) PLUS four columns
|
||||||
|
* this system adds: Ticket Code, Redeemed, SubmissionKey, LastScanAt.
|
||||||
|
*
|
||||||
|
* If the real column titles differ, change them here in one place.
|
||||||
|
*/
|
||||||
|
export const COL = {
|
||||||
|
id: "Id",
|
||||||
|
name: "Title", // first column in the 2025 table holds the purchaser name
|
||||||
|
email: "Email Address",
|
||||||
|
address: "Address",
|
||||||
|
isDonor: "Is Donor",
|
||||||
|
carParking: "Car Parking",
|
||||||
|
rvParking: "RV Parking",
|
||||||
|
iceAccess: "Ice Access",
|
||||||
|
paymentMethod: "Payment Method",
|
||||||
|
|
||||||
|
// Columns this system adds to the table:
|
||||||
|
code: "Ticket Code",
|
||||||
|
redeemed: "Redeemed",
|
||||||
|
submissionKey: "SubmissionKey",
|
||||||
|
lastScanAt: "LastScanAt",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Age-bracket columns, in order. */
|
||||||
|
export const AGE_COLUMNS = [
|
||||||
|
"Ages 0-3",
|
||||||
|
"Ages 4-7",
|
||||||
|
"Ages 8-12",
|
||||||
|
"Ages 13-17",
|
||||||
|
"Ages 18-25",
|
||||||
|
"Ages 26-45",
|
||||||
|
"Ages 46-64",
|
||||||
|
"Ages 65+",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Age brackets admitted free and NOT counted as redeemable tickets. */
|
||||||
|
export const FREE_AGE_COLUMNS: readonly string[] = ["Ages 0-3"];
|
||||||
|
|
||||||
|
export type NocoRecord = Record<string, unknown> & { Id: number };
|
||||||
|
|
||||||
|
function num(v: unknown): number {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bool(v: unknown): boolean {
|
||||||
|
if (typeof v === "boolean") return v;
|
||||||
|
if (typeof v === "number") return v !== 0;
|
||||||
|
if (typeof v === "string") return /^(1|true|yes|y|on)$/i.test(v.trim());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total redeemable tickets = sum of age brackets minus the free ones. */
|
||||||
|
export function computeTotal(rec: NocoRecord): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const col of AGE_COLUMNS) {
|
||||||
|
if (FREE_AGE_COLUMNS.includes(col)) continue;
|
||||||
|
total += num(rec[col]);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-bracket breakdown for display. */
|
||||||
|
export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
|
||||||
|
return AGE_COLUMNS.map((col) => ({
|
||||||
|
bracket: col.replace(/^Ages /, ""),
|
||||||
|
count: num(rec[col]),
|
||||||
|
free: FREE_AGE_COLUMNS.includes(col),
|
||||||
|
})).filter((b) => b.count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketView {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
total: number;
|
||||||
|
redeemed: number;
|
||||||
|
remaining: number;
|
||||||
|
extras: {
|
||||||
|
carParking: boolean;
|
||||||
|
rvParking: boolean;
|
||||||
|
iceAccess: boolean;
|
||||||
|
isDonor: boolean;
|
||||||
|
freeUnder4: number;
|
||||||
|
};
|
||||||
|
ages: { bracket: string; count: number; free: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toView(rec: NocoRecord): TicketView {
|
||||||
|
const total = computeTotal(rec);
|
||||||
|
const redeemed = num(rec[COL.redeemed]);
|
||||||
|
return {
|
||||||
|
code: String(rec[COL.code] ?? ""),
|
||||||
|
name: String(rec[COL.name] ?? ""),
|
||||||
|
email: String(rec[COL.email] ?? ""),
|
||||||
|
total,
|
||||||
|
redeemed,
|
||||||
|
remaining: Math.max(0, total - redeemed),
|
||||||
|
extras: {
|
||||||
|
carParking: bool(rec[COL.carParking]),
|
||||||
|
rvParking: bool(rec[COL.rvParking]),
|
||||||
|
iceAccess: bool(rec[COL.iceAccess]),
|
||||||
|
isDonor: bool(rec[COL.isDonor]),
|
||||||
|
freeUnder4: num(rec["Ages 0-3"]),
|
||||||
|
},
|
||||||
|
ages: ageBreakdown(rec),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { num as toNumber, bool as toBool };
|
||||||
40
backend/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ba = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
if (ba.length !== bb.length) {
|
||||||
|
// Still do a comparison to keep timing roughly constant.
|
||||||
|
timingSafeEqual(ba, ba);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
{
|
||||||
|
config: { rateLimit: { max: 10, timeWindow: "1 minute" } },
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["pin"],
|
||||||
|
properties: { pin: { type: "string", minLength: 1, maxLength: 100 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const { pin } = req.body as { pin: string };
|
||||||
|
if (!safeEqual(pin, app.ctx.config.EVENT_PIN)) {
|
||||||
|
return reply.code(401).send({ error: "invalid_pin" });
|
||||||
|
}
|
||||||
|
const token = await reply.jwtSign(
|
||||||
|
{ role: "staff" },
|
||||||
|
{ expiresIn: app.ctx.config.TOKEN_TTL },
|
||||||
|
);
|
||||||
|
return { token };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
158
backend/src/routes/tickets.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { normalizeCode, looksLikeCode } from "../services/code.js";
|
||||||
|
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
|
||||||
|
import { renderQrPng } from "../services/qrcode.js";
|
||||||
|
import { COL } from "../fields.js";
|
||||||
|
|
||||||
|
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
await req.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// Health (unauthenticated) — includes a NocoDB connectivity probe.
|
||||||
|
app.get("/api/health", async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
await app.ctx.nocodb.ping();
|
||||||
|
return { ok: true, nocodb: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return reply.code(503).send({ ok: false, nocodb: false, detail: e?.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Look up a ticket by scanned code (no mutation).
|
||||||
|
app.post(
|
||||||
|
"/api/lookup",
|
||||||
|
{
|
||||||
|
preHandler: requireStaff,
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["code"],
|
||||||
|
properties: { code: { type: "string", minLength: 1, maxLength: 64 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const { code } = req.body as { code: string };
|
||||||
|
return lookupByCode(app.ctx, normalizeCode(code));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search by name/email or exact code for the admin panel.
|
||||||
|
app.get(
|
||||||
|
"/api/tickets",
|
||||||
|
{ preHandler: requireStaff },
|
||||||
|
async (req) => {
|
||||||
|
const q = String((req.query as any)?.q ?? "").trim();
|
||||||
|
if (!q) return { results: [] };
|
||||||
|
if (looksLikeCode(q)) {
|
||||||
|
const res = await lookupByCode(app.ctx, normalizeCode(q));
|
||||||
|
return { results: res.ok && res.found ? [res.ticket] : [] };
|
||||||
|
}
|
||||||
|
return { results: await search(app.ctx, q) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redeem N tickets against a code (scanner check-in and admin adjust share this).
|
||||||
|
app.post(
|
||||||
|
"/api/redeem",
|
||||||
|
{
|
||||||
|
preHandler: requireStaff,
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["code"],
|
||||||
|
properties: {
|
||||||
|
code: { type: "string", minLength: 1, maxLength: 64 },
|
||||||
|
count: { type: "integer", minimum: -100, maximum: 100 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const { code, count } = req.body as { code: string; count?: number };
|
||||||
|
return redeem(app.ctx, normalizeCode(code), count ?? 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-send the ticket email (recovery when the webhook send failed).
|
||||||
|
app.post(
|
||||||
|
"/api/tickets/:code/resend-email",
|
||||||
|
{ preHandler: requireStaff },
|
||||||
|
async (req, reply) => {
|
||||||
|
const code = normalizeCode((req.params as any).code);
|
||||||
|
const rec = await app.ctx.nocodb.findByCode(code);
|
||||||
|
if (!rec) return reply.code(404).send({ error: "not_found" });
|
||||||
|
const email = String(rec[COL.email] ?? "");
|
||||||
|
const name = String(rec[COL.name] ?? "");
|
||||||
|
if (app.ctx.mailer.isBlockedRecipient(email)) {
|
||||||
|
return reply.code(422).send({ error: "trial_restriction", detail: "recipient not in MAIL_TEST_RECIPIENTS" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const qr = await renderQrPng(code);
|
||||||
|
const { computeTotal } = await import("../fields.js");
|
||||||
|
await app.ctx.mailer.sendTicket({
|
||||||
|
toEmail: email,
|
||||||
|
toName: name,
|
||||||
|
code,
|
||||||
|
quantity: computeTotal(rec),
|
||||||
|
qrPng: qr,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return reply.code(502).send({ error: "email_failed", detail: e?.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manual ticket creation for gate walk-ups / comps (admin). Not idempotent by
|
||||||
|
// submission; generates a fresh code and can email if an address is given.
|
||||||
|
app.post(
|
||||||
|
"/api/tickets",
|
||||||
|
{
|
||||||
|
preHandler: requireStaff,
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["name", "ages"],
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", minLength: 1 },
|
||||||
|
email: { type: "string" },
|
||||||
|
ages: { type: "object" },
|
||||||
|
sendEmail: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req) => {
|
||||||
|
const b = req.body as any;
|
||||||
|
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
|
||||||
|
const result = await createTicket(app.ctx, {
|
||||||
|
name: b.name,
|
||||||
|
email: b.email ?? "",
|
||||||
|
ages: b.ages,
|
||||||
|
submissionKey,
|
||||||
|
});
|
||||||
|
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {
|
||||||
|
try {
|
||||||
|
const qr = await renderQrPng(result.code);
|
||||||
|
const { computeTotal } = await import("../fields.js");
|
||||||
|
await app.ctx.mailer.sendTicket({
|
||||||
|
toEmail: b.email,
|
||||||
|
toName: b.name,
|
||||||
|
code: result.code,
|
||||||
|
quantity: computeTotal(result.record),
|
||||||
|
qrPng: qr,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error({ err: e }, "manual ticket email failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { code: result.code };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
117
backend/src/routes/webhook.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { AGE_COLUMNS, toBool, toNumber } from "../fields.js";
|
||||||
|
import { createTicket } from "../ticketService.js";
|
||||||
|
import { renderQrPng } from "../services/qrcode.js";
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ba = Buffer.from(a || "");
|
||||||
|
const bb = Buffer.from(b || "");
|
||||||
|
if (ba.length !== bb.length) return false;
|
||||||
|
return timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map
|
||||||
|
// the FluentForms fields to in the webhook feed.
|
||||||
|
const AGE_KEY_TO_COL: Record<string, string> = {
|
||||||
|
ages_0_3: "Ages 0-3",
|
||||||
|
ages_4_7: "Ages 4-7",
|
||||||
|
ages_8_12: "Ages 8-12",
|
||||||
|
ages_13_17: "Ages 13-17",
|
||||||
|
ages_18_25: "Ages 18-25",
|
||||||
|
ages_26_45: "Ages 26-45",
|
||||||
|
ages_46_64: "Ages 46-64",
|
||||||
|
ages_65: "Ages 65+",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const handler = async (req: any, reply: any) => {
|
||||||
|
const secret = req.headers["x-webhook-secret"];
|
||||||
|
if (typeof secret !== "string" || !safeEqual(secret, app.ctx.config.WEBHOOK_SECRET)) {
|
||||||
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const name = String(body.name ?? "").trim();
|
||||||
|
const email = String(body.email ?? "").trim();
|
||||||
|
if (!name || !email) {
|
||||||
|
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build age-bracket counts from whichever keys were provided.
|
||||||
|
const ages: Record<string, number> = {};
|
||||||
|
for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) {
|
||||||
|
if (body[key] !== undefined && body[key] !== null && body[key] !== "") {
|
||||||
|
ages[col] = toNumber(body[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0);
|
||||||
|
if (!anyAge) {
|
||||||
|
return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency key: prefer a stable submission id, else hash the content.
|
||||||
|
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id;
|
||||||
|
const submissionKey = submissionId
|
||||||
|
? `sub:${String(submissionId)}`
|
||||||
|
: "hash:" +
|
||||||
|
createHash("sha256")
|
||||||
|
.update(`${email}|${name}|${JSON.stringify(ages)}`)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 32);
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof createTicket>>;
|
||||||
|
try {
|
||||||
|
result = await createTicket(app.ctx, {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
address: body.address !== undefined ? String(body.address) : undefined,
|
||||||
|
isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined,
|
||||||
|
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined,
|
||||||
|
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined,
|
||||||
|
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined,
|
||||||
|
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
||||||
|
ages,
|
||||||
|
submissionKey,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error({ err: e }, "webhook: failed to create ticket");
|
||||||
|
return reply.code(502).send({ error: "db_error", detail: e?.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "duplicate") {
|
||||||
|
return { status: "duplicate", code: result.code };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the ticket email. If it fails, the row already exists — report 502
|
||||||
|
// so the failure is visible in FluentForms' delivery log; the ticket can be
|
||||||
|
// re-sent later via POST /api/tickets/:code/resend-email.
|
||||||
|
if (app.ctx.mailer.isBlockedRecipient(email)) {
|
||||||
|
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
|
||||||
|
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qr = await renderQrPng(result.code);
|
||||||
|
const quantity = // redeemable total for the email copy
|
||||||
|
AGE_COLUMNS.filter((c) => c !== "Ages 0-3").reduce((s, c) => s + (ages[c] ?? 0), 0);
|
||||||
|
await app.ctx.mailer.sendTicket({
|
||||||
|
toEmail: email,
|
||||||
|
toName: name,
|
||||||
|
code: result.code,
|
||||||
|
quantity,
|
||||||
|
qrPng: qr,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed");
|
||||||
|
return reply.code(502).send({ status: "created", code: result.code, emailSent: false, error: e?.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "created", code: result.code, emailSent: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public path (configure this in FluentForms): https://scan.beartariacampgrounds.com/webhook
|
||||||
|
app.post("/webhook", handler);
|
||||||
|
// Explicit alias.
|
||||||
|
app.post("/api/webhook/fluentforms", handler);
|
||||||
|
}
|
||||||
66
backend/src/server.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "@fastify/jwt";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import formbody from "@fastify/formbody";
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
import { buildContext } from "./context.js";
|
||||||
|
import { authRoutes } from "./routes/auth.js";
|
||||||
|
import { webhookRoutes } from "./routes/webhook.js";
|
||||||
|
import { ticketRoutes } from "./routes/tickets.js";
|
||||||
|
|
||||||
|
export async function build() {
|
||||||
|
const config = loadConfig();
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: config.LOG_LEVEL },
|
||||||
|
trustProxy: true, // behind nginx
|
||||||
|
bodyLimit: 1_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("ctx", buildContext(config));
|
||||||
|
|
||||||
|
await app.register(jwt, { secret: config.TOKEN_SECRET });
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
await app.register(formbody); // accept application/x-www-form-urlencoded webhooks too
|
||||||
|
|
||||||
|
await app.register(authRoutes);
|
||||||
|
await app.register(webhookRoutes);
|
||||||
|
await app.register(ticketRoutes);
|
||||||
|
|
||||||
|
// Serve the exported Expo web build (if present) with SPA fallback.
|
||||||
|
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");
|
||||||
|
if (existsSync(webDir)) {
|
||||||
|
await app.register(fastifyStatic, { root: webDir, wildcard: false });
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
// Let unknown /api routes 404 as JSON; everything else -> SPA index.
|
||||||
|
if (req.raw.url && req.raw.url.startsWith("/api")) {
|
||||||
|
return reply.code(404).send({ error: "not_found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
app.log.info({ webDir }, "serving static web build");
|
||||||
|
} else {
|
||||||
|
app.log.warn({ webDir }, "no web build found; serving API only");
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry point (skipped when imported by tests).
|
||||||
|
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||||||
|
if (isMain) {
|
||||||
|
const config = loadConfig();
|
||||||
|
build()
|
||||||
|
.then((app) => app.listen({ port: config.PORT, host: config.HOST }))
|
||||||
|
.then((addr) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`camptickets backend listening on ${addr}`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
34
backend/src/services/code.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { randomInt } from "node:crypto";
|
||||||
|
|
||||||
|
// Crockford-ish alphabet: no 0/O/1/I/L/U to avoid human/QR ambiguity.
|
||||||
|
const ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||||
|
const PREFIX = "BC26";
|
||||||
|
|
||||||
|
/** Generate a ticket code like BC26-XXXX-XXXX. */
|
||||||
|
export function generateCode(): string {
|
||||||
|
let body = "";
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
body += ALPHABET[randomInt(ALPHABET.length)];
|
||||||
|
if (i === 3) body += "-";
|
||||||
|
}
|
||||||
|
return `${PREFIX}-${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a scanned/typed code for lookup: uppercase, strip everything that
|
||||||
|
* isn't in the alphabet or the prefix, then re-hyphenate to canonical form.
|
||||||
|
* Accepts input with or without hyphens, with surrounding whitespace, etc.
|
||||||
|
*/
|
||||||
|
export function normalizeCode(raw: string): string {
|
||||||
|
const cleaned = (raw || "").toUpperCase().replace(/[^0-9A-Z]/g, "");
|
||||||
|
// Expected canonical: BC26 + 8 body chars = 12 chars total.
|
||||||
|
if (!cleaned.startsWith("BC26")) return cleaned;
|
||||||
|
const body = cleaned.slice(4);
|
||||||
|
if (body.length !== 8) return cleaned;
|
||||||
|
return `${PREFIX}-${body.slice(0, 4)}-${body.slice(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if a string looks like a ticket code (vs. a name search query). */
|
||||||
|
export function looksLikeCode(raw: string): boolean {
|
||||||
|
return /^BC26/i.test((raw || "").trim().replace(/[\s-]/g, ""));
|
||||||
|
}
|
||||||
152
backend/src/services/mailer.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import type { Config } from "../config.js";
|
||||||
|
|
||||||
|
const MAILERSEND_URL = "https://api.mailersend.com/v1/email";
|
||||||
|
const SUBJECT = "2026 Beartaria Campgrounds Tickets";
|
||||||
|
|
||||||
|
export interface TicketEmail {
|
||||||
|
toEmail: string;
|
||||||
|
toName: string;
|
||||||
|
code: string;
|
||||||
|
quantity: number;
|
||||||
|
qrPng: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MailerSendError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "MailerSendError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Mailer {
|
||||||
|
private readonly token: string;
|
||||||
|
private readonly fromEmail: string;
|
||||||
|
private readonly fromName: string;
|
||||||
|
private readonly testRecipients: Set<string> | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
cfg: Pick<
|
||||||
|
Config,
|
||||||
|
"MAILERSEND_API_TOKEN" | "MAIL_FROM_EMAIL" | "MAIL_FROM_NAME" | "MAIL_TEST_RECIPIENTS"
|
||||||
|
>,
|
||||||
|
private readonly fetchImpl: typeof fetch = fetch,
|
||||||
|
) {
|
||||||
|
this.token = cfg.MAILERSEND_API_TOKEN;
|
||||||
|
this.fromEmail = cfg.MAIL_FROM_EMAIL;
|
||||||
|
this.fromName = cfg.MAIL_FROM_NAME;
|
||||||
|
const list = (cfg.MAIL_TEST_RECIPIENTS || "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
this.testRecipients = list.length ? new Set(list) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if MailerSend trial restrictions would block this recipient. */
|
||||||
|
isBlockedRecipient(email: string): boolean {
|
||||||
|
if (!this.testRecipients) return false;
|
||||||
|
return !this.testRecipients.has(email.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTicket(mail: TicketEmail): Promise<void> {
|
||||||
|
const payload = {
|
||||||
|
from: { email: this.fromEmail, name: this.fromName },
|
||||||
|
to: [{ email: mail.toEmail, name: mail.toName || mail.toEmail }],
|
||||||
|
subject: SUBJECT,
|
||||||
|
html: renderHtml(mail),
|
||||||
|
text: renderText(mail),
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
content: mail.qrPng.toString("base64"),
|
||||||
|
filename: "ticket-qr.png",
|
||||||
|
disposition: "inline",
|
||||||
|
id: "qrcode",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await this.fetchImpl(MAILERSEND_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
throw new MailerSendError(
|
||||||
|
`MailerSend ${res.status}: ${body || res.statusText}`,
|
||||||
|
res.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return String(s).replace(/[&<>"]/g, (c) =>
|
||||||
|
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHtml(mail: TicketEmail): string {
|
||||||
|
const name = esc(mail.toName || "");
|
||||||
|
const qty = mail.quantity;
|
||||||
|
const ticketWord = qty === 1 ? "ticket" : "tickets";
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0f1a12;padding:24px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:12px;overflow:hidden;">
|
||||||
|
<tr><td style="background:#1b5e20;padding:20px 24px;color:#ffffff;font-size:18px;font-weight:bold;">
|
||||||
|
🐻 Beartaria Campgrounds 2026
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:24px;">
|
||||||
|
<p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p>
|
||||||
|
<p style="margin:0 0 16px;font-size:15px;line-height:1.5;">
|
||||||
|
Thank you for your purchase! This email is your ticket for
|
||||||
|
<strong>${qty} ${ticketWord}</strong> to the 2026 Beartaria Campgrounds event.
|
||||||
|
Show the QR code below at the gate.
|
||||||
|
</p>
|
||||||
|
<div style="text-align:center;margin:20px 0;">
|
||||||
|
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
|
||||||
|
style="width:280px;height:280px;border:8px solid #ffffff;border-radius:8px;" />
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 4px;font-size:13px;color:#555;text-align:center;">If the code above doesn't load, show this at the gate:</p>
|
||||||
|
<p style="margin:0 0 20px;font-size:22px;font-weight:bold;letter-spacing:2px;text-align:center;color:#1b5e20;">
|
||||||
|
${esc(mail.code)}
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:13px;color:#777;line-height:1.5;">
|
||||||
|
Each ticket admits one entry. This code is good for all ${qty} ${ticketWord} on one purchase —
|
||||||
|
gate staff will check people in against it. See you there!
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="color:#6c8f74;font-size:11px;margin:16px 0 0;">Beartaria Campgrounds · beartariacampgrounds.com</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderText(mail: TicketEmail): string {
|
||||||
|
const qty = mail.quantity;
|
||||||
|
const ticketWord = qty === 1 ? "ticket" : "tickets";
|
||||||
|
return [
|
||||||
|
`Hi ${mail.toName || "there"},`,
|
||||||
|
"",
|
||||||
|
`Thank you for your purchase! This is your ticket for ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.`,
|
||||||
|
"",
|
||||||
|
`Your ticket code: ${mail.code}`,
|
||||||
|
"",
|
||||||
|
"Show this code (or the QR code in the HTML version of this email) at the gate.",
|
||||||
|
`It is good for all ${qty} ${ticketWord} on this purchase.`,
|
||||||
|
"",
|
||||||
|
"See you there!",
|
||||||
|
"Beartaria Campgrounds · beartariacampgrounds.com",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
121
backend/src/services/nocodb.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import type { Config } from "../config.js";
|
||||||
|
import { COL, type NocoRecord } from "../fields.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin client over the NocoDB v2 records REST API.
|
||||||
|
* Docs: {baseUrl}/api/v2/tables/{tableId}/records (auth header: xc-token)
|
||||||
|
*/
|
||||||
|
export class NocoDBClient {
|
||||||
|
private readonly base: string;
|
||||||
|
private readonly token: string;
|
||||||
|
private readonly tableId: string;
|
||||||
|
|
||||||
|
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
|
||||||
|
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
|
||||||
|
this.token = cfg.NOCODB_API_TOKEN;
|
||||||
|
this.tableId = cfg.NOCODB_TABLE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get recordsUrl(): string {
|
||||||
|
return `${this.base}/api/v2/tables/${this.tableId}/records`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(url: string, init: RequestInit = {}): Promise<any> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"xc-token": this.token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let body: any = undefined;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail =
|
||||||
|
body && typeof body === "object" && body.msg
|
||||||
|
? body.msg
|
||||||
|
: typeof body === "string"
|
||||||
|
? body
|
||||||
|
: res.statusText;
|
||||||
|
throw new NocoDBError(`NocoDB ${res.status}: ${detail}`, res.status);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async list(where: string, limit = 25): Promise<NocoRecord[]> {
|
||||||
|
const url = new URL(this.recordsUrl);
|
||||||
|
if (where) url.searchParams.set("where", where);
|
||||||
|
url.searchParams.set("limit", String(limit));
|
||||||
|
const body = await this.request(url.toString());
|
||||||
|
return (body?.list ?? []) as NocoRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact lookup by ticket code. Returns null if not found. */
|
||||||
|
async findByCode(code: string): Promise<NocoRecord | null> {
|
||||||
|
const rows = await this.list(`(${COL.code},eq,${escapeValue(code)})`, 1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lookup by idempotency key. Returns null if not found. */
|
||||||
|
async findBySubmissionKey(key: string): Promise<NocoRecord | null> {
|
||||||
|
const rows = await this.list(`(${COL.submissionKey},eq,${escapeValue(key)})`, 1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Substring search across name and email. */
|
||||||
|
async search(query: string, limit = 25): Promise<NocoRecord[]> {
|
||||||
|
const q = escapeValue(query);
|
||||||
|
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||||
|
const body = await this.request(this.recordsUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(fields),
|
||||||
|
});
|
||||||
|
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patch fields on a record identified by its NocoDB Id. */
|
||||||
|
async update(id: number, fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||||
|
const body = await this.request(this.recordsUrl, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ Id: id, ...fields }),
|
||||||
|
});
|
||||||
|
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cheap connectivity probe for healthchecks. */
|
||||||
|
async ping(): Promise<boolean> {
|
||||||
|
const url = new URL(this.recordsUrl);
|
||||||
|
url.searchParams.set("limit", "1");
|
||||||
|
await this.request(url.toString());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NocoDBError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = "NocoDBError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a value for use inside a NocoDB `where=(Field,op,VALUE)` clause.
|
||||||
|
* Parentheses and commas are structural in the filter grammar; strip them.
|
||||||
|
* Ticket codes/emails/names never legitimately contain them for filtering.
|
||||||
|
*/
|
||||||
|
function escapeValue(v: string): string {
|
||||||
|
return String(v).replace(/[(),]/g, " ").trim();
|
||||||
|
}
|
||||||
11
backend/src/services/qrcode.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
/** Render a ticket code to a PNG buffer suitable for inline email embedding. */
|
||||||
|
export async function renderQrPng(code: string): Promise<Buffer> {
|
||||||
|
return QRCode.toBuffer(code, {
|
||||||
|
type: "png",
|
||||||
|
errorCorrectionLevel: "M",
|
||||||
|
width: 400,
|
||||||
|
margin: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
48
backend/src/services/redeemQueue.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Per-key serialization. NocoDB has no atomic increment, so all read-modify-write
|
||||||
|
* operations for a given ticket code must run one at a time. Operations on
|
||||||
|
* different codes run concurrently.
|
||||||
|
*
|
||||||
|
* IMPORTANT: correctness depends on a SINGLE backend instance. Never scale this
|
||||||
|
* service to multiple replicas — the queue is in-process only.
|
||||||
|
*/
|
||||||
|
export class RedeemQueue {
|
||||||
|
private chains = new Map<string, Promise<unknown>>();
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
|
||||||
|
constructor(timeoutMs = 10_000) {
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `fn` after any in-flight op for `key` completes. */
|
||||||
|
run<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const prior = this.chains.get(key) ?? Promise.resolve();
|
||||||
|
// Chain regardless of whether the prior op resolved or rejected.
|
||||||
|
const next = prior.catch(() => undefined).then(() => this.withTimeout(fn));
|
||||||
|
this.chains.set(key, next);
|
||||||
|
// Clean up the map entry once this is the tail of the chain.
|
||||||
|
next.finally(() => {
|
||||||
|
if (this.chains.get(key) === next) this.chains.delete(key);
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private withTimeout<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error("redeem operation timed out")),
|
||||||
|
this.timeoutMs,
|
||||||
|
);
|
||||||
|
fn().then(
|
||||||
|
(v) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(v);
|
||||||
|
},
|
||||||
|
(e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
35
backend/src/test/code.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { generateCode, normalizeCode, looksLikeCode } from "../services/code.js";
|
||||||
|
|
||||||
|
describe("code generation", () => {
|
||||||
|
it("produces BC26-XXXX-XXXX with unambiguous alphabet", () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const c = generateCode();
|
||||||
|
expect(c).toMatch(/^BC26-[23456789ABCDEFGHJKMNPQRSTVWXYZ]{4}-[23456789ABCDEFGHJKMNPQRSTVWXYZ]{4}$/);
|
||||||
|
expect(c).not.toMatch(/[01OILU]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is effectively unique across many draws", () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let i = 0; i < 5000; i++) seen.add(generateCode());
|
||||||
|
expect(seen.size).toBeGreaterThan(4990);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeCode", () => {
|
||||||
|
it("canonicalizes case, spaces, and missing hyphens", () => {
|
||||||
|
expect(normalizeCode("bc26abcd2345")).toBe("BC26-ABCD-2345");
|
||||||
|
expect(normalizeCode("BC26-ABCD-2345")).toBe("BC26-ABCD-2345");
|
||||||
|
expect(normalizeCode(" bc26 abcd 2345 ")).toBe("BC26-ABCD-2345");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("looksLikeCode", () => {
|
||||||
|
it("distinguishes codes from name queries", () => {
|
||||||
|
expect(looksLikeCode("BC26-ABCD-2345")).toBe(true);
|
||||||
|
expect(looksLikeCode("bc26abcd2345")).toBe(true);
|
||||||
|
expect(looksLikeCode("Smith")).toBe(false);
|
||||||
|
expect(looksLikeCode("jane@example.com")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
backend/src/test/fakeNocodb.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { AppContext } from "../context.js";
|
||||||
|
import { COL, type NocoRecord } from "../fields.js";
|
||||||
|
import { RedeemQueue } from "../services/redeemQueue.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory stand-in for NocoDBClient. Adds a small async delay to each op so
|
||||||
|
* the per-code serialization in RedeemQueue is actually exercised (a naive
|
||||||
|
* read-modify-write without the queue would lose updates under this delay).
|
||||||
|
*/
|
||||||
|
export class FakeNocoDB {
|
||||||
|
rows: NocoRecord[] = [];
|
||||||
|
private nextId = 1;
|
||||||
|
delayMs: number;
|
||||||
|
failNext = false;
|
||||||
|
|
||||||
|
constructor(delayMs = 5) {
|
||||||
|
this.delayMs = delayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delay() {
|
||||||
|
await new Promise((r) => setTimeout(r, this.delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<NocoRecord | null> {
|
||||||
|
await this.delay();
|
||||||
|
return this.rows.find((r) => r[COL.code] === code) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySubmissionKey(key: string): Promise<NocoRecord | null> {
|
||||||
|
await this.delay();
|
||||||
|
return this.rows.find((r) => r[COL.submissionKey] === key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string): Promise<NocoRecord[]> {
|
||||||
|
await this.delay();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return this.rows.filter(
|
||||||
|
(r) =>
|
||||||
|
String(r[COL.name] ?? "").toLowerCase().includes(q) ||
|
||||||
|
String(r[COL.email] ?? "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||||
|
await this.delay();
|
||||||
|
const rec = { Id: this.nextId++, ...fields } as NocoRecord;
|
||||||
|
this.rows.push(rec);
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||||
|
await this.delay();
|
||||||
|
if (this.failNext) {
|
||||||
|
this.failNext = false;
|
||||||
|
throw new Error("simulated NocoDB failure");
|
||||||
|
}
|
||||||
|
const rec = this.rows.find((r) => r.Id === id);
|
||||||
|
if (!rec) throw new Error("not found");
|
||||||
|
Object.assign(rec, fields);
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fakeContext(db: FakeNocoDB): AppContext {
|
||||||
|
return {
|
||||||
|
config: {} as any,
|
||||||
|
nocodb: db as any,
|
||||||
|
mailer: { isBlockedRecipient: () => false, sendTicket: async () => {} } as any,
|
||||||
|
queue: new RedeemQueue(2000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedTicket(
|
||||||
|
db: FakeNocoDB,
|
||||||
|
opts: { code: string; name?: string; email?: string; ages?: Record<string, number>; redeemed?: number },
|
||||||
|
): Promise<NocoRecord> {
|
||||||
|
const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
|
||||||
|
return db.create({
|
||||||
|
[COL.code]: opts.code,
|
||||||
|
[COL.name]: opts.name ?? "Test Bear",
|
||||||
|
[COL.email]: opts.email ?? "test@example.com",
|
||||||
|
[COL.redeemed]: opts.redeemed ?? 0,
|
||||||
|
...ages,
|
||||||
|
});
|
||||||
|
}
|
||||||
46
backend/src/test/fields.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeTotal, toView, COL } from "../fields.js";
|
||||||
|
|
||||||
|
describe("computeTotal", () => {
|
||||||
|
it("sums age brackets but excludes Ages 0-3 (free)", () => {
|
||||||
|
const rec = {
|
||||||
|
Id: 1,
|
||||||
|
"Ages 0-3": 2, // free, not counted
|
||||||
|
"Ages 4-7": 1,
|
||||||
|
"Ages 18-25": 2,
|
||||||
|
"Ages 26-45": 1,
|
||||||
|
};
|
||||||
|
expect(computeTotal(rec)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces string counts and treats blanks as 0", () => {
|
||||||
|
const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any;
|
||||||
|
expect(computeTotal(rec)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toView", () => {
|
||||||
|
it("derives remaining and surfaces extras", () => {
|
||||||
|
const rec = {
|
||||||
|
Id: 7,
|
||||||
|
[COL.code]: "BC26-ABCD-2345",
|
||||||
|
[COL.name]: "Jane Bear",
|
||||||
|
[COL.email]: "jane@example.com",
|
||||||
|
[COL.redeemed]: 2,
|
||||||
|
[COL.carParking]: true,
|
||||||
|
[COL.iceAccess]: "yes",
|
||||||
|
"Ages 0-3": 1,
|
||||||
|
"Ages 18-25": 2,
|
||||||
|
"Ages 26-45": 3,
|
||||||
|
};
|
||||||
|
const v = toView(rec);
|
||||||
|
expect(v.total).toBe(5);
|
||||||
|
expect(v.redeemed).toBe(2);
|
||||||
|
expect(v.remaining).toBe(3);
|
||||||
|
expect(v.extras.carParking).toBe(true);
|
||||||
|
expect(v.extras.iceAccess).toBe(true);
|
||||||
|
expect(v.extras.rvParking).toBe(false);
|
||||||
|
expect(v.extras.freeUnder4).toBe(1);
|
||||||
|
expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
122
backend/src/test/redeem.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { FakeNocoDB, fakeContext, seedTicket } from "./fakeNocodb.js";
|
||||||
|
import { redeem, lookupByCode, createTicket } from "../ticketService.js";
|
||||||
|
import { COL } from "../fields.js";
|
||||||
|
|
||||||
|
describe("redeem", () => {
|
||||||
|
it("checks in a single walk-up (default count 1)", async () => {
|
||||||
|
const db = new FakeNocoDB();
|
||||||
|
await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 4 } });
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
const r = await redeem(ctx, "BC26-AAAA-1111", 1);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.checkedIn).toBe(1);
|
||||||
|
expect(r.ticket.redeemed).toBe(1);
|
||||||
|
expect(r.ticket.remaining).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports group check-in and QR reuse across visits", async () => {
|
||||||
|
const db = new FakeNocoDB();
|
||||||
|
// Party of 7 (2 free under-4 not counted): total 5.
|
||||||
|
await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } });
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
|
||||||
|
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
|
||||||
|
expect(first.ok && first.ticket.remaining).toBe(3);
|
||||||
|
|
||||||
|
const second = await redeem(ctx, "BC26-FAM-0001", 3); // mother + 3 daughters
|
||||||
|
expect(second.ok && second.ticket.remaining).toBe(0);
|
||||||
|
|
||||||
|
const third = await redeem(ctx, "BC26-FAM-0001", 1); // nobody left
|
||||||
|
expect(third.ok).toBe(false);
|
||||||
|
if (!third.ok) expect(third.reason).toBe("exhausted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects over-redemption without mutating", async () => {
|
||||||
|
const db = new FakeNocoDB();
|
||||||
|
await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 2 } });
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
const r = await redeem(ctx, "BC26-BBBB-2222", 5);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toBe("insufficient");
|
||||||
|
expect(db.rows[0][COL.redeemed]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows negative count to undo, clamped at zero", async () => {
|
||||||
|
const db = new FakeNocoDB();
|
||||||
|
await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 3 }, redeemed: 2 });
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
const r = await redeem(ctx, "BC26-CCCC-3333", -5);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.ticket.redeemed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not_found for unknown codes", async () => {
|
||||||
|
const ctx = fakeContext(new FakeNocoDB());
|
||||||
|
const r = await redeem(ctx, "BC26-ZZZZ-9999", 1);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toBe("not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces db_error when the update fails", async () => {
|
||||||
|
const db = new FakeNocoDB();
|
||||||
|
await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 3 } });
|
||||||
|
db.failNext = true;
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
const r = await redeem(ctx, "BC26-DDDD-4444", 1);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toBe("db_error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
|
||||||
|
const db = new FakeNocoDB(8);
|
||||||
|
await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 5 } });
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 20 }, () => redeem(ctx, "BC26-RACE-0005", 1)),
|
||||||
|
);
|
||||||
|
const successes = results.filter((r) => r.ok).length;
|
||||||
|
expect(successes).toBe(5);
|
||||||
|
expect(db.rows[0][COL.redeemed]).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lookupByCode", () => {
|
||||||
|
it("returns the ticket view without mutating", async () => {
|
||||||
|
const db = new FakeNocoDB();
|
||||||
|
await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 3 } });
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
const r = await lookupByCode(ctx, "BC26-LOOK-0001");
|
||||||
|
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
|
||||||
|
expect(db.rows[0][COL.redeemed]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports not found", async () => {
|
||||||
|
const ctx = fakeContext(new FakeNocoDB());
|
||||||
|
const r = await lookupByCode(ctx, "BC26-NONE-0000");
|
||||||
|
expect(r.ok && !("found" in r ? false : true));
|
||||||
|
if (r.ok) expect(r.found).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTicket idempotency", () => {
|
||||||
|
it("does not create a second row for the same submission key", async () => {
|
||||||
|
const db = new FakeNocoDB();
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
const input = {
|
||||||
|
name: "Jane Bear",
|
||||||
|
email: "jane@example.com",
|
||||||
|
ages: { "Ages 26-45": 2 },
|
||||||
|
submissionKey: "sub:412",
|
||||||
|
};
|
||||||
|
const a = await createTicket(ctx, input);
|
||||||
|
const b = await createTicket(ctx, input);
|
||||||
|
expect(a.status).toBe("created");
|
||||||
|
expect(b.status).toBe("duplicate");
|
||||||
|
expect(b.code).toBe(a.code);
|
||||||
|
expect(db.rows.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
139
backend/src/ticketService.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import type { AppContext } from "./context.js";
|
||||||
|
import { generateCode } from "./services/code.js";
|
||||||
|
import { COL, toView, computeTotal, type NocoRecord, type TicketView } from "./fields.js";
|
||||||
|
|
||||||
|
export type { TicketView };
|
||||||
|
|
||||||
|
export type LookupResult =
|
||||||
|
| { ok: true; found: true; ticket: TicketView }
|
||||||
|
| { ok: true; found: false }
|
||||||
|
| { ok: false; reason: "db_error"; detail: string };
|
||||||
|
|
||||||
|
/** Read a ticket by code. No mutation. Used the instant a QR is scanned. */
|
||||||
|
export async function lookupByCode(ctx: AppContext, code: string): Promise<LookupResult> {
|
||||||
|
try {
|
||||||
|
const rec = await ctx.nocodb.findByCode(code);
|
||||||
|
if (!rec) return { ok: true, found: false };
|
||||||
|
return { ok: true, found: true, ticket: toView(rec) };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, reason: "db_error", detail: e?.message ?? "lookup failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RedeemResult =
|
||||||
|
| { ok: true; ticket: TicketView; checkedIn: number }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
reason: "not_found" | "exhausted" | "insufficient" | "db_error";
|
||||||
|
ticket?: TicketView;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem `count` tickets against a code. Serialized per-code so concurrent
|
||||||
|
* scans at multiple gates can never over-redeem.
|
||||||
|
*
|
||||||
|
* A single QR is reusable across visits until Redeemed reaches Total (e.g. a
|
||||||
|
* family splitting into two arrivals). `count` is how many people are entering
|
||||||
|
* on THIS visit (default 1 for a single walk-up).
|
||||||
|
*
|
||||||
|
* Positive count that exceeds the remaining balance is rejected (staff can't
|
||||||
|
* check in more people than the ticket allows). Negative count undoes a
|
||||||
|
* mistaken check-in, clamped so Redeemed never drops below 0.
|
||||||
|
*/
|
||||||
|
export async function redeem(ctx: AppContext, code: string, count: number): Promise<RedeemResult> {
|
||||||
|
const n = Math.trunc(count);
|
||||||
|
if (!Number.isFinite(n) || n === 0) {
|
||||||
|
return { ok: false, reason: "insufficient", detail: "count must be a non-zero integer" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.queue.run(code, async () => {
|
||||||
|
let rec: NocoRecord | null;
|
||||||
|
try {
|
||||||
|
rec = await ctx.nocodb.findByCode(code);
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, reason: "db_error", detail: e?.message ?? "lookup failed" };
|
||||||
|
}
|
||||||
|
if (!rec) return { ok: false, reason: "not_found" };
|
||||||
|
|
||||||
|
const total = computeTotal(rec);
|
||||||
|
const redeemed = Number(rec[COL.redeemed]) || 0;
|
||||||
|
const remaining = Math.max(0, total - redeemed);
|
||||||
|
|
||||||
|
if (n > 0 && remaining === 0) {
|
||||||
|
return { ok: false, reason: "exhausted", ticket: toView(rec) };
|
||||||
|
}
|
||||||
|
if (n > 0 && n > remaining) {
|
||||||
|
return { ok: false, reason: "insufficient", ticket: toView(rec) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = Math.min(total, Math.max(0, redeemed + n));
|
||||||
|
try {
|
||||||
|
const updated = await ctx.nocodb.update(rec.Id, {
|
||||||
|
[COL.redeemed]: next,
|
||||||
|
[COL.lastScanAt]: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
// Trust our computed `next` but prefer the DB's echoed value if present.
|
||||||
|
const confirmed = { ...rec, [COL.redeemed]: Number(updated?.[COL.redeemed] ?? next) };
|
||||||
|
return { ok: true, ticket: toView(confirmed), checkedIn: next - redeemed };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, reason: "db_error", ticket: toView(rec), detail: e?.message ?? "update failed" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Substring search by name/email for the admin panel. */
|
||||||
|
export async function search(ctx: AppContext, query: string): Promise<TicketView[]> {
|
||||||
|
const rows = await ctx.nocodb.search(query);
|
||||||
|
return rows.map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookInput {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
address?: string;
|
||||||
|
isDonor?: boolean;
|
||||||
|
carParking?: boolean;
|
||||||
|
rvParking?: boolean;
|
||||||
|
iceAccess?: boolean;
|
||||||
|
paymentMethod?: string;
|
||||||
|
ages: Record<string, number>; // NocoDB age-column title -> count
|
||||||
|
submissionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent ticket creation from a purchase webhook. Returns the code. */
|
||||||
|
export async function createTicket(
|
||||||
|
ctx: AppContext,
|
||||||
|
input: WebhookInput,
|
||||||
|
): Promise<{ status: "created" | "duplicate"; code: string; record: NocoRecord }> {
|
||||||
|
const existing = await ctx.nocodb.findBySubmissionKey(input.submissionKey);
|
||||||
|
if (existing) {
|
||||||
|
return { status: "duplicate", code: String(existing[COL.code] ?? ""), record: existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique code, retrying on the rare collision.
|
||||||
|
let code = generateCode();
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
const clash = await ctx.nocodb.findByCode(code);
|
||||||
|
if (!clash) break;
|
||||||
|
code = generateCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: Record<string, unknown> = {
|
||||||
|
[COL.name]: input.name,
|
||||||
|
[COL.email]: input.email,
|
||||||
|
[COL.code]: code,
|
||||||
|
[COL.redeemed]: 0,
|
||||||
|
[COL.submissionKey]: input.submissionKey,
|
||||||
|
...input.ages,
|
||||||
|
};
|
||||||
|
if (input.address !== undefined) fields[COL.address] = input.address;
|
||||||
|
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
|
||||||
|
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
|
||||||
|
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
|
||||||
|
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
|
||||||
|
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;
|
||||||
|
|
||||||
|
const record = await ctx.nocodb.create(fields);
|
||||||
|
return { status: "created", code, record };
|
||||||
|
}
|
||||||
19
backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "src/test/**"]
|
||||||
|
}
|
||||||
24
ci/set-version.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Set app.json version + Android versionCode from a git tag before prebuild.
|
||||||
|
// Usage: node ci/set-version.mjs v1.2.3
|
||||||
|
// versionName = 1.2.3 ; versionCode = 1*1_000_000 + 2*1_000 + 3 (monotonic,
|
||||||
|
// so Obtainium installs each new release over the previous one).
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const raw = process.argv[2] || "v0.0.0";
|
||||||
|
const m = raw.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||||
|
if (!m) {
|
||||||
|
console.error(`set-version: cannot parse tag "${raw}"`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const [, MA, MI, PA] = m.map(Number);
|
||||||
|
const versionName = `${MA}.${MI}.${PA}`;
|
||||||
|
const versionCode = MA * 1_000_000 + MI * 1_000 + PA;
|
||||||
|
|
||||||
|
const path = new URL("../app/app.json", import.meta.url);
|
||||||
|
const cfg = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
cfg.expo.version = versionName;
|
||||||
|
cfg.expo.android = cfg.expo.android || {};
|
||||||
|
cfg.expo.android.versionCode = versionCode;
|
||||||
|
writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
|
||||||
|
|
||||||
|
console.log(`set-version: versionName=${versionName} versionCode=${versionCode}`);
|
||||||
30
ci/signing.gradle
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Injected at build time with `--init-script` so it survives `expo prebuild`
|
||||||
|
// regenerating android/. Overrides the release signing config to use the
|
||||||
|
// keystore + credentials supplied via environment variables in CI.
|
||||||
|
gradle.projectsLoaded {
|
||||||
|
rootProject.subprojects { sub ->
|
||||||
|
sub.afterEvaluate {
|
||||||
|
if (sub.plugins.hasPlugin('com.android.application')) {
|
||||||
|
def storeFilePath = System.getenv('CAMPSCAN_STORE_FILE')
|
||||||
|
if (storeFilePath == null || storeFilePath.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub.android {
|
||||||
|
signingConfigs {
|
||||||
|
campscanRelease {
|
||||||
|
storeFile file(storeFilePath)
|
||||||
|
storePassword System.getenv('CAMPSCAN_STORE_PASSWORD')
|
||||||
|
keyAlias System.getenv('CAMPSCAN_KEY_ALIAS')
|
||||||
|
keyPassword System.getenv('CAMPSCAN_KEY_PASSWORD')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.campscanRelease
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
docker-compose.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
services:
|
||||||
|
camptickets:
|
||||||
|
build: .
|
||||||
|
image: camptickets:latest
|
||||||
|
container_name: camptickets
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Container always listens on 8080 internally; the host mapping below is
|
||||||
|
# what nginx proxies to. Keep this fixed regardless of .env PORT.
|
||||||
|
PORT: "8080"
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
ports:
|
||||||
|
# Bind to localhost only — nginx (on the host) terminates TLS for
|
||||||
|
# scan.beartariacampgrounds.com and proxies to this port.
|
||||||
|
- "127.0.0.1:${HOST_PORT:-8091}:8080"
|
||||||
|
# NocoDB runs elsewhere (its own container/host). If it is on THIS host,
|
||||||
|
# host.docker.internal resolves to the host gateway.
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
7
runner/.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Copy to runner/.env (git-ignored). Get a fresh registration token from
|
||||||
|
# git.mowden.top -> repo/org/site Settings -> Actions -> Runners -> Create.
|
||||||
|
# The token is consumed at first registration; after that the runner
|
||||||
|
# authenticates from /data/.runner and this value is no longer used.
|
||||||
|
REGISTRATION_TOKEN=
|
||||||
|
RUNNER_NAME=camptickets-runner
|
||||||
|
DOCKER_GID=988 # host: stat -c %g /var/run/docker.sock
|
||||||
34
runner/docker-compose.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
services:
|
||||||
|
runner:
|
||||||
|
image: code.forgejo.org/forgejo/runner:6.3.1
|
||||||
|
container_name: camptickets-runner
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /data
|
||||||
|
# Grant access to the host Docker socket group so the (uid 1000) runner can
|
||||||
|
# talk to the daemon. Set DOCKER_GID in runner/.env to your host's
|
||||||
|
# `stat -c %g /var/run/docker.sock` if it differs.
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-988}"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
# Runner spawns job containers via the host Docker daemon.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
RUNNER_NAME: ${RUNNER_NAME:-camptickets-runner}
|
||||||
|
# Register on first boot (when no .runner config exists yet), then run the
|
||||||
|
# daemon. The `docker` label maps to the node:22-bookworm job image the
|
||||||
|
# build-apk workflow expects (it installs the Android SDK itself).
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
if [ ! -f /data/.runner ]; then
|
||||||
|
echo "Registering runner with git.mowden.top ..."
|
||||||
|
forgejo-runner register --no-interactive \
|
||||||
|
--instance https://git.mowden.top \
|
||||||
|
--token "${REGISTRATION_TOKEN}" \
|
||||||
|
--name "${RUNNER_NAME:-camptickets-runner}" \
|
||||||
|
--labels "docker:docker://node:22-bookworm"
|
||||||
|
fi
|
||||||
|
exec forgejo-runner daemon
|
||||||
38
scripts/gen-keystore.sh
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generate a release signing keystore for the Android APK (run ONCE), then print
|
||||||
|
# the base64 + values to paste into Forgejo repo secrets. Keep the keystore file
|
||||||
|
# safe and constant forever — losing it or changing it breaks Obtainium updates
|
||||||
|
# (a differently-signed APK will not install over the old one).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
KEYSTORE="${1:-campscan-release.keystore}"
|
||||||
|
ALIAS="${2:-campscan}"
|
||||||
|
|
||||||
|
if [ -f "$KEYSTORE" ]; then
|
||||||
|
echo "Refusing to overwrite existing $KEYSTORE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -s -p "Choose a keystore password: " STOREPASS; echo
|
||||||
|
read -r -s -p "Confirm keystore password: " STOREPASS2; echo
|
||||||
|
[ "$STOREPASS" = "$STOREPASS2" ] || { echo "Passwords do not match" >&2; exit 1; }
|
||||||
|
|
||||||
|
keytool -genkeypair -v \
|
||||||
|
-keystore "$KEYSTORE" \
|
||||||
|
-alias "$ALIAS" \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-storepass "$STOREPASS" -keypass "$STOREPASS" \
|
||||||
|
-dname "CN=Beartaria Campgrounds, OU=Gate, O=Beartaria, L=, ST=, C=US"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==================== Forgejo repo secrets ===================="
|
||||||
|
echo "Set these under: git.mowden.top -> CampgroundTickets -> Settings -> Actions -> Secrets"
|
||||||
|
echo
|
||||||
|
echo "ANDROID_KEY_ALIAS = $ALIAS"
|
||||||
|
echo "ANDROID_KEYSTORE_PASSWORD = (the password you just entered)"
|
||||||
|
echo "ANDROID_KEY_PASSWORD = (the same password)"
|
||||||
|
echo "ANDROID_KEYSTORE_B64 = (paste the block below, single line)"
|
||||||
|
echo
|
||||||
|
base64 -w0 "$KEYSTORE"; echo
|
||||||
|
echo "============================================================="
|
||||||
|
echo "Store $KEYSTORE somewhere safe and OFF this repo (it is git-ignored)."
|
||||||