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:
parent
42043957e0
commit
0b4ad1c99f
5 changed files with 190 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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=<PUBLIC_LOOKUP_SECRET>`, 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=<SE
|
|||
If member and donor get the **same** discounted price, simplify: set the donor
|
||||
price to show when `donor_tier` **is not** `regular`, and you can ignore the
|
||||
member/donor distinction.
|
||||
|
||||
---
|
||||
|
||||
## Ticket-voucher entitlement
|
||||
|
||||
Returns how many **free tickets** a donor has earned from their giving, based on
|
||||
donations **on/after a cutoff date** (default `2025-09-04` — "9/4 last year").
|
||||
|
||||
```
|
||||
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<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
|
||||
<div id="voucher-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var API = "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers";
|
||||
var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
|
||||
var EMAIL_SELECTOR = 'input[name="email"]';
|
||||
var statusEl = document.getElementById("voucher-status");
|
||||
var last = "";
|
||||
|
||||
function show(n) {
|
||||
if (n > 0) {
|
||||
statusEl.textContent = "🎟️ You've earned " + n + " free ticket" + (n > 1 ? "s" : "") + "!";
|
||||
statusEl.style.color = "#1b7f3b";
|
||||
} else {
|
||||
statusEl.textContent = "";
|
||||
}
|
||||
// Optional: write n into a hidden field named "free_tickets" for conditional
|
||||
// logic / to cap a quantity. (Uses the native setter so FF's Vue model sees it.)
|
||||
var el = document.querySelector('input[name="free_tickets"]');
|
||||
if (el) {
|
||||
var proto = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value");
|
||||
(proto && proto.set ? proto.set : function (v) { el.value = v; }).call(el, String(n));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function check(email) {
|
||||
if (!email || email === last) return;
|
||||
last = email;
|
||||
fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { show(d && d.vouchers ? d.vouchers : 0); })
|
||||
.catch(function () { show(0); });
|
||||
}
|
||||
|
||||
function bind() {
|
||||
var el = document.querySelector(EMAIL_SELECTOR);
|
||||
if (!el) { return setTimeout(bind, 500); }
|
||||
el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
|
||||
}
|
||||
bind();
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```
|
||||
curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue