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>
This commit is contained in:
Hank 2026-07-08 03:47:59 +00:00
commit 3397e3e3ec
60 changed files with 14703 additions and 0 deletions

12
.dockerignore Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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" },
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
app/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

BIN
app/assets/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

BIN
app/assets/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
app/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
app/assets/sounds/error.wav Normal file

Binary file not shown.

Binary file not shown.

View 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" },
});

View 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" },
});

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

33
app/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

BIN
app/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 B

BIN
app/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

15
app/public/manifest.json Normal file
View 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" }
]
}

View 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
View 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

File diff suppressed because it is too large Load diff

29
backend/package.json Normal file
View 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
View 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
View 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
View 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 };

View 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 };
},
);
}

View 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 };
},
);
}

View 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
View 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);
});
}

View 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, ""));
}

View 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 === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;",
);
}
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");
}

View 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();
}

View 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,
});
}

View 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);
},
);
});
}
}

View 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);
});
});

View 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,
});
}

View 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);
});
});

View 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);
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)."