Add secret-gated public donor-eligibility lookup for checkout discount
GET /api/public/donor-eligibility?key=&email= returns only {eligible, tier}
(member/donor) — no names or dollar amounts — gated by PUBLIC_LOOKUP_SECRET,
rate-limited (30/min), and CORS-restricted to PUBLIC_LOOKUP_ORIGIN. Lets the
FluentForms checkout unlock a donor discount by email. Docs + ready-to-paste
form snippet in docs/fluentforms-donor-discount.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
774d00ff5b
commit
fb2fdcb6b8
4 changed files with 169 additions and 0 deletions
|
|
@ -21,6 +21,12 @@ const schema = z.object({
|
||||||
// sends a boolean (not an explicit bag count).
|
// sends a boolean (not an explicit bag count).
|
||||||
ICE_BAGS_DEFAULT: z.coerce.number().default(3),
|
ICE_BAGS_DEFAULT: z.coerce.number().default(3),
|
||||||
|
|
||||||
|
// Public donor-eligibility lookup (for the FluentForms checkout discount).
|
||||||
|
// Disabled unless a secret is set. Returns only eligibility + tier, never
|
||||||
|
// names or dollar amounts. Rate-limited + CORS-restricted.
|
||||||
|
PUBLIC_LOOKUP_SECRET: z.string().optional(),
|
||||||
|
PUBLIC_LOOKUP_ORIGIN: z.string().default("https://tickets.beartariacampgrounds.com"),
|
||||||
|
|
||||||
// Serve GET /test with sample QR codes. Seeds test personas into the current
|
// 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).
|
// NocoDB table, so keep this OFF in production (only enable against a TEST table).
|
||||||
ENABLE_TEST_PAGE: z
|
ENABLE_TEST_PAGE: z
|
||||||
|
|
|
||||||
60
backend/src/routes/publicLookup.ts
Normal file
60
backend/src/routes/publicLookup.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ba = Buffer.from(a || "");
|
||||||
|
const bb = Buffer.from(b || "");
|
||||||
|
if (ba.length !== bb.length) return false;
|
||||||
|
return timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, secret-gated donor-eligibility lookup for the FluentForms checkout.
|
||||||
|
* The form's JS calls this on email blur to decide whether to unlock a donor
|
||||||
|
* discount. Deliberately minimal: returns only { eligible, tier } — never
|
||||||
|
* names or dollar amounts — so even with the (page-source-visible) secret it
|
||||||
|
* can't leak donor financials. Rate-limited and CORS-restricted.
|
||||||
|
*/
|
||||||
|
export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
const cfg = app.ctx.config;
|
||||||
|
const origin = cfg.PUBLIC_LOOKUP_ORIGIN;
|
||||||
|
|
||||||
|
const cors = (reply: any) => {
|
||||||
|
reply.header("Access-Control-Allow-Origin", origin);
|
||||||
|
reply.header("Vary", "Origin");
|
||||||
|
reply.header("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preflight (in case the form sends one).
|
||||||
|
app.options("/api/public/donor-eligibility", async (_req, reply) => {
|
||||||
|
cors(reply);
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/api/public/donor-eligibility",
|
||||||
|
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
cors(reply);
|
||||||
|
// Disabled unless configured.
|
||||||
|
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
|
||||||
|
return reply.code(404).send({ error: "not_available" });
|
||||||
|
}
|
||||||
|
const { key, email } = (req.query ?? {}) as { key?: string; email?: string };
|
||||||
|
if (!key || !safeEqual(key, cfg.PUBLIC_LOOKUP_SECRET)) {
|
||||||
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
const addr = String(email ?? "").trim();
|
||||||
|
if (!addr) return { eligible: false, tier: null };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = await app.ctx.donors.lookup(addr);
|
||||||
|
const tier = d.found ? (d.isMember ? "member" : "donor") : null;
|
||||||
|
return { eligible: d.found, tier };
|
||||||
|
} catch {
|
||||||
|
// Fail closed — no discount rather than an error the form can't handle.
|
||||||
|
return { eligible: false, tier: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { ticketRoutes } from "./routes/tickets.js";
|
||||||
import { testRoutes } from "./routes/test.js";
|
import { testRoutes } from "./routes/test.js";
|
||||||
import { installRoutes } from "./routes/install.js";
|
import { installRoutes } from "./routes/install.js";
|
||||||
import { webhookDocRoutes } from "./routes/webhookDoc.js";
|
import { webhookDocRoutes } from "./routes/webhookDoc.js";
|
||||||
|
import { publicLookupRoutes } from "./routes/publicLookup.js";
|
||||||
|
|
||||||
export async function build() {
|
export async function build() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -34,6 +35,7 @@ export async function build() {
|
||||||
await app.register(testRoutes);
|
await app.register(testRoutes);
|
||||||
await app.register(installRoutes);
|
await app.register(installRoutes);
|
||||||
await app.register(webhookDocRoutes);
|
await app.register(webhookDocRoutes);
|
||||||
|
await app.register(publicLookupRoutes);
|
||||||
|
|
||||||
// Serve the exported Expo web build (if present) with SPA fallback.
|
// Serve the exported Expo web build (if present) with SPA fallback.
|
||||||
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");
|
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");
|
||||||
|
|
|
||||||
101
docs/fluentforms-donor-discount.md
Normal file
101
docs/fluentforms-donor-discount.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# FluentForms → donor discount lookup
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SECRET>&email=<email>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
|
||||||
|
- Returns minimal JSON — never names or dollar amounts:
|
||||||
|
- `{"eligible": true, "tier": "member"}`
|
||||||
|
- `{"eligible": true, "tier": "donor"}`
|
||||||
|
- `{"eligible": false, "tier": null}`
|
||||||
|
- Rate-limited (30/min/IP) and CORS-restricted to `PUBLIC_LOOKUP_ORIGIN`
|
||||||
|
(default `https://tickets.beartariacampgrounds.com`).
|
||||||
|
|
||||||
|
> The secret is visible in page source, so treat this as *deterrence, not
|
||||||
|
> security*. It only gates a discount and reveals a yes/no + tier, so the blast
|
||||||
|
> radius is small. Rotate the secret by changing `PUBLIC_LOOKUP_SECRET` and
|
||||||
|
> redeploying.
|
||||||
|
|
||||||
|
## Form setup
|
||||||
|
|
||||||
|
1. On the ticket form add a **Custom HTML** element (or use FluentForms Pro's
|
||||||
|
custom JS). Give your email field a known name (default FF `email`).
|
||||||
|
2. Decide the discount mechanism. Two common options:
|
||||||
|
- **Coupon:** configure a coupon in the form's payment settings; the JS
|
||||||
|
auto-fills + applies it for eligible emails.
|
||||||
|
- **Conditional price:** add a hidden field (e.g. `donor_tier`) and use
|
||||||
|
FluentForms conditional logic to show a discounted payment option when it
|
||||||
|
equals `member`/`donor`.
|
||||||
|
3. Paste the snippet below into the Custom HTML element, editing the marked
|
||||||
|
constants and the `applyDiscount()` body to match your form.
|
||||||
|
|
||||||
|
## Snippet
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="donor-status" style="margin:6px 0;font-size:14px;"></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var API = "https://scan.beartariacampgrounds.com/api/public/donor-eligibility";
|
||||||
|
var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
|
||||||
|
var EMAIL_SELECTOR = 'input[name="email"]'; // adjust to your field
|
||||||
|
var statusEl = document.getElementById("donor-status");
|
||||||
|
var lastChecked = "";
|
||||||
|
|
||||||
|
function applyDiscount(tier) {
|
||||||
|
// ── EDIT THIS to your form's discount mechanism ──────────────────
|
||||||
|
// Option A — set a hidden field that conditional logic keys off:
|
||||||
|
var hidden = document.querySelector('input[name="donor_tier"]');
|
||||||
|
if (hidden) { hidden.value = tier; hidden.dispatchEvent(new Event("change", {bubbles:true})); }
|
||||||
|
// Option B — auto-apply a coupon (uncomment + set the code):
|
||||||
|
// var coupon = document.querySelector('.ff_coupon_wrapper input[type="text"]');
|
||||||
|
// if (coupon) { coupon.value = "DONOR"; coupon.dispatchEvent(new Event("input",{bubbles:true}));
|
||||||
|
// var btn = document.querySelector('.ff_coupon_wrapper button'); if (btn) btn.click(); }
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
}
|
||||||
|
function clearDiscount() {
|
||||||
|
var hidden = document.querySelector('input[name="donor_tier"]');
|
||||||
|
if (hidden) { hidden.value = ""; hidden.dispatchEvent(new Event("change", {bubbles:true})); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(email) {
|
||||||
|
if (!email || email === lastChecked) return;
|
||||||
|
lastChecked = email;
|
||||||
|
statusEl.textContent = "Checking donor status…";
|
||||||
|
fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d && d.eligible) {
|
||||||
|
statusEl.textContent = (d.tier === "member" ? "🐻 Member" : "⭐ Donor") + " discount applied!";
|
||||||
|
statusEl.style.color = "#1b7f3b";
|
||||||
|
applyDiscount(d.tier);
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = "";
|
||||||
|
clearDiscount();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { statusEl.textContent = ""; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
var el = document.querySelector(EMAIL_SELECTOR);
|
||||||
|
if (!el) { return setTimeout(bind, 500); } // form may render late
|
||||||
|
el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
|
||||||
|
}
|
||||||
|
bind();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```
|
||||||
|
curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SECRET>&email=adam21stevens@gmail.com"
|
||||||
|
# -> {"eligible":true,"tier":"member"}
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue