diff --git a/Dockerfile b/Dockerfile index e5ce281..845477a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,11 +32,8 @@ ENV WEB_DIR=/srv/web ENV PORT=8080 ENV HOST=0.0.0.0 -# Run as the non-root node user shipped in the base image. /data is a mount -# 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 +# Run as the non-root node user shipped in the base image. +RUN chown -R node:node /srv USER node EXPOSE 8080 diff --git a/app/app.json b/app/app.json index 146efd3..ecfabbc 100644 --- a/app/app.json +++ b/app/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Camp Scan", "slug": "camptickets", - "version": "0.3.0", + "version": "0.2.0", "orientation": "portrait", "scheme": "campscan", "userInterfaceStyle": "automatic", @@ -10,7 +10,7 @@ "icon": "./assets/icon.png", "android": { "package": "top.mowden.campscan", - "versionCode": 3, + "versionCode": 2, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#0f1a12" diff --git a/app/app/comp.tsx b/app/app/comp.tsx new file mode 100644 index 0000000..364aac0 --- /dev/null +++ b/app/app/comp.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import { + StyleSheet, + View, + Text, + TextInput, + Pressable, + ScrollView, + Image, + KeyboardAvoidingView, + Platform, +} from "react-native"; +import { router } from "expo-router"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { portalVerify, portalCreate, AuthError, type PortalTicket } from "../lib/api"; +import { useAuth } from "../lib/auth"; +import { useMenu } from "../lib/menu"; +import { theme } from "../lib/theme"; + +const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"]; +const TYPE_ICON: Record = { + Guest: "🎫", + Worker: "🛠️", + Performer: "🎭", + Volunteer: "🙌", + Speaker: "🎤", +}; + +export default function CompScreen() { + const { operator } = useAuth(); + const { open: openMenu } = useMenu(); + const [password, setPassword] = useState(""); + const [unlocked, setUnlocked] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const [type, setType] = useState("Guest"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [result, setResult] = useState(null); + + async function unlock() { + if (!password || busy) return; + setBusy(true); + setError(""); + try { + await portalVerify(password); + setUnlocked(true); + } catch (e: any) { + setError(e instanceof AuthError ? "Wrong password" : (e?.message ?? "Failed")); + } finally { + setBusy(false); + } + } + + async function create() { + if (!name.trim() || !email.trim() || busy) return; + setBusy(true); + setError(""); + try { + const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator }); + setResult(r); + setName(""); + setEmail(""); + } catch (e: any) { + if (e instanceof AuthError) { + setUnlocked(false); // password rotated — re-gate + setError("Password changed — unlock again."); + } else { + setError(e?.message ?? "Failed to create ticket"); + } + } finally { + setBusy(false); + } + } + + return ( + + + + + + Comp Tickets + + + + + + {!unlocked ? ( + + Entry-only tickets for workers & guests. Enter the shared portal password. + Portal password + + {!!error && {error}} + + {busy ? "Checking…" : "Unlock"} + + + ) : ( + + Ticket type + + {TYPES.map((t) => ( + setType(t)} + > + + {(TYPE_ICON[t] ?? "🎫") + " " + t} + + + ))} + + + Full name + + + Email + + + {!!error && {error}} + + {busy ? "Creating…" : `Create ${type} ticket`} + + + {result && ( + + + {result.code} + + {result.type} · {result.name} + + + {result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"} + + + )} + + )} + + + + ); +} + +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: "600", width: 72 }, + lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 }, + label: { color: theme.textDim, fontSize: 13, marginTop: 16, marginBottom: 6 }, + input: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 14, + color: theme.text, + fontSize: 16, + }, + error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" }, + btn: { + backgroundColor: theme.successBright, + borderRadius: 13, + paddingVertical: 15, + alignItems: "center", + marginTop: 20, + }, + btnOff: { opacity: 0.4 }, + btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" }, + + types: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + typePill: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 999, + paddingHorizontal: 14, + paddingVertical: 9, + }, + typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary }, + typePillText: { color: theme.textDim, fontSize: 14, fontWeight: "700" }, + typePillTextOn: { color: "#fff" }, + + result: { + marginTop: 22, + alignItems: "center", + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 16, + padding: 20, + }, + qr: { width: 220, height: 220, backgroundColor: "#fff", borderRadius: 10 }, + rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 }, + rwho: { color: theme.text, fontSize: 16, marginTop: 4 }, + rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 }, +}); diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx index db0ca3c..522b2c3 100644 --- a/app/components/SideMenu.tsx +++ b/app/components/SideMenu.tsx @@ -4,12 +4,11 @@ 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" }, + { label: "Comp tickets", icon: "🎟️", route: "/comp", seg: "comp" }, + { label: "Admin lookup", icon: "🔎", route: "/admin", seg: "admin" }, ]; export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) { diff --git a/app/lib/api.ts b/app/lib/api.ts index d574790..9f9d4f1 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -252,56 +252,6 @@ export async function portalCreate(input: { return body; } -// ---- Admin actions (all gated by the portal password) ---- - -async function adminPost(path: string, password: string, extra: Record = {}): Promise { - 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 { - return adminPost("/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<{ enabled: boolean; entries: AuditEntry[]; diff --git a/backend/src/config.ts b/backend/src/config.ts index 944dd0f..88954cf 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -10,10 +10,6 @@ const schema = z.object({ // Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped. 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 // disabled. Online/offline are used as a fallback when a donor is not in the // master list. diff --git a/backend/src/context.ts b/backend/src/context.ts index 9bf2e2c..fcf2c43 100644 --- a/backend/src/context.ts +++ b/backend/src/context.ts @@ -4,7 +4,6 @@ import { Mailer } from "./services/mailer.js"; import { RedeemQueue } from "./services/redeemQueue.js"; import { AuditLogger } from "./services/audit.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. */ export interface AppContext { @@ -17,23 +16,12 @@ export interface 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 { config, - nocodb, + nocodb: new NocoDBClient(config), mailer: new Mailer(config), queue: new RedeemQueue(), - audit, + audit: new AuditLogger(config), donors: new DonorService(config), }; } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts deleted file mode 100644 index c2fc5a7..0000000 --- a/backend/src/routes/admin.ts +++ /dev/null @@ -1,122 +0,0 @@ -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 { - 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 }); - } - }); -} diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index e8f9d75..8c55c0e 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -103,350 +103,92 @@ const PAGE = ` -Camp Scan — Admin (crush33) +Camp Scan — Comp Tickets -
- -

