Some checks failed
Build Android APK / build-apk (push) Failing after 57m31s
- 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>
192 lines
6.5 KiB
TypeScript
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 [];
|
|
}
|