Non-food vendors get no entry ticket

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) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-16 21:57:22 +00:00
parent 7296555964
commit 267957d333
3 changed files with 48 additions and 51 deletions

View file

@ -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<void> {
// 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<void> {
// 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));
}

View file

@ -159,15 +159,15 @@ const PAGE = `<!doctype html>
</ol>
<h2>Vendor booth webhooks</h2>
<p class="sub">The two vendor forms on <b>vendors.beartariacampgrounds.com</b> post to their own endpoints (same <code>X-Webhook-Secret</code>). Each <b>named</b> booth person gets one entry pass; the booth name (<code>input_text</code>) becomes the ticket title, and the ticket is tagged with a vendor <b>Ticket Type</b> that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.</p>
<p class="sub"><b>Only food vendors receive entry tickets.</b> The vendor forms live on <b>vendors.beartariacampgrounds.com</b> and share the same <code>X-Webhook-Secret</code>. For a food booth, each <b>named</b> person gets one entry pass; the booth name (<code>input_text</code>) becomes the ticket title, and the ticket is tagged with a <b>Food Vendor</b> 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.</p>
<table>
<thead><tr><th>Form</th><th>Endpoint</th><th>Passes</th><th>Ticket Type</th></tr></thead>
<thead><tr><th>Form</th><th>Endpoint</th><th>Result</th></tr></thead>
<tbody>
<tr><td>Vendor Fee Food 2026</td><td><code>POST /vendor-webhook/food</code></td><td>up to 2 (<code>names</code> + <code>names_1</code>)</td><td>🍔 Food Vendor</td></tr>
<tr><td>Vendor Fee Non-Food 2026</td><td><code>POST /vendor-webhook/non-food</code></td><td>1 (<code>names</code>)</td><td>🛒 Vendor</td></tr>
<tr><td>Vendor Fee Food 2026</td><td><code>POST /vendor-webhook/food</code></td><td>🍔 up to 2 passes (<code>names</code> + <code>names_1</code>), Food Vendor ticket + QR email</td></tr>
<tr><td>Vendor Fee Non-Food 2026</td><td><code>POST /vendor-webhook/non-food</code></td><td>No ticket acknowledged only (<code>{"status":"ignored"}</code>). You can leave this form's webhook unconfigured.</td></tr>
</tbody>
</table>
<p class="sub">Relevant keys: <code>input_text</code> (Booth Name), <code>names</code> / <code>names_1</code> (pass-holders), <code>email</code>, <code>address_1</code>, <code>donor_tier</code> / <code>donor_eligible</code> / <code>input_radio</code> (donor), <code>payment_method</code>. Same idempotency (<code>id</code>/<code>submission_id</code>) and response shapes as above, plus a <code>passes</code> count.</p>
<p class="sub">Relevant food keys: <code>input_text</code> (Booth Name), <code>names</code> / <code>names_1</code> (pass-holders), <code>email</code>, <code>address_1</code>, <code>donor_tier</code> / <code>donor_eligible</code> / <code>input_radio</code> (donor), <code>payment_method</code>. Same idempotency (<code>id</code>/<code>submission_id</code>) and response shapes as above, plus a <code>passes</code> count.</p>
<pre><code>curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
-H "Content-Type: application/json" \\
-H "X-Webhook-Secret: &lt;your WEBHOOK_SECRET&gt;" \\

View file

@ -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<string, any>, 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);
});