diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts new file mode 100644 index 0000000..89a1b34 --- /dev/null +++ b/backend/src/routes/webhookDoc.ts @@ -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 { + app.get("/webhook-doc", async (_req, reply) => { + reply.type("text/html").send(PAGE); + }); +} + +const rows = FIELDS.map( + (f) => ` + ${f.key} + ${f.req} + ${f.type} + ${esc(f.desc)} + `, +).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: " \\ + -d '{"name":"Jane Bear","email":"jane@example.com","submission_id":"12345","ages_26_45":2,"ice_access":"yes"}'`); + +const PAGE = ` + + + + + +Camp Scan — Webhook + + + +
+

🐻 Camp Scan — Purchase Webhook

+

How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.

+ +
+
Endpoint  POST ${WEBHOOK_URL}
+
Auth header  X-Webhook-Secret: <the shared WEBHOOK_SECRET>
+
Body format  JSON (application/json) or form-encoded — both accepted.
+
+ +

What it does

+

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: "2026 Beartaria Campgrounds Tickets"). The total number of redeemable tickets is the sum of the age-bracket counts, excluding ages 0–3 (who are free).

+ +

Fields

+ + + ${rows} +
KeyRequiredTypeDescription
+

At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept 1/0, true/false, or yes/no.

+ +

Idempotency

+

Send a stable submission_id. If the backend sees the same one again it returns {"status":"duplicate"} without creating a second ticket or re-sending email — so FluentForms retries and accidental double-submits are safe.

+ +

Example payload

+
${exampleJson}
+

This issues 5 redeemable tickets (3×8–12 + 2×26–45; the two 0–3 are free), with car parking and 3 ice bags.

+ +

Test with curl

+
${exampleCurl}
+ +

Responses

+ + + + + + + + + +
StatusBodyMeaning
200{"status":"created","code":"BC26-…","emailSent":true}Ticket created and emailed.
200{"status":"duplicate","code":"BC26-…"}Same submission already processed — no-op.
400{"error":"missing_fields"} / "no_tickets"Missing name/email, or no age counts.
401{"error":"unauthorized"}Missing or wrong X-Webhook-Secret.
502{"status":"created","emailSent":false,…}Ticket row created but the email failed — re-send from the admin app.
+ +

FluentForms setup

+
    +
  1. On the ticket form: Settings & Integrations → Webhook → Add Webhook.
  2. +
  3. Request URL: ${WEBHOOK_URL}
  4. +
  5. Request Method: POST  ·  Format: JSON
  6. +
  7. Request Headers: add X-Webhook-Secret = the shared secret.
  8. +
  9. Request Body: map each form field to the keys in the table above.
  10. +
  11. Save, then submit a test purchase and confirm the QR email arrives.
  12. +
+ + +
+ +`; diff --git a/backend/src/server.ts b/backend/src/server.ts index eda5d68..1b662db 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -12,6 +12,7 @@ import { webhookRoutes } from "./routes/webhook.js"; import { ticketRoutes } from "./routes/tickets.js"; import { testRoutes } from "./routes/test.js"; import { installRoutes } from "./routes/install.js"; +import { webhookDocRoutes } from "./routes/webhookDoc.js"; export async function build() { const config = loadConfig(); @@ -32,6 +33,7 @@ export async function build() { await app.register(ticketRoutes); await app.register(testRoutes); await app.register(installRoutes); + await app.register(webhookDocRoutes); // Serve the exported Expo web build (if present) with SPA fallback. const webDir = config.WEB_DIR ?? join(process.cwd(), "web");