Admin · crush33

-

Admin-only area. Enter the shared portal password.

- - -
- ← Back to the scan app -
+
+
+ +

Comp Ticket Portal

+

Entry-only tickets for workers & guests

+
-
-
- ← Scanner -
🐻 Admin · crush33
-
Lock 🔒
-
-
-
- - - -
-
- -
-

Comp tickets

-

Entry-only tickets for guests & staff.

- -
- 🎫 Guest🛠️ Worker🎭 Performer🙌 Volunteer🎤 Speaker -
- - - - - -
-
- Ticket QR -
-
-
-
-
+ + - -
-

Donor lookup

-

🔒 Admin only · private donor info. Search by name, email, phone, address, bear name…

-
- - -
-
-
-
+ + - -
-

Actions

-

Event-management tools. These change live data — read the warnings.

-
-
Active event table
-
loading…
-
-
+ + -
-

🧹 Wipe the slate clean

-

Permanently deletes every ticket and every check-in in the active event table. Use before a run-through or a fresh event.

-
  • Does NOT affect donor data.
  • Cannot be undone.
- -
+ + -
-

🔀 Switch event table

-

Point the scanner at a different NocoDB table — start a new event on a fresh table while keeping the current one intact.

-
  • Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column).
  • The current event's data is NOT deleted, just no longer shown.
- - - - - -
-
-
-
-
+ +
-
- `; diff --git a/backend/src/server.ts b/backend/src/server.ts index 685e1a7..7895fe1 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -16,7 +16,6 @@ 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() { const config = loadConfig(); @@ -41,7 +40,6 @@ export async function build() { 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. const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts index c6b3a29..2782958 100644 --- a/backend/src/services/audit.ts +++ b/backend/src/services/audit.ts @@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry { export class AuditLogger { private readonly base: string; private readonly token: string; - private tableId: string | null; + private readonly tableId: string | null; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); @@ -46,56 +46,10 @@ export class AuditLogger { 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 { return `${this.base}/api/v2/tables/${this.tableId}/records`; } - /** Total audit row count (cheap — reads pageInfo). */ - async count(): Promise { - 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 { - 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 { if (!this.tableId) return; const sign = entry.people >= 0 ? "+" : ""; diff --git a/backend/src/services/donors.ts b/backend/src/services/donors.ts index 46e5e53..15909f0 100644 --- a/backend/src/services/donors.ts +++ b/backend/src/services/donors.ts @@ -1,17 +1,5 @@ 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 { found: boolean; email: string; @@ -169,67 +157,6 @@ export class DonorService { }; } - /** - * 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 { - 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(); - 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(); - - /** Discover the text columns worth searching (name/contact) from a sample row. */ - private async searchableColumns(tableId: string): Promise { - 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 { - 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 @@ -256,40 +183,6 @@ function num(v: unknown): number { 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). function isPaid(row: any): boolean { const s = String(row["Payment Status"] ?? "").trim(); diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts index 9e3416e..f26f452 100644 --- a/backend/src/services/nocodb.ts +++ b/backend/src/services/nocodb.ts @@ -8,24 +8,16 @@ import { COL, type NocoRecord } from "../fields.js"; export class NocoDBClient { private readonly base: string; private readonly token: string; - private _tableId: string; + private readonly tableId: string; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.token = cfg.NOCODB_API_TOKEN; - 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; + this.tableId = cfg.NOCODB_TABLE_ID; } 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 { @@ -146,47 +138,6 @@ export class NocoDBClient { return out; } - /** Total record count in the current table (cheap — reads pageInfo). */ - async count(): Promise { - 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 { - 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. */ async ping(): Promise { const url = new URL(this.recordsUrl); diff --git a/backend/src/services/state.ts b/backend/src/services/state.ts deleted file mode 100644 index b2cde2a..0000000 --- a/backend/src/services/state.ts +++ /dev/null @@ -1,32 +0,0 @@ -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"); -} diff --git a/docker-compose.yml b/docker-compose.yml index dc62d8d..3668405 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,11 +19,4 @@ services: # host.docker.internal resolves to the host gateway. extra_hosts: - "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 - -volumes: - camptickets-data: diff --git a/scripts/switch-event.sh b/scripts/switch-event.sh deleted file mode 100755 index 862ad54..0000000 --- a/scripts/switch-event.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/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 [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 [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)" diff --git a/scripts/wipe-slate.sh b/scripts/wipe-slate.sh deleted file mode 100755 index 58fc3a8..0000000 --- a/scripts/wipe-slate.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/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."