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>
122 lines
4.8 KiB
TypeScript
122 lines
4.8 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
}
|