From 267957d333446638badb3d0a0cf221d919318518 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 16 Jul 2026 21:57:22 +0000 Subject: [PATCH] Non-food vendors get no entry ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only food vendors receive gate passes. The /vendor-webhook/non-food endpoint now acknowledges the submission ({"status":"ignored"}) and issues nothing, instead of creating a 1-pass ticket — kept as a safe no-op so an accidentally-wired FluentForms feed doesn't 404. Food webhook unchanged. Doc + tests updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/vendorWebhook.ts | 72 +++++++++++++++------------- backend/src/routes/webhookDoc.ts | 10 ++-- backend/src/test/fluentforms.test.ts | 17 ++----- 3 files changed, 48 insertions(+), 51 deletions(-) diff --git a/backend/src/routes/vendorWebhook.ts b/backend/src/routes/vendorWebhook.ts index 95eb076..86722c2 100644 --- a/backend/src/routes/vendorWebhook.ts +++ b/backend/src/routes/vendorWebhook.ts @@ -6,35 +6,31 @@ import { safeEqual, nameGroup, addressLine, readDonor } from "../fluentforms.js" /** * Vendor booth webhooks (Vendor Fee Food / Non-Food 2026, on - * vendors.beartariacampgrounds.com). Structurally these are the same form; the - * only difference is how many entry passes a booth includes: + * vendors.beartariacampgrounds.com). Only FOOD vendors receive entry tickets: * * - Food: two named pass-holders (`names` = "Name Ticket 1", - * `names_1` = "Name Ticket #2") → up to 2 passes. - * - Non-Food: one named pass-holder (`names`) → 1 pass. + * `names_1` = "Name Ticket #2") → up to 2 gate passes. + * - Non-Food: NO entry ticket. The endpoint acknowledges the submission + * (so a wired FluentForms feed doesn't error) but issues nothing. * - * Each named person gets one gate ticket. The booth name becomes the ticket - * title (so gate staff see the booth) and the pass-holders are stored as the - * attendee names. The ticket is tagged with a vendor `Ticket Type` so it shows - * a badge on scan and rolls up in the event report. Booth size / additional - * space are logistics, not admissions, so they don't affect the pass count. + * For food, each named person gets one gate ticket. The booth name becomes the + * ticket title (so gate staff see the booth) and the pass-holders are stored as + * the attendee names. The ticket is tagged with a "Food Vendor" `Ticket Type` + * so it shows a badge on scan and rolls up in the event report. Booth size / + * additional space are logistics, not admissions, so they don't affect passes. * * Shares WEBHOOK_SECRET with the attendee webhook (same X-Webhook-Secret header). */ -interface VendorKind { - ticketType: string; // badge label shown on scan - nameSlots: string[]; // pass-holder name field bases, in order +const FOOD_NAME_SLOTS = ["names", "names_1"]; // pass-holder name field bases + +function checkSecret(app: FastifyInstance, req: any): boolean { + const secret = req.headers["x-webhook-secret"]; + return typeof secret === "string" && safeEqual(secret, app.ctx.config.WEBHOOK_SECRET); } -const KINDS: Record<"food" | "nonfood", VendorKind> = { - food: { ticketType: "Food Vendor", nameSlots: ["names", "names_1"] }, - nonfood: { ticketType: "Vendor", nameSlots: ["names"] }, -}; - -function makeHandler(app: FastifyInstance, kind: VendorKind) { +function foodHandler(app: FastifyInstance) { return async (req: any, reply: any) => { - const secret = req.headers["x-webhook-secret"]; - if (typeof secret !== "string" || !safeEqual(secret, app.ctx.config.WEBHOOK_SECRET)) { + if (!checkSecret(app, req)) { return reply.code(401).send({ error: "unauthorized" }); } @@ -42,7 +38,7 @@ function makeHandler(app: FastifyInstance, kind: VendorKind) { const boothName = String(body.input_text ?? "").trim(); // Pass-holder names (non-empty slots, in order). - const passHolders = kind.nameSlots.map((b) => nameGroup(body, b)).filter(Boolean); + const passHolders = FOOD_NAME_SLOTS.map((b) => nameGroup(body, b)).filter(Boolean); const primary = passHolders[0] ?? ""; // Ticket title = booth name (most useful at the gate), else the first person. const title = boothName || primary; @@ -63,7 +59,7 @@ function makeHandler(app: FastifyInstance, kind: VendorKind) { ? `sub:${String(submissionId)}` : "hash:" + createHash("sha256") - .update(`vendor|${kind.ticketType}|${email}|${title}|${passes}`) + .update(`vendor|Food Vendor|${email}|${title}|${passes}`) .digest("hex") .slice(0, 32); @@ -79,7 +75,7 @@ function makeHandler(app: FastifyInstance, kind: VendorKind) { address, isDonor, donorTier, - ticketType: kind.ticketType, + ticketType: "Food Vendor", counts, paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined, submissionKey, @@ -121,13 +117,25 @@ function makeHandler(app: FastifyInstance, kind: VendorKind) { }; } -export async function vendorWebhookRoutes(app: FastifyInstance): Promise { - // Configure these URLs in the two FluentForms vendor forms: - // Food: https://scan.beartariacampgrounds.com/vendor-webhook/food - // Non-Food: https://scan.beartariacampgrounds.com/vendor-webhook/non-food - app.post("/vendor-webhook/food", makeHandler(app, KINDS.food)); - app.post("/vendor-webhook/non-food", makeHandler(app, KINDS.nonfood)); - // Explicit API aliases. - app.post("/api/webhook/vendor-food", makeHandler(app, KINDS.food)); - app.post("/api/webhook/vendor-non-food", makeHandler(app, KINDS.nonfood)); +/** Non-food vendors don't get an entry ticket. Acknowledge and issue nothing + * (so a wired FluentForms feed doesn't error), but never create a ticket. */ +function nonFoodHandler(app: FastifyInstance) { + return async (req: any, reply: any) => { + if (!checkSecret(app, req)) { + return reply.code(401).send({ error: "unauthorized" }); + } + return { status: "ignored", reason: "non_food_no_ticket" }; + }; +} + +export async function vendorWebhookRoutes(app: FastifyInstance): Promise { + // Configure this URL in the FOOD vendor FluentForms form: + // https://scan.beartariacampgrounds.com/vendor-webhook/food + // Non-food vendors receive no entry ticket; the endpoint below is a safe + // no-op only so an accidentally-wired feed doesn't 404. + app.post("/vendor-webhook/food", foodHandler(app)); + app.post("/vendor-webhook/non-food", nonFoodHandler(app)); + // Explicit API aliases. + app.post("/api/webhook/vendor-food", foodHandler(app)); + app.post("/api/webhook/vendor-non-food", nonFoodHandler(app)); } diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index 276062b..b5c25cb 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -159,15 +159,15 @@ const PAGE = `

Vendor booth webhooks

-

The two vendor forms on vendors.beartariacampgrounds.com post to their own endpoints (same X-Webhook-Secret). Each named booth person gets one entry pass; the booth name (input_text) becomes the ticket title, and the ticket is tagged with a vendor Ticket Type that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.

+

Only food vendors receive entry tickets. The vendor forms live on vendors.beartariacampgrounds.com and share the same X-Webhook-Secret. For a food booth, each named person gets one entry pass; the booth name (input_text) becomes the ticket title, and the ticket is tagged with a Food Vendor Ticket Type that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.

- + - - + +
FormEndpointPassesTicket Type
FormEndpointResult
Vendor Fee Food 2026POST /vendor-webhook/foodup to 2 (names + names_1)🍔 Food Vendor
Vendor Fee Non-Food 2026POST /vendor-webhook/non-food1 (names)🛒 Vendor
Vendor Fee Food 2026POST /vendor-webhook/food🍔 up to 2 passes (names + names_1), Food Vendor ticket + QR email
Vendor Fee Non-Food 2026POST /vendor-webhook/non-foodNo ticket — acknowledged only ({"status":"ignored"}). You can leave this form's webhook unconfigured.
-

Relevant keys: input_text (Booth Name), names / names_1 (pass-holders), email, address_1, donor_tier / donor_eligible / input_radio (donor), payment_method. Same idempotency (id/submission_id) and response shapes as above, plus a passes count.

+

Relevant food keys: input_text (Booth Name), names / names_1 (pass-holders), email, address_1, donor_tier / donor_eligible / input_radio (donor), payment_method. Same idempotency (id/submission_id) and response shapes as above, plus a passes count.

curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
   -H "Content-Type: application/json" \\
   -H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts
index 4f21e4b..083f3b4 100644
--- a/backend/src/test/fluentforms.test.ts
+++ b/backend/src/test/fluentforms.test.ts
@@ -1,11 +1,10 @@
 import { describe, it, expect } from "vitest";
 import { nameGroup, qty, selected, addressLine, readDonor } from "../fluentforms.js";
 
-// Pass-holder name slots per the two vendor forms.
+// Food vendors are the only vendor tickets; two pass-holder name slots.
 const FOOD_SLOTS = ["names", "names_1"];
-const NONFOOD_SLOTS = ["names"];
 
-/** Mirror the vendor handler's pass count: one per named person, min 1. */
+/** Mirror the food vendor handler's pass count: one per named person, min 1. */
 function passCount(body: Record, slots: string[]): number {
   const holders = slots.map((b) => nameGroup(body, b)).filter(Boolean);
   return Math.max(1, holders.length);
@@ -25,7 +24,7 @@ describe("nameGroup", () => {
   });
 });
 
-describe("vendor pass counting", () => {
+describe("food vendor pass counting", () => {
   it("food booth with two named holders gets 2 passes", () => {
     const body = {
       input_text: "Joe's Tacos",
@@ -40,16 +39,6 @@ describe("vendor pass counting", () => {
     const body = { input_text: "Solo BBQ", "names[first_name]": "Sam", "names[last_name]": "Que" };
     expect(passCount(body, FOOD_SLOTS)).toBe(1);
   });
-  it("non-food booth gets 1 pass (only one name slot)", () => {
-    const body = {
-      input_text: "Craft Corner",
-      "names[first_name]": "Pat",
-      "names[last_name]": "Maker",
-      // a stray names_1 must NOT count for non-food
-      "names_1[first_name]": "Ignore",
-    };
-    expect(passCount(body, NONFOOD_SLOTS)).toBe(1);
-  });
   it("booth with no names still gets 1 pass", () => {
     expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1);
   });