Add event report dashboard, slide-out drawer, in-app comp portal + creator tracking
All checks were successful
Build Android APK / build-apk (push) Successful in 56m36s

- Reporting: GET /api/stats aggregates check-in progress, ice, ticket types,
  people breakdown, add-ons/donors, gate-crew leaderboard (from audit),
  comp tickets by creator, and a by-hour check-in timeline. New /stats screen.
- Slide-out drawer (custom RN Animated, no new native deps) replaces per-screen
  header links; available on every main screen via a hamburger.
- In-app comp portal (/comp), password-gated like /crush33, reusing the portal
  endpoints; records the issuing gate-staff name (Created By column) and reports
  comps per creator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 17:30:18 +00:00
parent 251edfce42
commit 43fddec286
15 changed files with 979 additions and 21 deletions

View file

@ -25,6 +25,7 @@ export const COL = {
iceAccess: "Ice Access",
paymentMethod: "Payment Method",
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps
createdBy: "Created By", // gate-staff name who issued a comp ticket (portal)
// Columns this system manages:
code: "Ticket Code",
@ -96,6 +97,7 @@ export interface TicketView {
name: string;
email: string;
ticketType: string; // "" for regular; Guest/Worker/... for special tickets
createdBy: string; // who issued a comp ticket
total: number;
redeemed: number;
remaining: number;
@ -124,6 +126,7 @@ export function toView(rec: NocoRecord): TicketView {
name: String(rec[COL.name] ?? ""),
email: String(rec[COL.email] ?? ""),
ticketType: String(rec[COL.ticketType] ?? ""),
createdBy: String(rec[COL.createdBy] ?? ""),
total,
redeemed,
remaining: Math.max(0, total - redeemed),

View file

@ -22,6 +22,21 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
reply.type("text/html").send(PAGE);
});
// Password check only (for the in-app portal to gate its form).
app.post(
"/api/portal/verify",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const cfg = app.ctx.config;
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
const b = (req.body ?? {}) as { password?: string };
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
return reply.code(401).send({ error: "bad_password" });
}
return { ok: true, types: TYPES };
},
);
app.post(
"/api/portal/create-ticket",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
@ -29,13 +44,21 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
const cfg = app.ctx.config;
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
const b = (req.body ?? {}) as { password?: string; name?: string; email?: string; type?: string };
const b = (req.body ?? {}) as {
password?: string;
name?: string;
email?: string;
type?: string;
createdBy?: string;
};
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
return reply.code(401).send({ error: "bad_password" });
}
const name = String(b.name ?? "").trim();
const email = String(b.email ?? "").trim();
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest";
// Who issued it — from the in-app portal (signed-in gate staff) or header.
const createdBy = String(b.createdBy ?? req.headers["x-operator"] ?? "").slice(0, 80).trim();
if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
}
@ -47,6 +70,7 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
adultNames: [name],
email,
ticketType: type,
createdBy,
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
});

View file

@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { normalizeCode, looksLikeCode } from "../services/code.js";
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js";
import { computeStats } from "../services/stats.js";
import { COL } from "../fields.js";
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
@ -42,6 +43,12 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
},
);
// Aggregate event report (check-in progress, ice, types, extras, operators).
app.get("/api/stats", { preHandler: requireStaff }, async (req) => {
const force = String((req.query as any)?.force ?? "") === "1";
return computeStats(app.ctx, force);
});
// Recent check-in audit log (all, or filtered to one code via ?code=).
app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;

View file

@ -79,6 +79,43 @@ export class AuditLogger {
}
}
private mapRow(r: any): AuditRow {
return {
id: r.Id,
code: r[AUDIT_COL.code] ?? "",
people: Number(r[AUDIT_COL.people]) || 0,
name: r[AUDIT_COL.name] ?? "",
operator: r[AUDIT_COL.operator] ?? "",
remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0,
at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "",
action: (r[AUDIT_COL.action] ?? "check-in") as AuditEntry["action"],
};
}
/** Every audit row, paginated (for reporting/aggregation). */
async all(): Promise<AuditRow[]> {
if (!this.tableId) return [];
const out: AuditRow[] = [];
const pageSize = 1000;
let offset = 0;
for (;;) {
const url = new URL(this.url);
url.searchParams.set("limit", String(pageSize));
url.searchParams.set("offset", String(offset));
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 ?? [];
out.push(...list.map((r: any) => this.mapRow(r)));
if (!list.length || body?.pageInfo?.isLastPage || list.length < pageSize) break;
offset += pageSize;
if (offset > 200000) break;
}
return out;
}
/** Recent entries, newest first, optionally filtered to one code. */
async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> {
if (!this.tableId) return [];

View file

@ -102,6 +102,26 @@ export class NocoDBClient {
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
}
/** Fetch every record in the table, paginating. */
async all(): Promise<NocoRecord[]> {
const out: NocoRecord[] = [];
const pageSize = 1000;
let offset = 0;
for (;;) {
const url = new URL(this.recordsUrl);
url.searchParams.set("limit", String(pageSize));
url.searchParams.set("offset", String(offset));
const body = await this.request(url.toString());
const list = (body?.list ?? []) as NocoRecord[];
out.push(...list);
const info = body?.pageInfo;
if (!list.length || info?.isLastPage || list.length < pageSize) break;
offset += pageSize;
if (offset > 200000) break; // safety
}
return out;
}
/** Cheap connectivity probe for healthchecks. */
async ping(): Promise<boolean> {
const url = new URL(this.recordsUrl);

View file

@ -0,0 +1,131 @@
import type { AppContext } from "../context.js";
import { COL, toView, toNumber, type NocoRecord } from "../fields.js";
export interface Stats {
orders: number;
tickets: { total: number; redeemed: number; remaining: number; pct: number };
people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number };
ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number };
types: { type: string; count: number; total: number; redeemed: number }[];
donors: { orders: number; members: number; vouchers: number };
extras: { carParking: number; rvParking: number; utv: number };
comps: { total: number; byCreator: { name: string; count: number }[] };
operators: { name: string; checkins: number; ice: number; undos: number }[];
checkinsByHour: { hour: string; count: number }[];
generatedAt: string;
}
let cache: { at: number; data: Stats } | null = null;
const TTL_MS = 20_000;
export async function computeStats(ctx: AppContext, force = false): Promise<Stats> {
const now = Date.now();
if (!force && cache && now - cache.at < TTL_MS) return cache.data;
const records = await ctx.nocodb.all();
const bagsPerTicket = ctx.config.ICE_BAGS_PER_TICKET || 3;
let total = 0,
redeemed = 0,
iceTotal = 0,
iceRedeemed = 0;
let adults = 0,
youth = 0,
kids12 = 0,
kids9 = 0,
kids4 = 0;
let carParking = 0,
rvParking = 0,
utv = 0,
donorOrders = 0,
members = 0,
vouchers = 0;
const typeMap = new Map<string, { count: number; total: number; redeemed: number }>();
const compByCreator = new Map<string, number>();
let compTotal = 0;
for (const r of records as NocoRecord[]) {
const v = toView(r);
if (v.ticketType) {
compTotal += 1;
const who = v.createdBy || "(unknown)";
compByCreator.set(who, (compByCreator.get(who) ?? 0) + 1);
}
total += v.total;
redeemed += v.redeemed;
iceTotal += v.ice.total;
iceRedeemed += v.ice.redeemed;
adults += toNumber(r[COL.adults]);
youth += toNumber(r[COL.youth]);
kids12 += toNumber(r[COL.kids12]);
kids9 += toNumber(r[COL.kids9]);
kids4 += toNumber(r[COL.kids4]);
const t = v.ticketType || "Regular";
const e = typeMap.get(t) ?? { count: 0, total: 0, redeemed: 0 };
e.count += 1;
e.total += v.total;
e.redeemed += v.redeemed;
typeMap.set(t, e);
if (v.extras.carParking) carParking += 1;
if (v.extras.rvParking) rvParking += 1;
if (v.extras.utv) utv += 1;
if (v.extras.isDonor) donorOrders += 1;
if (v.extras.donorTier === "member") members += 1;
vouchers += v.extras.vouchers;
}
// Operator activity + check-in timeline from the audit log.
const audit = await ctx.audit.all().catch(() => []);
const opMap = new Map<string, { checkins: number; ice: number; undos: number }>();
const hourMap = new Map<string, number>();
for (const a of audit) {
if (a.operator) {
const o = opMap.get(a.operator) ?? { checkins: 0, ice: 0, undos: 0 };
if (a.action === "check-in") o.checkins += a.people;
else if (a.action === "undo") o.undos += -a.people;
else if (a.action === "ice") o.ice += a.people;
opMap.set(a.operator, o);
}
if (a.action === "check-in" && a.people > 0 && a.at) {
const hour = String(a.at).slice(0, 13); // YYYY-MM-DDTHH
hourMap.set(hour, (hourMap.get(hour) ?? 0) + a.people);
}
}
const data: Stats = {
orders: records.length,
tickets: { total, redeemed, remaining: Math.max(0, total - redeemed), pct: total ? Math.round((redeemed / total) * 100) : 0 },
people: { adults, youth, kids12, kids9, kids4Free: kids4 },
ice: {
total: iceTotal,
redeemed: iceRedeemed,
remaining: Math.max(0, iceTotal - iceRedeemed),
pct: iceTotal ? Math.round((iceRedeemed / iceTotal) * 100) : 0,
ticketsSold: Math.round(iceTotal / bagsPerTicket),
},
types: [...typeMap.entries()]
.map(([type, e]) => ({ type, ...e }))
.sort((a, b) => b.total - a.total),
donors: { orders: donorOrders, members, vouchers },
extras: { carParking, rvParking, utv },
comps: {
total: compTotal,
byCreator: [...compByCreator.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count),
},
operators: [...opMap.entries()]
.map(([name, o]) => ({ name, ...o }))
.sort((a, b) => b.checkins - a.checkins),
checkinsByHour: [...hourMap.entries()]
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.slice(-12)
.map(([hour, count]) => ({ hour, count })),
generatedAt: new Date().toISOString(),
};
cache = { at: now, data };
return data;
}

View file

@ -131,6 +131,7 @@ export interface WebhookInput {
adultNames?: string[];
email: string;
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
createdBy?: string; // gate-staff name who issued a comp
address?: string;
isDonor?: boolean;
donorTier?: string;
@ -180,6 +181,7 @@ export async function createTicket(
};
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
if (input.ticketType) fields[COL.ticketType] = input.ticketType;
if (input.createdBy) fields[COL.createdBy] = input.createdBy;
if (input.address !== undefined) fields[COL.address] = input.address;
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;