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
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:
parent
251edfce42
commit
43fddec286
15 changed files with 979 additions and 21 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
131
backend/src/services/stats.ts
Normal file
131
backend/src/services/stats.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue