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 { 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 { 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 = 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 []; }