Admin hub in /crush33: sidebar, donor lookup, danger-zone actions

Rebuilt the password-gated /crush33 (in-app /comp) screen into an admin
hub with a left sidebar and three sections:

- Comp tickets — the existing entry-only comp creator.
- Donor lookup — admin-only free-text search across the donor master
  list + online/offline transaction tables by name / email / phone /
  address / bear name (columns discovered per table, deduped by email).
- Actions (danger zone) — heavy warnings, red buttons, and an
  "are you sure" modal that spells out exactly what will happen:
    • Wipe slate — delete ALL ticket + audit records in the active
      event table (donor data untouched, irreversible).
    • Switch event table — repoint the app at a different NocoDB
      tickets/audit table to start a new event while keeping the old
      one intact.

Backend:
- New /api/admin/{status,wipe,switch-table,donor-search}, all gated by
  PORTAL_PASSWORD (POST-only so it never lands in a URL/log).
- NocoDBClient + AuditLogger: runtime-switchable tableId, count(),
  deleteAll(), probeTable() (reachable + Id-PK check before switching).
- DonorService.search() with adaptive column discovery.
- Table switch persists across redeploys via a small state file on a
  new /data volume (Dockerfile creates it owned by node so it's
  writable); applied at startup in buildContext.

Also shipped equivalent CLI scripts: scripts/wipe-slate.sh and
scripts/switch-event.sh. Drawer: "Comp tickets" -> "Admin (crush33)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-23 01:29:10 +00:00
parent 60f0908299
commit 3a3119e324
15 changed files with 1042 additions and 172 deletions

View file

@ -10,6 +10,10 @@ 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.

View file

@ -4,6 +4,7 @@ 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 {
@ -16,12 +17,23 @@ 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: new NocoDBClient(config),
nocodb,
mailer: new Mailer(config),
queue: new RedeemQueue(),
audit: new AuditLogger(config),
audit,
donors: new DonorService(config),
};
}

122
backend/src/routes/admin.ts Normal file
View 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 });
}
});
}

View file

@ -16,6 +16,7 @@ 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();
@ -40,6 +41,7 @@ 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");

View file

@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry {
export class AuditLogger {
private readonly base: 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">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
@ -46,10 +46,56 @@ 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<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> {
if (!this.tableId) return;
const sign = entry.people >= 0 ? "+" : "";

View file

@ -1,5 +1,17 @@
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;
@ -157,6 +169,67 @@ 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<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
@ -183,6 +256,40 @@ 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();

View file

@ -8,16 +8,24 @@ import { COL, type NocoRecord } from "../fields.js";
export class NocoDBClient {
private readonly base: 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">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
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 {
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> {
@ -138,6 +146,47 @@ export class NocoDBClient {
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. */
async ping(): Promise<boolean> {
const url = new URL(this.recordsUrl);

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