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:
parent
d7fbb2a154
commit
774d00ff5b
2 changed files with 163 additions and 0 deletions
161
backend/src/routes/webhookDoc.ts
Normal file
161
backend/src/routes/webhookDoc.ts
Normal 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 0–3. Admitted free — NOT counted toward redeemable tickets." },
|
||||||
|
{ key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 4–7." },
|
||||||
|
{ key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 8–12." },
|
||||||
|
{ key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 13–17." },
|
||||||
|
{ key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 18–25." },
|
||||||
|
{ key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 26–45." },
|
||||||
|
{ key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 46–64." },
|
||||||
|
{ 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 === "&" ? "&" : c === "<" ? "<" : ">"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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> <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div>
|
||||||
|
<div><b>Auth header</b> <code>X-Webhook-Secret: <the shared WEBHOOK_SECRET></code></div>
|
||||||
|
<div><b>Body format</b> 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 0–3</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×8–12 + 2×26–45; the two 0–3 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 & Integrations → Webhook → Add Webhook</b>.</li>
|
||||||
|
<li><b>Request URL:</b> <code>${WEBHOOK_URL}</code></li>
|
||||||
|
<li><b>Request Method:</b> <code>POST</code> · <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>`;
|
||||||
|
|
@ -12,6 +12,7 @@ import { webhookRoutes } from "./routes/webhook.js";
|
||||||
import { ticketRoutes } from "./routes/tickets.js";
|
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";
|
||||||
|
|
||||||
export async function build() {
|
export async function build() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
@ -32,6 +33,7 @@ export async function build() {
|
||||||
await app.register(ticketRoutes);
|
await app.register(ticketRoutes);
|
||||||
await app.register(testRoutes);
|
await app.register(testRoutes);
|
||||||
await app.register(installRoutes);
|
await app.register(installRoutes);
|
||||||
|
await app.register(webhookDocRoutes);
|
||||||
|
|
||||||
// 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");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue