Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc93ef43f7 | |||
| efe331a77a | |||
| 1c8cb47209 | |||
| 3a3119e324 |
17 changed files with 899 additions and 315 deletions
|
|
@ -32,8 +32,11 @@ 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.
|
||||
RUN chown -R node:node /srv
|
||||
# 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
|
||||
USER node
|
||||
|
||||
EXPOSE 8080
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Camp Scan",
|
||||
"slug": "camptickets",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"orientation": "portrait",
|
||||
"scheme": "campscan",
|
||||
"userInterfaceStyle": "automatic",
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
"icon": "./assets/icon.png",
|
||||
"android": {
|
||||
"package": "top.mowden.campscan",
|
||||
"versionCode": 2,
|
||||
"versionCode": 3,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0f1a12"
|
||||
|
|
|
|||
239
app/app/comp.tsx
239
app/app/comp.tsx
|
|
@ -1,239 +0,0 @@
|
|||
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<string, string> = {
|
||||
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<PortalTicket | null>(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 (
|
||||
<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}>Comp Tickets</Text>
|
||||
<View style={{ width: 60 }} />
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 48 }}>
|
||||
{!unlocked ? (
|
||||
<View>
|
||||
<Text style={styles.lead}>Entry-only tickets for workers & guests. Enter the shared portal password.</Text>
|
||||
<Text style={styles.label}>Portal password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Shared admin password"
|
||||
placeholderTextColor={theme.textDim}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={unlock}
|
||||
/>
|
||||
{!!error && <Text style={styles.error}>{error}</Text>}
|
||||
<Pressable style={[styles.btn, (busy || !password) && styles.btnOff]} onPress={unlock} disabled={busy || !password}>
|
||||
<Text style={styles.btnText}>{busy ? "Checking…" : "Unlock"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<Text style={styles.label}>Ticket type</Text>
|
||||
<View style={styles.types}>
|
||||
{TYPES.map((t) => (
|
||||
<Pressable
|
||||
key={t}
|
||||
style={[styles.typePill, type === t && styles.typePillOn]}
|
||||
onPress={() => setType(t)}
|
||||
>
|
||||
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>
|
||||
{(TYPE_ICON[t] ?? "🎫") + " " + t}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.label}>Full name</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Attendee name"
|
||||
placeholderTextColor={theme.textDim}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Email</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="Where to send the ticket"
|
||||
placeholderTextColor={theme.textDim}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
|
||||
{!!error && <Text style={styles.error}>{error}</Text>}
|
||||
<Pressable
|
||||
style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]}
|
||||
onPress={create}
|
||||
disabled={busy || !name.trim() || !email.trim()}
|
||||
>
|
||||
<Text style={styles.btnText}>{busy ? "Creating…" : `Create ${type} ticket`}</Text>
|
||||
</Pressable>
|
||||
|
||||
{result && (
|
||||
<View style={styles.result}>
|
||||
<Image source={{ uri: result.qr }} style={styles.qr} />
|
||||
<Text style={styles.rcode}>{result.code}</Text>
|
||||
<Text style={styles.rwho}>
|
||||
{result.type} · {result.name}
|
||||
</Text>
|
||||
<Text style={styles.rmail}>
|
||||
{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</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: "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 },
|
||||
});
|
||||
|
|
@ -4,11 +4,12 @@ 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: "Comp tickets", icon: "🎟️", route: "/comp", seg: "comp" },
|
||||
{ label: "Admin lookup", icon: "🔎", route: "/admin", seg: "admin" },
|
||||
{ label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" },
|
||||
];
|
||||
|
||||
export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||
|
|
|
|||
|
|
@ -252,6 +252,56 @@ export async function portalCreate(input: {
|
|||
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<{
|
||||
enabled: boolean;
|
||||
entries: AuditEntry[];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -103,92 +103,350 @@ const PAGE = `<!doctype html>
|
|||
<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 — Comp Tickets</title>
|
||||
<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; }
|
||||
.wrap { max-width: 460px; margin: 0 auto; padding: 28px 20px 64px; }
|
||||
header { text-align: center; margin-bottom: 22px; }
|
||||
.logo { font-size: 52px; }
|
||||
h1 { font-size: 22px; margin: 8px 0 2px; }
|
||||
.sub { color: #9db3a4; font-size: 14px; margin: 0; }
|
||||
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: 14px; color: #eaf2ec; font-size: 16px; }
|
||||
button { width: 100%; background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px; border: none; padding: 15px; border-radius: 13px; margin-top: 18px; }
|
||||
button:disabled { opacity: 0.5; }
|
||||
.msg { margin-top: 14px; font-size: 15px; font-weight: 600; text-align: center; min-height: 20px; }
|
||||
.err { color: #e04343; }
|
||||
.ok { color: #58d68d; }
|
||||
.result { display: none; text-align: center; margin-top: 18px; background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 18px; }
|
||||
.result img { width: 220px; height: 220px; background: #fff; border-radius: 10px; padding: 8px; }
|
||||
.result .code { font-family: ui-monospace, Menlo, monospace; font-size: 20px; letter-spacing: 2px; margin: 12px 0 4px; color: #58d68d; }
|
||||
.result .who { font-size: 16px; color: #c4d6c9; }
|
||||
.hint { color: #6c8f74; font-size: 12px; text-align: center; margin-top: 10px; }
|
||||
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 class="wrap">
|
||||
<header>
|
||||
<div id="unlock">
|
||||
<div class="logo">🐻</div>
|
||||
<h1>Comp Ticket Portal</h1>
|
||||
<p class="sub">Entry-only tickets for workers & guests</p>
|
||||
</header>
|
||||
|
||||
<label>Portal password</label>
|
||||
<input id="pw" type="password" autocomplete="current-password" placeholder="Shared admin password" />
|
||||
<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>
|
||||
<select id="type">
|
||||
<option>Guest</option><option>Worker</option><option>Performer</option>
|
||||
<option>Volunteer</option><option>Speaker</option>
|
||||
</select>
|
||||
|
||||
<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="name" type="text" autocomplete="off" placeholder="Attendee name" />
|
||||
|
||||
<input id="cName" type="text" autocomplete="off" placeholder="Attendee name" />
|
||||
<label>Email</label>
|
||||
<input id="email" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
|
||||
<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>
|
||||
|
||||
<button id="go">Create ticket</button>
|
||||
<div id="msg" class="msg"></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>
|
||||
|
||||
<div id="result" class="result">
|
||||
<img id="qr" alt="Ticket QR" />
|
||||
<div class="code" id="rcode"></div>
|
||||
<div class="who" id="rwho"></div>
|
||||
<div class="hint" id="rmail"></div>
|
||||
<button id="another" style="background:transparent;color:#eaf2ec;border:1px solid #2e7d32;font-size:15px;">Create another</button>
|
||||
<!-- 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); };
|
||||
function setMsg(t, ok) { var m = $("msg"); m.textContent = t; m.className = "msg " + (ok ? "ok" : "err"); }
|
||||
var PW = "";
|
||||
var counts = { tickets: "?", audit: "?", table: "?" };
|
||||
var pendingAction = null;
|
||||
|
||||
$("go").addEventListener("click", function () {
|
||||
var pw = $("pw").value, name = $("name").value.trim(), email = $("email").value.trim(), type = $("type").value;
|
||||
if (!pw) return setMsg("Enter the portal password.");
|
||||
if (!name || !email) return setMsg("Name and email are required.");
|
||||
$("go").disabled = true; setMsg("Creating…", true);
|
||||
fetch("/api/portal/create-ticket", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: pw, name: name, email: email, type: type })
|
||||
}).then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$("go").disabled = false;
|
||||
if (x.s === 401) return setMsg("Wrong password.");
|
||||
if (x.s !== 200 || !x.d.ok) return setMsg(x.d.detail || x.d.error || "Failed to create ticket.");
|
||||
setMsg("");
|
||||
$("qr").src = x.d.qr; $("rcode").textContent = x.d.code;
|
||||
$("rwho").textContent = x.d.type + " · " + x.d.name;
|
||||
$("rmail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — show/screenshot this QR.";
|
||||
$("result").style.display = "block";
|
||||
$("name").value = ""; $("email").value = "";
|
||||
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 () { $("go").disabled = false; setMsg("Network error."); });
|
||||
.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."); });
|
||||
});
|
||||
$("another").addEventListener("click", function () { $("result").style.display = "none"; $("name").focus(); });
|
||||
|
||||
// ---- 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>`;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -19,4 +19,11 @@ 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:
|
||||
|
|
|
|||
73
scripts/switch-event.sh
Executable file
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
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."
|
||||
Loading…
Add table
Add a link
Reference in a new issue