Add public /webhook-doc page documenting the FluentForms webhook

Served at /webhook-doc: endpoint + auth header, full field table (name/email,
age brackets, ice, parking, donor, idempotency key), example JSON + curl,
response codes, and FluentForms feed setup steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 21:58:33 +00:00
parent d7fbb2a154
commit 774d00ff5b
2 changed files with 163 additions and 0 deletions

View file

@ -0,0 +1,161 @@
import type { FastifyInstance } from "fastify";
// Public documentation page for the FluentForms → /webhook integration.
const WEBHOOK_URL = "https://scan.beartariacampgrounds.com/webhook";
interface Field {
key: string;
req: "required" | "optional";
type: string;
desc: string;
}
const FIELDS: Field[] = [
{ key: "name", req: "required", type: "text", desc: "Purchaser's full name." },
{ key: "email", req: "required", type: "email", desc: "Purchaser's email — the QR ticket is sent here." },
{
key: "submission_id",
req: "optional",
type: "text/number",
desc: "Form entry/submission ID. Used for idempotency so retries or double-submits don't create duplicate tickets. If omitted, a hash of name+email+counts is used instead. (Aliases: submissionId, entry_id.)",
},
{ key: "ages_0_3", req: "optional", type: "number", desc: "Headcount ages 03. Admitted free — NOT counted toward redeemable tickets." },
{ key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 47." },
{ key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 812." },
{ key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 1317." },
{ key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 1825." },
{ key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 2645." },
{ key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 4664." },
{ key: "ages_65", req: "optional", type: "number", desc: "Headcount ages 65+." },
{ key: "ice_bags", req: "optional", type: "number", desc: "Prepaid ice bags. If omitted and ice_access is truthy, defaults to the configured amount (3)." },
{ key: "ice_access", req: "optional", type: "yes/no", desc: "Whether they bought ice access. Accepts 1/0, true/false, yes/no." },
{ key: "car_parking", req: "optional", type: "yes/no", desc: "Car parking pass." },
{ key: "rv_parking", req: "optional", type: "yes/no", desc: "RV parking pass." },
{ key: "is_donor", req: "optional", type: "yes/no", desc: "Donor flag." },
{ key: "address", req: "optional", type: "text", desc: "Mailing address." },
{ key: "payment_method", req: "optional", type: "text", desc: "Payment method label." },
];
function esc(s: string): string {
return s.replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
}
export async function webhookDocRoutes(app: FastifyInstance): Promise<void> {
app.get("/webhook-doc", async (_req, reply) => {
reply.type("text/html").send(PAGE);
});
}
const rows = FIELDS.map(
(f) => `<tr>
<td><code>${f.key}</code></td>
<td class="${f.req === "required" ? "req" : "opt"}">${f.req}</td>
<td>${f.type}</td>
<td>${esc(f.desc)}</td>
</tr>`,
).join("");
const exampleJson = esc(`{
"name": "Jane Bear",
"email": "jane@example.com",
"submission_id": "12345",
"ages_0_3": 2,
"ages_8_12": 3,
"ages_26_45": 2,
"car_parking": "yes",
"ice_access": "yes"
}`);
const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\
-H "Content-Type: application/json" \\
-H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
-d '{"name":"Jane Bear","email":"jane@example.com","submission_id":"12345","ages_26_45":2,"ice_access":"yes"}'`);
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1a12" />
<title>Camp Scan Webhook</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.55; }
.wrap { max-width: 820px; margin: 0 auto; padding: 28px 20px 64px; }
h1 { font-size: 26px; margin: 0 0 4px; }
h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; }
.sub { color: #9db3a4; margin: 0 0 8px; }
code { background: #16241a; border: 1px solid #24382a; border-radius: 6px; padding: 1px 6px; font-size: 13.5px; word-break: break-word; }
pre { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 16px; overflow-x: auto; font-size: 13px; line-height: 1.5; }
pre code { background: none; border: none; padding: 0; }
table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 14px; display: block; overflow-x: auto; }
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid #24382a; vertical-align: top; }
th { color: #9db3a4; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
td.req { color: #e0b341; font-weight: 700; }
td.opt { color: #6c8f74; }
.kv { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px 16px; margin: 12px 0; }
.kv div { margin: 4px 0; }
.pill { display: inline-block; background: #1b5e20; color: #fff; font-weight: 700; border-radius: 6px; padding: 2px 8px; font-size: 13px; }
ol li { margin: 8px 0; }
footer { text-align: center; color: #6c8f74; font-size: 12px; margin-top: 40px; }
a { color: #58d68d; }
</style>
</head>
<body>
<div class="wrap">
<h1>🐻 Camp Scan Purchase Webhook</h1>
<p class="sub">How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.</p>
<div class="kv">
<div><b>Endpoint</b>&nbsp; <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div>
<div><b>Auth header</b>&nbsp; <code>X-Webhook-Secret: &lt;the shared WEBHOOK_SECRET&gt;</code></div>
<div><b>Body format</b>&nbsp; JSON (<code>application/json</code>) or form-encoded both accepted.</div>
</div>
<h2>What it does</h2>
<p>On a valid request the backend generates a unique ticket code, creates a row in the "2026 Campground Tickets" NocoDB table, renders a QR code, and emails it to the purchaser (subject: <b>"2026 Beartaria Campgrounds Tickets"</b>). The total number of redeemable tickets is the <b>sum of the age-bracket counts, excluding ages 03</b> (who are free).</p>
<h2>Fields</h2>
<table>
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<p class="sub">At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept <code>1/0</code>, <code>true/false</code>, or <code>yes/no</code>.</p>
<h2>Idempotency</h2>
<p>Send a stable <code>submission_id</code>. If the backend sees the same one again it returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-sending email so FluentForms retries and accidental double-submits are safe.</p>
<h2>Example payload</h2>
<pre><code>${exampleJson}</code></pre>
<p class="sub">This issues 5 redeemable tickets (3×812 + 2×2645; the two 03 are free), with car parking and 3 ice bags.</p>
<h2>Test with curl</h2>
<pre><code>${exampleCurl}</code></pre>
<h2>Responses</h2>
<table>
<thead><tr><th>Status</th><th>Body</th><th>Meaning</th></tr></thead>
<tbody>
<tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr>
<tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr>
<tr><td>400</td><td><code>{"error":"missing_fields"}</code> / <code>"no_tickets"</code></td><td>Missing name/email, or no age counts.</td></tr>
<tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr>
<tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr>
</tbody>
</table>
<h2>FluentForms setup</h2>
<ol>
<li>On the ticket form: <b>Settings &amp; Integrations Webhook Add Webhook</b>.</li>
<li><b>Request URL:</b> <code>${WEBHOOK_URL}</code></li>
<li><b>Request Method:</b> <code>POST</code>&nbsp; · &nbsp;<b>Format:</b> <code>JSON</code></li>
<li><b>Request Headers:</b> add <code>X-Webhook-Secret</code> = the shared secret.</li>
<li><b>Request Body:</b> map each form field to the keys in the table above.</li>
<li>Save, then submit a test purchase and confirm the QR email arrives.</li>
</ol>
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
</div>
</body>
</html>`;