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:
parent
7296555964
commit
267957d333
3 changed files with 48 additions and 51 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: <your WEBHOOK_SECRET>" \\
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue