CampgroundTickets/backend/src/services/donors.ts
Hank 1ca2c38fbf
Some checks failed
Build Android APK / build-apk (push) Failing after 57m31s
Fix login hang after PIN + enrich Banquet mode
- Auth: introduce a shared AuthProvider/useAuth so entering the PIN updates
  reactive state and the layout's gate navigates immediately (previously it
  cached the token check at startup, so login appeared to hang until a manual
  refresh). login/scanner now use signIn/signOut.
- Banquet: donor lookup now returns lifetime giving, last-12-months giving
  (computed from dated transactions), member/donor status, bear name, and tags.
  The banquet result screen shows status badge + lifetime and last-year figures.
  Tickets are irrelevant in banquet mode.
- Admin: fix "‹ Scanner" back link wrapping (remove fixed width).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:01:41 +00:00

192 lines
6.5 KiB
TypeScript

import type { Config } from "../config.js";
export interface DonorLookup {
found: boolean;
email: string;
name: string;
bearName: string;
lifetime: number; // total all-time giving
lastYear: number; // giving in the last 12 months
online: number; // lifetime online
offline: number; // lifetime offline
isMember: boolean; // appears to be a current member
tags: string[];
status: string; // short human status line
source: "master" | "transactions" | "none";
}
/**
* Looks up a donor for Banquet mode. Lifetime totals come from the "Donors
* Master List" (authoritative pre-rolled totals); the last-12-months figure is
* computed from the online + offline transaction tables (the only source with
* dates). Tickets are irrelevant here — banquet only cares about giving.
*/
export class DonorService {
private readonly base: string;
private readonly token: string;
private readonly masterId: string | null;
private readonly onlineId: string | null;
private readonly offlineId: string | null;
constructor(
cfg: Pick<
Config,
| "NOCODB_BASE_URL"
| "NOCODB_API_TOKEN"
| "NOCODB_DONORS_TABLE_ID"
| "NOCODB_DONOR_ONLINE_TABLE_ID"
| "NOCODB_DONOR_OFFLINE_TABLE_ID"
>,
) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
this.token = cfg.NOCODB_API_TOKEN;
this.masterId = cfg.NOCODB_DONORS_TABLE_ID ?? null;
this.onlineId = cfg.NOCODB_DONOR_ONLINE_TABLE_ID ?? null;
this.offlineId = cfg.NOCODB_DONOR_OFFLINE_TABLE_ID ?? null;
}
get enabled(): boolean {
return this.masterId !== null || (this.onlineId !== null && this.offlineId !== null);
}
private async list(tableId: string, where: string, limit = 50): Promise<any[]> {
const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`);
if (where) url.searchParams.set("where", where);
url.searchParams.set("limit", String(limit));
const res = await fetch(url.toString(), {
headers: { "xc-token": this.token, "Content-Type": "application/json" },
});
if (!res.ok) throw new Error(`NocoDB ${res.status} on donor lookup`);
const body: any = await res.json();
return body?.list ?? [];
}
async lookup(rawEmail: string): Promise<DonorLookup> {
const email = rawEmail.trim();
const esc = email.replace(/[(),]/g, " ");
const empty: DonorLookup = {
found: false,
email,
name: "",
bearName: "",
lifetime: 0,
lastYear: 0,
online: 0,
offline: 0,
isMember: false,
tags: [],
status: "",
source: "none",
};
if (!email) return empty;
// Always pull transactions (needed for the last-12-months figure and as a
// lifetime fallback). Runs in parallel with the master-list lookup.
const txnP: Promise<{ online: any[]; offline: any[] }> =
this.onlineId && this.offlineId
? Promise.all([
this.list(this.onlineId, `(Email,eq,${esc})`, 500),
this.list(this.offlineId, `(Email,eq,${esc})`, 500),
]).then(([online, offline]) => ({ online, offline }))
: Promise.resolve({ online: [], offline: [] });
const masterP: Promise<any | null> = this.masterId
? this.list(this.masterId, `(Email,eq,${esc})~or(Alternate Email,eq,${esc})`, 1).then((r) => r[0] ?? null)
: Promise.resolve(null);
const [{ online: onlineRows, offline: offlineRows }, master] = await Promise.all([txnP, masterP]);
// Last 12 months, summed from dated transactions (Paid or unspecified).
const cutoff = new Date();
cutoff.setFullYear(cutoff.getFullYear() - 1);
const lastYear =
sumSince(onlineRows, "Donation Amount", "Donation Date", cutoff) +
sumSince(offlineRows, "Donation Amount", "Donation Date", cutoff);
const txnOnline = sumPaid(onlineRows, "Donation Amount");
const txnOffline = sumPaid(offlineRows, "Donation Amount");
if (master) {
const online = master["Total Online Donations"] !== undefined ? num(master["Total Online Donations"]) : txnOnline;
const offline =
master["Total Offline Donations"] !== undefined ? num(master["Total Offline Donations"]) : txnOffline;
const lifetime =
master["Total Donations"] !== undefined ? num(master["Total Donations"]) : online + offline;
const name =
master["Display Name"] ||
[master["First Name"], master["Last Name"]].filter(Boolean).join(" ") ||
master["Bear Name"] ||
"";
const tags = splitTags(master["Tags"]);
const upcoming = String(master["Upcoming Rewards"] ?? "");
const isMember = /member/i.test(upcoming) || tags.some((t) => /member/i.test(t));
return {
found: true,
email,
name,
bearName: String(master["Bear Name"] ?? ""),
lifetime,
lastYear,
online,
offline,
isMember,
tags,
status: isMember ? "Member" : "Donor",
source: "master",
};
}
// Not in the master list: build from transactions alone.
const found = txnOnline + txnOffline > 0 || onlineRows.length + offlineRows.length > 0;
if (!found) return empty;
const name = onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || "";
const bearName = onlineRows[0]?.["Bear Name"] || offlineRows[0]?.["Bear Name"] || "";
return {
found: true,
email,
name,
bearName,
lifetime: txnOnline + txnOffline,
lastYear,
online: txnOnline,
offline: txnOffline,
isMember: false,
tags: [],
status: "Donor",
source: "transactions",
};
}
}
function num(v: unknown): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
// Count a transaction unless it's explicitly not paid (refunded/failed/pending).
function isPaid(row: any): boolean {
const s = String(row["Payment Status"] ?? "").trim();
if (!s) return true;
return /paid|complete|success/i.test(s);
}
function sumPaid(rows: any[], amountField: string): number {
return rows.reduce((acc, r) => (isPaid(r) ? acc + num(r[amountField]) : acc), 0);
}
function sumSince(rows: any[], amountField: string, dateField: string, cutoff: Date): number {
return rows.reduce((acc, r) => {
if (!isPaid(r)) return acc;
const raw = r[dateField];
if (!raw) return acc;
const d = new Date(raw);
if (isNaN(d.getTime()) || d < cutoff) return acc;
return acc + num(r[amountField]);
}, 0);
}
function splitTags(v: unknown): string[] {
if (Array.isArray(v)) return v.map((x) => String(x)).filter(Boolean);
if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean);
return [];
}