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:
parent
60f0908299
commit
3a3119e324
15 changed files with 1042 additions and 172 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 ? "+" : "";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
32
backend/src/services/state.ts
Normal file
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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue