diff --git a/backend/src/routes/publicLookup.ts b/backend/src/routes/publicLookup.ts index d1437f9..998a7fb 100644 --- a/backend/src/routes/publicLookup.ts +++ b/backend/src/routes/publicLookup.ts @@ -69,9 +69,15 @@ 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. + // Ticket-voucher entitlement: how many FREE tickets a donor has left. This is + // the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the + // vouchers already consumed by their prior ticket orders (each order stores + // how many it used), so a donor can't keep claiming free tickets by + // re-submitting the form. `vouchers` is the remaining count the form should + // grant; `entitled`/`used`/`remaining` are the breakdown. No dollar amounts. + // + // To reset for testing: zero out (or delete) the "Vouchers" value on that + // donor's ticket order row(s) in NocoDB — `used` drops and `remaining` rises. app.get( "/api/public/ticket-vouchers", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, @@ -85,16 +91,18 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise { } const { email } = (req.query ?? {}) as { email?: string }; const addr = String(email ?? "").trim(); - if (!addr) return { vouchers: 0 }; + if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 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 }; + const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0; + const used = await app.ctx.nocodb.vouchersUsedByEmail(addr); + const remaining = Math.max(0, entitled - used); + return { vouchers: remaining, entitled, used, remaining }; } catch { // Fail closed — grant no vouchers rather than error. - return { vouchers: 0 }; + return { vouchers: 0, entitled: 0, used: 0, remaining: 0 }; } }, ); diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts index a9f8587..f26f452 100644 --- a/backend/src/services/nocodb.ts +++ b/backend/src/services/nocodb.ts @@ -76,6 +76,22 @@ export class NocoDBClient { return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit); } + /** Every order for an exact email (case-insensitive). */ + async findByEmail(email: string, limit = 1000): Promise { + const rows = await this.list(`(${COL.email},eq,${escapeValue(email)})`, limit); + // Belt-and-suspenders: some NocoDB backends do a case-sensitive eq, so + // narrow/confirm against a lowercased compare in JS. + const target = email.trim().toLowerCase(); + const exact = rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target); + return exact.length ? exact : rows; + } + + /** Sum of ticket vouchers a donor has already consumed across their orders. */ + async vouchersUsedByEmail(email: string): Promise { + const rows = await this.findByEmail(email); + return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0); + } + async create(fields: Record): Promise { const body = await this.request(this.recordsUrl, { method: "POST", diff --git a/backend/src/test/fakeNocodb.ts b/backend/src/test/fakeNocodb.ts index 9a158f8..d8fe66b 100644 --- a/backend/src/test/fakeNocodb.ts +++ b/backend/src/test/fakeNocodb.ts @@ -41,6 +41,17 @@ export class FakeNocoDB { ); } + async findByEmail(email: string): Promise { + await this.delay(); + const target = email.trim().toLowerCase(); + return this.rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target); + } + + async vouchersUsedByEmail(email: string): Promise { + const rows = await this.findByEmail(email); + return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0); + } + async create(fields: Record): Promise { await this.delay(); const rec = { Id: this.nextId++, ...fields } as NocoRecord; diff --git a/backend/src/test/vouchers.test.ts b/backend/src/test/vouchers.test.ts new file mode 100644 index 0000000..497887a --- /dev/null +++ b/backend/src/test/vouchers.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { FakeNocoDB } from "./fakeNocodb.js"; +import { COL } from "../fields.js"; + +/** Mirror the ticket-vouchers endpoint's remaining math. */ +function remaining(entitled: number, used: number): number { + return Math.max(0, entitled - used); +} + +describe("voucher consumption", () => { + it("sums vouchers used across a donor's orders", async () => { + const db = new FakeNocoDB(0); + await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 2 }); + await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 1 }); + await db.create({ [COL.email]: "someone-else@example.com", [COL.vouchers]: 2 }); + await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 0 }); // non-voucher order + expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(3); + }); + + it("matches email case-insensitively", async () => { + const db = new FakeNocoDB(0); + await db.create({ [COL.email]: "Donor@Example.com", [COL.vouchers]: 2 }); + expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(2); + }); + + it("returns 0 used for a donor with no orders", async () => { + const db = new FakeNocoDB(0); + expect(await db.vouchersUsedByEmail("nobody@example.com")).toBe(0); + }); + + it("remaining = entitled - used, floored at 0", () => { + expect(remaining(2, 0)).toBe(2); // fresh 2-voucher donor + expect(remaining(2, 1)).toBe(1); // used one + expect(remaining(2, 2)).toBe(0); // used both — no more free tickets + expect(remaining(1, 2)).toBe(0); // over-consumed (edge) never goes negative + expect(remaining(0, 0)).toBe(0); // non-donor + }); +}); diff --git a/docs/fluentforms-ticket-vouchers.md b/docs/fluentforms-ticket-vouchers.md index 6f079a5..af66c45 100644 --- a/docs/fluentforms-ticket-vouchers.md +++ b/docs/fluentforms-ticket-vouchers.md @@ -14,16 +14,32 @@ ticketing backend. GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email= ``` -Returns only the count — never names or dollar amounts: +Returns the **remaining** free-ticket count — never names or dollar amounts: ```json -{ "vouchers": 0 } // or 1, or 2 +{ "vouchers": 1, "entitled": 2, "used": 1, "remaining": 1 } ``` +- `vouchers` / `remaining` — how many free tickets are **still available** (this + is what the form should grant). Use `vouchers`; `remaining` is an alias. +- `entitled` — the tier entitlement earned from giving (0/1/2). +- `used` — vouchers already consumed by this donor's prior ticket orders. - `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`). - `email` = the donor's email (URL-encoded). - Rate-limited (30 requests / minute / IP) and CORS-restricted to - `PUBLIC_LOOKUP_ORIGIN` (default `https://tickets.beartariacampgrounds.com`). + `PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`). + +### Vouchers decrement as they're used + +`remaining = entitled − used`, where `used` is the sum of the **Vouchers** +column across every ticket order placed with that email. Each checkout stores +the vouchers it applied, so the next lookup returns fewer — a donor can't keep +claiming free tickets by re-submitting the form. Once `used ≥ entitled`, +`vouchers` is `0`. + +**To reset for testing:** in NocoDB, zero out (or delete) the **Vouchers** +value on that donor's ticket order row(s). `used` drops and `remaining` rises on +the next lookup — no redeploy needed. > The secret is visible in page source, so treat it as **deterrence, not > security** — it only gates a 0/1/2 count. Rotate it by changing @@ -107,9 +123,8 @@ field `free_tickets` you can use for conditional logic or to cap a quantity. ``` curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email=" -# >= $1000 since cutoff -> {"vouchers":2} -# >= $400 since cutoff -> {"vouchers":1} -# otherwise -> {"vouchers":0} +# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2} +# after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0} ``` Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the