Compare commits
28 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc93ef43f7 | |||
| efe331a77a | |||
| 1c8cb47209 | |||
| 3a3119e324 | |||
| 60f0908299 | |||
| 62231e9b10 | |||
| 63e00fae61 | |||
| 049f433930 | |||
| 49e45ff94c | |||
| a987e046da | |||
| e86651723d | |||
| 1ba3f9ad1c | |||
| 267957d333 | |||
| 7296555964 | |||
| 43fddec286 | |||
| 251edfce42 | |||
| 848c8c7ee8 | |||
| 4b78c82b35 | |||
| b7c77afe02 | |||
| 718d1515b0 | |||
| 0b4ad1c99f | |||
| 42043957e0 | |||
| 7a6653b658 | |||
| 65aa878192 | |||
| ba5cb8a9fb | |||
| fb2fdcb6b8 | |||
| 774d00ff5b | |||
| d7fbb2a154 |
15
.env.example
|
|
@ -17,8 +17,17 @@ NOCODB_DONORS_TABLE_ID=
|
||||||
NOCODB_DONOR_ONLINE_TABLE_ID=
|
NOCODB_DONOR_ONLINE_TABLE_ID=
|
||||||
NOCODB_DONOR_OFFLINE_TABLE_ID=
|
NOCODB_DONOR_OFFLINE_TABLE_ID=
|
||||||
|
|
||||||
# Ice bags granted when a purchase includes ice but the webhook sends only a boolean
|
# Ice: form sells 1-4 ice tickets at $20 each; one ticket = 3 bags. The webhook
|
||||||
ICE_BAGS_DEFAULT=3
|
# reads payment_ice as a ticket count (1-4) or a dollar total ($20-$80).
|
||||||
|
ICE_TICKET_PRICE=20
|
||||||
|
ICE_BAGS_PER_TICKET=3
|
||||||
|
|
||||||
|
# Ticket-voucher entitlement (donor free tickets). Donations on/after
|
||||||
|
# VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the
|
||||||
|
# date each year. NOTE: with no donations after the cutoff, everyone gets 0.
|
||||||
|
VOUCHER_SINCE=2025-09-04
|
||||||
|
VOUCHER_TIER1_MIN=400
|
||||||
|
VOUCHER_TIER2_MIN=1000
|
||||||
|
|
||||||
# Serve /test with sample QR codes (seeds test personas into the CURRENT table).
|
# Serve /test with sample QR codes (seeds test personas into the CURRENT table).
|
||||||
# Keep this false/empty in production — only enable when pointed at a TEST table.
|
# Keep this false/empty in production — only enable when pointed at a TEST table.
|
||||||
|
|
@ -26,7 +35,7 @@ ENABLE_TEST_PAGE=false
|
||||||
|
|
||||||
# MailerSend
|
# MailerSend
|
||||||
MAILERSEND_API_TOKEN=
|
MAILERSEND_API_TOKEN=
|
||||||
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com
|
MAIL_FROM_EMAIL=info@beartariacampgrounds.com
|
||||||
MAIL_FROM_NAME=Beartaria Campgrounds
|
MAIL_FROM_NAME=Beartaria Campgrounds
|
||||||
|
|
||||||
# Shared secret FluentForms sends in the X-Webhook-Secret header (long random string)
|
# Shared secret FluentForms sends in the X-Webhook-Secret header (long random string)
|
||||||
|
|
|
||||||
|
|
@ -139,14 +139,30 @@ jobs:
|
||||||
cp app/build/outputs/apk/release/app-release.apk \
|
cp app/build/outputs/apk/release/app-release.apk \
|
||||||
"$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk"
|
"$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk"
|
||||||
|
|
||||||
- name: Publish Forgejo release with APK
|
- name: Publish APK to Forgejo release
|
||||||
uses: actions/forgejo-release@v2
|
env:
|
||||||
with:
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
direction: upload
|
TAG: ${{ steps.ver.outputs.tag }}
|
||||||
url: https://git.mowden.top
|
run: |
|
||||||
repo: Beartaria/CampgroundTickets
|
set -eu
|
||||||
tag: ${{ steps.ver.outputs.tag }}
|
API="https://git.mowden.top/api/v1/repos/Beartaria/CampgroundTickets"
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
APK="$GITHUB_WORKSPACE/artifacts/camp-scan-${TAG}.apk"
|
||||||
release-dir: artifacts
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
release-notes: "Camp Scan ${{ steps.ver.outputs.tag }} — install/update via Obtainium."
|
# Create the release for this tag (ignore failure if it already exists).
|
||||||
override: true
|
curl -sS -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Camp Scan ${TAG}\",\"body\":\"Install/update via Obtainium.\"}" \
|
||||||
|
-o /dev/null -w "create release: %{http_code}\n" || true
|
||||||
|
# Look up the release id by tag.
|
||||||
|
REL_ID=$(curl -sS "$API/releases/tags/${TAG}" -H "$AUTH" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
echo "release id: ${REL_ID}"
|
||||||
|
test -n "$REL_ID"
|
||||||
|
# Remove a same-named asset from a prior run, then upload the APK.
|
||||||
|
EXISTING=$(curl -sS "$API/releases/${REL_ID}/assets" -H "$AUTH" \
|
||||||
|
| tr '}' '\n' | grep -F "camp-scan-${TAG}.apk" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*' || true)
|
||||||
|
if [ -n "${EXISTING:-}" ]; then
|
||||||
|
curl -sS -X DELETE "$API/releases/${REL_ID}/assets/${EXISTING}" -H "$AUTH" -o /dev/null -w "delete old asset: %{http_code}\n"
|
||||||
|
fi
|
||||||
|
curl -sS -f -X POST "$API/releases/${REL_ID}/assets?name=camp-scan-${TAG}.apk" \
|
||||||
|
-H "$AUTH" -F "attachment=@${APK};type=application/vnd.android.package-archive" \
|
||||||
|
-o /dev/null -w "upload apk: %{http_code}\n"
|
||||||
|
echo "Published camp-scan-${TAG}.apk"
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,11 @@ ENV WEB_DIR=/srv/web
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
# Run as the non-root node user shipped in the base image.
|
# Run as the non-root node user shipped in the base image. /data is a mount
|
||||||
RUN chown -R node:node /srv
|
# point for the runtime state volume — create it owned by node so a fresh named
|
||||||
|
# volume inherits writable ownership.
|
||||||
|
RUN chown -R node:node /srv && mkdir -p /data && chown node:node /data
|
||||||
|
ENV STATE_DIR=/data
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ The app expects the **2026 Campground Tickets** table to be a clone of the 2025
|
||||||
| `Ice Total` | Number (prepaid ice bags) |
|
| `Ice Total` | Number (prepaid ice bags) |
|
||||||
| `Ice Redeemed` | Number (default 0) |
|
| `Ice Redeemed` | Number (default 0) |
|
||||||
|
|
||||||
Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Ice bags remaining = `Ice Total − Ice Redeemed`. Column names are mapped in [`backend/src/fields.ts`](./backend/src/fields.ts) — change them there if the real titles differ. Put the table's ID (right-click table → *Copy Table ID*) in `NOCODB_TABLE_ID`.
|
Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Ice bags remaining = `Ice Total − Ice Redeemed`.
|
||||||
|
|
||||||
|
> **The table MUST have an `Id` primary key.** NocoDB's v2 `PATCH /records` with no primary key updates *every row in the table*, so a PK-less table would make each scan rewrite all tickets. The backend now refuses to update a record with no `Id` (fail-safe), but the table itself must have one. Tables cloned from the existing 2025 table already have `Id`; if you build one by hand via the API, include an `{"title":"Id","uidt":"ID"}` column. Column names are mapped in [`backend/src/fields.ts`](./backend/src/fields.ts) — change them there if the real titles differ. Put the table's ID (right-click table → *Copy Table ID*) in `NOCODB_TABLE_ID`.
|
||||||
|
|
||||||
### Audit log table — "2026 Ticket Audit Logs"
|
### Audit log table — "2026 Ticket Audit Logs"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Camp Scan",
|
"name": "Camp Scan",
|
||||||
"slug": "camptickets",
|
"slug": "camptickets",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "campscan",
|
"scheme": "campscan",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
"android": {
|
"android": {
|
||||||
"package": "top.mowden.campscan",
|
"package": "top.mowden.campscan",
|
||||||
"versionCode": 1,
|
"versionCode": 3,
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#0f1a12"
|
||||||
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
"android.permission.VIBRATE"
|
"android.permission.VIBRATE"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Stack, useRouter, useSegments } from "expo-router";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { AuthProvider, useAuth } from "../lib/auth";
|
import { AuthProvider, useAuth } from "../lib/auth";
|
||||||
|
import { MenuProvider } from "../lib/menu";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
|
@ -11,7 +12,9 @@ export default function RootLayout() {
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthGate />
|
<MenuProvider>
|
||||||
|
<AuthGate />
|
||||||
|
</MenuProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { router } from "expo-router";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api";
|
import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api";
|
||||||
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||||
|
import { useMenu } from "../lib/menu";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
function fmtTime(iso: string): string {
|
function fmtTime(iso: string): string {
|
||||||
|
|
@ -43,6 +44,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminScreen() {
|
export default function AdminScreen() {
|
||||||
|
const { open: openMenu } = useMenu();
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [results, setResults] = useState<TicketView[]>([]);
|
const [results, setResults] = useState<TicketView[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
@ -119,10 +121,8 @@ export default function AdminScreen() {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
<View style={styles.topbar}>
|
<View style={styles.topbar}>
|
||||||
<Pressable onPress={() => router.replace("/")} hitSlop={10}>
|
<Pressable onPress={openMenu} hitSlop={12}>
|
||||||
<Text style={styles.link} numberOfLines={1}>
|
<Text style={styles.hamburger}>☰</Text>
|
||||||
‹ Scanner
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.brand}>Admin lookup</Text>
|
<Text style={styles.brand}>Admin lookup</Text>
|
||||||
<View style={{ width: 72 }} />
|
<View style={{ width: 72 }} />
|
||||||
|
|
@ -201,11 +201,15 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
|
||||||
}, [ticket.redeemed, showHistory]);
|
}, [ticket.redeemed, showHistory]);
|
||||||
|
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (ticket.extras.carParking) tags.push("🚗 Car");
|
const e = ticket.extras;
|
||||||
if (ticket.extras.rvParking) tags.push("🚐 RV");
|
if (ticket.ticketType) tags.push(`🎫 ${ticket.ticketType}`);
|
||||||
if (ticket.extras.iceAccess) tags.push("🧊 Ice");
|
if (e.donorTier === "member") tags.push("🐻 Member");
|
||||||
if (ticket.extras.isDonor) tags.push("⭐ Donor");
|
else if (e.isDonor) tags.push("⭐ Donor");
|
||||||
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`);
|
if (e.carParking) tags.push("🚗 Car");
|
||||||
|
if (e.rvParking) tags.push("🚐 RV");
|
||||||
|
if (e.utv) tags.push("🏍️ UTV");
|
||||||
|
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`);
|
||||||
|
if (e.freeKids > 0) tags.push(`👶 ${e.freeKids} free kids`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
|
|
@ -272,6 +276,7 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
||||||
|
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
|
||||||
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
|
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
|
||||||
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
|
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
|
||||||
input: {
|
input: {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import QRScanner from "../components/QRScanner";
|
||||||
import ResultOverlay from "../components/ResultOverlay";
|
import ResultOverlay from "../components/ResultOverlay";
|
||||||
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
|
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
|
||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
|
import { useMenu } from "../lib/menu";
|
||||||
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
|
@ -19,7 +20,8 @@ const MODES: { key: Mode; label: string; icon: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ScannerScreen() {
|
export default function ScannerScreen() {
|
||||||
const { signOut, operator } = useAuth();
|
const { operator } = useAuth();
|
||||||
|
const { open: openMenu } = useMenu();
|
||||||
const [mode, setMode] = useState<Mode>("tickets");
|
const [mode, setMode] = useState<Mode>("tickets");
|
||||||
const [phase, setPhase] = useState<Phase>("scanning");
|
const [phase, setPhase] = useState<Phase>("scanning");
|
||||||
const [ticket, setTicket] = useState<TicketView | null>(null);
|
const [ticket, setTicket] = useState<TicketView | null>(null);
|
||||||
|
|
@ -107,8 +109,9 @@ export default function ScannerScreen() {
|
||||||
feedbackSuccess();
|
feedbackSuccess();
|
||||||
const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining;
|
const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining;
|
||||||
setTicket(res.ticket);
|
setTicket(res.ticket);
|
||||||
// Ice: default to grabbing all remaining bags at once. Tickets: default 1.
|
// Default to 1 (people usually grab ice a bag at a time); staff can bump
|
||||||
setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining));
|
// the count up. Clamp to what's left so a 0-remaining ticket stays at 0.
|
||||||
|
setCount(Math.min(1, remaining));
|
||||||
setPhase("confirm");
|
setPhase("confirm");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === "AuthError") return router.replace("/login");
|
if (e?.name === "AuthError") return router.replace("/login");
|
||||||
|
|
@ -147,29 +150,19 @@ export default function ScannerScreen() {
|
||||||
}
|
}
|
||||||
}, [ticket, count, mode, resume, showError]);
|
}, [ticket, count, mode, resume, showError]);
|
||||||
|
|
||||||
const doLogout = useCallback(async () => {
|
|
||||||
await signOut();
|
|
||||||
// The auth gate redirects to /login when signedIn flips to false.
|
|
||||||
}, [signOut]);
|
|
||||||
|
|
||||||
const isIce = mode === "ice";
|
const isIce = mode === "ice";
|
||||||
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
|
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
<View style={styles.topbar}>
|
<View style={styles.topbar}>
|
||||||
<View>
|
<Pressable onPress={openMenu} hitSlop={12}>
|
||||||
|
<Text style={styles.hamburger}>☰</Text>
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.titleWrap}>
|
||||||
<Text style={styles.brand}>🐻 Camp Scan</Text>
|
<Text style={styles.brand}>🐻 Camp Scan</Text>
|
||||||
{!!operator && <Text style={styles.operator}>{operator}</Text>}
|
{!!operator && <Text style={styles.operator}>{operator}</Text>}
|
||||||
</View>
|
</View>
|
||||||
<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>
|
||||||
|
|
||||||
<View style={styles.modeBar}>
|
<View style={styles.modeBar}>
|
||||||
|
|
@ -186,6 +179,31 @@ export default function ScannerScreen() {
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{mode === "banquet" && (phase === "scanning" || phase === "banquet") && (
|
||||||
|
// Kept above the scanner (near the top) so the on-screen keyboard,
|
||||||
|
// which covers the bottom of the screen, never hides this input.
|
||||||
|
<View style={styles.emailBar}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.emailInput}
|
||||||
|
placeholder="Look up an email"
|
||||||
|
placeholderTextColor={theme.textDim}
|
||||||
|
value={manualEmail}
|
||||||
|
onChangeText={setManualEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
returnKeyType="search"
|
||||||
|
onSubmitEditing={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
style={styles.emailBtn}
|
||||||
|
onPress={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
||||||
|
>
|
||||||
|
<Text style={styles.emailBtnText}>Look up</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.scannerArea}>
|
<View style={styles.scannerArea}>
|
||||||
<QRScanner onScan={handleScan} active={phase === "scanning"} />
|
<QRScanner onScan={handleScan} active={phase === "scanning"} />
|
||||||
{phase === "scanning" && (
|
{phase === "scanning" && (
|
||||||
|
|
@ -217,6 +235,7 @@ export default function ScannerScreen() {
|
||||||
{phase === "success" && ticket && (
|
{phase === "success" && ticket && (
|
||||||
<ResultOverlay status="success" onDismiss={resume}>
|
<ResultOverlay status="success" onDismiss={resume}>
|
||||||
<Text style={styles.bigIcon}>✓</Text>
|
<Text style={styles.bigIcon}>✓</Text>
|
||||||
|
<TypeBadge type={ticket.ticketType} />
|
||||||
{isIce ? (
|
{isIce ? (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.bigTitle}>
|
<Text style={styles.bigTitle}>
|
||||||
|
|
@ -234,6 +253,8 @@ export default function ScannerScreen() {
|
||||||
<Text style={styles.counts}>
|
<Text style={styles.counts}>
|
||||||
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
|
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
|
||||||
</Text>
|
</Text>
|
||||||
|
<PartyPanel ticket={ticket} />
|
||||||
|
<AdultNames names={ticket.adultNames} />
|
||||||
<ExtrasRow ticket={ticket} />
|
<ExtrasRow ticket={ticket} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -264,29 +285,6 @@ export default function ScannerScreen() {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{mode === "banquet" && (phase === "scanning" || phase === "banquet") && (
|
|
||||||
<View style={styles.emailBar}>
|
|
||||||
<TextInput
|
|
||||||
style={styles.emailInput}
|
|
||||||
placeholder="Or look up an email manually"
|
|
||||||
placeholderTextColor={theme.textDim}
|
|
||||||
value={manualEmail}
|
|
||||||
onChangeText={setManualEmail}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="email-address"
|
|
||||||
returnKeyType="search"
|
|
||||||
onSubmitEditing={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
style={styles.emailBtn}
|
|
||||||
onPress={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
|
||||||
>
|
|
||||||
<Text style={styles.emailBtnText}>Look up</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -343,11 +341,14 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
|
||||||
|
|
||||||
function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (ticket.extras.carParking) tags.push("🚗 Car parking");
|
const e = ticket.extras;
|
||||||
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
|
if (e.donorTier === "member") tags.push("🐻 Member");
|
||||||
if (ticket.extras.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
|
else if (e.isDonor) tags.push("⭐ Donor");
|
||||||
if (ticket.extras.isDonor) tags.push("⭐ Donor");
|
if (e.carParking) tags.push("🚗 Car parking");
|
||||||
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
|
if (e.rvParking) tags.push("🚐 RV parking");
|
||||||
|
if (e.utv) tags.push("🏍️ UTV/ATV");
|
||||||
|
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
|
||||||
|
if (e.freeKids > 0) tags.push(`👶 ${e.freeKids} ${e.freeKids === 1 ? "kid" : "kids"} 12 & under (free)`);
|
||||||
if (!tags.length) return null;
|
if (!tags.length) return null;
|
||||||
return (
|
return (
|
||||||
<View style={styles.tags}>
|
<View style={styles.tags}>
|
||||||
|
|
@ -360,6 +361,79 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_ICON: Record<string, string> = {
|
||||||
|
Guest: "🎫",
|
||||||
|
Worker: "🛠️",
|
||||||
|
Performer: "🎭",
|
||||||
|
Volunteer: "🙌",
|
||||||
|
Speaker: "🎤",
|
||||||
|
"Food Vendor": "🍔",
|
||||||
|
Vendor: "🛒",
|
||||||
|
};
|
||||||
|
|
||||||
|
function TypeBadge({ type }: { type: string }) {
|
||||||
|
if (!type) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.typeBadge}>
|
||||||
|
<Text style={styles.typeBadgeText}>
|
||||||
|
{(TYPE_ICON[type] ?? "🎫") + " " + type.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdultNames({ names }: { names: string[] }) {
|
||||||
|
if (!names.length) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.namesBox}>
|
||||||
|
{names.map((n, i) => (
|
||||||
|
<Text key={i} style={styles.nameLine}>
|
||||||
|
{n}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Big Adults / Youth / Kids breakdown so gate staff can eyeball the party
|
||||||
|
* against the ticket — a deterrent for adults signing up under a (free/cheaper)
|
||||||
|
* younger bracket. Adults (18+) and Youth (13-16) are the paid tickets; Kids
|
||||||
|
* (0-12) are free. A detail line breaks the kids into their age bands.
|
||||||
|
*/
|
||||||
|
function PartyPanel({ ticket }: { ticket: TicketView }) {
|
||||||
|
const get = (b: string) => ticket.ages.find((a) => a.bracket === b)?.count ?? 0;
|
||||||
|
const adults = get("Adults");
|
||||||
|
const youth = get("Youth 13-16");
|
||||||
|
const kidBrackets = ticket.ages.filter((a) => a.bracket.startsWith("Kids"));
|
||||||
|
const kids = kidBrackets.reduce((s, a) => s + a.count, 0);
|
||||||
|
return (
|
||||||
|
<View style={styles.party}>
|
||||||
|
<View style={styles.partyRow}>
|
||||||
|
<View style={styles.partyCell}>
|
||||||
|
<Text style={styles.partyNum}>{adults}</Text>
|
||||||
|
<Text style={styles.partyLbl}>ADULTS{"\n"}18+</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.partyDivider} />
|
||||||
|
<View style={styles.partyCell}>
|
||||||
|
<Text style={styles.partyNum}>{youth}</Text>
|
||||||
|
<Text style={styles.partyLbl}>YOUTH{"\n"}13-16</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.partyDivider} />
|
||||||
|
<View style={styles.partyCell}>
|
||||||
|
<Text style={styles.partyNum}>{kids}</Text>
|
||||||
|
<Text style={styles.partyLbl}>KIDS{"\n"}0-12</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{kidBrackets.length > 0 && (
|
||||||
|
<Text style={styles.partyDetail}>
|
||||||
|
kids: {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^Kids\s*/, "")}`).join(" · ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ConfirmCard({
|
function ConfirmCard({
|
||||||
ticket,
|
ticket,
|
||||||
isIce,
|
isIce,
|
||||||
|
|
@ -386,11 +460,14 @@ function ConfirmCard({
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.card} contentContainerStyle={styles.cardContent}>
|
<ScrollView style={styles.card} contentContainerStyle={styles.cardContent}>
|
||||||
<Text style={styles.cardName}>{ticket.name}</Text>
|
<Text style={styles.cardName}>{ticket.name}</Text>
|
||||||
|
<TypeBadge type={ticket.ticketType} />
|
||||||
<Text style={styles.cardCode}>{ticket.code}</Text>
|
<Text style={styles.cardCode}>{ticket.code}</Text>
|
||||||
|
{!isIce && <PartyPanel ticket={ticket} />}
|
||||||
<Text style={styles.cardCounts}>
|
<Text style={styles.cardCounts}>
|
||||||
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
|
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
|
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
|
||||||
|
{!isIce && <AdultNames names={ticket.adultNames} />}
|
||||||
{!isIce && <ExtrasRow ticket={ticket} />}
|
{!isIce && <ExtrasRow ticket={ticket} />}
|
||||||
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
|
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
|
||||||
|
|
||||||
|
|
@ -433,6 +510,8 @@ const styles = StyleSheet.create({
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
|
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700", paddingRight: 4 },
|
||||||
|
titleWrap: { flex: 1, marginLeft: 12 },
|
||||||
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
||||||
operator: { color: theme.textDim, fontSize: 13, marginTop: 1 },
|
operator: { color: theme.textDim, fontSize: 13, marginTop: 1 },
|
||||||
topActions: { flexDirection: "row", gap: 18, alignItems: "center" },
|
topActions: { flexDirection: "row", gap: 18, alignItems: "center" },
|
||||||
|
|
@ -487,6 +566,33 @@ const styles = StyleSheet.create({
|
||||||
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
|
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
|
||||||
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
|
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
|
||||||
|
|
||||||
|
typeBadge: {
|
||||||
|
backgroundColor: "rgba(255,255,255,0.22)",
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 8,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 },
|
||||||
|
namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
|
||||||
|
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
|
||||||
|
party: {
|
||||||
|
alignSelf: "stretch",
|
||||||
|
backgroundColor: "#1d2a1f",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: theme.warn,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
marginTop: 10,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
partyRow: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
|
||||||
|
partyCell: { flex: 1, alignItems: "center" },
|
||||||
|
partyNum: { color: theme.text, fontSize: 36, fontWeight: "900", lineHeight: 40 },
|
||||||
|
partyLbl: { color: theme.warn, fontSize: 11, fontWeight: "800", letterSpacing: 0.5, marginTop: 1, textAlign: "center", lineHeight: 13 },
|
||||||
|
partyDivider: { width: 1.5, height: 42, backgroundColor: theme.cardBorder },
|
||||||
|
partyDetail: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 8, fontWeight: "600" },
|
||||||
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
|
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" },
|
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },
|
||||||
|
|
||||||
|
|
|
||||||
308
app/app/stats.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { StyleSheet, View, Text, Pressable, ScrollView, ActivityIndicator, RefreshControl } from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { getStats, type Stats } from "../lib/api";
|
||||||
|
import { useMenu } from "../lib/menu";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
const TYPE_ICON: Record<string, string> = {
|
||||||
|
Regular: "🎟️",
|
||||||
|
Guest: "🎫",
|
||||||
|
Worker: "🛠️",
|
||||||
|
Performer: "🎭",
|
||||||
|
Volunteer: "🙌",
|
||||||
|
Speaker: "🎤",
|
||||||
|
"Food Vendor": "🍔",
|
||||||
|
Vendor: "🛒",
|
||||||
|
};
|
||||||
|
const MEDAL = ["🥇", "🥈", "🥉"];
|
||||||
|
|
||||||
|
function Bar({ pct, color }: { pct: number; color?: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.barTrack}>
|
||||||
|
<View style={[styles.barFill, { width: `${Math.min(100, Math.max(0, pct))}%`, backgroundColor: color ?? theme.successBright }]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ value, label, accent }: { value: string | number; label: string; accent?: boolean }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.tile}>
|
||||||
|
<Text style={[styles.tileValue, accent && { color: theme.successBright }]}>{value}</Text>
|
||||||
|
<Text style={styles.tileLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsScreen() {
|
||||||
|
const { open: openMenu } = useMenu();
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async (force = false) => {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
setStats(await getStats(force));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AuthError") return router.replace("/login");
|
||||||
|
setError(e?.message ?? "Failed to load report");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
load(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const peakHour = stats?.checkinsByHour.length
|
||||||
|
? stats.checkinsByHour.reduce((a, b) => (b.count > a.count ? b : a))
|
||||||
|
: null;
|
||||||
|
const maxHour = stats ? Math.max(1, ...stats.checkinsByHour.map((h) => h.count)) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
|
<View style={styles.topbar}>
|
||||||
|
<Pressable onPress={openMenu} hitSlop={12}>
|
||||||
|
<Text style={styles.hamburger}>☰</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.brand}>Event Report</Text>
|
||||||
|
<Pressable onPress={onRefresh} hitSlop={10}>
|
||||||
|
<Text style={styles.link}>↻</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={theme.successBright} size="large" style={{ marginTop: 40 }} />
|
||||||
|
) : error ? (
|
||||||
|
<Text style={styles.error}>{error}</Text>
|
||||||
|
) : stats ? (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.successBright} />}
|
||||||
|
>
|
||||||
|
{/* Hero: check-in progress */}
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Text style={styles.heroPct}>{stats.tickets.pct}%</Text>
|
||||||
|
<Text style={styles.heroSub}>checked in</Text>
|
||||||
|
<Bar pct={stats.tickets.pct} />
|
||||||
|
<Text style={styles.heroCounts}>
|
||||||
|
{stats.tickets.redeemed} of {stats.tickets.total} tickets · {stats.tickets.remaining} to go
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Core tiles */}
|
||||||
|
<View style={styles.tileRow}>
|
||||||
|
<Tile value={stats.orders} label="orders" />
|
||||||
|
<Tile value={stats.tickets.total} label="tickets sold" />
|
||||||
|
<Tile value={stats.tickets.redeemed} label="checked in" accent />
|
||||||
|
<Tile value={stats.tickets.remaining} label="remaining" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Ice */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>🧊 Ice</Text>
|
||||||
|
<Bar pct={stats.ice.pct} color="#4fc3f7" />
|
||||||
|
<Text style={styles.cardSub}>
|
||||||
|
{stats.ice.redeemed} of {stats.ice.total} bags handed out · {stats.ice.remaining} left · {stats.ice.ticketsSold} ice tickets sold
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Ticket types */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Ticket types</Text>
|
||||||
|
{stats.types.map((t) => (
|
||||||
|
<View key={t.type} style={styles.typeRow}>
|
||||||
|
<Text style={styles.typeName}>
|
||||||
|
{(TYPE_ICON[t.type] ?? "🎫") + " " + t.type}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.typeBarWrap}>
|
||||||
|
<Bar pct={t.total ? (t.redeemed / t.total) * 100 : 0} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.typeCount}>
|
||||||
|
{t.redeemed}/{t.total}
|
||||||
|
<Text style={styles.typeOrders}> · {t.count}×</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* People breakdown */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Who's coming</Text>
|
||||||
|
<View style={styles.tileRow}>
|
||||||
|
<Tile value={stats.people.adults} label="adults (paid)" />
|
||||||
|
<Tile value={stats.people.youth} label="youth 13-16 (paid)" />
|
||||||
|
<Tile value={stats.people.kids12 + stats.people.kids9} label="kids 5-12 (free)" />
|
||||||
|
<Tile value={stats.people.kids4Free} label="under 5 (free)" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Extras + donors */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Add-ons & donors</Text>
|
||||||
|
<View style={styles.chips}>
|
||||||
|
<Text style={styles.chip}>🚗 {stats.extras.carParking} parking</Text>
|
||||||
|
<Text style={styles.chip}>🚐 {stats.extras.rvParking} RV</Text>
|
||||||
|
<Text style={styles.chip}>🏍️ {stats.extras.utv} UTV</Text>
|
||||||
|
<Text style={styles.chip}>🐻 {stats.donors.members} members</Text>
|
||||||
|
<Text style={styles.chip}>⭐ {stats.donors.orders} donor orders</Text>
|
||||||
|
<Text style={styles.chip}>🎟️ {stats.donors.vouchers} vouchers</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Operator leaderboard */}
|
||||||
|
{stats.operators.length > 0 && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Gate crew leaderboard</Text>
|
||||||
|
{stats.operators.slice(0, 8).map((o, i) => (
|
||||||
|
<View key={o.name} style={styles.opRow}>
|
||||||
|
<Text style={styles.opRank}>{MEDAL[i] ?? `${i + 1}.`}</Text>
|
||||||
|
<Text style={styles.opName} numberOfLines={1}>
|
||||||
|
{o.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.opStat}>
|
||||||
|
{o.checkins} check-ins{o.ice ? ` · ${o.ice} ice` : ""}
|
||||||
|
{o.undos ? ` · ${o.undos} undo` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comp tickets issued */}
|
||||||
|
{stats.comps.total > 0 && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>🎟️ Comp tickets issued ({stats.comps.total})</Text>
|
||||||
|
{stats.comps.byCreator.map((c) => (
|
||||||
|
<View key={c.name} style={styles.opRow}>
|
||||||
|
<Text style={styles.opName} numberOfLines={1}>
|
||||||
|
{c.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.opStat}>{c.count} issued</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Check-in timeline */}
|
||||||
|
{stats.checkinsByHour.length > 0 && (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>Check-ins by hour</Text>
|
||||||
|
<View style={styles.spark}>
|
||||||
|
{stats.checkinsByHour.map((h) => (
|
||||||
|
<View key={h.hour} style={styles.sparkCol}>
|
||||||
|
<Text style={styles.sparkVal}>{h.count}</Text>
|
||||||
|
<View style={[styles.sparkBar, { height: 6 + (h.count / maxHour) * 80 }]} />
|
||||||
|
<Text style={styles.sparkLabel}>{h.hour.slice(11)}h</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{peakHour && (
|
||||||
|
<Text style={styles.cardSub}>Busiest hour: {peakHour.count} checked in around {peakHour.hour.slice(11)}:00</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.stamp}>Updated {new Date(stats.generatedAt).toLocaleTimeString()} · pull to refresh</Text>
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
|
||||||
|
link: { color: theme.textDim, fontSize: 16, fontWeight: "700" },
|
||||||
|
error: { color: theme.dangerBright, textAlign: "center", marginTop: 40, fontSize: 15 },
|
||||||
|
|
||||||
|
hero: {
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 22,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
heroPct: { color: theme.successBright, fontSize: 64, fontWeight: "900", lineHeight: 66 },
|
||||||
|
heroSub: { color: theme.textDim, fontSize: 15, marginBottom: 14 },
|
||||||
|
heroCounts: { color: theme.text, fontSize: 15, marginTop: 10, textAlign: "center" },
|
||||||
|
|
||||||
|
barTrack: { width: "100%", height: 12, borderRadius: 6, backgroundColor: theme.cardBorder, overflow: "hidden" },
|
||||||
|
barFill: { height: "100%", borderRadius: 6 },
|
||||||
|
|
||||||
|
tileRow: { flexDirection: "row", flexWrap: "wrap", gap: 10, marginTop: 12 },
|
||||||
|
tile: {
|
||||||
|
flexGrow: 1,
|
||||||
|
flexBasis: "22%",
|
||||||
|
minWidth: 74,
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
tileValue: { color: theme.text, fontSize: 24, fontWeight: "800" },
|
||||||
|
tileLabel: { color: theme.textDim, fontSize: 11, marginTop: 2, textAlign: "center" },
|
||||||
|
|
||||||
|
card: {
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
cardTitle: { color: theme.text, fontSize: 16, fontWeight: "800", marginBottom: 10 },
|
||||||
|
cardSub: { color: theme.textDim, fontSize: 13, marginTop: 8, lineHeight: 18 },
|
||||||
|
|
||||||
|
typeRow: { flexDirection: "row", alignItems: "center", gap: 10, marginVertical: 5 },
|
||||||
|
typeName: { color: theme.text, fontSize: 14, fontWeight: "600", width: 120 },
|
||||||
|
typeBarWrap: { flex: 1 },
|
||||||
|
typeCount: { color: theme.text, fontSize: 13, fontWeight: "700", minWidth: 66, textAlign: "right" },
|
||||||
|
typeOrders: { color: theme.textDim, fontWeight: "400" },
|
||||||
|
|
||||||
|
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||||
|
chip: {
|
||||||
|
color: theme.text,
|
||||||
|
backgroundColor: theme.cardBorder,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
|
||||||
|
opRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
|
||||||
|
opRank: { fontSize: 16, width: 28, textAlign: "center", color: theme.textDim, fontWeight: "800" },
|
||||||
|
opName: { color: theme.text, fontSize: 15, fontWeight: "600", flex: 1 },
|
||||||
|
opStat: { color: theme.textDim, fontSize: 13 },
|
||||||
|
|
||||||
|
spark: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", gap: 4, height: 118, marginTop: 4 },
|
||||||
|
sparkCol: { flex: 1, alignItems: "center", justifyContent: "flex-end" },
|
||||||
|
sparkVal: { color: theme.textDim, fontSize: 10, marginBottom: 3 },
|
||||||
|
sparkBar: { width: "70%", minWidth: 8, backgroundColor: theme.successBright, borderRadius: 3 },
|
||||||
|
sparkLabel: { color: theme.textDim, fontSize: 9, marginTop: 3 },
|
||||||
|
|
||||||
|
stamp: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 20 },
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 195 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 773 B After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 112 KiB |
|
|
@ -1,6 +1,6 @@
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { StyleSheet, View, Text, Pressable } from "react-native";
|
import { StyleSheet, View, Text, Pressable, type LayoutChangeEvent } from "react-native";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from "expo-camera";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
export interface QRScannerProps {
|
export interface QRScannerProps {
|
||||||
|
|
@ -8,10 +8,48 @@ export interface QRScannerProps {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Native (Android/iOS) scanner using expo-camera. */
|
// Side of the accept square, matching the visible reticle (index.tsx uses 240),
|
||||||
|
// with a little tolerance so a code aimed inside the box always registers.
|
||||||
|
const RETICLE = 260;
|
||||||
|
|
||||||
|
/** Native (Android/iOS) scanner using expo-camera. Only accepts codes whose
|
||||||
|
* position falls within the centered reticle square. */
|
||||||
export default function QRScanner({ onScan, active }: QRScannerProps) {
|
export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
|
const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 });
|
||||||
|
const layout = useRef({ w: 0, h: 0 });
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => {
|
||||||
|
layout.current = { w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height };
|
||||||
|
};
|
||||||
|
|
||||||
|
// True if the scanned code sits inside the centered reticle. Fails OPEN when
|
||||||
|
// geometry is missing/unknown so scanning never silently breaks.
|
||||||
|
const inReticle = (res: BarcodeScanningResult): boolean => {
|
||||||
|
const { w, h } = layout.current;
|
||||||
|
if (!w || !h) return true;
|
||||||
|
const pts = res.cornerPoints as { x: number; y: number }[] | undefined;
|
||||||
|
let cx: number | undefined;
|
||||||
|
let cy: number | undefined;
|
||||||
|
if (pts && pts.length) {
|
||||||
|
cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
|
||||||
|
cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
|
||||||
|
} else {
|
||||||
|
const b: any = (res as any).bounds;
|
||||||
|
if (b?.origin && b?.size) {
|
||||||
|
cx = b.origin.x + b.size.width / 2;
|
||||||
|
cy = b.origin.y + b.size.height / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cx === undefined || cy === undefined) return true;
|
||||||
|
// Some platforms report normalized [0,1] coords — scale to view size.
|
||||||
|
if (cx <= 1 && cy <= 1) {
|
||||||
|
cx *= w;
|
||||||
|
cy *= h;
|
||||||
|
}
|
||||||
|
const half = RETICLE / 2;
|
||||||
|
return Math.abs(cx - w / 2) <= half && Math.abs(cy - h / 2) <= half;
|
||||||
|
};
|
||||||
|
|
||||||
if (!permission) {
|
if (!permission) {
|
||||||
return <View style={styles.fill} />;
|
return <View style={styles.fill} />;
|
||||||
|
|
@ -31,10 +69,13 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
<CameraView
|
<CameraView
|
||||||
style={styles.fill}
|
style={styles.fill}
|
||||||
facing="back"
|
facing="back"
|
||||||
|
onLayout={onLayout}
|
||||||
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
||||||
onBarcodeScanned={
|
onBarcodeScanned={
|
||||||
active
|
active
|
||||||
? ({ data }) => {
|
? (res) => {
|
||||||
|
if (!inReticle(res)) return; // ignore codes outside the square
|
||||||
|
const data = res.data;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Debounce repeated frames of the same code.
|
// Debounce repeated frames of the same code.
|
||||||
if (data === lastScan.current.code && now - lastScan.current.at < 3000) return;
|
if (data === lastScan.current.code && now - lastScan.current.at < 3000) return;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
}
|
}
|
||||||
const detector = new BarcodeDetector({ formats: ["qr_code"] });
|
const detector = new BarcodeDetector({ formats: ["qr_code"] });
|
||||||
|
// Offscreen canvas holding just the centered reticle crop — we only run
|
||||||
|
// detection on this region so QR codes elsewhere in view are ignored.
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const cctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
let busy = false;
|
let busy = false;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
@ -68,7 +72,21 @@ export default function QRScanner({ onScan, active }: QRScannerProps) {
|
||||||
if (busy || !activeRef.current) return;
|
if (busy || !activeRef.current) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const codes = await detector.detect(v);
|
// Crop the central square of the frame (matches the on-screen
|
||||||
|
// reticle) and detect only within it.
|
||||||
|
const vw = v.videoWidth;
|
||||||
|
const vh = v.videoHeight;
|
||||||
|
let target: HTMLVideoElement | HTMLCanvasElement = v;
|
||||||
|
if (cctx && vw && vh) {
|
||||||
|
const side = Math.round(Math.min(vw, vh) * 0.62);
|
||||||
|
const sx = Math.round((vw - side) / 2);
|
||||||
|
const sy = Math.round((vh - side) / 2);
|
||||||
|
canvas.width = side;
|
||||||
|
canvas.height = side;
|
||||||
|
cctx.drawImage(v, sx, sy, side, side, 0, 0, side, side);
|
||||||
|
target = canvas;
|
||||||
|
}
|
||||||
|
const codes = await detector.detect(target);
|
||||||
if (codes && codes.length) {
|
if (codes && codes.length) {
|
||||||
const data = codes[0].rawValue;
|
const data = codes[0].rawValue;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
|
||||||
113
app/components/SideMenu.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions } from "react-native";
|
||||||
|
import { router, useSegments } from "expo-router";
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
|
// Note: the /crush33 admin hub is intentionally NOT listed here — it's an
|
||||||
|
// admin-only URL, not surfaced to gate staff in the app drawer.
|
||||||
|
const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [
|
||||||
|
{ label: "Scanner", icon: "📷", route: "/", seg: "" },
|
||||||
|
{ label: "Event report", icon: "📊", route: "/stats", seg: "stats" },
|
||||||
|
{ label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||||
|
const { operator, signOut } = useAuth();
|
||||||
|
const segments = useSegments();
|
||||||
|
const current = segments[0] ?? "";
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const panelW = Math.min(320, width * 0.84);
|
||||||
|
const tx = useRef(new Animated.Value(-panelW)).current;
|
||||||
|
const fade = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(tx, {
|
||||||
|
toValue: visible ? 0 : -panelW,
|
||||||
|
duration: 220,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(fade, { toValue: visible ? 1 : 0, duration: 220, useNativeDriver: true }),
|
||||||
|
]).start();
|
||||||
|
}, [visible, panelW, tx, fade]);
|
||||||
|
|
||||||
|
const go = (item: { route: string; seg: string }) => {
|
||||||
|
onClose();
|
||||||
|
if (item.seg !== current) router.replace(item.route as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View pointerEvents={visible ? "auto" : "none"} style={StyleSheet.absoluteFill}>
|
||||||
|
<Animated.View style={[styles.scrim, { opacity: fade }]}>
|
||||||
|
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View style={[styles.panel, { width: panelW, transform: [{ translateX: tx }] }]}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.logo}>🐻 Camp Scan</Text>
|
||||||
|
{!!operator && <Text style={styles.operator}>{operator}</Text>}
|
||||||
|
</View>
|
||||||
|
<View style={styles.items}>
|
||||||
|
{ITEMS.map((it) => {
|
||||||
|
const active = it.seg === current;
|
||||||
|
return (
|
||||||
|
<Pressable key={it.route} style={[styles.item, active && styles.itemActive]} onPress={() => go(it)}>
|
||||||
|
<Text style={styles.itemIcon}>{it.icon}</Text>
|
||||||
|
<Text style={[styles.itemText, active && styles.itemTextActive]}>{it.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
<Pressable
|
||||||
|
style={styles.signout}
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
signOut();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.itemIcon}>🚪</Text>
|
||||||
|
<Text style={styles.signoutText}>Sign out</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.55)" },
|
||||||
|
panel: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: theme.cardBorder,
|
||||||
|
paddingTop: 54,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingBottom: 28,
|
||||||
|
},
|
||||||
|
header: { paddingHorizontal: 8, paddingBottom: 14, borderBottomWidth: 1, borderBottomColor: theme.cardBorder },
|
||||||
|
logo: { color: theme.text, fontSize: 20, fontWeight: "800" },
|
||||||
|
operator: { color: theme.textDim, fontSize: 14, marginTop: 3 },
|
||||||
|
items: { marginTop: 14, gap: 4 },
|
||||||
|
item: { flexDirection: "row", alignItems: "center", gap: 14, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 12 },
|
||||||
|
itemActive: { backgroundColor: theme.primary },
|
||||||
|
itemIcon: { fontSize: 20, width: 26, textAlign: "center" },
|
||||||
|
itemText: { color: theme.text, fontSize: 17, fontWeight: "600" },
|
||||||
|
itemTextActive: { color: "#fff", fontWeight: "800" },
|
||||||
|
spacer: { flex: 1 },
|
||||||
|
signout: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.cardBorder,
|
||||||
|
},
|
||||||
|
signoutText: { color: theme.dangerBright, fontSize: 17, fontWeight: "700" },
|
||||||
|
});
|
||||||
115
app/lib/api.ts
|
|
@ -21,16 +21,22 @@ export interface TicketView {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
ticketType: string;
|
||||||
|
createdBy: string;
|
||||||
total: number;
|
total: number;
|
||||||
redeemed: number;
|
redeemed: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
ice: ResourceCount;
|
ice: ResourceCount;
|
||||||
|
adultNames: string[];
|
||||||
extras: {
|
extras: {
|
||||||
carParking: boolean;
|
carParking: boolean;
|
||||||
rvParking: boolean;
|
rvParking: boolean;
|
||||||
|
utv: boolean;
|
||||||
iceAccess: boolean;
|
iceAccess: boolean;
|
||||||
isDonor: boolean;
|
isDonor: boolean;
|
||||||
freeUnder4: number;
|
donorTier: string;
|
||||||
|
vouchers: number;
|
||||||
|
freeKids: number;
|
||||||
};
|
};
|
||||||
ages: { bracket: string; count: number; free: boolean }[];
|
ages: { bracket: string; count: number; free: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +195,113 @@ export interface AuditEntry {
|
||||||
action: "check-in" | "undo" | "ice" | "ice-undo";
|
action: "check-in" | "undo" | "ice" | "ice-undo";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
orders: number;
|
||||||
|
tickets: { total: number; redeemed: number; remaining: number; pct: number };
|
||||||
|
people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number };
|
||||||
|
ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number };
|
||||||
|
types: { type: string; count: number; total: number; redeemed: number }[];
|
||||||
|
donors: { orders: number; members: number; vouchers: number };
|
||||||
|
extras: { carParking: number; rvParking: number; utv: number };
|
||||||
|
comps: { total: number; byCreator: { name: string; count: number }[] };
|
||||||
|
operators: { name: string; checkins: number; ice: number; undos: number }[];
|
||||||
|
checkinsByHour: { hour: string; count: number }[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStats(force = false): Promise<Stats> {
|
||||||
|
return authed<Stats>(`/api/stats${force ? "?force=1" : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comp-ticket portal (password-gated; separate from the staff PIN).
|
||||||
|
export async function portalVerify(password: string): Promise<{ ok: boolean; types: string[] }> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/portal/verify`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new AuthError("Wrong password");
|
||||||
|
if (!res.ok) throw new ApiError(`Verify failed (${res.status})`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortalTicket {
|
||||||
|
ok: boolean;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
emailSent: boolean;
|
||||||
|
qr: string; // data URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function portalCreate(input: {
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
type: string;
|
||||||
|
createdBy?: string;
|
||||||
|
}): Promise<PortalTicket> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/portal/create-ticket`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new AuthError("Wrong password");
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Create failed (${res.status})`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Admin actions (all gated by the portal password) ----
|
||||||
|
|
||||||
|
async function adminPost<T>(path: string, password: string, extra: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password, ...extra }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) throw new AuthError("Wrong password");
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Request failed (${res.status})`);
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStatus {
|
||||||
|
tickets: { tableId: string; count: number };
|
||||||
|
audit: { tableId: string | null; count: number; enabled: boolean };
|
||||||
|
defaults: { ticketsTableId: string; auditTableId: string | null };
|
||||||
|
}
|
||||||
|
export function adminStatus(password: string): Promise<AdminStatus> {
|
||||||
|
return adminPost<AdminStatus>("/api/admin/status", password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminWipe(password: string): Promise<{ ok: boolean; ticketsDeleted: number; auditDeleted: number }> {
|
||||||
|
return adminPost("/api/admin/wipe", password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminSwitchTable(
|
||||||
|
password: string,
|
||||||
|
ticketsTableId: string,
|
||||||
|
auditTableId?: string,
|
||||||
|
): Promise<{ ok: boolean; tickets: { tableId: string }; audit: { tableId: string | null } }> {
|
||||||
|
return adminPost("/api/admin/switch-table", password, { ticketsTableId, auditTableId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DonorSearchResult {
|
||||||
|
name: string;
|
||||||
|
bearName: string;
|
||||||
|
email: string;
|
||||||
|
altEmail: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
lifetime: number | null;
|
||||||
|
tags: string[];
|
||||||
|
source: "master" | "transactions";
|
||||||
|
}
|
||||||
|
export function adminDonorSearch(password: string, query: string): Promise<{ results: DonorSearchResult[]; query: string }> {
|
||||||
|
return adminPost("/api/admin/donor-search", password, { query });
|
||||||
|
}
|
||||||
|
|
||||||
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
|
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
entries: AuditEntry[];
|
entries: AuditEntry[];
|
||||||
|
|
|
||||||
21
app/lib/menu.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
import SideMenu from "../components/SideMenu";
|
||||||
|
|
||||||
|
interface MenuState {
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<MenuState>({ open: () => {}, close: () => {} });
|
||||||
|
|
||||||
|
export function MenuProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<Ctx.Provider value={{ open: () => setVisible(true), close: () => setVisible(false) }}>
|
||||||
|
{children}
|
||||||
|
<SideMenu visible={visible} onClose={() => setVisible(false)} />
|
||||||
|
</Ctx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMenu = () => useContext(Ctx);
|
||||||
|
Before Width: | Height: | Size: 195 B After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 773 B After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 83 KiB |
|
|
@ -10,6 +10,10 @@ const schema = z.object({
|
||||||
// Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped.
|
// Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped.
|
||||||
NOCODB_AUDIT_TABLE_ID: z.string().optional(),
|
NOCODB_AUDIT_TABLE_ID: z.string().optional(),
|
||||||
|
|
||||||
|
// Writable dir (mounted volume) for small runtime state — e.g. the active
|
||||||
|
// event table override set from the admin area, so it survives redeploys.
|
||||||
|
STATE_DIR: z.string().default("/data"),
|
||||||
|
|
||||||
// Donor tables for Banquet mode. If the master-list id is unset, banquet is
|
// Donor tables for Banquet mode. If the master-list id is unset, banquet is
|
||||||
// disabled. Online/offline are used as a fallback when a donor is not in the
|
// disabled. Online/offline are used as a fallback when a donor is not in the
|
||||||
// master list.
|
// master list.
|
||||||
|
|
@ -17,9 +21,33 @@ const schema = z.object({
|
||||||
NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(),
|
NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(),
|
||||||
NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(),
|
NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(),
|
||||||
|
|
||||||
// Prepaid ice bags granted when a purchase includes ice but the webhook only
|
// Ice: the form sells 1-4 ice "tickets" at $20 each; one ticket = 3 bags.
|
||||||
// sends a boolean (not an explicit bag count).
|
// The webhook reads payment_ice as either a ticket count (1-4) or a dollar
|
||||||
ICE_BAGS_DEFAULT: z.coerce.number().default(3),
|
// total ($20-$80) and stores bags = tickets * ICE_BAGS_PER_TICKET.
|
||||||
|
ICE_TICKET_PRICE: z.coerce.number().default(20),
|
||||||
|
ICE_BAGS_PER_TICKET: z.coerce.number().default(3),
|
||||||
|
|
||||||
|
// Public donor-eligibility lookup (for the FluentForms checkout discount).
|
||||||
|
// Disabled unless a secret is set. Returns only eligibility + tier, never
|
||||||
|
// names or dollar amounts. Rate-limited + CORS-restricted.
|
||||||
|
PUBLIC_LOOKUP_SECRET: z.string().optional(),
|
||||||
|
// Comma-separated allowlist of browser origins permitted to call the public
|
||||||
|
// lookups (the request's Origin is echoed back only if it matches one).
|
||||||
|
PUBLIC_LOOKUP_ORIGIN: z
|
||||||
|
.string()
|
||||||
|
.default("https://tickets.beartariacampgrounds.com,https://vendors.beartariacampgrounds.com")
|
||||||
|
.transform((s) =>
|
||||||
|
s
|
||||||
|
.split(",")
|
||||||
|
.map((o) => o.trim().replace(/\/+$/, ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling
|
||||||
|
// >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year.
|
||||||
|
VOUCHER_SINCE: z.string().default("2025-09-04"),
|
||||||
|
VOUCHER_TIER1_MIN: z.coerce.number().default(400),
|
||||||
|
VOUCHER_TIER2_MIN: z.coerce.number().default(1000),
|
||||||
|
|
||||||
// Serve GET /test with sample QR codes. Seeds test personas into the current
|
// Serve GET /test with sample QR codes. Seeds test personas into the current
|
||||||
// NocoDB table, so keep this OFF in production (only enable against a TEST table).
|
// NocoDB table, so keep this OFF in production (only enable against a TEST table).
|
||||||
|
|
@ -34,6 +62,8 @@ const schema = z.object({
|
||||||
|
|
||||||
WEBHOOK_SECRET: z.string().min(1),
|
WEBHOOK_SECRET: z.string().min(1),
|
||||||
EVENT_PIN: z.string().min(1),
|
EVENT_PIN: z.string().min(1),
|
||||||
|
// Shared password for the /crush33 comp-ticket portal (workers/guests).
|
||||||
|
PORTAL_PASSWORD: z.string().optional(),
|
||||||
TOKEN_SECRET: z.string().min(16),
|
TOKEN_SECRET: z.string().min(16),
|
||||||
TOKEN_TTL: z.string().default("30d"),
|
TOKEN_TTL: z.string().default("30d"),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Mailer } from "./services/mailer.js";
|
||||||
import { RedeemQueue } from "./services/redeemQueue.js";
|
import { RedeemQueue } from "./services/redeemQueue.js";
|
||||||
import { AuditLogger } from "./services/audit.js";
|
import { AuditLogger } from "./services/audit.js";
|
||||||
import { DonorService } from "./services/donors.js";
|
import { DonorService } from "./services/donors.js";
|
||||||
|
import { loadActiveTables } from "./services/state.js";
|
||||||
|
|
||||||
/** Shared services wired once at startup and hung off the Fastify instance. */
|
/** Shared services wired once at startup and hung off the Fastify instance. */
|
||||||
export interface AppContext {
|
export interface AppContext {
|
||||||
|
|
@ -16,12 +17,23 @@ export interface AppContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildContext(config: Config): AppContext {
|
export function buildContext(config: Config): AppContext {
|
||||||
|
const nocodb = new NocoDBClient(config);
|
||||||
|
const audit = new AuditLogger(config);
|
||||||
|
|
||||||
|
// Apply a persisted "active event table" override (set from the admin area),
|
||||||
|
// so switching the event survives redeploys without editing .env.
|
||||||
|
const override = loadActiveTables(config.STATE_DIR);
|
||||||
|
if (override) {
|
||||||
|
nocodb.setTableId(override.ticketsTableId);
|
||||||
|
audit.setTableId(override.auditTableId ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
nocodb: new NocoDBClient(config),
|
nocodb,
|
||||||
mailer: new Mailer(config),
|
mailer: new Mailer(config),
|
||||||
queue: new RedeemQueue(),
|
queue: new RedeemQueue(),
|
||||||
audit: new AuditLogger(config),
|
audit,
|
||||||
donors: new DonorService(config),
|
donors: new DonorService(config),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,41 @@
|
||||||
/**
|
/**
|
||||||
* Mapping between the NocoDB "2026 Campground Tickets" table columns, the
|
* Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026
|
||||||
* webhook payload keys, and the view we return to the app.
|
* FluentForms "Tickets 2026" schema. Change titles here if the columns differ.
|
||||||
*
|
|
||||||
* 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 = {
|
export const COL = {
|
||||||
id: "Id",
|
id: "Id",
|
||||||
name: "Title", // first column in the 2025 table holds the purchaser name
|
name: "Title", // purchaser full name
|
||||||
|
adultNames: "Adult Names", // newline-separated list of adult attendee names
|
||||||
email: "Email Address",
|
email: "Email Address",
|
||||||
address: "Address",
|
address: "Address",
|
||||||
isDonor: "Is Donor",
|
isDonor: "Is Donor",
|
||||||
|
donorTier: "Donor Tier", // member / donor / ""
|
||||||
|
vouchers: "Vouchers",
|
||||||
|
|
||||||
|
// Attendee counts by group:
|
||||||
|
adults: "Adults",
|
||||||
|
youth: "Youth 13-16",
|
||||||
|
kids12: "Kids 10-12",
|
||||||
|
kids9: "Kids 5-9",
|
||||||
|
kids4: "Kids 0-4", // free — NOT counted toward the scannable total
|
||||||
|
|
||||||
carParking: "Car Parking",
|
carParking: "Car Parking",
|
||||||
rvParking: "RV Parking",
|
rvParking: "RV Parking",
|
||||||
|
utv: "UTV",
|
||||||
iceAccess: "Ice Access",
|
iceAccess: "Ice Access",
|
||||||
paymentMethod: "Payment Method",
|
paymentMethod: "Payment Method",
|
||||||
|
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
||||||
|
createdBy: "Created By", // gate-staff name who issued a comp ticket (portal)
|
||||||
|
|
||||||
// Columns this system adds to the table:
|
// Columns this system manages:
|
||||||
code: "Ticket Code",
|
code: "Ticket Code",
|
||||||
redeemed: "Redeemed",
|
redeemed: "Redeemed",
|
||||||
submissionKey: "SubmissionKey",
|
submissionKey: "SubmissionKey",
|
||||||
lastScanAt: "LastScanAt",
|
lastScanAt: "LastScanAt",
|
||||||
iceTotal: "Ice Total", // prepaid ice bags
|
iceTotal: "Ice Total",
|
||||||
iceRedeemed: "Ice Redeemed", // bags picked up
|
iceRedeemed: "Ice Redeemed",
|
||||||
} as const;
|
} 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 };
|
export type NocoRecord = Record<string, unknown> & { Id: number };
|
||||||
|
|
||||||
function num(v: unknown): number {
|
function num(v: unknown): number {
|
||||||
|
|
@ -57,23 +50,46 @@ function bool(v: unknown): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Total redeemable tickets = sum of age brackets minus the free ones. */
|
/**
|
||||||
|
* Total scannable (paid) tickets = adults + youth 13-16. Children 12 and under
|
||||||
|
* (kids 10-12 / 5-9 / 0-4) are admitted free and not counted; charging starts
|
||||||
|
* at age 13.
|
||||||
|
*/
|
||||||
export function computeTotal(rec: NocoRecord): number {
|
export function computeTotal(rec: NocoRecord): number {
|
||||||
let total = 0;
|
return num(rec[COL.adults]) + num(rec[COL.youth]);
|
||||||
for (const col of AGE_COLUMNS) {
|
|
||||||
if (FREE_AGE_COLUMNS.includes(col)) continue;
|
|
||||||
total += num(rec[col]);
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-bracket breakdown for display. */
|
/** Free children (age 12 and under). */
|
||||||
|
export function freeKidsCount(rec: NocoRecord): number {
|
||||||
|
return num(rec[COL.kids12]) + num(rec[COL.kids9]) + num(rec[COL.kids4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeIceTotal(rec: NocoRecord): number {
|
||||||
|
return num(rec[COL.iceTotal]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-group breakdown for display. */
|
||||||
export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
|
export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
|
||||||
return AGE_COLUMNS.map((col) => ({
|
return [
|
||||||
bracket: col.replace(/^Ages /, ""),
|
{ bracket: "Adults", count: num(rec[COL.adults]), free: false },
|
||||||
count: num(rec[col]),
|
{ bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false },
|
||||||
free: FREE_AGE_COLUMNS.includes(col),
|
{ bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: true },
|
||||||
})).filter((b) => b.count > 0);
|
{ bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: true },
|
||||||
|
{ bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true },
|
||||||
|
].filter((b) => b.count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adult names stored as a newline-separated list. */
|
||||||
|
export function parseAdultNames(rec: NocoRecord): string[] {
|
||||||
|
const raw = rec[COL.adultNames];
|
||||||
|
if (Array.isArray(raw)) return raw.map((x) => String(x)).filter(Boolean);
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
return raw
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceCount {
|
export interface ResourceCount {
|
||||||
|
|
@ -86,24 +102,26 @@ export interface TicketView {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
ticketType: string; // "" for regular; Guest/Worker/... for special tickets
|
||||||
|
createdBy: string; // who issued a comp ticket
|
||||||
total: number;
|
total: number;
|
||||||
redeemed: number;
|
redeemed: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
ice: ResourceCount;
|
ice: ResourceCount;
|
||||||
|
adultNames: string[];
|
||||||
extras: {
|
extras: {
|
||||||
carParking: boolean;
|
carParking: boolean;
|
||||||
rvParking: boolean;
|
rvParking: boolean;
|
||||||
|
utv: boolean;
|
||||||
iceAccess: boolean;
|
iceAccess: boolean;
|
||||||
isDonor: boolean;
|
isDonor: boolean;
|
||||||
freeUnder4: number;
|
donorTier: string;
|
||||||
|
vouchers: number;
|
||||||
|
freeKids: number; // children 12 & under (free admission)
|
||||||
};
|
};
|
||||||
ages: { bracket: string; count: number; free: boolean }[];
|
ages: { bracket: string; count: number; free: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeIceTotal(rec: NocoRecord): number {
|
|
||||||
return num(rec[COL.iceTotal]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toView(rec: NocoRecord): TicketView {
|
export function toView(rec: NocoRecord): TicketView {
|
||||||
const total = computeTotal(rec);
|
const total = computeTotal(rec);
|
||||||
const redeemed = num(rec[COL.redeemed]);
|
const redeemed = num(rec[COL.redeemed]);
|
||||||
|
|
@ -113,6 +131,8 @@ export function toView(rec: NocoRecord): TicketView {
|
||||||
code: String(rec[COL.code] ?? ""),
|
code: String(rec[COL.code] ?? ""),
|
||||||
name: String(rec[COL.name] ?? ""),
|
name: String(rec[COL.name] ?? ""),
|
||||||
email: String(rec[COL.email] ?? ""),
|
email: String(rec[COL.email] ?? ""),
|
||||||
|
ticketType: String(rec[COL.ticketType] ?? ""),
|
||||||
|
createdBy: String(rec[COL.createdBy] ?? ""),
|
||||||
total,
|
total,
|
||||||
redeemed,
|
redeemed,
|
||||||
remaining: Math.max(0, total - redeemed),
|
remaining: Math.max(0, total - redeemed),
|
||||||
|
|
@ -121,12 +141,16 @@ export function toView(rec: NocoRecord): TicketView {
|
||||||
redeemed: iceRedeemed,
|
redeemed: iceRedeemed,
|
||||||
remaining: Math.max(0, iceTotal - iceRedeemed),
|
remaining: Math.max(0, iceTotal - iceRedeemed),
|
||||||
},
|
},
|
||||||
|
adultNames: parseAdultNames(rec),
|
||||||
extras: {
|
extras: {
|
||||||
carParking: bool(rec[COL.carParking]),
|
carParking: bool(rec[COL.carParking]),
|
||||||
rvParking: bool(rec[COL.rvParking]),
|
rvParking: bool(rec[COL.rvParking]),
|
||||||
|
utv: bool(rec[COL.utv]),
|
||||||
iceAccess: bool(rec[COL.iceAccess]),
|
iceAccess: bool(rec[COL.iceAccess]),
|
||||||
isDonor: bool(rec[COL.isDonor]),
|
isDonor: bool(rec[COL.isDonor]),
|
||||||
freeUnder4: num(rec["Ages 0-3"]),
|
donorTier: String(rec[COL.donorTier] ?? ""),
|
||||||
|
vouchers: num(rec[COL.vouchers]),
|
||||||
|
freeKids: freeKidsCount(rec),
|
||||||
},
|
},
|
||||||
ages: ageBreakdown(rec),
|
ages: ageBreakdown(rec),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
96
backend/src/fluentforms.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { toBool, toNumber } from "./fields.js";
|
||||||
|
|
||||||
|
/** Constant-time string compare for shared webhook secrets. */
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a FluentForms compound name field, given as a nested object
|
||||||
|
* (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
|
||||||
|
export function nameGroup(body: Record<string, any>, base: string): string {
|
||||||
|
const obj = body[base];
|
||||||
|
let first: any, middle: any, last: any;
|
||||||
|
if (obj && typeof obj === "object") {
|
||||||
|
({ first_name: first, middle_name: middle, last_name: last } = obj);
|
||||||
|
} else {
|
||||||
|
first = body[`${base}[first_name]`];
|
||||||
|
middle = body[`${base}[middle_name]`];
|
||||||
|
last = body[`${base}[last_name]`];
|
||||||
|
}
|
||||||
|
return [first, middle, last]
|
||||||
|
.map((x) => (x == null ? "" : String(x).trim()))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read an item_quantity / payment field's numeric value (handles nested
|
||||||
|
* objects like {quantity} / {value} and money strings like "$40.00"). */
|
||||||
|
export function qty(v: any): number {
|
||||||
|
if (v == null || v === "") return 0;
|
||||||
|
if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0);
|
||||||
|
if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, ""));
|
||||||
|
return toNumber(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A payment/extra field counts as "selected" if it has a meaningful value.
|
||||||
|
* Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */
|
||||||
|
export function selected(v: any): boolean {
|
||||||
|
if (v == null || v === "") return false;
|
||||||
|
if (typeof v === "object") {
|
||||||
|
if ("selected" in v) return toBool((v as any).selected);
|
||||||
|
return qty(v) > 0 || Object.keys(v).length > 0;
|
||||||
|
}
|
||||||
|
const s = String(v).trim().toLowerCase();
|
||||||
|
if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten a FluentForms compound address (`address_1`) to a single line. */
|
||||||
|
export function addressLine(v: any): string | undefined {
|
||||||
|
if (v && typeof v === "object") return Object.values(v).filter(Boolean).join(", ");
|
||||||
|
if (v !== undefined) return String(v);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NUMBER_WORDS: Record<string, number> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total bags of ice from the `payment_ice` field. The form sends a descriptive
|
||||||
|
* option label, e.g. "One Ice ticket good for one bag per day (3 total bags)",
|
||||||
|
* so the reliable signal is the "(N total bags)" the label states. Falls back to
|
||||||
|
* a worded ticket count ("Two Ice tickets" → 2 × bagsPerTicket), then to a
|
||||||
|
* numeric dollar-total/ticket-count for forward compatibility.
|
||||||
|
*/
|
||||||
|
export function iceBagsFromPayment(
|
||||||
|
value: unknown,
|
||||||
|
opts: { bagsPerTicket: number; ticketPrice: number },
|
||||||
|
): number {
|
||||||
|
const { bagsPerTicket, ticketPrice } = opts;
|
||||||
|
const s = typeof value === "string" ? value : "";
|
||||||
|
// Preferred: the label states the total bags directly.
|
||||||
|
const bagsMatch = s.match(/(\d+)\s*total\s*bags/i);
|
||||||
|
if (bagsMatch) return Math.max(0, parseInt(bagsMatch[1], 10));
|
||||||
|
// Worded ticket count: "One Ice ticket", "Two Ice tickets".
|
||||||
|
const wordMatch = s.match(/\b(one|two|three|four|five|six)\b\s+ice/i);
|
||||||
|
if (wordMatch) return NUMBER_WORDS[wordMatch[1].toLowerCase()] * bagsPerTicket;
|
||||||
|
// Numeric fallback: a dollar total (>= price) → tickets; else a small count.
|
||||||
|
const n = qty(value);
|
||||||
|
if (n <= 0) return 0;
|
||||||
|
const tickets = n >= ticketPrice ? Math.round(n / ticketPrice) : Math.round(n);
|
||||||
|
return Math.max(0, tickets) * bagsPerTicket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Donor status from the hidden lookup fields + the "are you a donor?" radio. */
|
||||||
|
export function readDonor(body: Record<string, any>): { isDonor: boolean; donorTier: string } {
|
||||||
|
const donorTier = String(body.donor_tier ?? "").trim();
|
||||||
|
const isDonor =
|
||||||
|
donorTier === "member" ||
|
||||||
|
donorTier === "donor" ||
|
||||||
|
toBool(body.donor_eligible) ||
|
||||||
|
selected(body.input_radio); // "Are you a campground donor?"
|
||||||
|
return { isDonor, donorTier };
|
||||||
|
}
|
||||||
122
backend/src/routes/admin.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { saveActiveTables } from "../services/state.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin actions for the /crush33 area — all gated by the same PORTAL_PASSWORD
|
||||||
|
* that unlocks the portal. POST-only so the password never lands in a URL/log.
|
||||||
|
*
|
||||||
|
* POST /api/admin/status -> current event tables + record counts
|
||||||
|
* POST /api/admin/wipe -> delete all ticket + audit records
|
||||||
|
* POST /api/admin/switch-table -> point the app at different event table(s)
|
||||||
|
* POST /api/admin/donor-search -> admin-only donor directory search (PII)
|
||||||
|
*/
|
||||||
|
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const cfg = app.ctx.config;
|
||||||
|
|
||||||
|
const gate = (req: any, reply: any): boolean => {
|
||||||
|
if (!cfg.PORTAL_PASSWORD) {
|
||||||
|
reply.code(404).send({ error: "admin_disabled" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pw = (req.body ?? {}).password;
|
||||||
|
if (typeof pw !== "string" || !safeEqual(pw, cfg.PORTAL_PASSWORD)) {
|
||||||
|
reply.code(401).send({ error: "bad_password" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rl = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
|
||||||
|
|
||||||
|
app.post("/api/admin/status", rl, async (req, reply) => {
|
||||||
|
if (!gate(req, reply)) return;
|
||||||
|
const [tickets, audit] = await Promise.all([
|
||||||
|
app.ctx.nocodb.count().catch(() => -1),
|
||||||
|
app.ctx.audit.count().catch(() => -1),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
tickets: { tableId: app.ctx.nocodb.tableId, count: tickets },
|
||||||
|
audit: { tableId: app.ctx.audit.currentTableId, count: audit, enabled: app.ctx.audit.enabled },
|
||||||
|
// What .env would use if the override were cleared (for reference).
|
||||||
|
defaults: { ticketsTableId: cfg.NOCODB_TABLE_ID, auditTableId: cfg.NOCODB_AUDIT_TABLE_ID ?? null },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/wipe", rl, async (req, reply) => {
|
||||||
|
if (!gate(req, reply)) return;
|
||||||
|
let ticketsDeleted = 0;
|
||||||
|
let auditDeleted = 0;
|
||||||
|
try {
|
||||||
|
ticketsDeleted = await app.ctx.nocodb.deleteAll();
|
||||||
|
} catch (e: any) {
|
||||||
|
return reply.code(502).send({ error: "wipe_failed", detail: e?.message });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
auditDeleted = await app.ctx.audit.deleteAll();
|
||||||
|
} catch {
|
||||||
|
// Audit wipe is best-effort; tickets are the important part.
|
||||||
|
}
|
||||||
|
req.log.warn({ ticketsDeleted, auditDeleted }, "admin: wiped slate");
|
||||||
|
return { ok: true, ticketsDeleted, auditDeleted };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/switch-table", rl, async (req, reply) => {
|
||||||
|
if (!gate(req, reply)) return;
|
||||||
|
const b = (req.body ?? {}) as { ticketsTableId?: string; auditTableId?: string };
|
||||||
|
const ticketsTableId = String(b.ticketsTableId ?? "").trim();
|
||||||
|
const auditTableId = String(b.auditTableId ?? "").trim();
|
||||||
|
if (!ticketsTableId) {
|
||||||
|
return reply.code(400).send({ error: "missing_tickets_table" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the new tickets table is reachable and has an Id primary key —
|
||||||
|
// switching to a PK-less table would make check-in updates hit every row.
|
||||||
|
const probe = await app.ctx.nocodb.probeTable(ticketsTableId);
|
||||||
|
if (!probe.ok) {
|
||||||
|
return reply.code(400).send({ error: "tickets_table_unreachable", status: probe.status });
|
||||||
|
}
|
||||||
|
if (!probe.hasIdPk) {
|
||||||
|
return reply.code(400).send({ error: "tickets_table_no_id_pk" });
|
||||||
|
}
|
||||||
|
if (auditTableId) {
|
||||||
|
const ap = await app.ctx.nocodb.probeTable(auditTableId);
|
||||||
|
if (!ap.ok) return reply.code(400).send({ error: "audit_table_unreachable", status: ap.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hot-swap the live clients, then persist so it survives a redeploy.
|
||||||
|
app.ctx.nocodb.setTableId(ticketsTableId);
|
||||||
|
app.ctx.audit.setTableId(auditTableId || app.ctx.audit.currentTableId);
|
||||||
|
saveActiveTables(cfg.STATE_DIR, {
|
||||||
|
ticketsTableId,
|
||||||
|
auditTableId: auditTableId || app.ctx.audit.currentTableId || undefined,
|
||||||
|
});
|
||||||
|
req.log.warn({ ticketsTableId, auditTableId }, "admin: switched event table");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
tickets: { tableId: app.ctx.nocodb.tableId },
|
||||||
|
audit: { tableId: app.ctx.audit.currentTableId },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/donor-search", rl, async (req, reply) => {
|
||||||
|
if (!gate(req, reply)) return;
|
||||||
|
if (!app.ctx.donors.enabled) return reply.code(404).send({ error: "donors_unavailable" });
|
||||||
|
const q = String(((req.body ?? {}) as { query?: string }).query ?? "").trim();
|
||||||
|
if (q.length < 2) return { results: [], query: q };
|
||||||
|
try {
|
||||||
|
const results = await app.ctx.donors.search(q, 40);
|
||||||
|
return { results, query: q };
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error({ err: e }, "admin: donor search failed");
|
||||||
|
return reply.code(502).send({ error: "search_failed", detail: e?.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
117
backend/src/routes/install.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
// Public install landing page. Points Obtainium at the Forgejo repo and gives
|
||||||
|
// iPhone PWA instructions. Served unauthenticated at /install.
|
||||||
|
const REPO_URL = "https://git.mowden.top/Beartaria/CampgroundTickets";
|
||||||
|
const OBTAINIUM_ADD = `obtainium://add/${REPO_URL}`;
|
||||||
|
const RELEASES_URL = `${REPO_URL}/releases`;
|
||||||
|
const OBTAINIUM_GET = "https://github.com/ImranR98/Obtainium/releases/latest";
|
||||||
|
const PWA_URL = "https://scan.beartariacampgrounds.com/";
|
||||||
|
|
||||||
|
export async function installRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/install", async (_req, reply) => {
|
||||||
|
reply.type("text/html").send(PAGE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f1a12" />
|
||||||
|
<title>Install Camp Scan</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: #0f1a12; color: #eaf2ec;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.5; -webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 560px; margin: 0 auto; padding: 28px 20px 56px; }
|
||||||
|
header { text-align: center; margin-bottom: 28px; }
|
||||||
|
.logo { font-size: 60px; line-height: 1; }
|
||||||
|
h1 { font-size: 26px; margin: 10px 0 4px; }
|
||||||
|
.sub { color: #9db3a4; font-size: 15px; margin: 0; }
|
||||||
|
.card {
|
||||||
|
background: #16241a; border: 1px solid #24382a; border-radius: 16px;
|
||||||
|
padding: 20px; margin: 16px 0;
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 19px; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.card .lead { color: #c4d6c9; font-size: 14px; margin: 0 0 14px; }
|
||||||
|
ol { margin: 0; padding-left: 20px; }
|
||||||
|
ol li { margin: 8px 0; }
|
||||||
|
a.btn {
|
||||||
|
display: block; text-align: center; text-decoration: none;
|
||||||
|
background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px;
|
||||||
|
padding: 16px; border-radius: 13px; margin: 14px 0;
|
||||||
|
}
|
||||||
|
a.btn.secondary { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; font-weight: 600; font-size: 15px; padding: 13px; }
|
||||||
|
a.inline { color: #58d68d; text-decoration: none; font-weight: 600; }
|
||||||
|
.note { font-size: 13px; color: #9db3a4; margin-top: 12px; }
|
||||||
|
.divider { text-align: center; color: #6c8f74; font-size: 13px; margin: 8px 0; }
|
||||||
|
code { background: #0f1a12; border: 1px solid #24382a; border-radius: 6px; padding: 1px 6px; font-size: 13px; word-break: break-all; }
|
||||||
|
.platform-tag { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: #58d68d; }
|
||||||
|
footer { text-align: center; color: #6c8f74; font-size: 12px; margin-top: 24px; }
|
||||||
|
.hide { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<div class="logo">🐻</div>
|
||||||
|
<h1>Install Camp Scan</h1>
|
||||||
|
<p class="sub">Ticket scanner for Beartaria Campgrounds gate staff</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ANDROID -->
|
||||||
|
<div class="card" id="android">
|
||||||
|
<span class="platform-tag">Android</span>
|
||||||
|
<h2>📲 Install & auto-update via Obtainium</h2>
|
||||||
|
<p class="lead">Obtainium keeps the app updated straight from our server — no Play Store needed.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Don't have Obtainium yet? <a class="inline" href="${OBTAINIUM_GET}">Download it here</a> and install the APK (you may need to allow "install unknown apps").</li>
|
||||||
|
<li>Then tap the button below — it opens Obtainium with Camp Scan ready to add:</li>
|
||||||
|
</ol>
|
||||||
|
<a class="btn" href="${OBTAINIUM_ADD}">➕ Add Camp Scan to Obtainium</a>
|
||||||
|
<p class="note">If the button doesn't open Obtainium: open Obtainium → <b>Add App</b> → paste <code>${REPO_URL}</code> → Add.</p>
|
||||||
|
<div class="divider">— or —</div>
|
||||||
|
<a class="btn secondary" href="${RELEASES_URL}">⬇︎ Download the APK directly</a>
|
||||||
|
<p class="note">Direct installs won't auto-update — Obtainium is recommended.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IPHONE -->
|
||||||
|
<div class="card" id="iphone">
|
||||||
|
<span class="platform-tag">iPhone & iPad</span>
|
||||||
|
<h2>🍎 Add to Home Screen</h2>
|
||||||
|
<p class="lead">No App Store needed — it runs as a full-screen web app.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Open <b>this page in Safari</b> (not Chrome): <code>${PWA_URL}install</code></li>
|
||||||
|
<li>Tap the <b>Share</b> button, then <b>Add to Home Screen</b> → <b>Add</b>.</li>
|
||||||
|
<li>Launch <b>Camp Scan</b> from your home screen and allow the camera.</li>
|
||||||
|
</ol>
|
||||||
|
<a class="btn secondary" href="${PWA_URL}">Open Camp Scan now</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>🔑 First launch</h2>
|
||||||
|
<p class="lead" style="margin-bottom:0">Open the app, enter the gate <b>PIN</b>, then type <b>your name</b> (recorded with every check-in). You stay signed in for the event.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Reorder so the visitor's platform shows first.
|
||||||
|
try {
|
||||||
|
var ua = navigator.userAgent || "";
|
||||||
|
var isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||||
|
if (isIOS) {
|
||||||
|
var ip = document.getElementById("iphone");
|
||||||
|
ip.parentNode.insertBefore(ip, document.getElementById("android"));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
452
backend/src/routes/portal.ts
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { createTicket } from "../ticketService.js";
|
||||||
|
import { renderQrPng, renderQrDataUrl } from "../services/qrcode.js";
|
||||||
|
|
||||||
|
const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /crush33 — password-gated comp-ticket portal for admins. Creates entry-only
|
||||||
|
* tickets (1 admission, no demographics/ice) with a category (Guest/Worker/…)
|
||||||
|
* that shows on the scanner. Password checked server-side per request.
|
||||||
|
*/
|
||||||
|
export async function portalRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/crush33", async (_req, reply) => {
|
||||||
|
reply.type("text/html").send(PAGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password check only (for the in-app portal to gate its form).
|
||||||
|
app.post(
|
||||||
|
"/api/portal/verify",
|
||||||
|
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const cfg = app.ctx.config;
|
||||||
|
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
|
||||||
|
const b = (req.body ?? {}) as { password?: string };
|
||||||
|
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
|
||||||
|
return reply.code(401).send({ error: "bad_password" });
|
||||||
|
}
|
||||||
|
return { ok: true, types: TYPES };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/portal/create-ticket",
|
||||||
|
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const cfg = app.ctx.config;
|
||||||
|
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
|
||||||
|
|
||||||
|
const b = (req.body ?? {}) as {
|
||||||
|
password?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
type?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
};
|
||||||
|
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
|
||||||
|
return reply.code(401).send({ error: "bad_password" });
|
||||||
|
}
|
||||||
|
const name = String(b.name ?? "").trim();
|
||||||
|
const email = String(b.email ?? "").trim();
|
||||||
|
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest";
|
||||||
|
// Who issued it — from the in-app portal (signed-in gate staff) or header.
|
||||||
|
const createdBy = String(b.createdBy ?? req.headers["x-operator"] ?? "").slice(0, 80).trim();
|
||||||
|
if (!name || !email) {
|
||||||
|
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof createTicket>>;
|
||||||
|
try {
|
||||||
|
result = await createTicket(app.ctx, {
|
||||||
|
name,
|
||||||
|
adultNames: [name],
|
||||||
|
email,
|
||||||
|
ticketType: type,
|
||||||
|
createdBy,
|
||||||
|
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
|
||||||
|
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error({ err: e }, "portal: create failed");
|
||||||
|
return reply.code(502).send({ error: "db_error", detail: e?.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email the QR (best-effort — the portal also shows it on-screen).
|
||||||
|
let emailSent = false;
|
||||||
|
if (!app.ctx.mailer.isBlockedRecipient(email)) {
|
||||||
|
try {
|
||||||
|
const png = await renderQrPng(result.code);
|
||||||
|
await app.ctx.mailer.sendTicket({ toEmail: email, toName: name, code: result.code, quantity: 1, qrPng: png });
|
||||||
|
emailSent = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error({ err: e, code: result.code }, "portal: email failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qr = await renderQrDataUrl(result.code);
|
||||||
|
return { ok: true, code: result.code, type, name, emailSent, qr };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f1a12" />
|
||||||
|
<title>Camp Scan — Admin (crush33)</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
|
||||||
|
a { color: #58d68d; }
|
||||||
|
label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; }
|
||||||
|
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 12px 14px; color: #eaf2ec; font-size: 15px; }
|
||||||
|
.btn { background: #25c05a; color: #06210f; font-weight: 800; font-size: 16px; border: none; padding: 13px 18px; border-radius: 12px; cursor: pointer; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.btn-red { background: #e04343; color: #fff; }
|
||||||
|
.btn-ghost { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; }
|
||||||
|
.msg { margin-top: 12px; font-size: 14px; font-weight: 600; min-height: 18px; }
|
||||||
|
.err { color: #e04343; } .ok { color: #58d68d; }
|
||||||
|
.mono { font-family: ui-monospace, Menlo, monospace; }
|
||||||
|
|
||||||
|
/* Unlock */
|
||||||
|
#unlock { max-width: 420px; margin: 0 auto; padding: 48px 20px; text-align: center; }
|
||||||
|
#unlock .logo { font-size: 52px; }
|
||||||
|
#unlock h1 { font-size: 22px; margin: 8px 0 4px; }
|
||||||
|
#unlock .sub { color: #9db3a4; font-size: 14px; }
|
||||||
|
#unlock input { text-align: center; margin-top: 18px; }
|
||||||
|
#unlock .btn { width: 100%; margin-top: 16px; }
|
||||||
|
.backlink { display: inline-block; margin-top: 18px; color: #9db3a4; font-size: 14px; text-decoration: none; }
|
||||||
|
.backlink:hover { color: #eaf2ec; }
|
||||||
|
.top-back { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Hub */
|
||||||
|
#hub { display: none; min-height: 100vh; }
|
||||||
|
.top { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-bottom: 1px solid #24382a; }
|
||||||
|
.top .brand { font-weight: 800; font-size: 17px; }
|
||||||
|
.top .lock { color: #9db3a4; font-size: 13px; cursor: pointer; }
|
||||||
|
.layout { display: flex; align-items: flex-start; }
|
||||||
|
.side { width: 190px; flex: none; border-right: 1px solid #24382a; padding: 12px 0; min-height: calc(100vh - 50px); }
|
||||||
|
.nav { display: flex; align-items: center; gap: 10px; padding: 13px 18px; color: #9db3a4; cursor: pointer; border-left: 3px solid transparent; font-weight: 700; font-size: 15px; }
|
||||||
|
.nav .i { font-size: 18px; }
|
||||||
|
.nav.on { color: #eaf2ec; background: #16241a; border-left-color: #2e7d32; }
|
||||||
|
.main { flex: 1; padding: 22px 26px 64px; max-width: 760px; }
|
||||||
|
.sec { display: none; }
|
||||||
|
.sec.on { display: block; }
|
||||||
|
h2 { font-size: 22px; margin: 0 0 4px; }
|
||||||
|
.lead { color: #9db3a4; font-size: 14px; margin: 0 0 8px; line-height: 1.5; }
|
||||||
|
.card { background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 16px; margin-top: 14px; }
|
||||||
|
.pills { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.pill { border: 1px solid #24382a; background: #16241a; border-radius: 999px; padding: 8px 14px; cursor: pointer; font-weight: 700; font-size: 14px; color: #9db3a4; }
|
||||||
|
.pill.on { background: #2e7d32; border-color: #2e7d32; color: #fff; }
|
||||||
|
.row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.result { display: none; text-align: center; margin-top: 16px; }
|
||||||
|
.result img { width: 200px; height: 200px; background: #fff; border-radius: 10px; padding: 8px; }
|
||||||
|
.result .code { font-size: 20px; letter-spacing: 2px; margin: 10px 0 2px; color: #58d68d; }
|
||||||
|
|
||||||
|
/* Donor cards */
|
||||||
|
.donor { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 13px 15px; margin-top: 11px; }
|
||||||
|
.donor .h { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.donor .nm { font-weight: 800; font-size: 16px; }
|
||||||
|
.donor .amt { color: #58d68d; font-weight: 800; }
|
||||||
|
.donor .ln { color: #9db3a4; font-size: 14px; margin-top: 3px; }
|
||||||
|
.tags { margin-top: 8px; }
|
||||||
|
.tag { display: inline-block; background: #1b5e20; color: #fff; border-radius: 6px; padding: 2px 7px; font-size: 12px; margin-right: 5px; }
|
||||||
|
.src { display: inline-block; border: 1px solid #24382a; border-radius: 6px; padding: 2px 6px; font-size: 11px; color: #9db3a4; text-transform: uppercase; margin-right: 5px; }
|
||||||
|
|
||||||
|
/* Danger */
|
||||||
|
.danger { background: #241717; border: 1px solid #8f1d1d; border-radius: 14px; padding: 16px; margin-top: 18px; }
|
||||||
|
.danger h3 { color: #ff9a9a; margin: 0 0 6px; font-size: 17px; }
|
||||||
|
.danger p, .danger li { color: #e9cfcf; font-size: 14px; line-height: 1.5; }
|
||||||
|
.danger ul { margin: 6px 0 0; padding-left: 20px; }
|
||||||
|
.status { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; }
|
||||||
|
.status .k { color: #9db3a4; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.status .v { font-size: 14px; margin-top: 5px; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.scrim { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.72); align-items: center; justify-content: center; padding: 20px; z-index: 10; }
|
||||||
|
.scrim.on { display: flex; }
|
||||||
|
.modal { background: #1a1010; border: 2px solid #e04343; border-radius: 18px; padding: 22px; max-width: 420px; width: 100%; }
|
||||||
|
.modal .warn { font-size: 40px; text-align: center; }
|
||||||
|
.modal h3 { text-align: center; margin: 4px 0 12px; font-size: 20px; }
|
||||||
|
.modal pre { white-space: pre-wrap; color: #f0d9d9; font-size: 14px; line-height: 1.55; font-family: inherit; margin: 0; }
|
||||||
|
.modal .btn { width: 100%; margin-top: 16px; }
|
||||||
|
.modal .cancel { width: 100%; margin-top: 8px; background: transparent; border: none; color: #9db3a4; font-weight: 700; font-size: 15px; padding: 12px; cursor: pointer; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.side { width: 74px; }
|
||||||
|
.nav { flex-direction: column; gap: 3px; padding: 12px 4px; font-size: 11px; text-align: center; }
|
||||||
|
.main { padding: 18px 14px 48px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="unlock">
|
||||||
|
<div class="logo">🐻</div>
|
||||||
|
<h1>Admin · crush33</h1>
|
||||||
|
<p class="sub">Admin-only area. Enter the shared portal password.</p>
|
||||||
|
<input id="pw" type="password" autocomplete="current-password" placeholder="Portal password" />
|
||||||
|
<button class="btn" id="unlockBtn">Unlock</button>
|
||||||
|
<div id="unlockMsg" class="msg" style="text-align:center"></div>
|
||||||
|
<a class="backlink" href="/">← Back to the scan app</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hub">
|
||||||
|
<div class="top">
|
||||||
|
<a class="backlink top-back" href="/">← Scanner</a>
|
||||||
|
<div class="brand">🐻 Admin · crush33</div>
|
||||||
|
<div class="lock" id="relock">Lock 🔒</div>
|
||||||
|
</div>
|
||||||
|
<div class="layout">
|
||||||
|
<div class="side">
|
||||||
|
<div class="nav on" data-sec="comp"><span class="i">🎟️</span> Comp tickets</div>
|
||||||
|
<div class="nav" data-sec="donors"><span class="i">🔎</span> Donor lookup</div>
|
||||||
|
<div class="nav" data-sec="actions"><span class="i">⚠️</span> Actions</div>
|
||||||
|
</div>
|
||||||
|
<div class="main">
|
||||||
|
<!-- Comp -->
|
||||||
|
<div class="sec on" id="sec-comp">
|
||||||
|
<h2>Comp tickets</h2>
|
||||||
|
<p class="lead">Entry-only tickets for guests & staff.</p>
|
||||||
|
<label>Ticket type</label>
|
||||||
|
<div class="pills" id="typePills">
|
||||||
|
<span class="pill on">🎫 Guest</span><span class="pill">🛠️ Worker</span><span class="pill">🎭 Performer</span><span class="pill">🙌 Volunteer</span><span class="pill">🎤 Speaker</span>
|
||||||
|
</div>
|
||||||
|
<label>Full name</label>
|
||||||
|
<input id="cName" type="text" autocomplete="off" placeholder="Attendee name" />
|
||||||
|
<label>Email</label>
|
||||||
|
<input id="cEmail" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
|
||||||
|
<button class="btn" id="cGo" style="width:100%;margin-top:18px">Create ticket</button>
|
||||||
|
<div id="cMsg" class="msg"></div>
|
||||||
|
<div id="cResult" class="result">
|
||||||
|
<img id="cQr" alt="Ticket QR" />
|
||||||
|
<div class="code mono" id="cCode"></div>
|
||||||
|
<div class="lead" id="cWho"></div>
|
||||||
|
<div class="lead" id="cMail"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Donors -->
|
||||||
|
<div class="sec" id="sec-donors">
|
||||||
|
<h2>Donor lookup</h2>
|
||||||
|
<p class="lead">🔒 Admin only · private donor info. Search by name, email, phone, address, bear name…</p>
|
||||||
|
<div class="row">
|
||||||
|
<input id="dQ" type="text" autocomplete="off" placeholder="Search donors…" style="flex:1" />
|
||||||
|
<button class="btn" id="dGo">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="dMsg" class="msg"></div>
|
||||||
|
<div id="dResults"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="sec" id="sec-actions">
|
||||||
|
<h2>Actions</h2>
|
||||||
|
<p class="lead">Event-management tools. These change live data — read the warnings.</p>
|
||||||
|
<div class="status">
|
||||||
|
<div class="k">Active event table <span id="aRefresh" style="float:right;cursor:pointer">↻</span></div>
|
||||||
|
<div class="v mono" id="aStatus">loading…</div>
|
||||||
|
</div>
|
||||||
|
<div id="aMsg" class="msg"></div>
|
||||||
|
|
||||||
|
<div class="danger">
|
||||||
|
<h3>🧹 Wipe the slate clean</h3>
|
||||||
|
<p>Permanently deletes <b>every ticket and every check-in</b> in the active event table. Use before a run-through or a fresh event.</p>
|
||||||
|
<ul><li>Does NOT affect donor data.</li><li>Cannot be undone.</li></ul>
|
||||||
|
<button class="btn btn-red" id="wipeBtn" style="width:100%">Wipe slate…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="danger">
|
||||||
|
<h3>🔀 Switch event table</h3>
|
||||||
|
<p>Point the scanner at a <b>different NocoDB table</b> — start a new event on a fresh table while keeping the current one intact.</p>
|
||||||
|
<ul><li>Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column).</li><li>The current event's data is NOT deleted, just no longer shown.</li></ul>
|
||||||
|
<label>New tickets table ID</label>
|
||||||
|
<input id="swTickets" type="text" autocomplete="off" placeholder="e.g. mv1a2b3c…" />
|
||||||
|
<label>New audit table ID (optional)</label>
|
||||||
|
<input id="swAudit" type="text" autocomplete="off" placeholder="leave blank to keep current" />
|
||||||
|
<button class="btn btn-red" id="switchBtn" style="width:100%;margin-top:14px">Switch table…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scrim" id="scrim">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="warn">⚠️</div>
|
||||||
|
<h3 id="mTitle"></h3>
|
||||||
|
<pre id="mBody"></pre>
|
||||||
|
<button class="btn btn-red" id="mConfirm"></button>
|
||||||
|
<button class="cancel" id="mCancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
var PW = "";
|
||||||
|
var counts = { tickets: "?", audit: "?", table: "?" };
|
||||||
|
var pendingAction = null;
|
||||||
|
|
||||||
|
function api(path, body) {
|
||||||
|
return fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(Object.assign({ password: PW }, body || {})) })
|
||||||
|
.then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); });
|
||||||
|
}
|
||||||
|
function relock(m) { PW = ""; $("hub").style.display = "none"; $("unlock").style.display = "block";
|
||||||
|
$("unlockMsg").textContent = m || ""; $("unlockMsg").className = "msg err"; }
|
||||||
|
|
||||||
|
// ---- Unlock ----
|
||||||
|
function unlock() {
|
||||||
|
var pw = $("pw").value;
|
||||||
|
if (!pw) { $("unlockMsg").textContent = "Enter the password."; $("unlockMsg").className = "msg err"; return; }
|
||||||
|
$("unlockBtn").disabled = true; $("unlockMsg").textContent = "Checking…"; $("unlockMsg").className = "msg ok";
|
||||||
|
fetch("/api/portal/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }) })
|
||||||
|
.then(function (r) { return r.status; })
|
||||||
|
.then(function (s) {
|
||||||
|
$("unlockBtn").disabled = false;
|
||||||
|
if (s !== 200) { $("unlockMsg").textContent = "Wrong password."; $("unlockMsg").className = "msg err"; return; }
|
||||||
|
PW = pw; $("unlockMsg").textContent = ""; $("unlock").style.display = "none"; $("hub").style.display = "block";
|
||||||
|
loadStatus();
|
||||||
|
})
|
||||||
|
.catch(function () { $("unlockBtn").disabled = false; $("unlockMsg").textContent = "Network error."; });
|
||||||
|
}
|
||||||
|
$("unlockBtn").addEventListener("click", unlock);
|
||||||
|
$("pw").addEventListener("keydown", function (e) { if (e.key === "Enter") unlock(); });
|
||||||
|
$("relock").addEventListener("click", function () { relock(""); $("unlockMsg").textContent = ""; });
|
||||||
|
|
||||||
|
// ---- Nav ----
|
||||||
|
var navs = document.querySelectorAll(".nav");
|
||||||
|
for (var i = 0; i < navs.length; i++) navs[i].addEventListener("click", function () {
|
||||||
|
var sec = this.getAttribute("data-sec");
|
||||||
|
for (var j = 0; j < navs.length; j++) navs[j].classList.toggle("on", navs[j] === this);
|
||||||
|
var secs = document.querySelectorAll(".sec");
|
||||||
|
for (var k = 0; k < secs.length; k++) secs[k].classList.toggle("on", secs[k].id === "sec-" + sec);
|
||||||
|
}.bind(navs[i]));
|
||||||
|
|
||||||
|
// ---- Comp ----
|
||||||
|
var compType = "Guest";
|
||||||
|
var pills = document.querySelectorAll("#typePills .pill");
|
||||||
|
for (var p = 0; p < pills.length; p++) pills[p].addEventListener("click", function () {
|
||||||
|
for (var q = 0; q < pills.length; q++) pills[q].classList.toggle("on", pills[q] === this);
|
||||||
|
compType = this.textContent.replace(/^[^A-Za-z]+/, "").trim();
|
||||||
|
}.bind(pills[p]));
|
||||||
|
function setC(t, ok) { $("cMsg").textContent = t; $("cMsg").className = "msg " + (ok ? "ok" : "err"); }
|
||||||
|
$("cGo").addEventListener("click", function () {
|
||||||
|
var name = $("cName").value.trim(), email = $("cEmail").value.trim();
|
||||||
|
if (!name || !email) return setC("Name and email are required.");
|
||||||
|
$("cGo").disabled = true; setC("Creating…", true);
|
||||||
|
api("/api/portal/create-ticket", { name: name, email: email, type: compType }).then(function (x) {
|
||||||
|
$("cGo").disabled = false;
|
||||||
|
if (x.s === 401) return relock("Password changed — unlock again.");
|
||||||
|
if (x.s !== 200 || !x.d.ok) return setC(x.d.detail || x.d.error || "Failed.");
|
||||||
|
setC("");
|
||||||
|
$("cQr").src = x.d.qr; $("cCode").textContent = x.d.code;
|
||||||
|
$("cWho").textContent = x.d.type + " · " + x.d.name;
|
||||||
|
$("cMail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — screenshot this QR.";
|
||||||
|
$("cResult").style.display = "block"; $("cName").value = ""; $("cEmail").value = "";
|
||||||
|
}).catch(function () { $("cGo").disabled = false; setC("Network error."); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Donors ----
|
||||||
|
function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, function (c) { return c === "&" ? "&" : c === "<" ? "<" : ">"; }); }
|
||||||
|
function money(n) { return "$" + Math.round(n).toLocaleString(); }
|
||||||
|
function searchDonors() {
|
||||||
|
var q = $("dQ").value.trim();
|
||||||
|
if (q.length < 2) { $("dMsg").textContent = "Type at least 2 characters."; $("dMsg").className = "msg err"; return; }
|
||||||
|
$("dGo").disabled = true; $("dMsg").textContent = "Searching…"; $("dMsg").className = "msg ok"; $("dResults").innerHTML = "";
|
||||||
|
api("/api/admin/donor-search", { query: q }).then(function (x) {
|
||||||
|
$("dGo").disabled = false;
|
||||||
|
if (x.s === 401) return relock("Password changed — unlock again.");
|
||||||
|
if (x.s !== 200) { $("dMsg").textContent = (x.d && (x.d.detail || x.d.error)) || "Search failed."; $("dMsg").className = "msg err"; return; }
|
||||||
|
var r = x.d.results || [];
|
||||||
|
$("dMsg").textContent = r.length ? r.length + " result" + (r.length === 1 ? "" : "s") : "No donors match “" + q + "”.";
|
||||||
|
$("dMsg").className = "msg";
|
||||||
|
var html = "";
|
||||||
|
for (var i = 0; i < r.length; i++) {
|
||||||
|
var d = r[i];
|
||||||
|
html += '<div class="donor"><div class="h"><span class="nm">' + esc(d.name || d.email || "(unnamed)") + '</span>';
|
||||||
|
if (d.lifetime != null) html += '<span class="amt">' + money(d.lifetime) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
if (d.bearName) html += '<div class="ln">🐻 ' + esc(d.bearName) + '</div>';
|
||||||
|
if (d.email) html += '<div class="ln">✉️ ' + esc(d.email) + '</div>';
|
||||||
|
if (d.altEmail) html += '<div class="ln">✉️ ' + esc(d.altEmail) + ' (alt)</div>';
|
||||||
|
if (d.phone) html += '<div class="ln">📞 ' + esc(d.phone) + '</div>';
|
||||||
|
if (d.address) html += '<div class="ln">🏠 ' + esc(d.address) + '</div>';
|
||||||
|
html += '<div class="tags"><span class="src">' + (d.source === "master" ? "directory" : "transactions") + '</span>';
|
||||||
|
for (var t = 0; t < (d.tags || []).length; t++) html += '<span class="tag">' + esc(d.tags[t]) + '</span>';
|
||||||
|
html += '</div></div>';
|
||||||
|
}
|
||||||
|
$("dResults").innerHTML = html;
|
||||||
|
}).catch(function () { $("dGo").disabled = false; $("dMsg").textContent = "Network error."; $("dMsg").className = "msg err"; });
|
||||||
|
}
|
||||||
|
$("dGo").addEventListener("click", searchDonors);
|
||||||
|
$("dQ").addEventListener("keydown", function (e) { if (e.key === "Enter") searchDonors(); });
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
function loadStatus() {
|
||||||
|
$("aStatus").textContent = "loading…";
|
||||||
|
api("/api/admin/status", {}).then(function (x) {
|
||||||
|
if (x.s === 401) return relock("Password changed — unlock again.");
|
||||||
|
if (x.s !== 200) { $("aStatus").textContent = "error"; return; }
|
||||||
|
var t = x.d.tickets, a = x.d.audit;
|
||||||
|
counts = { tickets: t.count, audit: a.count, table: t.tableId };
|
||||||
|
$("aStatus").textContent = "tickets: " + t.tableId + " · " + t.count + " records\\naudit: " + (a.tableId || "—") + " · " + a.count + " records";
|
||||||
|
}).catch(function () { $("aStatus").textContent = "network error"; });
|
||||||
|
}
|
||||||
|
$("aRefresh").addEventListener("click", loadStatus);
|
||||||
|
function aMsg(t, ok) { $("aMsg").textContent = t; $("aMsg").className = "msg " + (ok ? "ok" : "err"); }
|
||||||
|
|
||||||
|
function openModal(title, body, confirmLabel, action) {
|
||||||
|
$("mTitle").textContent = title; $("mBody").textContent = body;
|
||||||
|
$("mConfirm").textContent = confirmLabel; pendingAction = action; $("scrim").classList.add("on");
|
||||||
|
}
|
||||||
|
function closeModal() { $("scrim").classList.remove("on"); pendingAction = null; $("mConfirm").disabled = false; }
|
||||||
|
$("mCancel").addEventListener("click", closeModal);
|
||||||
|
$("mConfirm").addEventListener("click", function () { if (pendingAction) { $("mConfirm").disabled = true; pendingAction(); } });
|
||||||
|
|
||||||
|
$("wipeBtn").addEventListener("click", function () {
|
||||||
|
openModal("Wipe the slate clean?",
|
||||||
|
"This will PERMANENTLY DELETE all data in the active event table:\\n" +
|
||||||
|
"• " + counts.tickets + " ticket records (" + counts.table + ")\\n" +
|
||||||
|
"• " + counts.audit + " check-in / audit records\\n\\n" +
|
||||||
|
"Donor data is not touched. This CANNOT be undone.",
|
||||||
|
"Yes, delete everything", doWipe);
|
||||||
|
});
|
||||||
|
function doWipe() {
|
||||||
|
api("/api/admin/wipe", {}).then(function (x) {
|
||||||
|
closeModal();
|
||||||
|
if (x.s === 401) return relock("Password changed — unlock again.");
|
||||||
|
if (x.s !== 200 || !x.d.ok) return aMsg((x.d && (x.d.detail || x.d.error)) || "Wipe failed.");
|
||||||
|
aMsg("✓ Wiped " + x.d.ticketsDeleted + " tickets and " + x.d.auditDeleted + " audit rows.", true);
|
||||||
|
loadStatus();
|
||||||
|
}).catch(function () { closeModal(); aMsg("Network error."); });
|
||||||
|
}
|
||||||
|
|
||||||
|
$("switchBtn").addEventListener("click", function () {
|
||||||
|
var t = $("swTickets").value.trim(), a = $("swAudit").value.trim();
|
||||||
|
if (!t) return aMsg("Enter the new tickets table ID.");
|
||||||
|
openModal("Switch the active event table?",
|
||||||
|
"The scanner will start using:\\n• tickets → " + t + "\\n• audit → " + (a || "unchanged") + "\\n\\n" +
|
||||||
|
"The current event (" + counts.table + ", " + counts.tickets + " records) stays intact but will no longer be shown until you switch back. New purchases and scans go to the new table.",
|
||||||
|
"Yes, switch table", function () { doSwitch(t, a); });
|
||||||
|
});
|
||||||
|
function doSwitch(t, a) {
|
||||||
|
api("/api/admin/switch-table", { ticketsTableId: t, auditTableId: a || undefined }).then(function (x) {
|
||||||
|
closeModal();
|
||||||
|
if (x.s === 401) return relock("Password changed — unlock again.");
|
||||||
|
if (x.s !== 200 || !x.d.ok) return aMsg((x.d && (x.d.detail || x.d.error)) || "Switch failed.");
|
||||||
|
aMsg("✓ Now using tickets table " + x.d.tickets.tableId + ".", true);
|
||||||
|
$("swTickets").value = ""; $("swAudit").value = ""; loadStatus();
|
||||||
|
}).catch(function () { closeModal(); aMsg("Network error."); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
109
backend/src/routes/publicLookup.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
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) return false;
|
||||||
|
return timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, secret-gated donor-eligibility lookup for the FluentForms checkout.
|
||||||
|
* The form's JS calls this on email blur to decide whether to unlock a donor
|
||||||
|
* discount. Deliberately minimal: returns only { eligible, tier } — never
|
||||||
|
* names or dollar amounts — so even with the (page-source-visible) secret it
|
||||||
|
* can't leak donor financials. Rate-limited and CORS-restricted.
|
||||||
|
*/
|
||||||
|
export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const cfg = app.ctx.config;
|
||||||
|
const allowed = cfg.PUBLIC_LOOKUP_ORIGIN; // string[] allowlist
|
||||||
|
|
||||||
|
const cors = (req: any, reply: any) => {
|
||||||
|
const reqOrigin = String(req.headers?.origin ?? "").replace(/\/+$/, "");
|
||||||
|
// Echo the caller's origin only if it's on the allowlist; otherwise fall
|
||||||
|
// back to the first configured origin (keeps non-browser callers working).
|
||||||
|
const origin = allowed.includes(reqOrigin) ? reqOrigin : allowed[0];
|
||||||
|
reply.header("Access-Control-Allow-Origin", origin);
|
||||||
|
reply.header("Vary", "Origin");
|
||||||
|
reply.header("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preflight (in case the form sends one).
|
||||||
|
const preflight = async (req: any, reply: any) => {
|
||||||
|
cors(req, reply);
|
||||||
|
return reply.code(204).send();
|
||||||
|
};
|
||||||
|
app.options("/api/public/donor-eligibility", preflight);
|
||||||
|
app.options("/api/public/ticket-vouchers", preflight);
|
||||||
|
|
||||||
|
const checkSecret = (req: any): boolean => {
|
||||||
|
const { key } = (req.query ?? {}) as { key?: string };
|
||||||
|
return !!cfg.PUBLIC_LOOKUP_SECRET && !!key && safeEqual(key, cfg.PUBLIC_LOOKUP_SECRET);
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/api/public/donor-eligibility",
|
||||||
|
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
cors(req, reply);
|
||||||
|
// Disabled unless configured.
|
||||||
|
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
|
||||||
|
return reply.code(404).send({ error: "not_available" });
|
||||||
|
}
|
||||||
|
const { key, email } = (req.query ?? {}) as { key?: string; email?: string };
|
||||||
|
if (!key || !safeEqual(key, cfg.PUBLIC_LOOKUP_SECRET)) {
|
||||||
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
const addr = String(email ?? "").trim();
|
||||||
|
if (!addr) return { eligible: false, tier: null };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = await app.ctx.donors.lookup(addr);
|
||||||
|
const tier = d.found ? (d.isMember ? "member" : "donor") : null;
|
||||||
|
return { eligible: d.found, tier };
|
||||||
|
} catch {
|
||||||
|
// Fail closed — no discount rather than an error the form can't handle.
|
||||||
|
return { eligible: false, tier: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ticket-voucher entitlement: how many FREE tickets a donor has left. This is
|
||||||
|
// the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the
|
||||||
|
// vouchers already consumed by their prior ticket orders (each order stores
|
||||||
|
// how many it used), so a donor can't keep claiming free tickets by
|
||||||
|
// re-submitting the form. `vouchers` is the remaining count the form should
|
||||||
|
// grant; `entitled`/`used`/`remaining` are the breakdown. No dollar amounts.
|
||||||
|
//
|
||||||
|
// To reset for testing: zero out (or delete) the "Vouchers" value on that
|
||||||
|
// donor's ticket order row(s) in NocoDB — `used` drops and `remaining` rises.
|
||||||
|
app.get(
|
||||||
|
"/api/public/ticket-vouchers",
|
||||||
|
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
cors(req, reply);
|
||||||
|
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
|
||||||
|
return reply.code(404).send({ error: "not_available" });
|
||||||
|
}
|
||||||
|
if (!checkSecret(req)) {
|
||||||
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
const { email } = (req.query ?? {}) as { email?: string };
|
||||||
|
const addr = String(email ?? "").trim();
|
||||||
|
if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(cfg.VOUCHER_SINCE);
|
||||||
|
const { amount } = await app.ctx.donors.amountSince(addr, cutoff);
|
||||||
|
const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
|
||||||
|
const used = await app.ctx.nocodb.vouchersUsedByEmail(addr);
|
||||||
|
const remaining = Math.max(0, entitled - used);
|
||||||
|
return { vouchers: remaining, entitled, used, remaining };
|
||||||
|
} catch {
|
||||||
|
// Fail closed — grant no vouchers rather than error.
|
||||||
|
return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,59 +7,90 @@ interface Persona {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
ages: Record<string, number>;
|
adultNames?: string[];
|
||||||
|
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
|
||||||
iceBags?: number;
|
iceBags?: number;
|
||||||
carParking?: boolean;
|
carParking?: boolean;
|
||||||
rvParking?: boolean;
|
rvParking?: boolean;
|
||||||
|
utv?: boolean;
|
||||||
isDonor?: boolean;
|
isDonor?: boolean;
|
||||||
|
donorTier?: string;
|
||||||
|
ticketType?: string;
|
||||||
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
|
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
|
||||||
blurb: string;
|
blurb: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const C = (adults = 0, youth = 0, kids12 = 0, kids9 = 0, kids4 = 0) => ({ adults, youth, kids12, kids9, kids4 });
|
||||||
|
|
||||||
// A curated set covering the different attribute combinations to test.
|
// A curated set covering the different attribute combinations to test.
|
||||||
const PERSONAS: Persona[] = [
|
const PERSONAS: Persona[] = [
|
||||||
{
|
{
|
||||||
key: "solo",
|
key: "solo",
|
||||||
name: "Solo Sam",
|
name: "Solo Sam",
|
||||||
email: "solo@test.beartaria",
|
email: "solo@test.beartaria",
|
||||||
ages: { "Ages 18-25": 1 },
|
adultNames: ["Solo Sam"],
|
||||||
|
counts: C(1),
|
||||||
blurb: "1 ticket, no extras. Check-in mode → green, 1/1.",
|
blurb: "1 ticket, no extras. Check-in mode → green, 1/1.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "family",
|
key: "family",
|
||||||
name: "Family Fay",
|
name: "Family Fay",
|
||||||
email: "family@test.beartaria",
|
email: "family@test.beartaria",
|
||||||
ages: { "Ages 0-3": 2, "Ages 8-12": 3, "Ages 26-45": 2 },
|
adultNames: ["Family Fay", "Frank Fay"],
|
||||||
|
counts: C(2, 1, 1, 2, 2), // 2 adults + 1 youth = 3 paid; 5 kids 12 & under free
|
||||||
iceBags: 3,
|
iceBags: 3,
|
||||||
carParking: true,
|
carParking: true,
|
||||||
blurb:
|
blurb:
|
||||||
"5 tickets (2 under-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse; then Ice mode.",
|
"3 paid tickets (2 adults + 1 youth 13-16); 5 kids 12 & under free; car parking, 3 ice bags. Check-in a few at a time to test QR reuse + see adult names; then Ice mode.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "donor",
|
key: "donor2",
|
||||||
name: "Adam Stevens (real donor)",
|
name: "Donor Dan",
|
||||||
email: "adam21stevens@gmail.com",
|
email: "donor@example.test",
|
||||||
ages: { "Ages 26-45": 2 },
|
adultNames: ["Donor Dan", "Donna Dan"],
|
||||||
|
counts: C(2),
|
||||||
rvParking: true,
|
rvParking: true,
|
||||||
|
utv: true,
|
||||||
isDonor: true,
|
isDonor: true,
|
||||||
blurb: "2 tickets, RV parking. Banquet mode → shows real donation total ($801).",
|
donorTier: "member",
|
||||||
|
blurb:
|
||||||
|
"2 tickets, RV + UTV, donor/member. Banquet mode: this test email has no real donations — use Banquet's manual email lookup with a real donor's address.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ice",
|
key: "ice",
|
||||||
name: "Ice Ike",
|
name: "Ice Ike",
|
||||||
email: "ice@test.beartaria",
|
email: "ice@test.beartaria",
|
||||||
ages: { "Ages 18-25": 1 },
|
adultNames: ["Ice Ike"],
|
||||||
iceBags: 3,
|
counts: C(1),
|
||||||
blurb: "1 ticket + 3 ice bags. Ice mode → grab all 3 at once, then scan again → exhausted.",
|
iceBags: 6, // 2 ice tickets
|
||||||
|
blurb: "1 ticket + 6 ice bags (2 ice tickets). Ice mode → grab bags, then scan again → exhausted.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "exhausted",
|
key: "exhausted",
|
||||||
name: "Done Dora",
|
name: "Done Dora",
|
||||||
email: "done@test.beartaria",
|
email: "done@test.beartaria",
|
||||||
ages: { "Ages 26-45": 2 },
|
counts: C(2),
|
||||||
exhaust: true,
|
exhaust: true,
|
||||||
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
|
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "worker",
|
||||||
|
name: "Wanda Worker",
|
||||||
|
email: "worker@test.beartaria",
|
||||||
|
adultNames: ["Wanda Worker"],
|
||||||
|
counts: C(1),
|
||||||
|
ticketType: "Worker",
|
||||||
|
blurb: "Entry-only WORKER comp ticket. Check-in mode → green with a Worker badge.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "performer",
|
||||||
|
name: "Perry Performer",
|
||||||
|
email: "performer@test.beartaria",
|
||||||
|
adultNames: ["Perry Performer"],
|
||||||
|
counts: C(1),
|
||||||
|
ticketType: "Performer",
|
||||||
|
blurb: "Entry-only PERFORMER comp ticket. Check-in mode → green with a Performer badge.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found"
|
const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found"
|
||||||
|
|
@ -73,22 +104,23 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
|
||||||
for (const p of PERSONAS) {
|
for (const p of PERSONAS) {
|
||||||
const result = await createTicket(app.ctx, {
|
const result = await createTicket(app.ctx, {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
adultNames: p.adultNames,
|
||||||
email: p.email,
|
email: p.email,
|
||||||
ages: p.ages,
|
counts: p.counts,
|
||||||
iceBags: p.iceBags,
|
iceBags: p.iceBags,
|
||||||
carParking: p.carParking,
|
carParking: p.carParking,
|
||||||
rvParking: p.rvParking,
|
rvParking: p.rvParking,
|
||||||
|
utv: p.utv,
|
||||||
isDonor: p.isDonor,
|
isDonor: p.isDonor,
|
||||||
|
donorTier: p.donorTier,
|
||||||
|
ticketType: p.ticketType,
|
||||||
submissionKey: `test:${p.key}`,
|
submissionKey: `test:${p.key}`,
|
||||||
});
|
});
|
||||||
// Keep the "exhausted" persona fully redeemed on every load so its state
|
// Keep the "exhausted" persona fully redeemed on every load so its state
|
||||||
// is deterministic (compute the total from the persona's own age counts,
|
// is deterministic (total = scannable count from the persona's counts).
|
||||||
// since NocoDB's create response may not echo them back).
|
|
||||||
if (p.exhaust) {
|
if (p.exhaust) {
|
||||||
const total = Object.entries(p.ages)
|
const { adults, youth } = p.counts;
|
||||||
.filter(([col]) => col !== "Ages 0-3")
|
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth });
|
||||||
.reduce((s, [, n]) => s + n, 0);
|
|
||||||
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: total });
|
|
||||||
}
|
}
|
||||||
cards.push({
|
cards.push({
|
||||||
code: result.code,
|
code: result.code,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { normalizeCode, looksLikeCode } from "../services/code.js";
|
import { normalizeCode, looksLikeCode } from "../services/code.js";
|
||||||
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
|
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
|
||||||
import { renderQrPng } from "../services/qrcode.js";
|
import { renderQrPng } from "../services/qrcode.js";
|
||||||
|
import { computeStats } from "../services/stats.js";
|
||||||
import { COL } from "../fields.js";
|
import { COL } from "../fields.js";
|
||||||
|
|
||||||
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
|
@ -42,6 +43,12 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Aggregate event report (check-in progress, ice, types, extras, operators).
|
||||||
|
app.get("/api/stats", { preHandler: requireStaff }, async (req) => {
|
||||||
|
const force = String((req.query as any)?.force ?? "") === "1";
|
||||||
|
return computeStats(app.ctx, force);
|
||||||
|
});
|
||||||
|
|
||||||
// Recent check-in audit log (all, or filtered to one code via ?code=).
|
// Recent check-in audit log (all, or filtered to one code via ?code=).
|
||||||
app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
|
app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
|
||||||
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;
|
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;
|
||||||
|
|
@ -167,11 +174,16 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
schema: {
|
schema: {
|
||||||
body: {
|
body: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["name", "ages"],
|
required: ["name"],
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string", minLength: 1 },
|
name: { type: "string", minLength: 1 },
|
||||||
email: { type: "string" },
|
email: { type: "string" },
|
||||||
ages: { type: "object" },
|
adults: { type: "integer", minimum: 0 },
|
||||||
|
youth: { type: "integer", minimum: 0 },
|
||||||
|
kids12: { type: "integer", minimum: 0 },
|
||||||
|
kids9: { type: "integer", minimum: 0 },
|
||||||
|
kids4: { type: "integer", minimum: 0 },
|
||||||
|
adultNames: { type: "array", items: { type: "string" } },
|
||||||
sendEmail: { type: "boolean" },
|
sendEmail: { type: "boolean" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -182,8 +194,15 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
|
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
|
||||||
const result = await createTicket(app.ctx, {
|
const result = await createTicket(app.ctx, {
|
||||||
name: b.name,
|
name: b.name,
|
||||||
|
adultNames: b.adultNames,
|
||||||
email: b.email ?? "",
|
email: b.email ?? "",
|
||||||
ages: b.ages,
|
counts: {
|
||||||
|
adults: b.adults ?? 1,
|
||||||
|
youth: b.youth ?? 0,
|
||||||
|
kids12: b.kids12 ?? 0,
|
||||||
|
kids9: b.kids9 ?? 0,
|
||||||
|
kids4: b.kids4 ?? 0,
|
||||||
|
},
|
||||||
submissionKey,
|
submissionKey,
|
||||||
});
|
});
|
||||||
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {
|
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {
|
||||||
|
|
|
||||||
141
backend/src/routes/vendorWebhook.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { createTicket } from "../ticketService.js";
|
||||||
|
import { renderQrPng } from "../services/qrcode.js";
|
||||||
|
import { safeEqual, nameGroup, addressLine, readDonor } from "../fluentforms.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vendor booth webhooks (Vendor Fee Food / Non-Food 2026, on
|
||||||
|
* vendors.beartariacampgrounds.com). Only FOOD vendors receive entry tickets:
|
||||||
|
*
|
||||||
|
* - Food: two named pass-holders (`names` = "Name Ticket 1",
|
||||||
|
* `names_1` = "Name Ticket #2") → up to 2 gate passes.
|
||||||
|
* - Non-Food: NO entry ticket. The endpoint acknowledges the submission
|
||||||
|
* (so a wired FluentForms feed doesn't error) but issues nothing.
|
||||||
|
*
|
||||||
|
* For food, each named person gets one gate ticket. The booth name becomes the
|
||||||
|
* ticket title (so gate staff see the booth) and the pass-holders are stored as
|
||||||
|
* the attendee names. The ticket is tagged with a "Food Vendor" `Ticket Type`
|
||||||
|
* so it shows a badge on scan and rolls up in the event report. Booth size /
|
||||||
|
* additional space are logistics, not admissions, so they don't affect passes.
|
||||||
|
*
|
||||||
|
* Shares WEBHOOK_SECRET with the attendee webhook (same X-Webhook-Secret header).
|
||||||
|
*/
|
||||||
|
const FOOD_NAME_SLOTS = ["names", "names_1"]; // pass-holder name field bases
|
||||||
|
|
||||||
|
function checkSecret(app: FastifyInstance, req: any): boolean {
|
||||||
|
const secret = req.headers["x-webhook-secret"];
|
||||||
|
return typeof secret === "string" && safeEqual(secret, app.ctx.config.WEBHOOK_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
function foodHandler(app: FastifyInstance) {
|
||||||
|
return async (req: any, reply: any) => {
|
||||||
|
if (!checkSecret(app, req)) {
|
||||||
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (req.body ?? {}) as Record<string, any>;
|
||||||
|
|
||||||
|
const boothName = String(body.input_text ?? "").trim();
|
||||||
|
// Pass-holder names (non-empty slots, in order).
|
||||||
|
const passHolders = FOOD_NAME_SLOTS.map((b) => nameGroup(body, b)).filter(Boolean);
|
||||||
|
const primary = passHolders[0] ?? "";
|
||||||
|
// Ticket title = booth name (most useful at the gate), else the first person.
|
||||||
|
const title = boothName || primary;
|
||||||
|
if (!title) {
|
||||||
|
return reply.code(400).send({ error: "missing_fields", detail: "booth name or vendor name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(body.email ?? "").trim();
|
||||||
|
// One entry pass per named person; a booth with no names still gets 1.
|
||||||
|
const passes = Math.max(1, passHolders.length);
|
||||||
|
|
||||||
|
const { isDonor, donorTier } = readDonor(body);
|
||||||
|
const address = addressLine(body.address_1);
|
||||||
|
|
||||||
|
// Idempotency: prefer a stable submission id, else hash the content.
|
||||||
|
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
|
||||||
|
const submissionKey = submissionId
|
||||||
|
? `sub:${String(submissionId)}`
|
||||||
|
: "hash:" +
|
||||||
|
createHash("sha256")
|
||||||
|
.update(`vendor|Food Vendor|${email}|${title}|${passes}`)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 32);
|
||||||
|
|
||||||
|
// Vendor passes are adult admissions; no youth/kids/ice/parking.
|
||||||
|
const counts = { adults: passes, youth: 0, kids12: 0, kids9: 0, kids4: 0 };
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof createTicket>>;
|
||||||
|
try {
|
||||||
|
result = await createTicket(app.ctx, {
|
||||||
|
name: title,
|
||||||
|
adultNames: passHolders,
|
||||||
|
email,
|
||||||
|
address,
|
||||||
|
isDonor,
|
||||||
|
donorTier,
|
||||||
|
ticketType: "Food Vendor",
|
||||||
|
counts,
|
||||||
|
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
||||||
|
submissionKey,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error({ err: e }, "vendor 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email the ticket QR (FluentForms sends the receipt separately).
|
||||||
|
if (!email) {
|
||||||
|
req.log.warn({ code: result.code }, "vendor webhook: ticket created but no email");
|
||||||
|
return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "no_email" };
|
||||||
|
}
|
||||||
|
if (app.ctx.mailer.isBlockedRecipient(email)) {
|
||||||
|
req.log.warn({ email }, "vendor webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
|
||||||
|
return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "trial_restriction" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const qr = await renderQrPng(result.code);
|
||||||
|
await app.ctx.mailer.sendTicket({
|
||||||
|
toEmail: email,
|
||||||
|
toName: primary || title,
|
||||||
|
code: result.code,
|
||||||
|
quantity: passes,
|
||||||
|
qrPng: qr,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
req.log.error({ err: e, code: result.code }, "vendor webhook: created but email failed");
|
||||||
|
return reply.code(502).send({ status: "created", code: result.code, passes, emailSent: false, error: e?.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "created", code: result.code, passes, emailSent: true };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-food vendors don't get an entry ticket. Acknowledge and issue nothing
|
||||||
|
* (so a wired FluentForms feed doesn't error), but never create a ticket. */
|
||||||
|
function nonFoodHandler(app: FastifyInstance) {
|
||||||
|
return async (req: any, reply: any) => {
|
||||||
|
if (!checkSecret(app, req)) {
|
||||||
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
return { status: "ignored", reason: "non_food_no_ticket" };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vendorWebhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// Configure this URL in the FOOD vendor FluentForms form:
|
||||||
|
// https://scan.beartariacampgrounds.com/vendor-webhook/food
|
||||||
|
// Non-food vendors receive no entry ticket; the endpoint below is a safe
|
||||||
|
// no-op only so an accidentally-wired feed doesn't 404.
|
||||||
|
app.post("/vendor-webhook/food", foodHandler(app));
|
||||||
|
app.post("/vendor-webhook/non-food", nonFoodHandler(app));
|
||||||
|
// Explicit API aliases.
|
||||||
|
app.post("/api/webhook/vendor-food", foodHandler(app));
|
||||||
|
app.post("/api/webhook/vendor-non-food", nonFoodHandler(app));
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,21 @@
|
||||||
import { createHash, timingSafeEqual } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { AGE_COLUMNS, toBool, toNumber } from "../fields.js";
|
import { toBool } from "../fields.js";
|
||||||
import { createTicket } from "../ticketService.js";
|
import { createTicket } from "../ticketService.js";
|
||||||
import { renderQrPng } from "../services/qrcode.js";
|
import { renderQrPng } from "../services/qrcode.js";
|
||||||
|
import { safeEqual, nameGroup, qty, selected, addressLine, iceBagsFromPayment } from "../fluentforms.js";
|
||||||
|
|
||||||
function safeEqual(a: string, b: string): boolean {
|
// Regular adult attendee name groups (Adult Ticket #1–#10), in order.
|
||||||
const ba = Buffer.from(a || "");
|
const REGULAR_NAME_BASES = [
|
||||||
const bb = Buffer.from(b || "");
|
"names",
|
||||||
if (ba.length !== bb.length) return false;
|
"names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9",
|
||||||
return timingSafeEqual(ba, bb);
|
];
|
||||||
}
|
// Donor voucher ticket name groups. Each FILLED group is one free voucher adult
|
||||||
|
// ticket — the voucher tickets live in these two name fields (there's no
|
||||||
// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map
|
// separate quantity field for them).
|
||||||
// the FluentForms fields to in the webhook feed.
|
const DONOR_NAME_BASES = ["names_Donor_1", "names_Donor_2"];
|
||||||
const AGE_KEY_TO_COL: Record<string, string> = {
|
// All adult names for the gate display list.
|
||||||
ages_0_3: "Ages 0-3",
|
const ADULT_NAME_BASES = [...REGULAR_NAME_BASES, ...DONOR_NAME_BASES];
|
||||||
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> {
|
export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
const handler = async (req: any, reply: any) => {
|
const handler = async (req: any, reply: any) => {
|
||||||
|
|
@ -31,57 +24,102 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
return reply.code(401).send({ error: "unauthorized" });
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
const body = (req.body ?? {}) as Record<string, any>;
|
||||||
const name = String(body.name ?? "").trim();
|
|
||||||
const email = String(body.email ?? "").trim();
|
const email = String(body.email ?? "").trim();
|
||||||
if (!name || !email) {
|
|
||||||
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
|
// Billing/customer name (the purchaser — may differ from attendees, e.g.
|
||||||
|
// buying for others or add-ons only) + the attendee name groups.
|
||||||
|
const customerName = nameGroup(body, "customer_name") || String(body.name ?? "").trim();
|
||||||
|
const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean);
|
||||||
|
// Free voucher adult tickets = number of donor name fields filled.
|
||||||
|
const voucherTickets = DONOR_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean).length;
|
||||||
|
// Ticket title + email recipient = the billing/customer name (fall back to
|
||||||
|
// the first attendee only if the customer name is somehow missing).
|
||||||
|
const purchaser = customerName || adultNames[0];
|
||||||
|
const title = customerName || adultNames[0];
|
||||||
|
if (!title) {
|
||||||
|
return reply.code(400).send({ error: "missing_fields", detail: "customer or attendee name is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build age-bracket counts from whichever keys were provided.
|
// Attendee counts. Adults = regular (paid) tickets + additional paid donor
|
||||||
const ages: Record<string, number> = {};
|
// tickets + free voucher tickets (one per donor name provided).
|
||||||
for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) {
|
const counts = {
|
||||||
if (body[key] !== undefined && body[key] !== null && body[key] !== "") {
|
adults:
|
||||||
ages[col] = toNumber(body[key]);
|
qty(body.item_quantity_adult_ticket_reg) +
|
||||||
}
|
qty(body.item_quantity_adult_ticket_donor) +
|
||||||
}
|
voucherTickets,
|
||||||
const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0);
|
youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor),
|
||||||
if (!anyAge) {
|
kids12: qty(body.item_quantity_kids_12),
|
||||||
return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" });
|
kids9: qty(body.item_quantity_kids_9),
|
||||||
|
kids4: qty(body.item_quantity_kids_4),
|
||||||
|
};
|
||||||
|
// Paid/scannable admissions = adults + youth 13-16. Children 12 & under are
|
||||||
|
// free (charging starts at 13) and are stored but not counted at the gate.
|
||||||
|
const scannable = counts.adults + counts.youth;
|
||||||
|
|
||||||
|
// Donor info (hidden fields from the eligibility/voucher lookups) + radio.
|
||||||
|
const donorTier = String(body.donor_tier ?? "").trim();
|
||||||
|
const isDonor =
|
||||||
|
donorTier === "member" ||
|
||||||
|
donorTier === "donor" ||
|
||||||
|
toBool(body.donor_eligible) ||
|
||||||
|
selected(body.input_radio); // "Are you a campground donor?"
|
||||||
|
// Vouchers consumed in this order = the free voucher tickets actually taken
|
||||||
|
// (donor names filled), which is what the ticket-voucher lookup subtracts.
|
||||||
|
const vouchers = voucherTickets;
|
||||||
|
|
||||||
|
// Extras (best-effort from payment fields — donor variants may be free/$0).
|
||||||
|
const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor);
|
||||||
|
const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor);
|
||||||
|
const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor);
|
||||||
|
// Ice: payment_ice is a descriptive option label whose "(N total bags)"
|
||||||
|
// states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags.
|
||||||
|
const iceBags = iceBagsFromPayment(body.payment_ice, {
|
||||||
|
bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET,
|
||||||
|
ticketPrice: app.ctx.config.ICE_TICKET_PRICE,
|
||||||
|
});
|
||||||
|
const iceAccess = iceBags > 0 || selected(body.input_radio_7);
|
||||||
|
|
||||||
|
// Tickets are optional: a customer can buy ice/UTV/parking with no admission
|
||||||
|
// ticket, or buy tickets for others. Only reject a truly empty order —
|
||||||
|
// nothing to check in, redeem, or verify at the gate.
|
||||||
|
const hasIssuable = scannable > 0 || iceBags > 0 || utv || carParking || rvParking;
|
||||||
|
if (!hasIssuable) {
|
||||||
|
req.log.warn({ body }, "webhook: submission has nothing to issue");
|
||||||
|
return reply.code(400).send({ error: "no_items", detail: "no tickets, ice, or add-ons in submission" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency key: prefer a stable submission id, else hash the content.
|
const address = addressLine(body.address_1);
|
||||||
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id;
|
|
||||||
|
// Idempotency: prefer a stable submission id, else hash the content
|
||||||
|
// (include ice/extras so distinct add-on-only orders don't collide).
|
||||||
|
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
|
||||||
const submissionKey = submissionId
|
const submissionKey = submissionId
|
||||||
? `sub:${String(submissionId)}`
|
? `sub:${String(submissionId)}`
|
||||||
: "hash:" +
|
: "hash:" +
|
||||||
createHash("sha256")
|
createHash("sha256")
|
||||||
.update(`${email}|${name}|${JSON.stringify(ages)}`)
|
.update(`${email}|${title}|${JSON.stringify(counts)}|${iceBags}|${carParking}|${rvParking}|${utv}`)
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 32);
|
.slice(0, 32);
|
||||||
|
|
||||||
// Ice: prefer an explicit bag count; else grant the default when a boolean
|
|
||||||
// ice option is truthy; else 0.
|
|
||||||
let iceBags = 0;
|
|
||||||
if (body.ice_bags !== undefined && body.ice_bags !== null && body.ice_bags !== "") {
|
|
||||||
iceBags = toNumber(body.ice_bags);
|
|
||||||
} else if (body.ice_access !== undefined && toBool(body.ice_access)) {
|
|
||||||
iceBags = app.ctx.config.ICE_BAGS_DEFAULT;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: Awaited<ReturnType<typeof createTicket>>;
|
let result: Awaited<ReturnType<typeof createTicket>>;
|
||||||
try {
|
try {
|
||||||
result = await createTicket(app.ctx, {
|
result = await createTicket(app.ctx, {
|
||||||
name,
|
name: title,
|
||||||
|
adultNames,
|
||||||
email,
|
email,
|
||||||
address: body.address !== undefined ? String(body.address) : undefined,
|
address,
|
||||||
isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined,
|
isDonor,
|
||||||
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined,
|
donorTier,
|
||||||
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined,
|
vouchers,
|
||||||
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined,
|
counts,
|
||||||
|
carParking,
|
||||||
|
rvParking,
|
||||||
|
utv,
|
||||||
|
iceAccess,
|
||||||
iceBags,
|
iceBags,
|
||||||
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
||||||
ages,
|
|
||||||
submissionKey,
|
submissionKey,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -93,9 +131,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
return { status: "duplicate", code: result.code };
|
return { status: "duplicate", code: result.code };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the ticket email. If it fails, the row already exists — report 502
|
// Send the ticket QR email (FluentForms sends the receipt separately). If it
|
||||||
// so the failure is visible in FluentForms' delivery log; the ticket can be
|
// fails, the row already exists — report 502 so it's visible in the feed
|
||||||
// re-sent later via POST /api/tickets/:code/resend-email.
|
// log; re-send later via POST /api/tickets/:code/resend-email.
|
||||||
|
if (!email) {
|
||||||
|
req.log.warn({ code: result.code }, "webhook: ticket created but no email to send to");
|
||||||
|
return { status: "created", code: result.code, emailSent: false, emailSkipped: "no_email" };
|
||||||
|
}
|
||||||
if (app.ctx.mailer.isBlockedRecipient(email)) {
|
if (app.ctx.mailer.isBlockedRecipient(email)) {
|
||||||
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
|
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
|
||||||
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
|
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
|
||||||
|
|
@ -103,14 +145,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qr = await renderQrPng(result.code);
|
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({
|
await app.ctx.mailer.sendTicket({
|
||||||
toEmail: email,
|
toEmail: email,
|
||||||
toName: name,
|
toName: purchaser,
|
||||||
code: result.code,
|
code: result.code,
|
||||||
quantity,
|
quantity: scannable,
|
||||||
qrPng: qr,
|
qrPng: qr,
|
||||||
|
iceBags,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed");
|
req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed");
|
||||||
|
|
|
||||||
184
backend/src/routes/webhookDoc.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
// Public documentation page for the FluentForms → /webhook integration.
|
||||||
|
const WEBHOOK_URL = "https://scan.beartariacampgrounds.com/webhook";
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
key: string;
|
||||||
|
req: "required" | "optional";
|
||||||
|
type: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELDS: Field[] = [
|
||||||
|
{ key: "customer_name", req: "required", type: "name (compound)", desc: "Billing / customer name — the buyer. Stored as the ticket title and used to address the email. Object {first_name, middle_name, last_name}; flat customer_name[first_name] keys also accepted." },
|
||||||
|
{ key: "names", req: "optional", type: "name (compound)", desc: "Adult Ticket #1 attendee — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys. May be empty when buying only donor tickets or add-ons." },
|
||||||
|
{ key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional regular adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." },
|
||||||
|
{ key: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor voucher ticket names. Each FILLED group is one FREE voucher adult ticket — this is how voucher tickets are counted (there's no quantity field for them). Also added to the gate name list and recorded as the vouchers consumed." },
|
||||||
|
{ key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." },
|
||||||
|
{ key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." },
|
||||||
|
{ key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Regular (non-donor) adult tickets." },
|
||||||
|
{ key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "ADDITIONAL paid donor adult tickets bought beyond the free vouchers. Added to the adult total; does NOT include the voucher tickets (those come from names_Donor_1/2)." },
|
||||||
|
{ key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." },
|
||||||
|
{ key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. FREE — stored but NOT counted toward the scannable ticket total." },
|
||||||
|
{ key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE — stored but NOT counted toward the scannable ticket total." },
|
||||||
|
{ key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — stored but NOT counted toward the scannable ticket total." },
|
||||||
|
{ key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." },
|
||||||
|
{ key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." },
|
||||||
|
{ key: "vouchers", req: "optional", type: "hidden", desc: "Voucher entitlement from the ticket-voucher lookup (informational). The vouchers actually consumed are counted from the filled names_Donor_1/2 groups, not this field." },
|
||||||
|
{ key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." },
|
||||||
|
{ key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." },
|
||||||
|
{ key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
|
||||||
|
{ key: "payment_utv_reg / _donor", req: "optional", type: "payment", desc: "ATV/UTV. Flagged if either variant is selected." },
|
||||||
|
{ key: "payment_ice", req: "optional", type: "payment", desc: "Ice tickets (1-4 at $20 each). One ice ticket = 3 bags; stored as bags = tickets × 3. Accepts a ticket count (1-4) or a dollar total ($20-$80)." },
|
||||||
|
{ key: "payment_method", req: "optional", type: "payment", desc: "Payment method label." },
|
||||||
|
{ key: "id / submission_id", req: "optional", type: "text", desc: "Entry/submission id for idempotency (retries won't duplicate). Falls back to a content hash." },
|
||||||
|
];
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function webhookDocRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/webhook-doc", async (_req, reply) => {
|
||||||
|
reply.type("text/html").send(PAGE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = FIELDS.map(
|
||||||
|
(f) => `<tr>
|
||||||
|
<td><code>${esc(f.key)}</code></td>
|
||||||
|
<td class="${f.req === "required" ? "req" : "opt"}">${f.req}</td>
|
||||||
|
<td>${esc(f.type)}</td>
|
||||||
|
<td>${esc(f.desc)}</td>
|
||||||
|
</tr>`,
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
const exampleJson = esc(`{
|
||||||
|
"id": "412",
|
||||||
|
"customer_name": { "first_name": "Jane", "last_name": "Bear" },
|
||||||
|
"names": { "first_name": "Jane", "last_name": "Bear" },
|
||||||
|
"names_1": { "first_name": "John", "last_name": "Bear" },
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"item_quantity_adult_ticket_reg": 2,
|
||||||
|
"item_quantity_adult_ticket_donor": 0,
|
||||||
|
"item_quantity_youth_ticket_reg": 1,
|
||||||
|
"item_quantity_kids_9": 2,
|
||||||
|
"item_quantity_kids_4": 2,
|
||||||
|
"donor_tier": "member",
|
||||||
|
"vouchers": 2,
|
||||||
|
"payment_parking_reg": "$40.00",
|
||||||
|
"payment_ice": 2,
|
||||||
|
"payment_method": "stripe"
|
||||||
|
}`);
|
||||||
|
|
||||||
|
const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
|
||||||
|
-d @submission.json`);
|
||||||
|
|
||||||
|
const PAGE = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f1a12" />
|
||||||
|
<title>Camp Scan — Webhook</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.55; }
|
||||||
|
.wrap { max-width: 900px; margin: 0 auto; padding: 28px 20px 64px; }
|
||||||
|
h1 { font-size: 26px; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; }
|
||||||
|
.sub { color: #9db3a4; margin: 0 0 8px; }
|
||||||
|
code { background: #16241a; border: 1px solid #24382a; border-radius: 6px; padding: 1px 6px; font-size: 13.5px; word-break: break-word; }
|
||||||
|
pre { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 16px; overflow-x: auto; font-size: 13px; line-height: 1.5; }
|
||||||
|
pre code { background: none; border: none; padding: 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 14px; display: block; overflow-x: auto; }
|
||||||
|
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid #24382a; vertical-align: top; }
|
||||||
|
th { color: #9db3a4; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
td.req { color: #e0b341; font-weight: 700; }
|
||||||
|
td.opt { color: #6c8f74; }
|
||||||
|
.kv { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px 16px; margin: 12px 0; }
|
||||||
|
.kv div { margin: 4px 0; }
|
||||||
|
.pill { display: inline-block; background: #1b5e20; color: #fff; font-weight: 700; border-radius: 6px; padding: 2px 8px; font-size: 13px; }
|
||||||
|
ol li { margin: 8px 0; }
|
||||||
|
footer { text-align: center; color: #6c8f74; font-size: 12px; margin-top: 40px; }
|
||||||
|
a { color: #58d68d; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>🐻 Camp Scan — Purchase Webhook (Tickets 2026)</h1>
|
||||||
|
<p class="sub">How the FluentForms "Tickets 2026" checkout notifies the ticketing backend to create a ticket and email the QR code.</p>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<div><b>Endpoint</b> <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div>
|
||||||
|
<div><b>Auth header</b> <code>X-Webhook-Secret: <the shared WEBHOOK_SECRET></code></div>
|
||||||
|
<div><b>Body format</b> JSON (<code>application/json</code>) or form-encoded — both accepted. Send all form fields.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What it does</h2>
|
||||||
|
<p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p>
|
||||||
|
<p><b>Scannable ticket total</b> = adults + youth (13-16). <b>Adults</b> = <code>item_quantity_adult_ticket_reg</code> (regular) + <code>item_quantity_adult_ticket_donor</code> (extra paid donor tickets) + the number of donor voucher names (<code>names_Donor_1/2</code> — each filled name is one free voucher ticket). <b>Children 12 & under are free</b> (charging starts at 13) — stored and shown to gate staff, but not counted toward the total. Each adult name provided is stored and shown on a successful scan; the ticket title is the <code>customer_name</code>.</p>
|
||||||
|
<p><b>Tickets are optional.</b> A customer can buy ice, an ATV/UTV pass, or parking with no admission ticket, or buy tickets for other people. A record + QR is still created as long as there's something to redeem or verify at the gate (a ticket, ice, or an add-on). Only a truly empty order is rejected.</p>
|
||||||
|
|
||||||
|
<h2>Fields</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="sub">Compound name fields arrive as objects (<code>names: {first_name,…}</code>) or flattened <code>names[first_name]</code> keys — both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or <code>{quantity}</code> objects. Counts come from the <code>item_quantity_*</code> fields, so pure pricing line items (<code>payment_adult_reg</code>, <code>payment_youth_*</code>, <code>payment_kids_free</code>, <code>payment_donor_voucher1/2</code>, <code>custom-payment-amount</code>/Tax) are ignored — the <code>vouchers</code> hidden count is authoritative for donor vouchers.</p>
|
||||||
|
|
||||||
|
<h2>Idempotency</h2>
|
||||||
|
<p>Send a stable <code>id</code> / <code>submission_id</code>. A repeat returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-emailing — safe for retries and double-submits.</p>
|
||||||
|
|
||||||
|
<h2>Example payload</h2>
|
||||||
|
<pre><code>${exampleJson}</code></pre>
|
||||||
|
<p class="sub">This issues 3 scannable tickets (2 adults + 1 youth 13-16; all four kids 12 & under are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).</p>
|
||||||
|
|
||||||
|
<h2>Test with curl</h2>
|
||||||
|
<pre><code>${exampleCurl}</code></pre>
|
||||||
|
|
||||||
|
<h2>Responses</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Status</th><th>Body</th><th>Meaning</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr>
|
||||||
|
<tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed — no-op.</td></tr>
|
||||||
|
<tr><td>400</td><td><code>{"error":"missing_fields"}</code></td><td>No customer name and no attendee names.</td></tr>
|
||||||
|
<tr><td>400</td><td><code>{"error":"no_items"}</code></td><td>Empty order — no tickets, ice, or add-ons.</td></tr>
|
||||||
|
<tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr>
|
||||||
|
<tr><td>502</td><td><code>{"status":"created","emailSent":false,…}</code></td><td>Ticket row created but the email failed — re-send from the admin app.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>FluentForms setup</h2>
|
||||||
|
<ol>
|
||||||
|
<li>On the ticket form: <b>Settings & Integrations → Webhook → Add Webhook</b>.</li>
|
||||||
|
<li><b>Request URL:</b> <code>${WEBHOOK_URL}</code> · <b>Method:</b> <code>POST</code> · <b>Format:</b> <code>JSON</code></li>
|
||||||
|
<li><b>Request Headers:</b> add <code>X-Webhook-Secret</code> = the shared secret.</li>
|
||||||
|
<li><b>Request Body:</b> send <b>all fields</b> (the field names above are the FluentForms field keys).</li>
|
||||||
|
<li>Save, submit a test purchase, and confirm the QR email arrives.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Vendor booth webhooks</h2>
|
||||||
|
<p class="sub"><b>Only food vendors receive entry tickets.</b> The vendor forms live on <b>vendors.beartariacampgrounds.com</b> and share the same <code>X-Webhook-Secret</code>. For a food booth, each <b>named</b> person gets one entry pass; the booth name (<code>input_text</code>) becomes the ticket title, and the ticket is tagged with a <b>Food Vendor</b> Ticket Type that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Form</th><th>Endpoint</th><th>Result</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Vendor Fee Food 2026</td><td><code>POST /vendor-webhook/food</code></td><td>🍔 up to 2 passes (<code>names</code> + <code>names_1</code>), Food Vendor ticket + QR email</td></tr>
|
||||||
|
<tr><td>Vendor Fee Non-Food 2026</td><td><code>POST /vendor-webhook/non-food</code></td><td>No ticket — acknowledged only (<code>{"status":"ignored"}</code>). You can leave this form's webhook unconfigured.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="sub">Relevant food keys: <code>input_text</code> (Booth Name), <code>names</code> / <code>names_1</code> (pass-holders), <code>email</code>, <code>address_1</code>, <code>donor_tier</code> / <code>donor_eligible</code> / <code>input_radio</code> (donor), <code>payment_method</code>. Same idempotency (<code>id</code>/<code>submission_id</code>) and response shapes as above, plus a <code>passes</code> count.</p>
|
||||||
|
<pre><code>curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
|
||||||
|
-d '{"id":"v-101","input_text":"Joe'\\''s Tacos","names":{"first_name":"Joe","last_name":"Taco"},"names_1":{"first_name":"Jane","last_name":"Taco"},"email":"joe@example.com","donor_tier":"member","payment_method":"stripe"}'</code></pre>
|
||||||
|
|
||||||
|
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
@ -9,8 +9,14 @@ import { loadConfig } from "./config.js";
|
||||||
import { buildContext } from "./context.js";
|
import { buildContext } from "./context.js";
|
||||||
import { authRoutes } from "./routes/auth.js";
|
import { authRoutes } from "./routes/auth.js";
|
||||||
import { webhookRoutes } from "./routes/webhook.js";
|
import { webhookRoutes } from "./routes/webhook.js";
|
||||||
|
import { vendorWebhookRoutes } from "./routes/vendorWebhook.js";
|
||||||
import { ticketRoutes } from "./routes/tickets.js";
|
import { ticketRoutes } from "./routes/tickets.js";
|
||||||
import { testRoutes } from "./routes/test.js";
|
import { testRoutes } from "./routes/test.js";
|
||||||
|
import { installRoutes } from "./routes/install.js";
|
||||||
|
import { webhookDocRoutes } from "./routes/webhookDoc.js";
|
||||||
|
import { publicLookupRoutes } from "./routes/publicLookup.js";
|
||||||
|
import { portalRoutes } from "./routes/portal.js";
|
||||||
|
import { adminRoutes } from "./routes/admin.js";
|
||||||
|
|
||||||
export async function build() {
|
export async function build() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -28,8 +34,14 @@ export async function build() {
|
||||||
|
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
await app.register(webhookRoutes);
|
await app.register(webhookRoutes);
|
||||||
|
await app.register(vendorWebhookRoutes);
|
||||||
await app.register(ticketRoutes);
|
await app.register(ticketRoutes);
|
||||||
await app.register(testRoutes);
|
await app.register(testRoutes);
|
||||||
|
await app.register(installRoutes);
|
||||||
|
await app.register(webhookDocRoutes);
|
||||||
|
await app.register(publicLookupRoutes);
|
||||||
|
await app.register(portalRoutes);
|
||||||
|
await app.register(adminRoutes);
|
||||||
|
|
||||||
// Serve the exported Expo web build (if present) with SPA fallback.
|
// Serve the exported Expo web build (if present) with SPA fallback.
|
||||||
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");
|
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry {
|
||||||
export class AuditLogger {
|
export class AuditLogger {
|
||||||
private readonly base: string;
|
private readonly base: string;
|
||||||
private readonly token: string;
|
private readonly token: string;
|
||||||
private readonly tableId: string | null;
|
private tableId: string | null;
|
||||||
|
|
||||||
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) {
|
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) {
|
||||||
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
|
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
|
||||||
|
|
@ -46,10 +46,56 @@ export class AuditLogger {
|
||||||
return this.tableId !== null;
|
return this.tableId !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The audit table id (switchable at runtime by the admin action). */
|
||||||
|
get currentTableId(): string | null {
|
||||||
|
return this.tableId;
|
||||||
|
}
|
||||||
|
setTableId(id: string | null): void {
|
||||||
|
this.tableId = id || null;
|
||||||
|
}
|
||||||
|
|
||||||
private get url(): string {
|
private get url(): string {
|
||||||
return `${this.base}/api/v2/tables/${this.tableId}/records`;
|
return `${this.base}/api/v2/tables/${this.tableId}/records`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Total audit row count (cheap — reads pageInfo). */
|
||||||
|
async count(): Promise<number> {
|
||||||
|
if (!this.tableId) return 0;
|
||||||
|
const url = new URL(this.url);
|
||||||
|
url.searchParams.set("limit", "1");
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "xc-token": this.token, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return 0;
|
||||||
|
const body: any = await res.json().catch(() => ({}));
|
||||||
|
return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every audit row in the current table. Returns the count deleted. */
|
||||||
|
async deleteAll(): Promise<number> {
|
||||||
|
if (!this.tableId) return 0;
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const url = new URL(this.url);
|
||||||
|
url.searchParams.set("limit", "1000");
|
||||||
|
url.searchParams.set("fields", "Id");
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "xc-token": this.token, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) break;
|
||||||
|
const body: any = await res.json().catch(() => ({}));
|
||||||
|
const list = body?.list ?? [];
|
||||||
|
if (!list.length) break;
|
||||||
|
await fetch(this.url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "xc-token": this.token, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(list.map((r: any) => ({ Id: r.Id }))),
|
||||||
|
});
|
||||||
|
total += list.length;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
async log(entry: AuditEntry): Promise<void> {
|
async log(entry: AuditEntry): Promise<void> {
|
||||||
if (!this.tableId) return;
|
if (!this.tableId) return;
|
||||||
const sign = entry.people >= 0 ? "+" : "";
|
const sign = entry.people >= 0 ? "+" : "";
|
||||||
|
|
@ -79,6 +125,43 @@ export class AuditLogger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mapRow(r: any): AuditRow {
|
||||||
|
return {
|
||||||
|
id: r.Id,
|
||||||
|
code: r[AUDIT_COL.code] ?? "",
|
||||||
|
people: Number(r[AUDIT_COL.people]) || 0,
|
||||||
|
name: r[AUDIT_COL.name] ?? "",
|
||||||
|
operator: r[AUDIT_COL.operator] ?? "",
|
||||||
|
remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0,
|
||||||
|
at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "",
|
||||||
|
action: (r[AUDIT_COL.action] ?? "check-in") as AuditEntry["action"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every audit row, paginated (for reporting/aggregation). */
|
||||||
|
async all(): Promise<AuditRow[]> {
|
||||||
|
if (!this.tableId) return [];
|
||||||
|
const out: AuditRow[] = [];
|
||||||
|
const pageSize = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const url = new URL(this.url);
|
||||||
|
url.searchParams.set("limit", String(pageSize));
|
||||||
|
url.searchParams.set("offset", String(offset));
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "xc-token": this.token, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) break;
|
||||||
|
const body: any = await res.json().catch(() => ({}));
|
||||||
|
const list = body?.list ?? [];
|
||||||
|
out.push(...list.map((r: any) => this.mapRow(r)));
|
||||||
|
if (!list.length || body?.pageInfo?.isLastPage || list.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
if (offset > 200000) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Recent entries, newest first, optionally filtered to one code. */
|
/** Recent entries, newest first, optionally filtered to one code. */
|
||||||
async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> {
|
async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> {
|
||||||
if (!this.tableId) return [];
|
if (!this.tableId) return [];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
import type { Config } from "../config.js";
|
import type { Config } from "../config.js";
|
||||||
|
|
||||||
|
export interface DonorSearchResult {
|
||||||
|
name: string;
|
||||||
|
bearName: string;
|
||||||
|
email: string;
|
||||||
|
altEmail: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
lifetime: number | null;
|
||||||
|
tags: string[];
|
||||||
|
source: "master" | "transactions";
|
||||||
|
}
|
||||||
|
|
||||||
export interface DonorLookup {
|
export interface DonorLookup {
|
||||||
found: boolean;
|
found: boolean;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -156,6 +168,87 @@ export class DonorService {
|
||||||
source: "transactions",
|
source: "transactions",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only free-text donor search across the master list + transaction
|
||||||
|
* tables. Matches the query (substring, case-insensitive) against any
|
||||||
|
* name / email / phone / address / bear-name column each table exposes —
|
||||||
|
* columns are discovered from a sample row so it adapts to the schema.
|
||||||
|
* Results are de-duped by email (then name). PRIVACY: gate this to admins.
|
||||||
|
*/
|
||||||
|
async search(rawQuery: string, limit = 40): Promise<DonorSearchResult[]> {
|
||||||
|
const q = rawQuery.trim();
|
||||||
|
if (!q || !this.enabled) return [];
|
||||||
|
const tables: { id: string | null; source: "master" | "transactions" }[] = [
|
||||||
|
{ id: this.masterId, source: "master" },
|
||||||
|
{ id: this.onlineId, source: "transactions" },
|
||||||
|
{ id: this.offlineId, source: "transactions" },
|
||||||
|
];
|
||||||
|
const out = new Map<string, DonorSearchResult>();
|
||||||
|
for (const t of tables) {
|
||||||
|
if (!t.id || out.size >= limit) continue;
|
||||||
|
let rows: any[];
|
||||||
|
try {
|
||||||
|
rows = await this.searchTable(t.id, q, limit);
|
||||||
|
} catch {
|
||||||
|
continue; // a table without matching columns / transient error — skip
|
||||||
|
}
|
||||||
|
for (const r of rows) {
|
||||||
|
const res = mapDonorRow(r, t.source);
|
||||||
|
const key = (res.email || res.name || JSON.stringify(r)).toLowerCase();
|
||||||
|
const existing = out.get(key);
|
||||||
|
// Prefer the master-list record (richer) when the same donor appears twice.
|
||||||
|
if (!existing || (existing.source === "transactions" && res.source === "master")) {
|
||||||
|
out.set(key, existing ? { ...res, lifetime: res.lifetime ?? existing.lifetime } : res);
|
||||||
|
}
|
||||||
|
if (out.size >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...out.values()].slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private colCache = new Map<string, string[]>();
|
||||||
|
|
||||||
|
/** Discover the text columns worth searching (name/contact) from a sample row. */
|
||||||
|
private async searchableColumns(tableId: string): Promise<string[]> {
|
||||||
|
const cached = this.colCache.get(tableId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const sample = await this.list(tableId, "", 1);
|
||||||
|
const keys = sample.length ? Object.keys(sample[0]) : [];
|
||||||
|
const want = /name|email|phone|mobile|cell|address|street|city|state|zip|postal|province|country|bear/i;
|
||||||
|
const skip = /[(),]/; // field names with filter-grammar chars can't be queried
|
||||||
|
const cols = keys.filter((k) => want.test(k) && !skip.test(k));
|
||||||
|
this.colCache.set(tableId, cols);
|
||||||
|
return cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchTable(tableId: string, q: string, limit: number): Promise<any[]> {
|
||||||
|
const cols = await this.searchableColumns(tableId);
|
||||||
|
if (!cols.length) return [];
|
||||||
|
const esc = q.replace(/[(),]/g, " ");
|
||||||
|
const where = cols.map((c) => `(${c},like,%${esc}%)`).join("~or");
|
||||||
|
return this.list(tableId, where, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total Paid donations for an email on/after `cutoff`, summed from the
|
||||||
|
* transaction tables (the only dated source). Used for ticket-voucher
|
||||||
|
* entitlement. found=false if donor tables aren't configured or there are no
|
||||||
|
* transactions for the email at all.
|
||||||
|
*/
|
||||||
|
async amountSince(rawEmail: string, cutoff: Date): Promise<{ found: boolean; amount: number }> {
|
||||||
|
const email = rawEmail.trim();
|
||||||
|
if (!email || !this.onlineId || !this.offlineId) return { found: false, amount: 0 };
|
||||||
|
const esc = email.replace(/[(),]/g, " ");
|
||||||
|
const [online, offline] = await Promise.all([
|
||||||
|
this.list(this.onlineId, `(Email,eq,${esc})`, 1000),
|
||||||
|
this.list(this.offlineId, `(Email,eq,${esc})`, 1000),
|
||||||
|
]);
|
||||||
|
const amount =
|
||||||
|
sumSince(online, "Donation Amount", "Donation Date", cutoff) +
|
||||||
|
sumSince(offline, "Donation Amount", "Donation Date", cutoff);
|
||||||
|
return { found: online.length + offline.length > 0, amount };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function num(v: unknown): number {
|
function num(v: unknown): number {
|
||||||
|
|
@ -163,6 +256,40 @@ function num(v: unknown): number {
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First non-empty value whose column name matches `rx`. */
|
||||||
|
function pick(row: any, rx: RegExp): string {
|
||||||
|
for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") return String(row[k]);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
/** Join all non-empty values whose column name matches `rx` (e.g. address parts). */
|
||||||
|
function pickAll(row: any, rx: RegExp): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") parts.push(String(row[k]));
|
||||||
|
return [...new Set(parts)].join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDonorRow(r: any, source: "master" | "transactions"): DonorSearchResult {
|
||||||
|
const name =
|
||||||
|
r["Display Name"] ||
|
||||||
|
r["Name"] ||
|
||||||
|
[r["First Name"], r["Last Name"]].filter(Boolean).join(" ") ||
|
||||||
|
r["Bear Name"] ||
|
||||||
|
pick(r, /name/i) ||
|
||||||
|
"";
|
||||||
|
const lifetimeRaw = r["Total Donations"];
|
||||||
|
return {
|
||||||
|
name: String(name),
|
||||||
|
bearName: String(r["Bear Name"] ?? ""),
|
||||||
|
email: String(r["Email"] ?? pick(r, /email/i)),
|
||||||
|
altEmail: String(r["Alternate Email"] ?? ""),
|
||||||
|
phone: pick(r, /phone|mobile|cell/i),
|
||||||
|
address: pickAll(r, /address|street|city|state|zip|postal|province|country/i),
|
||||||
|
lifetime: lifetimeRaw !== undefined && lifetimeRaw !== null && lifetimeRaw !== "" ? num(lifetimeRaw) : null,
|
||||||
|
tags: splitTags(r["Tags"]),
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Count a transaction unless it's explicitly not paid (refunded/failed/pending).
|
// Count a transaction unless it's explicitly not paid (refunded/failed/pending).
|
||||||
function isPaid(row: any): boolean {
|
function isPaid(row: any): boolean {
|
||||||
const s = String(row["Payment Status"] ?? "").trim();
|
const s = String(row["Payment Status"] ?? "").trim();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,43 @@ export interface TicketEmail {
|
||||||
code: string;
|
code: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
qrPng: Buffer;
|
qrPng: Buffer;
|
||||||
|
iceBags?: number; // for add-on-only (ticketless) orders
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Describe what a purchase is good for — handles ticketless (ice/UTV) orders. */
|
||||||
|
function purchaseSummary(mail: TicketEmail): { lead: string; footer: string } {
|
||||||
|
const qty = mail.quantity;
|
||||||
|
if (qty > 0) {
|
||||||
|
const w = qty === 1 ? "ticket" : "tickets";
|
||||||
|
return {
|
||||||
|
lead: `This email is your ticket for <strong>${qty} ${w}</strong> to the 2026 Beartaria Campgrounds event. Show the QR code below at the gate.`,
|
||||||
|
footer: `Each ticket admits one entry. This code is good for all ${qty} ${w} on one purchase — gate staff will check people in against it. See you there!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bags = mail.iceBags ?? 0;
|
||||||
|
const extra = bags > 0 ? ` It includes <strong>${bags} bag${bags === 1 ? "" : "s"} of ice</strong>.` : "";
|
||||||
|
return {
|
||||||
|
lead: `This email is your gate pass for your 2026 Beartaria Campgrounds purchase (add-ons such as ice, parking, or an ATV/UTV).${extra} Show the QR code below at the gate.`,
|
||||||
|
footer: `Show this QR at the gate and staff will redeem your add-ons against it. See you there!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-text version of purchaseSummary (no HTML tags). */
|
||||||
|
function purchaseSummaryText(mail: TicketEmail): { lead: string; footer: string } {
|
||||||
|
const qty = mail.quantity;
|
||||||
|
if (qty > 0) {
|
||||||
|
const w = qty === 1 ? "ticket" : "tickets";
|
||||||
|
return {
|
||||||
|
lead: `This is your ticket for ${qty} ${w} to the 2026 Beartaria Campgrounds event.`,
|
||||||
|
footer: `It is good for all ${qty} ${w} on this purchase.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bags = mail.iceBags ?? 0;
|
||||||
|
const extra = bags > 0 ? ` It includes ${bags} bag${bags === 1 ? "" : "s"} of ice.` : "";
|
||||||
|
return {
|
||||||
|
lead: `This is your gate pass for your purchase (add-ons such as ice, parking, or an ATV/UTV).${extra}`,
|
||||||
|
footer: `Show this code at the gate and staff will redeem your add-ons against it.`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MailerSendError extends Error {
|
export class MailerSendError extends Error {
|
||||||
|
|
@ -94,8 +131,7 @@ function esc(s: string): string {
|
||||||
|
|
||||||
function renderHtml(mail: TicketEmail): string {
|
function renderHtml(mail: TicketEmail): string {
|
||||||
const name = esc(mail.toName || "");
|
const name = esc(mail.toName || "");
|
||||||
const qty = mail.quantity;
|
const { lead, footer } = purchaseSummary(mail);
|
||||||
const ticketWord = qty === 1 ? "ticket" : "tickets";
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;">
|
<body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;">
|
||||||
|
|
@ -108,9 +144,7 @@ function renderHtml(mail: TicketEmail): string {
|
||||||
<tr><td style="padding:24px;">
|
<tr><td style="padding:24px;">
|
||||||
<p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p>
|
<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;">
|
<p style="margin:0 0 16px;font-size:15px;line-height:1.5;">
|
||||||
Thank you for your purchase! This email is your ticket for
|
Thank you for your purchase! ${lead}
|
||||||
<strong>${qty} ${ticketWord}</strong> to the 2026 Beartaria Campgrounds event.
|
|
||||||
Show the QR code below at the gate.
|
|
||||||
</p>
|
</p>
|
||||||
<div style="text-align:center;margin:20px 0;">
|
<div style="text-align:center;margin:20px 0;">
|
||||||
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
|
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
|
||||||
|
|
@ -121,8 +155,7 @@ function renderHtml(mail: TicketEmail): string {
|
||||||
${esc(mail.code)}
|
${esc(mail.code)}
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;font-size:13px;color:#777;line-height:1.5;">
|
<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 —
|
${footer}
|
||||||
gate staff will check people in against it. See you there!
|
|
||||||
</p>
|
</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -134,17 +167,16 @@ function renderHtml(mail: TicketEmail): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderText(mail: TicketEmail): string {
|
function renderText(mail: TicketEmail): string {
|
||||||
const qty = mail.quantity;
|
const { lead, footer } = purchaseSummaryText(mail);
|
||||||
const ticketWord = qty === 1 ? "ticket" : "tickets";
|
|
||||||
return [
|
return [
|
||||||
`Hi ${mail.toName || "there"},`,
|
`Hi ${mail.toName || "there"},`,
|
||||||
"",
|
"",
|
||||||
`Thank you for your purchase! This is your ticket for ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.`,
|
`Thank you for your purchase! ${lead}`,
|
||||||
"",
|
"",
|
||||||
`Your ticket code: ${mail.code}`,
|
`Your ticket code: ${mail.code}`,
|
||||||
"",
|
"",
|
||||||
"Show this code (or the QR code in the HTML version of this email) at the gate.",
|
"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.`,
|
footer,
|
||||||
"",
|
"",
|
||||||
"See you there!",
|
"See you there!",
|
||||||
"Beartaria Campgrounds · beartariacampgrounds.com",
|
"Beartaria Campgrounds · beartariacampgrounds.com",
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,24 @@ import { COL, type NocoRecord } from "../fields.js";
|
||||||
export class NocoDBClient {
|
export class NocoDBClient {
|
||||||
private readonly base: string;
|
private readonly base: string;
|
||||||
private readonly token: string;
|
private readonly token: string;
|
||||||
private readonly tableId: string;
|
private _tableId: string;
|
||||||
|
|
||||||
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
|
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
|
||||||
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
|
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
|
||||||
this.token = cfg.NOCODB_API_TOKEN;
|
this.token = cfg.NOCODB_API_TOKEN;
|
||||||
this.tableId = cfg.NOCODB_TABLE_ID;
|
this._tableId = cfg.NOCODB_TABLE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The table this client currently reads/writes (switchable at runtime). */
|
||||||
|
get tableId(): string {
|
||||||
|
return this._tableId;
|
||||||
|
}
|
||||||
|
setTableId(id: string): void {
|
||||||
|
this._tableId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get recordsUrl(): string {
|
private get recordsUrl(): string {
|
||||||
return `${this.base}/api/v2/tables/${this.tableId}/records`;
|
return `${this.base}/api/v2/tables/${this._tableId}/records`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request(url: string, init: RequestInit = {}): Promise<any> {
|
private async request(url: string, init: RequestInit = {}): Promise<any> {
|
||||||
|
|
@ -76,6 +84,22 @@ export class NocoDBClient {
|
||||||
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
|
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every order for an exact email (case-insensitive). */
|
||||||
|
async findByEmail(email: string, limit = 1000): Promise<NocoRecord[]> {
|
||||||
|
const rows = await this.list(`(${COL.email},eq,${escapeValue(email)})`, limit);
|
||||||
|
// Belt-and-suspenders: some NocoDB backends do a case-sensitive eq, so
|
||||||
|
// narrow/confirm against a lowercased compare in JS.
|
||||||
|
const target = email.trim().toLowerCase();
|
||||||
|
const exact = rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
|
||||||
|
return exact.length ? exact : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum of ticket vouchers a donor has already consumed across their orders. */
|
||||||
|
async vouchersUsedByEmail(email: string): Promise<number> {
|
||||||
|
const rows = await this.findByEmail(email);
|
||||||
|
return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||||
const body = await this.request(this.recordsUrl, {
|
const body = await this.request(this.recordsUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -86,6 +110,15 @@ export class NocoDBClient {
|
||||||
|
|
||||||
/** Patch fields on a record identified by its NocoDB Id. */
|
/** Patch fields on a record identified by its NocoDB Id. */
|
||||||
async update(id: number, fields: Record<string, unknown>): Promise<NocoRecord> {
|
async update(id: number, fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||||
|
// Fail safe: without a valid primary key, a v2 PATCH /records applies to
|
||||||
|
// EVERY row in the table. Refuse rather than mass-corrupt ticket counts.
|
||||||
|
// (A table missing its Id column will trip this — use a table with a PK.)
|
||||||
|
if (id === undefined || id === null || (typeof id === "number" && !Number.isFinite(id))) {
|
||||||
|
throw new NocoDBError(
|
||||||
|
"record has no Id — refusing to update (the table is missing its primary key)",
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
const body = await this.request(this.recordsUrl, {
|
const body = await this.request(this.recordsUrl, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ Id: id, ...fields }),
|
body: JSON.stringify({ Id: id, ...fields }),
|
||||||
|
|
@ -93,6 +126,67 @@ export class NocoDBClient {
|
||||||
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
|
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch every record in the table, paginating. */
|
||||||
|
async all(): Promise<NocoRecord[]> {
|
||||||
|
const out: NocoRecord[] = [];
|
||||||
|
const pageSize = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const url = new URL(this.recordsUrl);
|
||||||
|
url.searchParams.set("limit", String(pageSize));
|
||||||
|
url.searchParams.set("offset", String(offset));
|
||||||
|
const body = await this.request(url.toString());
|
||||||
|
const list = (body?.list ?? []) as NocoRecord[];
|
||||||
|
out.push(...list);
|
||||||
|
const info = body?.pageInfo;
|
||||||
|
if (!list.length || info?.isLastPage || list.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
if (offset > 200000) break; // safety
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total record count in the current table (cheap — reads pageInfo). */
|
||||||
|
async count(): Promise<number> {
|
||||||
|
const url = new URL(this.recordsUrl);
|
||||||
|
url.searchParams.set("limit", "1");
|
||||||
|
const body = await this.request(url.toString());
|
||||||
|
return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every record in the current table (paginated bulk delete). Returns
|
||||||
|
* the number deleted. Used by the admin "wipe slate" action. */
|
||||||
|
async deleteAll(): Promise<number> {
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const rows = await this.list("", 1000);
|
||||||
|
if (!rows.length) break;
|
||||||
|
const ids = rows.map((r) => ({ Id: (r as any).Id }));
|
||||||
|
await this.request(this.recordsUrl, { method: "DELETE", body: JSON.stringify(ids) });
|
||||||
|
total += rows.length;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability + primary-key probe for a candidate table id (admin switch).
|
||||||
|
* Returns { ok, hasIdPk }. hasIdPk is false only if rows exist without an Id. */
|
||||||
|
async probeTable(tableId: string): Promise<{ ok: boolean; hasIdPk: boolean; status: number }> {
|
||||||
|
const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`);
|
||||||
|
url.searchParams.set("limit", "1");
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "xc-token": this.token, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return { ok: false, hasIdPk: false, status: res.status };
|
||||||
|
const body: any = await res.json().catch(() => ({}));
|
||||||
|
const list = body?.list ?? [];
|
||||||
|
const hasIdPk = list.length === 0 || "Id" in list[0];
|
||||||
|
return { ok: true, hasIdPk, status: 200 };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, hasIdPk: false, status: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Cheap connectivity probe for healthchecks. */
|
/** Cheap connectivity probe for healthchecks. */
|
||||||
async ping(): Promise<boolean> {
|
async ping(): Promise<boolean> {
|
||||||
const url = new URL(this.recordsUrl);
|
const url = new URL(this.recordsUrl);
|
||||||
|
|
|
||||||
32
backend/src/services/state.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny persisted state, stored as JSON on a mounted volume (STATE_DIR). Used for
|
||||||
|
* the admin "switch event table" action so the choice survives a redeploy —
|
||||||
|
* otherwise the app would revert to the .env table IDs on every restart.
|
||||||
|
*/
|
||||||
|
export interface ActiveTables {
|
||||||
|
ticketsTableId: string;
|
||||||
|
auditTableId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE = "active-tables.json";
|
||||||
|
|
||||||
|
export function loadActiveTables(dir: string): ActiveTables | null {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(join(dir, FILE), "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed.ticketsTableId === "string" && parsed.ticketsTableId) {
|
||||||
|
return { ticketsTableId: parsed.ticketsTableId, auditTableId: parsed.auditTableId || undefined };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No override or unreadable — fall back to .env config.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveActiveTables(dir: string, tables: ActiveTables): void {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, FILE), JSON.stringify(tables, null, 2), "utf8");
|
||||||
|
}
|
||||||
131
backend/src/services/stats.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import type { AppContext } from "../context.js";
|
||||||
|
import { COL, toView, toNumber, type NocoRecord } from "../fields.js";
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
orders: number;
|
||||||
|
tickets: { total: number; redeemed: number; remaining: number; pct: number };
|
||||||
|
people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number };
|
||||||
|
ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number };
|
||||||
|
types: { type: string; count: number; total: number; redeemed: number }[];
|
||||||
|
donors: { orders: number; members: number; vouchers: number };
|
||||||
|
extras: { carParking: number; rvParking: number; utv: number };
|
||||||
|
comps: { total: number; byCreator: { name: string; count: number }[] };
|
||||||
|
operators: { name: string; checkins: number; ice: number; undos: number }[];
|
||||||
|
checkinsByHour: { hour: string; count: number }[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: { at: number; data: Stats } | null = null;
|
||||||
|
const TTL_MS = 20_000;
|
||||||
|
|
||||||
|
export async function computeStats(ctx: AppContext, force = false): Promise<Stats> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && cache && now - cache.at < TTL_MS) return cache.data;
|
||||||
|
|
||||||
|
const records = await ctx.nocodb.all();
|
||||||
|
const bagsPerTicket = ctx.config.ICE_BAGS_PER_TICKET || 3;
|
||||||
|
|
||||||
|
let total = 0,
|
||||||
|
redeemed = 0,
|
||||||
|
iceTotal = 0,
|
||||||
|
iceRedeemed = 0;
|
||||||
|
let adults = 0,
|
||||||
|
youth = 0,
|
||||||
|
kids12 = 0,
|
||||||
|
kids9 = 0,
|
||||||
|
kids4 = 0;
|
||||||
|
let carParking = 0,
|
||||||
|
rvParking = 0,
|
||||||
|
utv = 0,
|
||||||
|
donorOrders = 0,
|
||||||
|
members = 0,
|
||||||
|
vouchers = 0;
|
||||||
|
const typeMap = new Map<string, { count: number; total: number; redeemed: number }>();
|
||||||
|
const compByCreator = new Map<string, number>();
|
||||||
|
let compTotal = 0;
|
||||||
|
|
||||||
|
for (const r of records as NocoRecord[]) {
|
||||||
|
const v = toView(r);
|
||||||
|
if (v.ticketType) {
|
||||||
|
compTotal += 1;
|
||||||
|
const who = v.createdBy || "(unknown)";
|
||||||
|
compByCreator.set(who, (compByCreator.get(who) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
total += v.total;
|
||||||
|
redeemed += v.redeemed;
|
||||||
|
iceTotal += v.ice.total;
|
||||||
|
iceRedeemed += v.ice.redeemed;
|
||||||
|
adults += toNumber(r[COL.adults]);
|
||||||
|
youth += toNumber(r[COL.youth]);
|
||||||
|
kids12 += toNumber(r[COL.kids12]);
|
||||||
|
kids9 += toNumber(r[COL.kids9]);
|
||||||
|
kids4 += toNumber(r[COL.kids4]);
|
||||||
|
|
||||||
|
const t = v.ticketType || "Regular";
|
||||||
|
const e = typeMap.get(t) ?? { count: 0, total: 0, redeemed: 0 };
|
||||||
|
e.count += 1;
|
||||||
|
e.total += v.total;
|
||||||
|
e.redeemed += v.redeemed;
|
||||||
|
typeMap.set(t, e);
|
||||||
|
|
||||||
|
if (v.extras.carParking) carParking += 1;
|
||||||
|
if (v.extras.rvParking) rvParking += 1;
|
||||||
|
if (v.extras.utv) utv += 1;
|
||||||
|
if (v.extras.isDonor) donorOrders += 1;
|
||||||
|
if (v.extras.donorTier === "member") members += 1;
|
||||||
|
vouchers += v.extras.vouchers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator activity + check-in timeline from the audit log.
|
||||||
|
const audit = await ctx.audit.all().catch(() => []);
|
||||||
|
const opMap = new Map<string, { checkins: number; ice: number; undos: number }>();
|
||||||
|
const hourMap = new Map<string, number>();
|
||||||
|
for (const a of audit) {
|
||||||
|
if (a.operator) {
|
||||||
|
const o = opMap.get(a.operator) ?? { checkins: 0, ice: 0, undos: 0 };
|
||||||
|
if (a.action === "check-in") o.checkins += a.people;
|
||||||
|
else if (a.action === "undo") o.undos += -a.people;
|
||||||
|
else if (a.action === "ice") o.ice += a.people;
|
||||||
|
opMap.set(a.operator, o);
|
||||||
|
}
|
||||||
|
if (a.action === "check-in" && a.people > 0 && a.at) {
|
||||||
|
const hour = String(a.at).slice(0, 13); // YYYY-MM-DDTHH
|
||||||
|
hourMap.set(hour, (hourMap.get(hour) ?? 0) + a.people);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Stats = {
|
||||||
|
orders: records.length,
|
||||||
|
tickets: { total, redeemed, remaining: Math.max(0, total - redeemed), pct: total ? Math.round((redeemed / total) * 100) : 0 },
|
||||||
|
people: { adults, youth, kids12, kids9, kids4Free: kids4 },
|
||||||
|
ice: {
|
||||||
|
total: iceTotal,
|
||||||
|
redeemed: iceRedeemed,
|
||||||
|
remaining: Math.max(0, iceTotal - iceRedeemed),
|
||||||
|
pct: iceTotal ? Math.round((iceRedeemed / iceTotal) * 100) : 0,
|
||||||
|
ticketsSold: Math.round(iceTotal / bagsPerTicket),
|
||||||
|
},
|
||||||
|
types: [...typeMap.entries()]
|
||||||
|
.map(([type, e]) => ({ type, ...e }))
|
||||||
|
.sort((a, b) => b.total - a.total),
|
||||||
|
donors: { orders: donorOrders, members, vouchers },
|
||||||
|
extras: { carParking, rvParking, utv },
|
||||||
|
comps: {
|
||||||
|
total: compTotal,
|
||||||
|
byCreator: [...compByCreator.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count),
|
||||||
|
},
|
||||||
|
operators: [...opMap.entries()]
|
||||||
|
.map(([name, o]) => ({ name, ...o }))
|
||||||
|
.sort((a, b) => b.checkins - a.checkins),
|
||||||
|
checkinsByHour: [...hourMap.entries()]
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
.slice(-12)
|
||||||
|
.map(([hour, count]) => ({ hour, count })),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
cache = { at: now, data };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,17 @@ export class FakeNocoDB {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<NocoRecord[]> {
|
||||||
|
await this.delay();
|
||||||
|
const target = email.trim().toLowerCase();
|
||||||
|
return this.rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async vouchersUsedByEmail(email: string): Promise<number> {
|
||||||
|
const rows = await this.findByEmail(email);
|
||||||
|
return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||||
await this.delay();
|
await this.delay();
|
||||||
const rec = { Id: this.nextId++, ...fields } as NocoRecord;
|
const rec = { Id: this.nextId++, ...fields } as NocoRecord;
|
||||||
|
|
@ -86,14 +97,13 @@ export function fakeContext(db: FakeNocoDB): AppContext {
|
||||||
|
|
||||||
export async function seedTicket(
|
export async function seedTicket(
|
||||||
db: FakeNocoDB,
|
db: FakeNocoDB,
|
||||||
opts: { code: string; name?: string; email?: string; ages?: Record<string, number>; redeemed?: number },
|
opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number },
|
||||||
): Promise<NocoRecord> {
|
): Promise<NocoRecord> {
|
||||||
const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
|
|
||||||
return db.create({
|
return db.create({
|
||||||
[COL.code]: opts.code,
|
[COL.code]: opts.code,
|
||||||
[COL.name]: opts.name ?? "Test Bear",
|
[COL.name]: opts.name ?? "Test Bear",
|
||||||
[COL.email]: opts.email ?? "test@example.com",
|
[COL.email]: opts.email ?? "test@example.com",
|
||||||
|
[COL.adults]: opts.adults ?? 5,
|
||||||
[COL.redeemed]: opts.redeemed ?? 0,
|
[COL.redeemed]: opts.redeemed ?? 0,
|
||||||
...ages,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,45 +2,52 @@ import { describe, it, expect } from "vitest";
|
||||||
import { computeTotal, toView, COL } from "../fields.js";
|
import { computeTotal, toView, COL } from "../fields.js";
|
||||||
|
|
||||||
describe("computeTotal", () => {
|
describe("computeTotal", () => {
|
||||||
it("sums age brackets but excludes Ages 0-3 (free)", () => {
|
it("sums adults + youth 13-16 only; all kids 12 & under are free", () => {
|
||||||
const rec = {
|
const rec = {
|
||||||
Id: 1,
|
Id: 1,
|
||||||
"Ages 0-3": 2, // free, not counted
|
[COL.adults]: 2,
|
||||||
"Ages 4-7": 1,
|
[COL.youth]: 1,
|
||||||
"Ages 18-25": 2,
|
[COL.kids12]: 1, // free, not counted
|
||||||
"Ages 26-45": 1,
|
[COL.kids9]: 1, // free, not counted
|
||||||
|
[COL.kids4]: 3, // free, not counted
|
||||||
};
|
};
|
||||||
expect(computeTotal(rec)).toBe(4);
|
expect(computeTotal(rec)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coerces string counts and treats blanks as 0", () => {
|
it("coerces string counts and treats blanks as 0", () => {
|
||||||
const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any;
|
const rec = { Id: 1, [COL.adults]: "3", [COL.youth]: "" } as any;
|
||||||
expect(computeTotal(rec)).toBe(3);
|
expect(computeTotal(rec)).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toView", () => {
|
describe("toView", () => {
|
||||||
it("derives remaining and surfaces extras", () => {
|
it("derives remaining and surfaces adult names, donor tier, and extras", () => {
|
||||||
const rec = {
|
const rec = {
|
||||||
Id: 7,
|
Id: 7,
|
||||||
[COL.code]: "BC26-ABCD-2345",
|
[COL.code]: "BC26-ABCD-2345",
|
||||||
[COL.name]: "Jane Bear",
|
[COL.name]: "Jane Bear",
|
||||||
[COL.email]: "jane@example.com",
|
[COL.email]: "jane@example.com",
|
||||||
|
[COL.adultNames]: "Jane Bear\nJohn Bear",
|
||||||
[COL.redeemed]: 2,
|
[COL.redeemed]: 2,
|
||||||
|
[COL.adults]: 2,
|
||||||
|
[COL.youth]: 3,
|
||||||
|
[COL.kids4]: 1,
|
||||||
[COL.carParking]: true,
|
[COL.carParking]: true,
|
||||||
[COL.iceAccess]: "yes",
|
[COL.iceAccess]: "yes",
|
||||||
"Ages 0-3": 1,
|
[COL.donorTier]: "member",
|
||||||
"Ages 18-25": 2,
|
[COL.vouchers]: 2,
|
||||||
"Ages 26-45": 3,
|
|
||||||
};
|
};
|
||||||
const v = toView(rec);
|
const v = toView(rec);
|
||||||
expect(v.total).toBe(5);
|
expect(v.total).toBe(5);
|
||||||
expect(v.redeemed).toBe(2);
|
expect(v.redeemed).toBe(2);
|
||||||
expect(v.remaining).toBe(3);
|
expect(v.remaining).toBe(3);
|
||||||
|
expect(v.adultNames).toEqual(["Jane Bear", "John Bear"]);
|
||||||
expect(v.extras.carParking).toBe(true);
|
expect(v.extras.carParking).toBe(true);
|
||||||
expect(v.extras.iceAccess).toBe(true);
|
expect(v.extras.iceAccess).toBe(true);
|
||||||
expect(v.extras.rvParking).toBe(false);
|
expect(v.extras.rvParking).toBe(false);
|
||||||
expect(v.extras.freeUnder4).toBe(1);
|
expect(v.extras.donorTier).toBe("member");
|
||||||
expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true);
|
expect(v.extras.vouchers).toBe(2);
|
||||||
|
expect(v.extras.freeKids).toBe(1);
|
||||||
|
expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
98
backend/src/test/fluentforms.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { nameGroup, qty, selected, addressLine, readDonor, iceBagsFromPayment } from "../fluentforms.js";
|
||||||
|
|
||||||
|
const ICE = { bagsPerTicket: 3, ticketPrice: 20 };
|
||||||
|
|
||||||
|
// Food vendors are the only vendor tickets; two pass-holder name slots.
|
||||||
|
const FOOD_SLOTS = ["names", "names_1"];
|
||||||
|
|
||||||
|
/** Mirror the food vendor handler's pass count: one per named person, min 1. */
|
||||||
|
function passCount(body: Record<string, any>, slots: string[]): number {
|
||||||
|
const holders = slots.map((b) => nameGroup(body, b)).filter(Boolean);
|
||||||
|
return Math.max(1, holders.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("nameGroup", () => {
|
||||||
|
it("reads flattened bracket keys", () => {
|
||||||
|
const body = { "names[first_name]": "Joe", "names[last_name]": "Taco" };
|
||||||
|
expect(nameGroup(body, "names")).toBe("Joe Taco");
|
||||||
|
});
|
||||||
|
it("reads a nested object and includes the middle name", () => {
|
||||||
|
const body = { names: { first_name: "Ann", middle_name: "B", last_name: "Cole" } };
|
||||||
|
expect(nameGroup(body, "names")).toBe("Ann B Cole");
|
||||||
|
});
|
||||||
|
it("returns empty string when the group is blank", () => {
|
||||||
|
expect(nameGroup({}, "names_1")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("food vendor pass counting", () => {
|
||||||
|
it("food booth with two named holders gets 2 passes", () => {
|
||||||
|
const body = {
|
||||||
|
input_text: "Joe's Tacos",
|
||||||
|
"names[first_name]": "Joe",
|
||||||
|
"names[last_name]": "Taco",
|
||||||
|
"names_1[first_name]": "Jane",
|
||||||
|
"names_1[last_name]": "Taco",
|
||||||
|
};
|
||||||
|
expect(passCount(body, FOOD_SLOTS)).toBe(2);
|
||||||
|
});
|
||||||
|
it("food booth with only the first name gets 1 pass", () => {
|
||||||
|
const body = { input_text: "Solo BBQ", "names[first_name]": "Sam", "names[last_name]": "Que" };
|
||||||
|
expect(passCount(body, FOOD_SLOTS)).toBe(1);
|
||||||
|
});
|
||||||
|
it("booth with no names still gets 1 pass", () => {
|
||||||
|
expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("iceBagsFromPayment", () => {
|
||||||
|
it("reads '(N total bags)' from the real form label", () => {
|
||||||
|
expect(iceBagsFromPayment("One Ice ticket good for one bag per day (3 total bags)", ICE)).toBe(3);
|
||||||
|
expect(iceBagsFromPayment("Two Ice tickets good for one bag per day (6 total bags)", ICE)).toBe(6);
|
||||||
|
});
|
||||||
|
it("falls back to a worded ice-ticket count", () => {
|
||||||
|
expect(iceBagsFromPayment("Two Ice tickets", ICE)).toBe(6); // 2 × 3
|
||||||
|
expect(iceBagsFromPayment("Four Ice tickets", ICE)).toBe(12);
|
||||||
|
});
|
||||||
|
it("falls back to a dollar total at the ticket price", () => {
|
||||||
|
expect(iceBagsFromPayment("$40.00", ICE)).toBe(6); // 2 tickets × 3
|
||||||
|
expect(iceBagsFromPayment(20, ICE)).toBe(3); // 1 ticket × 3
|
||||||
|
});
|
||||||
|
it("treats a small plain count as ticket count", () => {
|
||||||
|
expect(iceBagsFromPayment(2, ICE)).toBe(6); // 2 tickets × 3
|
||||||
|
});
|
||||||
|
it("is 0 for blank / no ice", () => {
|
||||||
|
expect(iceBagsFromPayment("", ICE)).toBe(0);
|
||||||
|
expect(iceBagsFromPayment(undefined, ICE)).toBe(0);
|
||||||
|
expect(iceBagsFromPayment(0, ICE)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readDonor", () => {
|
||||||
|
it("treats donor_tier=member as a donor", () => {
|
||||||
|
expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" });
|
||||||
|
});
|
||||||
|
it("honors the donor_eligible hidden flag", () => {
|
||||||
|
expect(readDonor({ donor_eligible: "true" }).isDonor).toBe(true);
|
||||||
|
});
|
||||||
|
it("is not a donor when nothing indicates it", () => {
|
||||||
|
expect(readDonor({ input_radio: "No" })).toEqual({ isDonor: false, donorTier: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qty / selected / addressLine", () => {
|
||||||
|
it("parses money strings and nested quantities", () => {
|
||||||
|
expect(qty("$40.00")).toBe(40);
|
||||||
|
expect(qty({ quantity: 2 })).toBe(2);
|
||||||
|
expect(qty("")).toBe(0);
|
||||||
|
});
|
||||||
|
it("selected() treats $0.00 / no / blank as unselected", () => {
|
||||||
|
expect(selected("$0.00")).toBe(false);
|
||||||
|
expect(selected("No")).toBe(false);
|
||||||
|
expect(selected("Yes")).toBe(true);
|
||||||
|
});
|
||||||
|
it("flattens a compound address", () => {
|
||||||
|
expect(addressLine({ address_line_1: "1 Main", city: "Boise", state: "ID" })).toBe("1 Main, Boise, ID");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,7 +6,7 @@ import { COL } from "../fields.js";
|
||||||
describe("redeem", () => {
|
describe("redeem", () => {
|
||||||
it("checks in a single walk-up (default count 1)", async () => {
|
it("checks in a single walk-up (default count 1)", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 4 } });
|
await seedTicket(db, { code: "BC26-AAAA-1111", adults: 4 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await redeem(ctx, "BC26-AAAA-1111", 1);
|
const r = await redeem(ctx, "BC26-AAAA-1111", 1);
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
|
|
@ -20,7 +20,7 @@ describe("redeem", () => {
|
||||||
it("supports group check-in and QR reuse across visits", async () => {
|
it("supports group check-in and QR reuse across visits", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
// Party of 7 (2 free under-4 not counted): total 5.
|
// 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 } });
|
await seedTicket(db, { code: "BC26-FAM-0001", adults: 5 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
|
|
||||||
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
|
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
|
||||||
|
|
@ -36,7 +36,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("rejects over-redemption without mutating", async () => {
|
it("rejects over-redemption without mutating", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 2 } });
|
await seedTicket(db, { code: "BC26-BBBB-2222", adults: 2 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await redeem(ctx, "BC26-BBBB-2222", 5);
|
const r = await redeem(ctx, "BC26-BBBB-2222", 5);
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
|
|
@ -46,7 +46,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("allows negative count to undo, clamped at zero", async () => {
|
it("allows negative count to undo, clamped at zero", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 3 }, redeemed: 2 });
|
await seedTicket(db, { code: "BC26-CCCC-3333", adults: 3, redeemed: 2 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await redeem(ctx, "BC26-CCCC-3333", -5);
|
const r = await redeem(ctx, "BC26-CCCC-3333", -5);
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
|
|
@ -55,7 +55,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("writes an audit entry on each successful check-in and undo", async () => {
|
it("writes an audit entry on each successful check-in and undo", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 4 } });
|
await seedTicket(db, { code: "BC26-AUDT-0001", adults: 4 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
await redeem(ctx, "BC26-AUDT-0001", 2);
|
await redeem(ctx, "BC26-AUDT-0001", 2);
|
||||||
await redeem(ctx, "BC26-AUDT-0001", -1);
|
await redeem(ctx, "BC26-AUDT-0001", -1);
|
||||||
|
|
@ -67,7 +67,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("does not audit a no-op (undo when nothing redeemed)", async () => {
|
it("does not audit a no-op (undo when nothing redeemed)", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 3 }, redeemed: 0 });
|
await seedTicket(db, { code: "BC26-AUDT-0002", adults: 3, redeemed: 0 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
|
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
|
||||||
expect((ctx.audit as any).entries).toHaveLength(0);
|
expect((ctx.audit as any).entries).toHaveLength(0);
|
||||||
|
|
@ -77,7 +77,7 @@ describe("redeem", () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, {
|
await seedTicket(db, {
|
||||||
code: "BC26-ICE-0003",
|
code: "BC26-ICE-0003",
|
||||||
ages: { "Ages 26-45": 2 },
|
adults: 2,
|
||||||
});
|
});
|
||||||
// Give the ticket 3 prepaid ice bags.
|
// Give the ticket 3 prepaid ice bags.
|
||||||
db.rows[0]["Ice Total"] = 3;
|
db.rows[0]["Ice Total"] = 3;
|
||||||
|
|
@ -112,7 +112,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("surfaces db_error when the update fails", async () => {
|
it("surfaces db_error when the update fails", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 3 } });
|
await seedTicket(db, { code: "BC26-DDDD-4444", adults: 3 });
|
||||||
db.failNext = true;
|
db.failNext = true;
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await redeem(ctx, "BC26-DDDD-4444", 1);
|
const r = await redeem(ctx, "BC26-DDDD-4444", 1);
|
||||||
|
|
@ -122,7 +122,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
|
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
|
||||||
const db = new FakeNocoDB(8);
|
const db = new FakeNocoDB(8);
|
||||||
await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 5 } });
|
await seedTicket(db, { code: "BC26-RACE-0005", adults: 5 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
|
|
@ -137,7 +137,7 @@ describe("redeem", () => {
|
||||||
describe("lookupByCode", () => {
|
describe("lookupByCode", () => {
|
||||||
it("returns the ticket view without mutating", async () => {
|
it("returns the ticket view without mutating", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 3 } });
|
await seedTicket(db, { code: "BC26-LOOK-0001", adults: 3 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await lookupByCode(ctx, "BC26-LOOK-0001");
|
const r = await lookupByCode(ctx, "BC26-LOOK-0001");
|
||||||
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
|
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
|
||||||
|
|
@ -159,7 +159,7 @@ describe("createTicket idempotency", () => {
|
||||||
const input = {
|
const input = {
|
||||||
name: "Jane Bear",
|
name: "Jane Bear",
|
||||||
email: "jane@example.com",
|
email: "jane@example.com",
|
||||||
ages: { "Ages 26-45": 2 },
|
counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
|
||||||
submissionKey: "sub:412",
|
submissionKey: "sub:412",
|
||||||
};
|
};
|
||||||
const a = await createTicket(ctx, input);
|
const a = await createTicket(ctx, input);
|
||||||
|
|
|
||||||
38
backend/src/test/vouchers.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { FakeNocoDB } from "./fakeNocodb.js";
|
||||||
|
import { COL } from "../fields.js";
|
||||||
|
|
||||||
|
/** Mirror the ticket-vouchers endpoint's remaining math. */
|
||||||
|
function remaining(entitled: number, used: number): number {
|
||||||
|
return Math.max(0, entitled - used);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("voucher consumption", () => {
|
||||||
|
it("sums vouchers used across a donor's orders", async () => {
|
||||||
|
const db = new FakeNocoDB(0);
|
||||||
|
await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 2 });
|
||||||
|
await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 1 });
|
||||||
|
await db.create({ [COL.email]: "someone-else@example.com", [COL.vouchers]: 2 });
|
||||||
|
await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 0 }); // non-voucher order
|
||||||
|
expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches email case-insensitively", async () => {
|
||||||
|
const db = new FakeNocoDB(0);
|
||||||
|
await db.create({ [COL.email]: "Donor@Example.com", [COL.vouchers]: 2 });
|
||||||
|
expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 used for a donor with no orders", async () => {
|
||||||
|
const db = new FakeNocoDB(0);
|
||||||
|
expect(await db.vouchersUsedByEmail("nobody@example.com")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remaining = entitled - used, floored at 0", () => {
|
||||||
|
expect(remaining(2, 0)).toBe(2); // fresh 2-voucher donor
|
||||||
|
expect(remaining(2, 1)).toBe(1); // used one
|
||||||
|
expect(remaining(2, 2)).toBe(0); // used both — no more free tickets
|
||||||
|
expect(remaining(1, 2)).toBe(0); // over-consumed (edge) never goes negative
|
||||||
|
expect(remaining(0, 0)).toBe(0); // non-donor
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -128,15 +128,21 @@ export async function search(ctx: AppContext, query: string): Promise<TicketView
|
||||||
|
|
||||||
export interface WebhookInput {
|
export interface WebhookInput {
|
||||||
name: string;
|
name: string;
|
||||||
|
adultNames?: string[];
|
||||||
email: string;
|
email: string;
|
||||||
|
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
||||||
|
createdBy?: string; // gate-staff name who issued a comp
|
||||||
address?: string;
|
address?: string;
|
||||||
isDonor?: boolean;
|
isDonor?: boolean;
|
||||||
|
donorTier?: string;
|
||||||
|
vouchers?: number;
|
||||||
|
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
|
||||||
carParking?: boolean;
|
carParking?: boolean;
|
||||||
rvParking?: boolean;
|
rvParking?: boolean;
|
||||||
|
utv?: boolean;
|
||||||
iceAccess?: boolean;
|
iceAccess?: boolean;
|
||||||
iceBags?: number; // prepaid ice bags
|
iceBags?: number; // prepaid ice bags/tickets
|
||||||
paymentMethod?: string;
|
paymentMethod?: string;
|
||||||
ages: Record<string, number>; // NocoDB age-column title -> count
|
|
||||||
submissionKey: string;
|
submissionKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,20 +164,31 @@ export async function createTicket(
|
||||||
code = generateCode();
|
code = generateCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const c = input.counts;
|
||||||
const fields: Record<string, unknown> = {
|
const fields: Record<string, unknown> = {
|
||||||
[COL.name]: input.name,
|
[COL.name]: input.name,
|
||||||
[COL.email]: input.email,
|
[COL.email]: input.email,
|
||||||
[COL.code]: code,
|
[COL.code]: code,
|
||||||
[COL.redeemed]: 0,
|
[COL.redeemed]: 0,
|
||||||
|
[COL.adults]: c.adults,
|
||||||
|
[COL.youth]: c.youth,
|
||||||
|
[COL.kids12]: c.kids12,
|
||||||
|
[COL.kids9]: c.kids9,
|
||||||
|
[COL.kids4]: c.kids4,
|
||||||
[COL.iceTotal]: input.iceBags ?? 0,
|
[COL.iceTotal]: input.iceBags ?? 0,
|
||||||
[COL.iceRedeemed]: 0,
|
[COL.iceRedeemed]: 0,
|
||||||
[COL.submissionKey]: input.submissionKey,
|
[COL.submissionKey]: input.submissionKey,
|
||||||
...input.ages,
|
|
||||||
};
|
};
|
||||||
|
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
|
||||||
|
if (input.ticketType) fields[COL.ticketType] = input.ticketType;
|
||||||
|
if (input.createdBy) fields[COL.createdBy] = input.createdBy;
|
||||||
if (input.address !== undefined) fields[COL.address] = input.address;
|
if (input.address !== undefined) fields[COL.address] = input.address;
|
||||||
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
|
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
|
||||||
|
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;
|
||||||
|
if (input.vouchers !== undefined) fields[COL.vouchers] = input.vouchers;
|
||||||
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
|
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
|
||||||
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
|
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
|
||||||
|
if (input.utv !== undefined) fields[COL.utv] = input.utv;
|
||||||
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
|
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
|
||||||
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;
|
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ services:
|
||||||
build: .
|
build: .
|
||||||
image: camptickets:latest
|
image: camptickets:latest
|
||||||
container_name: camptickets
|
container_name: camptickets
|
||||||
env_file: .env
|
# Single source of truth for secrets — edit backend/.env, then
|
||||||
|
# `docker compose up -d`. (Was ./.env; consolidated to avoid a stale copy.)
|
||||||
|
env_file: backend/.env
|
||||||
environment:
|
environment:
|
||||||
# Container always listens on 8080 internally; the host mapping below is
|
# Container always listens on 8080 internally; the host mapping below is
|
||||||
# what nginx proxies to. Keep this fixed regardless of .env PORT.
|
# what nginx proxies to. Keep this fixed regardless of .env PORT.
|
||||||
|
|
@ -17,4 +19,11 @@ services:
|
||||||
# host.docker.internal resolves to the host gateway.
|
# host.docker.internal resolves to the host gateway.
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
# Small writable volume for runtime state (the active event-table override
|
||||||
|
# set from the admin area), so it survives redeploys.
|
||||||
|
volumes:
|
||||||
|
- camptickets-data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
camptickets-data:
|
||||||
|
|
|
||||||
152
docs/fluentforms-donor-discount.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# FluentForms → donor lookup APIs
|
||||||
|
|
||||||
|
FluentForms has no native way to query an external database from a field. These
|
||||||
|
wire it up with a small Custom JS block that calls a secret-gated endpoint on
|
||||||
|
the ticketing backend. Two endpoints are available (same key, CORS, and rate
|
||||||
|
limit):
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/public/donor-eligibility` | Is this email a donor/member? → unlock a discount |
|
||||||
|
| `GET /api/public/ticket-vouchers` | How many free tickets has this donor earned? → 0 / 1 / 2 |
|
||||||
|
|
||||||
|
Both require `?key=<PUBLIC_LOOKUP_SECRET>`, are rate-limited (30/min/IP), and
|
||||||
|
CORS-restricted to `PUBLIC_LOOKUP_ORIGIN` (default
|
||||||
|
`https://tickets.beartariacampgrounds.com`). Neither returns names or dollar
|
||||||
|
amounts. The secret is visible in page source, so treat it as deterrence, not
|
||||||
|
security; rotate it by changing `PUBLIC_LOOKUP_SECRET` and redeploying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Donor discount lookup
|
||||||
|
|
||||||
|
Unlocks a discount when the entered email belongs to a donor/member.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SECRET>&email=<email>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
|
||||||
|
- Returns minimal JSON — never names or dollar amounts:
|
||||||
|
- `{"eligible": true, "tier": "member"}`
|
||||||
|
- `{"eligible": true, "tier": "donor"}`
|
||||||
|
- `{"eligible": false, "tier": null}`
|
||||||
|
- Rate-limited (30/min/IP) and CORS-restricted to `PUBLIC_LOOKUP_ORIGIN`
|
||||||
|
(default `https://tickets.beartariacampgrounds.com`).
|
||||||
|
|
||||||
|
> The secret is visible in page source, so treat this as *deterrence, not
|
||||||
|
> security*. It only gates a discount and reveals a yes/no + tier, so the blast
|
||||||
|
> radius is small. Rotate the secret by changing `PUBLIC_LOOKUP_SECRET` and
|
||||||
|
> redeploying.
|
||||||
|
|
||||||
|
## Form setup (conditional pricing)
|
||||||
|
|
||||||
|
The idea: a **hidden field** `donor_tier` holds `regular` / `donor` / `member`.
|
||||||
|
The JS sets it from the email lookup, and your payment options are shown/hidden
|
||||||
|
by FluentForms conditional logic based on its value.
|
||||||
|
|
||||||
|
1. **Hidden field.** Add a *Hidden Field*, name it exactly `donor_tier`, default
|
||||||
|
value `regular`.
|
||||||
|
2. **Email field.** Note its name (default `email`).
|
||||||
|
3. **Payment options.** Set up two payment items (or two options of a
|
||||||
|
multiple-choice payment field) — a regular price and a discounted price — and
|
||||||
|
give each **conditional logic**:
|
||||||
|
- **Regular price:** show when `donor_tier` **is** `regular`
|
||||||
|
- **Donor price:** show when `donor_tier` **is** `donor` **OR** `donor_tier`
|
||||||
|
**is** `member` (add both rules with "match any").
|
||||||
|
4. **Custom HTML.** Add a *Custom HTML* element and paste the snippet below,
|
||||||
|
setting `KEY` to your `PUBLIC_LOOKUP_SECRET` (and `EMAIL_SELECTOR` if your
|
||||||
|
email field isn't named `email`).
|
||||||
|
|
||||||
|
> FluentForms is Vue-driven, so a plain `input.value = …` won't update its
|
||||||
|
> model and conditional logic won't fire. The snippet uses the native value
|
||||||
|
> setter + dispatches `input`/`change`, which is the reliable way to make FF
|
||||||
|
> notice a programmatic change. Test on your form; if conditional logic still
|
||||||
|
> doesn't react, tell me your FF version and I'll adapt.
|
||||||
|
|
||||||
|
## Snippet
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="donor-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var API = "https://scan.beartariacampgrounds.com/api/public/donor-eligibility";
|
||||||
|
var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
|
||||||
|
var EMAIL_SELECTOR = 'input[name="email"]'; // adjust if needed
|
||||||
|
var TIER_SELECTOR = 'input[name="donor_tier"]'; // the hidden field
|
||||||
|
var statusEl = document.getElementById("donor-status");
|
||||||
|
var lastChecked = "";
|
||||||
|
|
||||||
|
// Set a framework-bound input's value so Vue/React actually notice it.
|
||||||
|
function setNativeValue(el, value) {
|
||||||
|
var proto = Object.getPrototypeOf(el);
|
||||||
|
var protoSetter = Object.getOwnPropertyDescriptor(proto, "value");
|
||||||
|
var ownSetter = Object.getOwnPropertyDescriptor(el, "value");
|
||||||
|
if (ownSetter && protoSetter && ownSetter.set !== protoSetter.set) {
|
||||||
|
protoSetter.set.call(el, value);
|
||||||
|
} else if (protoSetter) {
|
||||||
|
protoSetter.set.call(el, value);
|
||||||
|
} else {
|
||||||
|
el.value = value;
|
||||||
|
}
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTier(tier) {
|
||||||
|
var el = document.querySelector(TIER_SELECTOR);
|
||||||
|
if (el) setNativeValue(el, tier); // "regular" | "donor" | "member"
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(email) {
|
||||||
|
if (!email || email === lastChecked) return;
|
||||||
|
lastChecked = email;
|
||||||
|
statusEl.textContent = "Checking donor status…";
|
||||||
|
statusEl.style.color = "#888";
|
||||||
|
fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d && d.eligible) {
|
||||||
|
setTier(d.tier); // "member" or "donor"
|
||||||
|
statusEl.textContent = (d.tier === "member" ? "🐻 Member" : "⭐ Donor") + " pricing unlocked!";
|
||||||
|
statusEl.style.color = "#1b7f3b";
|
||||||
|
} else {
|
||||||
|
setTier("regular");
|
||||||
|
statusEl.textContent = "";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { setTier("regular"); statusEl.textContent = ""; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
var el = document.querySelector(EMAIL_SELECTOR);
|
||||||
|
if (!el) { return setTimeout(bind, 500); } // form may render late
|
||||||
|
setTier("regular"); // start at regular price
|
||||||
|
el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
|
||||||
|
}
|
||||||
|
bind();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```
|
||||||
|
curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SECRET>&email=<a-real-donor-email>"
|
||||||
|
# donor/member -> {"eligible":true,"tier":"member"}
|
||||||
|
# anyone else -> {"eligible":false,"tier":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
If member and donor get the **same** discounted price, simplify: set the donor
|
||||||
|
price to show when `donor_tier` **is not** `regular`, and you can ignore the
|
||||||
|
member/donor distinction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ticket-voucher entitlement
|
||||||
|
|
||||||
|
The donor **ticket-voucher lookup** (how many free tickets a donor earned) is a
|
||||||
|
separate endpoint documented on its own page:
|
||||||
|
[`fluentforms-ticket-vouchers.md`](./fluentforms-ticket-vouchers.md).
|
||||||
131
docs/fluentforms-ticket-vouchers.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# FluentForms → ticket-voucher lookup
|
||||||
|
|
||||||
|
Look up, by email, how many **free tickets** a donor has earned from their
|
||||||
|
giving. Intended for the ticket-rewards / checkout form: enter an email, call
|
||||||
|
this endpoint, and show / apply the earned vouchers.
|
||||||
|
|
||||||
|
FluentForms can't query an external database from a field natively, so this is
|
||||||
|
done with a small Custom JS block that calls a secret-gated endpoint on the
|
||||||
|
ticketing backend.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email>
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the **remaining** free-ticket count — never names or dollar amounts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "vouchers": 1, "entitled": 2, "used": 1, "remaining": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `vouchers` / `remaining` — how many free tickets are **still available** (this
|
||||||
|
is what the form should grant). Use `vouchers`; `remaining` is an alias.
|
||||||
|
- `entitled` — the tier entitlement earned from giving (0/1/2).
|
||||||
|
- `used` — vouchers already consumed by this donor's prior ticket orders.
|
||||||
|
- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
|
||||||
|
- `email` = the donor's email (URL-encoded).
|
||||||
|
- Rate-limited (30 requests / minute / IP) and CORS-restricted to
|
||||||
|
`PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`).
|
||||||
|
|
||||||
|
### Vouchers decrement as they're used
|
||||||
|
|
||||||
|
`remaining = entitled − used`, where `used` is the sum of the **Vouchers**
|
||||||
|
column across every ticket order placed with that email. Each checkout stores
|
||||||
|
the vouchers it applied, so the next lookup returns fewer — a donor can't keep
|
||||||
|
claiming free tickets by re-submitting the form. Once `used ≥ entitled`,
|
||||||
|
`vouchers` is `0`.
|
||||||
|
|
||||||
|
**To reset for testing:** in NocoDB, zero out (or delete) the **Vouchers**
|
||||||
|
value on that donor's ticket order row(s). `used` drops and `remaining` rises on
|
||||||
|
the next lookup — no redeploy needed.
|
||||||
|
|
||||||
|
> The secret is visible in page source, so treat it as **deterrence, not
|
||||||
|
> security** — it only gates a 0/1/2 count. Rotate it by changing
|
||||||
|
> `PUBLIC_LOOKUP_SECRET` and redeploying.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
Donations are summed for the email across the online + offline transaction
|
||||||
|
tables, counting only **Paid** rows dated **on/after `VOUCHER_SINCE`**:
|
||||||
|
|
||||||
|
| Total since the cutoff | Vouchers |
|
||||||
|
|---|---|
|
||||||
|
| ≥ $1000 | 2 |
|
||||||
|
| ≥ $400 | 1 |
|
||||||
|
| otherwise | 0 |
|
||||||
|
|
||||||
|
Configurable in the backend `.env`:
|
||||||
|
|
||||||
|
| Var | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `VOUCHER_SINCE` | `2025-09-04` | Only donations on/after this date count. Bump each year. |
|
||||||
|
| `VOUCHER_TIER1_MIN` | `400` | Dollar total for 1 voucher |
|
||||||
|
| `VOUCHER_TIER2_MIN` | `1000` | Dollar total for 2 vouchers |
|
||||||
|
|
||||||
|
The count comes from the **dated transaction tables** (the donor master-list
|
||||||
|
rollups have no dates), so donations must exist in those tables for the window.
|
||||||
|
|
||||||
|
## Form snippet
|
||||||
|
|
||||||
|
Add a **Custom HTML** element to the form and paste this, setting `KEY` to your
|
||||||
|
`PUBLIC_LOOKUP_SECRET` (and `EMAIL_SELECTOR` if the email field isn't named
|
||||||
|
`email`). It shows the earned count on email blur and writes it into a hidden
|
||||||
|
field `free_tickets` you can use for conditional logic or to cap a quantity.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="voucher-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var API = "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers";
|
||||||
|
var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
|
||||||
|
var EMAIL_SELECTOR = 'input[name="email"]';
|
||||||
|
var statusEl = document.getElementById("voucher-status");
|
||||||
|
var last = "";
|
||||||
|
|
||||||
|
function show(n) {
|
||||||
|
statusEl.textContent = n > 0
|
||||||
|
? "🎟️ You've earned " + n + " free ticket" + (n > 1 ? "s" : "") + "!"
|
||||||
|
: "";
|
||||||
|
statusEl.style.color = "#1b7f3b";
|
||||||
|
// Optional: write the count into a hidden field named "free_tickets".
|
||||||
|
// Uses the native setter so FluentForms' Vue model registers the change.
|
||||||
|
var el = document.querySelector('input[name="free_tickets"]');
|
||||||
|
if (el) {
|
||||||
|
var d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value");
|
||||||
|
(d && d.set ? d.set : function (v) { el.value = v; }).call(el, String(n));
|
||||||
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(email) {
|
||||||
|
if (!email || email === last) return;
|
||||||
|
last = email;
|
||||||
|
fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { show(d && d.vouchers ? d.vouchers : 0); })
|
||||||
|
.catch(function () { show(0); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
var el = document.querySelector(EMAIL_SELECTOR);
|
||||||
|
if (!el) { return setTimeout(bind, 500); } // form may render late
|
||||||
|
el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
|
||||||
|
}
|
||||||
|
bind();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```
|
||||||
|
curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
|
||||||
|
# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2}
|
||||||
|
# after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0}
|
||||||
|
```
|
||||||
|
|
||||||
|
Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the
|
||||||
|
companion donor-discount eligibility lookup (same key / CORS / rate limit).
|
||||||
73
scripts/switch-event.sh
Executable file
|
|
@ -0,0 +1,73 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
#
|
||||||
|
# switch-event.sh — point the scanner app at a DIFFERENT NocoDB tickets (and
|
||||||
|
# optionally audit) table, e.g. to start a NEW event on a fresh table while
|
||||||
|
# keeping the old table intact for archive. Backs up backend/.env, updates it,
|
||||||
|
# and restarts the app container. The old table is never touched.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/switch-event.sh <TICKETS_TABLE_ID> [AUDIT_TABLE_ID]
|
||||||
|
#
|
||||||
|
# FIRST create the new table(s): in the NocoDB UI, DUPLICATE the current table
|
||||||
|
# with "structure only" (no records). That preserves every column AND the Id
|
||||||
|
# primary key — critical, because updates against a table with no primary key
|
||||||
|
# would hit every row. Then grab the new table id from its URL/API and pass it
|
||||||
|
# here. (The app also fail-safes: it refuses to update a row that has no Id.)
|
||||||
|
#
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
ENV_FILE="$ROOT/backend/.env"
|
||||||
|
CONTAINER="${CONTAINER:-camptickets}"
|
||||||
|
|
||||||
|
NEW_TICKETS="${1:-}"
|
||||||
|
NEW_AUDIT="${2:-}"
|
||||||
|
[ -n "$NEW_TICKETS" ] || { echo "Usage: $0 <TICKETS_TABLE_ID> [AUDIT_TABLE_ID]" >&2; exit 1; }
|
||||||
|
|
||||||
|
get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; }
|
||||||
|
BASE_URL="$(get NOCODB_BASE_URL)"
|
||||||
|
TOKEN="$(get NOCODB_API_TOKEN)"
|
||||||
|
CUR_TICKETS="$(get NOCODB_TABLE_ID)"
|
||||||
|
CUR_AUDIT="$(get NOCODB_AUDIT_TABLE_ID)"
|
||||||
|
|
||||||
|
# Validate a table is reachable and (if it has rows) exposes an Id primary key.
|
||||||
|
check() {
|
||||||
|
local table="$1" tmp http
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
http="$(curl -s -o "$tmp" -w '%{http_code}' -H "xc-token: $TOKEN" \
|
||||||
|
"$BASE_URL/api/v2/tables/$table/records?limit=1")"
|
||||||
|
if [ "$http" != "200" ]; then
|
||||||
|
echo " ✗ $table not reachable (HTTP $http)"; rm -f "$tmp"; return 1
|
||||||
|
fi
|
||||||
|
if ! python3 -c 'import sys,json; l=json.load(open(sys.argv[1]))["list"]; sys.exit(0 if (not l or "Id" in l[0]) else 1)' "$tmp"; then
|
||||||
|
echo " ✗ $table has rows without an Id primary key — refusing"; rm -f "$tmp"; return 1
|
||||||
|
fi
|
||||||
|
rm -f "$tmp"; echo " ✓ $table reachable"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Validating new table(s) on $BASE_URL ..."
|
||||||
|
check "$NEW_TICKETS" || exit 1
|
||||||
|
[ -n "$NEW_AUDIT" ] && { check "$NEW_AUDIT" || exit 1; }
|
||||||
|
|
||||||
|
BK="$ENV_FILE.bak.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$BK"
|
||||||
|
echo "Backed up env -> $BK"
|
||||||
|
|
||||||
|
echo "Switching tables:"
|
||||||
|
echo " tickets: $CUR_TICKETS -> $NEW_TICKETS"
|
||||||
|
sed -i -E "s|^NOCODB_TABLE_ID=.*|NOCODB_TABLE_ID=$NEW_TICKETS|" "$ENV_FILE"
|
||||||
|
if [ -n "$NEW_AUDIT" ]; then
|
||||||
|
echo " audit: $CUR_AUDIT -> $NEW_AUDIT"
|
||||||
|
sed -i -E "s|^NOCODB_AUDIT_TABLE_ID=.*|NOCODB_AUDIT_TABLE_ID=$NEW_AUDIT|" "$ENV_FILE"
|
||||||
|
else
|
||||||
|
echo " audit: unchanged ($CUR_AUDIT) — pass a second arg to switch it too"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Restarting $CONTAINER ..."
|
||||||
|
( cd "$ROOT" && docker compose up -d --force-recreate >/dev/null )
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "Now active:"
|
||||||
|
echo " NOCODB_TABLE_ID=$(get NOCODB_TABLE_ID)"
|
||||||
|
echo " NOCODB_AUDIT_TABLE_ID=$(get NOCODB_AUDIT_TABLE_ID)"
|
||||||
|
echo "Old tickets table $CUR_TICKETS kept intact. (env backup: $BK)"
|
||||||
57
scripts/wipe-slate.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
#
|
||||||
|
# wipe-slate.sh — clear ALL ticket + audit records from the tables the scanner
|
||||||
|
# app currently uses, for a clean event run-through. Leaves the table SCHEMAS
|
||||||
|
# intact and does NOT touch donor data. Reads NocoDB creds from backend/.env.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/wipe-slate.sh # prompts for confirmation
|
||||||
|
# scripts/wipe-slate.sh --yes # skip the prompt (for automation)
|
||||||
|
#
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../backend/.env}"
|
||||||
|
|
||||||
|
get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; }
|
||||||
|
BASE_URL="$(get NOCODB_BASE_URL)"
|
||||||
|
TOKEN="$(get NOCODB_API_TOKEN)"
|
||||||
|
TICKETS="$(get NOCODB_TABLE_ID)"
|
||||||
|
AUDIT="$(get NOCODB_AUDIT_TABLE_ID)"
|
||||||
|
|
||||||
|
[ -n "$BASE_URL" ] && [ -n "$TOKEN" ] && [ -n "$TICKETS" ] || {
|
||||||
|
echo "Missing NocoDB config in $ENV_FILE" >&2; exit 1; }
|
||||||
|
|
||||||
|
YES=0
|
||||||
|
case "${1:-}" in -y|--yes) YES=1;; esac
|
||||||
|
|
||||||
|
count() {
|
||||||
|
curl -s -H "xc-token: $TOKEN" "$BASE_URL/api/v2/tables/$1/records?limit=1" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("pageInfo",{}).get("totalRows",0))'
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Target: $BASE_URL"
|
||||||
|
echo " tickets ($TICKETS): $(count "$TICKETS") records"
|
||||||
|
[ -n "$AUDIT" ] && echo " audit ($AUDIT): $(count "$AUDIT") records"
|
||||||
|
|
||||||
|
if [ "$YES" -ne 1 ]; then
|
||||||
|
read -rp "Delete ALL of the above? This cannot be undone. [y/N] " ans
|
||||||
|
case "$ans" in y|Y|yes|YES) ;; *) echo "aborted"; exit 1;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
wipe() {
|
||||||
|
local label="$1" table="$2" total=0 ids n
|
||||||
|
while :; do
|
||||||
|
ids="$(curl -s -H "xc-token: $TOKEN" "$BASE_URL/api/v2/tables/$table/records?limit=1000&fields=Id" \
|
||||||
|
| python3 -c 'import sys,json;print(json.dumps([{"Id":r["Id"]} for r in json.load(sys.stdin)["list"]]))')"
|
||||||
|
n="$(printf '%s' "$ids" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')"
|
||||||
|
[ "$n" -eq 0 ] && break
|
||||||
|
curl -s -o /dev/null -X DELETE -H "xc-token: $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
"$BASE_URL/api/v2/tables/$table/records" --data "$ids"
|
||||||
|
total=$((total + n))
|
||||||
|
done
|
||||||
|
echo " $label: deleted $total"
|
||||||
|
}
|
||||||
|
|
||||||
|
wipe "tickets" "$TICKETS"
|
||||||
|
[ -n "$AUDIT" ] && wipe "audit" "$AUDIT"
|
||||||
|
echo "Done — slate is clean."
|
||||||