diff --git a/.env.example b/.env.example index c52cdc0..edaaaef 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,13 @@ NOCODB_DONOR_OFFLINE_TABLE_ID= # Ice bags granted when a purchase includes ice but the webhook sends only a boolean ICE_BAGS_DEFAULT=3 +# Ticket-voucher entitlement (donor free tickets). Donations on/after +# VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the +# date each year. NOTE: with no donations after the cutoff, everyone gets 0. +VOUCHER_SINCE=2025-09-04 +VOUCHER_TIER1_MIN=400 +VOUCHER_TIER2_MIN=1000 + # Serve /test with sample QR codes (seeds test personas into the CURRENT table). # Keep this false/empty in production — only enable when pointed at a TEST table. ENABLE_TEST_PAGE=false diff --git a/backend/src/config.ts b/backend/src/config.ts index ab39649..b8941ef 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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 diff --git a/backend/src/routes/publicLookup.ts b/backend/src/routes/publicLookup.ts index 8c283d5..c804b30 100644 --- a/backend/src/routes/publicLookup.ts +++ b/backend/src/routes/publicLookup.ts @@ -26,10 +26,17 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise { }; // 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 { } }, ); + + // 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 }; + } + }, + ); } diff --git a/backend/src/services/donors.ts b/backend/src/services/donors.ts index bd13de8..15909f0 100644 --- a/backend/src/services/donors.ts +++ b/backend/src/services/donors.ts @@ -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 { diff --git a/docs/fluentforms-donor-discount.md b/docs/fluentforms-donor-discount.md index dcec3fe..296d7f2 100644 --- a/docs/fluentforms-donor-discount.md +++ b/docs/fluentforms-donor-discount.md @@ -1,8 +1,26 @@ -# FluentForms → donor discount lookup +# FluentForms → donor lookup APIs -FluentForms has no native way to query an external database from a field. This -wires it up with a small Custom JS block that calls our secret-gated endpoint -and unlocks a discount when the entered email belongs to a donor/member. +FluentForms has no native way to query an external database from a field. These +wire it up with a small Custom JS block that calls a secret-gated endpoint on +the ticketing backend. Two endpoints are available (same key, CORS, and rate +limit): + +| Endpoint | Purpose | +|---|---| +| `GET /api/public/donor-eligibility` | Is this email a donor/member? → unlock a discount | +| `GET /api/public/ticket-vouchers` | How many free tickets has this donor earned? → 0 / 1 / 2 | + +Both require `?key=`, are rate-limited (30/min/IP), and +CORS-restricted to `PUBLIC_LOOKUP_ORIGIN` (default +`https://tickets.beartariacampgrounds.com`). Neither returns names or dollar +amounts. The secret is visible in page source, so treat it as deterrence, not +security; rotate it by changing `PUBLIC_LOOKUP_SECRET` and redeploying. + +--- + +## Donor discount lookup + +Unlocks a discount when the entered email belongs to a donor/member. ## Endpoint @@ -124,3 +142,99 @@ curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=&email= +-> {"vouchers": 0} | {"vouchers": 1} | {"vouchers": 2} +``` + +**Rules** (donations summed on/after the cutoff): + +| Total since cutoff | Vouchers | +|---|---| +| ≥ $1000 | 2 | +| ≥ $400 | 1 | +| otherwise | 0 | + +**Config** (backend `.env`): + +| Var | Default | Meaning | +|---|---|---| +| `VOUCHER_SINCE` | `2025-09-04` | Only donations on/after this date count. Bump each year. | +| `VOUCHER_TIER1_MIN` | `400` | Dollar total for 1 voucher | +| `VOUCHER_TIER2_MIN` | `1000` | Dollar total for 2 vouchers | + +> The count is computed from the **dated transaction tables** (online + offline) +> — the master-list rollups have no dates. Only `Paid` transactions count. + +### Form snippet + +Displays the voucher count and (optionally) sets a hidden field / caps a +quantity. Same pattern as the discount lookup — paste into a Custom HTML element +and set `KEY`. + +```html +
+ +``` + +### Test + +``` +curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email=" +# -> {"vouchers":2} (>= $1000 since the cutoff) +``` + +> **Heads-up on the cutoff:** with `VOUCHER_SINCE=2025-09-04`, everyone currently +> returns `0` because the donation data in NocoDB ends **2025-05-22** — there are +> no transactions after the cutoff yet. Adjust `VOUCHER_SINCE` (or wait for new +> donations to sync) so the window matches real giving.