Add ticket-voucher entitlement lookup + document both public APIs

GET /api/public/ticket-vouchers?key=&email= returns 0/1/2 free tickets based on
donations on/after VOUCHER_SINCE (default 2025-09-04): >= $400 -> 1, >= $1000 -> 2.
Same secret/CORS/rate-limit as donor-eligibility; returns only the count. Cutoff
and thresholds are env-configurable. Documented both lookup APIs (discount +
vouchers) in docs/fluentforms-donor-discount.md with form snippets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-10 06:27:32 +00:00
parent 42043957e0
commit 0b4ad1c99f
5 changed files with 190 additions and 6 deletions

View file

@ -27,6 +27,12 @@ const schema = z.object({
PUBLIC_LOOKUP_SECRET: z.string().optional(),
PUBLIC_LOOKUP_ORIGIN: z.string().default("https://tickets.beartariacampgrounds.com"),
// Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling
// >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year.
VOUCHER_SINCE: z.string().default("2025-09-04"),
VOUCHER_TIER1_MIN: z.coerce.number().default(400),
VOUCHER_TIER2_MIN: z.coerce.number().default(1000),
// Serve GET /test with sample QR codes. Seeds test personas into the current
// NocoDB table, so keep this OFF in production (only enable against a TEST table).
ENABLE_TEST_PAGE: z

View file

@ -26,10 +26,17 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
};
// Preflight (in case the form sends one).
app.options("/api/public/donor-eligibility", async (_req, reply) => {
const preflight = async (_req: any, reply: any) => {
cors(reply);
return reply.code(204).send();
});
};
app.options("/api/public/donor-eligibility", preflight);
app.options("/api/public/ticket-vouchers", preflight);
const checkSecret = (req: any): boolean => {
const { key } = (req.query ?? {}) as { key?: string };
return !!cfg.PUBLIC_LOOKUP_SECRET && !!key && safeEqual(key, cfg.PUBLIC_LOOKUP_SECRET);
};
app.get(
"/api/public/donor-eligibility",
@ -57,4 +64,34 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
}
},
);
// Ticket-voucher entitlement: how many free tickets a donor has earned from
// giving on/after VOUCHER_SINCE. Same secret/CORS/rate-limit as above.
// Returns only the count (0/1/2) — no dollar amounts.
app.get(
"/api/public/ticket-vouchers",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
cors(reply);
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
return reply.code(404).send({ error: "not_available" });
}
if (!checkSecret(req)) {
return reply.code(401).send({ error: "unauthorized" });
}
const { email } = (req.query ?? {}) as { email?: string };
const addr = String(email ?? "").trim();
if (!addr) return { vouchers: 0 };
try {
const cutoff = new Date(cfg.VOUCHER_SINCE);
const { amount } = await app.ctx.donors.amountSince(addr, cutoff);
const vouchers = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
return { vouchers };
} catch {
// Fail closed — grant no vouchers rather than error.
return { vouchers: 0 };
}
},
);
}

View file

@ -156,6 +156,26 @@ export class DonorService {
source: "transactions",
};
}
/**
* Total Paid donations for an email on/after `cutoff`, summed from the
* transaction tables (the only dated source). Used for ticket-voucher
* entitlement. found=false if donor tables aren't configured or there are no
* transactions for the email at all.
*/
async amountSince(rawEmail: string, cutoff: Date): Promise<{ found: boolean; amount: number }> {
const email = rawEmail.trim();
if (!email || !this.onlineId || !this.offlineId) return { found: false, amount: 0 };
const esc = email.replace(/[(),]/g, " ");
const [online, offline] = await Promise.all([
this.list(this.onlineId, `(Email,eq,${esc})`, 1000),
this.list(this.offlineId, `(Email,eq,${esc})`, 1000),
]);
const amount =
sumSince(online, "Donation Amount", "Donation Date", cutoff) +
sumSince(offline, "Donation Amount", "Donation Date", cutoff);
return { found: online.length + offline.length > 0, amount };
}
}
function num(v: unknown): number {