From b4e964b3a6edebd884eb48914698a3dc6a9796b5 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:38:00 +0000 Subject: [PATCH 1/4] Give survey finishers a free sticker claim link Each saved survey claims one random one-time Shopify code, at most one per normalized email, in the same D1 batch as the answers. The thank-you view links to the store's cart with the code applied and the email filled in. BB-Thread-ID: thr_yz6t8hi7s8 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/community/handler.test.ts | 88 +++++++++++++++++---- apps/web/src/community/handler.ts | 58 ++++++++++++-- apps/web/src/community/survey-page.test.tsx | 14 +++- apps/web/src/community/survey-page.tsx | 21 ++++- apps/web/src/community/survey.ts | 15 +++- apps/web/src/server/env.ts | 1 + apps/web/wrangler.jsonc | 2 + docs/community/forms.md | 16 +++- 8 files changed, 184 insertions(+), 31 deletions(-) diff --git a/apps/web/src/community/handler.test.ts b/apps/web/src/community/handler.test.ts index 4bbf225b087..7ed2bacc079 100644 --- a/apps/web/src/community/handler.test.ts +++ b/apps/web/src/community/handler.test.ts @@ -1,12 +1,19 @@ import { Miniflare } from "miniflare"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { applyConnectDbMigrationsToD1 } from "@bb/connect-db/testing"; -import { handleSurveySubmission, rateLimitKey } from "./handler"; -import { surveyReply } from "./survey"; +import { emailKey, handleSurveySubmission, rateLimitKey } from "./handler"; +import { + ALREADY_CLAIMED, + CLAIM_NOT_OPEN, + SOLD_OUT, + surveyReply, + type SurveyReply, +} from "./survey"; let runtime: Miniflare; let db: D1Database; const origin = "https://community.test"; +const claimBase = "https://test-shop.myshopify.com/cart/123:1"; const defaults = { discovery: "GitHub", role: "Developer or engineer", @@ -21,6 +28,7 @@ const defaults = { const enabled = { COMMUNITY_FORMS_ENABLED: "true", COMMUNITY_FORM_RATE_LIMITER: { limit: async () => ({ success: true }) }, + COMMUNITY_STICKER_CLAIM_URL: claimBase, }; const request = (body: string, headers: Record = {}) => new Request(`${origin}/api/community/survey`, { @@ -51,6 +59,8 @@ const post = async ( body: surveyReply.parse(await response.json()), }; }; +const codeFrom = (body: SurveyReply) => + body.result?.claimLink?.match(/\?discount=([A-Z0-9]+)/)?.[1]; const count = (sql: string) => db.prepare(`SELECT COUNT(*) AS n FROM ${sql}`).first("n"); beforeAll(async () => { @@ -64,7 +74,14 @@ beforeAll(async () => { await applyConnectDbMigrationsToD1(db); }); beforeEach(async () => { - await db.batch([db.prepare("DELETE FROM community_survey_response")]); + await db.batch([ + db.prepare("DELETE FROM community_survey_response"), + db.prepare("DELETE FROM community_survey_email"), + db.prepare("DELETE FROM community_gift_code"), + db.prepare( + "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 10) INSERT INTO community_gift_code (code) SELECT printf('CODE%04d', i) FROM n", + ), + ]); }); afterAll(async () => { await runtime?.dispose(); @@ -143,7 +160,7 @@ describe("survey submissions", () => { } }); - it("saves only visible answers once per submission", async () => { + it("saves only visible answers and gives one claim link per submission", async () => { const id = crypto.randomUUID(); const fields = { work: "Just me", @@ -155,9 +172,10 @@ describe("survey submissions", () => { "Build things with other bb users", ], }; - expect((await post(fields, {}, id)).body).toEqual({ - result: { duplicate: false }, - }); + const { body } = await post(fields, {}, id); + expect(body.result?.claimLink).toBe( + `${claimBase}?discount=${codeFrom(body)}&checkout%5Bemail%5D=ada%40example.test`, + ); const stored = await db .prepare("SELECT answers FROM community_survey_response WHERE id = ?") .bind(id) @@ -176,23 +194,65 @@ describe("survey submissions", () => { ), ); expect((await post(fields, {}, id)).body).toEqual({ - result: { duplicate: true }, + result: { duplicate: true, claimLink: null, claimNote: null }, }); expect(await count("community_survey_response")).toBe(1); + expect(await count("community_gift_code WHERE claimed = 1")).toBe(1); + }); + + it("gives each email one code, ignoring case and +tags", async () => { + expect(codeFrom((await post()).body)).toBeDefined(); + for (const email of ["Ada@Example.test", " ada+again@example.test "]) { + const { body } = await post({ email }); + expect(body.result).toEqual({ + duplicate: false, + claimLink: null, + claimNote: ALREADY_CLAIMED, + }); + } + expect( + codeFrom((await post({ email: "grace@example.test" })).body), + ).toBeDefined(); + expect(await count("community_survey_response")).toBe(4); + expect(await count("community_gift_code WHERE claimed = 1")).toBe(2); + expect(emailKey(" Ada+Tag@Example.TEST ")).toBe("ada@example.test"); + }); + + it("hands out the last code once and still saves answers when codes run out", async () => { + await db + .prepare( + "UPDATE community_gift_code SET claimed = 1 WHERE code != 'CODE0001'", + ) + .run(); + const replies = await Promise.all( + [0, 1, 2, 3].map((index) => post({ email: `ada${index}@example.test` })), + ); + expect(replies.map(({ body }) => codeFrom(body)).filter(Boolean)).toEqual([ + "CODE0001", + ]); + expect( + replies.filter(({ body }) => body.result?.claimNote === SOLD_OUT), + ).toHaveLength(3); + expect(await count("community_survey_response")).toBe(4); + }); + + it("keeps codes while claiming is off", async () => { + const { body } = await post({}, { COMMUNITY_STICKER_CLAIM_URL: "" }); + expect(body.result?.claimNote).toBe(CLAIM_NOT_OPEN); + expect(await count("community_gift_code WHERE claimed = 1")).toBe(0); }); - it("reports a save failure so the page can keep the answers", async () => { + it("rolls back answers when claiming a code fails", async () => { await db .prepare( - "CREATE TRIGGER reject_response BEFORE INSERT ON community_survey_response BEGIN SELECT RAISE(ABORT, 'unavailable'); END", + "CREATE TRIGGER reject_code BEFORE UPDATE ON community_gift_code BEGIN SELECT RAISE(ABORT, 'unavailable'); END", ) .run(); try { - const { status, body } = await post(); - expect(status).toBe(503); - expect(body.error).toContain("couldn't save"); + expect((await post()).status).toBe(503); + expect(await count("community_survey_response")).toBe(0); } finally { - await db.prepare("DROP TRIGGER reject_response").run(); + await db.prepare("DROP TRIGGER reject_code").run(); } }); diff --git a/apps/web/src/community/handler.ts b/apps/web/src/community/handler.ts index 941ba893f41..c5b07577fd9 100644 --- a/apps/web/src/community/handler.ts +++ b/apps/web/src/community/handler.ts @@ -1,6 +1,10 @@ +import { sha256Hex } from "@bb/connect-db"; import { z } from "zod"; import type { Env } from "../server/env"; import { + ALREADY_CLAIMED, + CLAIM_NOT_OPEN, + SOLD_OUT, surveyAnswers, surveyFields, validateSurvey, @@ -9,7 +13,10 @@ import { type CommunityEnv = Pick< Env, - "DB" | "COMMUNITY_FORMS_ENABLED" | "COMMUNITY_FORM_RATE_LIMITER" + | "DB" + | "COMMUNITY_FORMS_ENABLED" + | "COMMUNITY_STICKER_CLAIM_URL" + | "COMMUNITY_FORM_RATE_LIMITER" >; const MAX_BODY_BYTES = 16384; @@ -22,6 +29,19 @@ const submission = z.object({ ), }); +export function emailKey(email: string): string { + const normalized = email.trim().toLowerCase(); + const at = normalized.lastIndexOf("@"); + return `${normalized.slice(0, at).split("+")[0]}${normalized.slice(at)}`; +} + +function claimUrl(base: string, code: string, email: string): string { + const url = new URL(base); + url.searchParams.set("discount", code); + url.searchParams.set("checkout[email]", email); + return url.href; +} + export function rateLimitKey(ip: string | null): string { if (!ip) return "local-preview"; if (!ip.includes(":")) return ip; @@ -128,13 +148,37 @@ export async function handleSurveySubmission( const { submission_id: id, answers: values } = read; const errors = validateSurvey(values); if (Object.keys(errors).length) return reply({ errors }, 400); + const email = values.email?.[0] ?? ""; + const claimBase = env.COMMUNITY_STICKER_CLAIM_URL; try { - const response = await env.DB.prepare( - "INSERT INTO community_survey_response (id, created_at, answers) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING", - ) - .bind(id, Date.now(), JSON.stringify(surveyAnswers(values))) - .run(); - return reply({ result: { duplicate: !response.meta.changes } }); + const [response, emailRow, allocation] = await env.DB.batch([ + env.DB.prepare( + "INSERT INTO community_survey_response (id, created_at, answers) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING", + ).bind(id, Date.now(), JSON.stringify(surveyAnswers(values))), + env.DB.prepare( + "INSERT INTO community_survey_email (email_hash) SELECT ? WHERE changes() = 1 ON CONFLICT(email_hash) DO NOTHING", + ).bind(await sha256Hex(emailKey(email))), + env.DB.prepare( + "UPDATE community_gift_code SET claimed = 1 WHERE code = (SELECT code FROM community_gift_code WHERE claimed = 0 ORDER BY random() LIMIT 1) AND changes() = 1 AND ? RETURNING code", + ).bind(claimBase ? 1 : 0), + ]); + const duplicate = !response?.meta.changes; + const code = z + .object({ code: z.string() }) + .safeParse(allocation?.results[0]); + const claimLink = + claimBase && code.success + ? claimUrl(claimBase, code.data.code, email) + : null; + const claimNote = + duplicate || claimLink + ? null + : !emailRow?.meta.changes + ? ALREADY_CLAIMED + : claimBase + ? SOLD_OUT + : CLAIM_NOT_OPEN; + return reply({ result: { duplicate, claimLink, claimNote } }); } catch { return reply( { error: "We couldn't save your response. Please try again." }, diff --git a/apps/web/src/community/survey-page.test.tsx b/apps/web/src/community/survey-page.test.tsx index f4b2ef36fa8..741a8570857 100644 --- a/apps/web/src/community/survey-page.test.tsx +++ b/apps/web/src/community/survey-page.test.tsx @@ -94,9 +94,15 @@ describe("survey page", () => { expect(field('[name="community_other"]')).toBeNull(); }); - it("sends visible answers once, then focuses the thanks and clears progress", async () => { + it("sends visible answers once, then shows the claim link, focuses the thanks and clears progress", async () => { const fetch = vi.fn(async (_url: string, _init: RequestInit) => - Response.json({ result: { duplicate: false } }), + Response.json({ + result: { + duplicate: false, + claimLink: "https://shop.example/cart/1:1?discount=CODE", + claimNote: null, + }, + }), ); vi.stubGlobal("fetch", fetch); vi.stubGlobal("scrollTo", () => {}); @@ -107,7 +113,9 @@ describe("survey page", () => { expect(fetch).toHaveBeenCalledOnce(); const body = JSON.parse(fetch.mock.calls[0]![1].body as string); expect(body.answers.work).toBeUndefined(); - expect(field("main")!.textContent).toContain("We got your answers."); + expect(field("main a.btn")!.href).toBe( + "https://shop.example/cart/1:1?discount=CODE", + ); expect(document.activeElement).toBe(field(".community-thanks")); expect(localStorage.getItem(key)).toBeNull(); }); diff --git a/apps/web/src/community/survey-page.tsx b/apps/web/src/community/survey-page.tsx index 58f4179d495..c59c8a5bf71 100644 --- a/apps/web/src/community/survey-page.tsx +++ b/apps/web/src/community/survey-page.tsx @@ -241,11 +241,24 @@ export function SurveyPage({ ref={thanks} >

- Thanks!{" "} - {result.duplicate - ? "We already have your answers." - : "We got your answers."} + Thanks! + {result.duplicate ? " We already have your answers." : ""}

+ {!result.duplicate && ( +

+ {result.claimLink ? ( + + Claim my bb stickers + + ) : ( + result.claimNote + )} +

+ )} ) : ( <> diff --git a/apps/web/src/community/survey.ts b/apps/web/src/community/survey.ts index 094f97a8c01..1d776b452d4 100644 --- a/apps/web/src/community/survey.ts +++ b/apps/web/src/community/survey.ts @@ -14,6 +14,13 @@ export function questionVisible(name: string, values: FormValues): boolean { return true; } +export const SOLD_OUT = + "We've given out all the stickers for now. Thank you for answering."; +export const CLAIM_NOT_OPEN = + "Sticker claims aren't open yet. We'll post in Discord when they are."; +export const ALREADY_CLAIMED = + "This email already has a sticker code. Thank you for answering again!"; + type Question = { name: string; label: string; @@ -143,7 +150,13 @@ export const formValues = z.object({}).catchall(z.array(z.string())); export type FormValues = z.infer; export type FormErrors = Record; export const surveyReply = z.object({ - result: z.object({ duplicate: z.literal([true, false]) }).optional(), + result: z + .object({ + duplicate: z.literal([true, false]), + claimLink: z.string().nullable(), + claimNote: z.string().nullable(), + }) + .optional(), errors: z.object({}).catchall(z.string()).optional(), error: z.string().optional(), }); diff --git a/apps/web/src/server/env.ts b/apps/web/src/server/env.ts index 67bc75a991c..e07932e9ab8 100644 --- a/apps/web/src/server/env.ts +++ b/apps/web/src/server/env.ts @@ -3,6 +3,7 @@ import { env as workerEnv } from "cloudflare:workers"; export interface Env { DB: D1Database; COMMUNITY_FORMS_ENABLED?: string; + COMMUNITY_STICKER_CLAIM_URL?: string; COMMUNITY_FORM_RATE_LIMITER?: RateLimit; TUNNEL_DO: DurableObjectNamespace; LINK_START_RATE_LIMITER: RateLimit; diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 8e6592f9cd7..3bc3fb8a802 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -37,6 +37,7 @@ "RESEND_AUDIENCE_ID": "299db4ee-f3db-476d-9847-0bf4344d9c44", "PRESENCE_WRITES": "on-change", "COMMUNITY_FORMS_ENABLED": "false", + "COMMUNITY_STICKER_CLAIM_URL": "", "NEXT_BASE_DOMAIN": "next.vibecodethis.site", "GATE_PUBLIC_KEYS": "{\"keys\":[{\"kid\":\"staging-2026-10-20\",\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"R5uinj0FFjpFtS9QHKb7LY7wsPo9RZkoRRsxegheihw\"}]}", }, @@ -114,6 +115,7 @@ "APP_URL": "https://getbb.app", "PRESENCE_WRITES": "on-change", "COMMUNITY_FORMS_ENABLED": "false", + "COMMUNITY_STICKER_CLAIM_URL": "https://y1f5zj-bk.myshopify.com/cart/67856686448953:1", "NEXT_BASE_DOMAIN": "next.getbb.app", "GATE_PUBLIC_KEYS": "{\"keys\":[{\"kid\":\"prod-2026-10-21\",\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"sSHHgGnUQNXtsJ2_4EDXj4ZpIq9y45fZDvMGkKsWnGU\"}]}", "RESEND_AUDIENCE_ID": "299db4ee-f3db-476d-9847-0bf4344d9c44", diff --git a/docs/community/forms.md b/docs/community/forms.md index 2907cbbe519..2e46c882bce 100644 --- a/docs/community/forms.md +++ b/docs/community/forms.md @@ -6,6 +6,8 @@ Collection defaults to off in both environments. `COMMUNITY_FORMS_ENABLED=true` enables survey submissions. Keep it off until the copy, data handling and exact build pass review. Apply the generated connect-db migrations before enabling collection. +`COMMUNITY_STICKER_CLAIM_URL` is empty in staging and set to the store's sticker-pack cart permalink in production. Before enabling collection, load the unused Shopify codes with `INSERT INTO community_gift_code (code) VALUES (...)` through `wrangler d1 execute DB --remote --file `, keeping the SQL file outside the repository, and leave out any code already used in a test order. + The shared review preview keeps collection off. Local synthetic QA may override these values against a fresh local D1 database; never use production bindings or real addresses for QA. ## Survey submission contract @@ -22,10 +24,20 @@ Q1 shuffles every choice except “I don't remember” and “Other”, which st The page saves progress, including email, in `localStorage` under `bb-community-survey`, restores it on a later visit, and removes it once the answers are saved. -Requests must be JSON, which cross-site forms can't send without a CORS preflight the route never allows. A 16 KiB body limit (checked against `Content-Length` when sent, and counted in bytes while the body streams, which stops reading past the limit) and six submissions per minute per client apply; IPv4 clients are keyed by address and IPv6 clients by their /64 network. Unknown fields are rejected. Success returns `{ "result": { "duplicate": false } }`. Validation returns HTTP 400 with `errors` by field, an unreadable body returns 400, collection-off or storage failure returns 503, and rate limiting returns 429, each with an `error` message. The page keeps the answers on screen after any failure. +Requests must be JSON, which cross-site forms can't send without a CORS preflight the route never allows. A 16 KiB body limit (checked against `Content-Length` when sent, and counted in bytes while the body streams, which stops reading past the limit) and six submissions per minute per client apply; IPv4 clients are keyed by address and IPv6 clients by their /64 network. Unknown fields are rejected. Success returns `{ "result": { "duplicate", "claimLink", "claimNote" } }`. Validation returns HTTP 400 with `errors` by field, an unreadable body returns 400, collection-off or storage failure returns 503, and rate limiting returns 429, each with an `error` message. The page keeps the answers on screen after any failure. + +## Gifts and sticker codes + +The gift is two bb stickers, claimed through a Shopify store. `COMMUNITY_STICKER_CLAIM_URL` is the store's cart permalink for one sticker pack, for example `https://.myshopify.com/cart/:1`. While it is empty, finishers see “Sticker claims aren't open yet.” instead of a link. + +`community_gift_code` holds one-time Shopify discount codes that each make one pack free. The codes are generated in Shopify and loaded into the production database out of band; never commit them. A successful survey save atomically marks one random unclaimed code as claimed, at most one per email. Email is compared lowercased and trimmed with any `+tag` removed; a repeat email saves its answers and gets “This email already has a sticker code.” as `claimNote`. `community_survey_email` keeps only a SHA-256 of that normalized email. When no unclaimed code remains, `claimNote` is “We've given out all the stickers for now. Thank you for answering.” A duplicate submission UUID cannot claim another code. + +`claimLink` is the claim URL with `discount=` and `checkout[email]=`, so checkout opens with the pack, the code applied, a $0 total and the email filled in. The thank-you view shows it once. + +The store must not accept payments, so a cart without a code or with more than one pack cannot be completed. Shopify keeps each order's name and mailing address; fulfill orders from Shopify admin and mark them fulfilled there. bb never stores addresses. ## Privacy -Survey responses contain a random submission ID, creation time and answers, including email. Email is used for stickers and results, never a marketing list. Do not add request-body logging, analytics, or a join between these datasets. Infrastructure may still handle request metadata; review logging before launch. IP addresses are used by the short-lived rate limiter, not stored with answers. +Survey responses contain a random submission ID, creation time and answers, including email. Email is used for stickers and results, never a marketing list. `community_gift_code` contains only a code and a claimed flag: no survey ID, answer, contact detail or timestamp. Do not add request-body logging, analytics, or a join between these datasets. Infrastructure may still handle request metadata; review logging before launch. IP addresses are used by the short-lived rate limiter, not stored with answers. Choose a survey closing date and response-deletion date before launch. Delete raw survey responses when no longer needed and retain anonymous aggregate findings. Database backups can retain deleted survey answers under the provider's recovery policy. From e5bae23618e4de31fa4015a21f29d067b84d5afb Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:00:39 +0000 Subject: [PATCH 2/4] Create each survey sticker code in Shopify instead of loading a list The save batch now reserves a claim with a random stored code, under a pack limit and one claim per normalized email, then creates a one-time discount for that code through the Admin API. A claim Shopify can't create stays pending. Nothing has to be loaded into the database, and the deploy workflow syncs the Shopify app credentials when they exist. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy-web.yml | 14 ++ apps/web/src/community/handler.test.ts | 149 ++++++++++++++---- apps/web/src/community/handler.ts | 91 +++++++---- apps/web/src/community/shopify.ts | 206 +++++++++++++++++++++++++ apps/web/src/community/survey.ts | 1 + apps/web/src/server/env.ts | 6 +- apps/web/wrangler.jsonc | 8 +- docs/community/forms.md | 12 +- 8 files changed, 421 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/community/shopify.ts diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index e5c596d6de2..060fbef582e 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -107,6 +107,20 @@ jobs: fi printf '%s' "$RESEND_API_KEY" | pnpm --filter @bb/web exec wrangler secret put RESEND_API_KEY --name bb-web + - name: Sync survey sticker secrets + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + SHOPIFY_CLIENT_ID: ${{ secrets.SHOPIFY_CLIENT_ID }} + SHOPIFY_CLIENT_SECRET: ${{ secrets.SHOPIFY_CLIENT_SECRET }} + run: | + for name in SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET; do + if [ -z "${!name}" ]; then + echo "$name is not set; survey sticker claims stay closed." + continue + fi + printf '%s' "${!name}" | pnpm --filter @bb/web exec wrangler secret put "$name" --name bb-web + done + # Apply any pending connect-db migrations BEFORE the new worker ships so # it never queries columns prod D1 doesn't have yet. Runs off the gate's # wrangler config (which declares migrations_dir); the d1_migrations diff --git a/apps/web/src/community/handler.test.ts b/apps/web/src/community/handler.test.ts index 7ed2bacc079..d9d0b877e10 100644 --- a/apps/web/src/community/handler.test.ts +++ b/apps/web/src/community/handler.test.ts @@ -1,10 +1,21 @@ import { Miniflare } from "miniflare"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { applyConnectDbMigrationsToD1 } from "@bb/connect-db/testing"; import { emailKey, handleSurveySubmission, rateLimitKey } from "./handler"; +import { resetShopifyTokenCache } from "./shopify"; import { ALREADY_CLAIMED, CLAIM_NOT_OPEN, + CLAIM_PENDING, SOLD_OUT, surveyReply, type SurveyReply, @@ -14,6 +25,48 @@ let runtime: Miniflare; let db: D1Database; const origin = "https://community.test"; const claimBase = "https://test-shop.myshopify.com/cart/123:1"; +const shopify = { + codes: new Set(), + tokenRequests: 0, + down: false, + loseReply: false, +}; +async function fakeShopify(url: string, init: RequestInit) { + if (url.endsWith("/admin/oauth/access_token")) { + shopify.tokenRequests++; + return Response.json({ access_token: "token", expires_in: 86399 }); + } + if (shopify.down) return new Response("unavailable", { status: 503 }); + const { query, variables } = JSON.parse(String(init.body)); + if (query.includes("codeDiscountNodeByCode")) + return Response.json({ + data: { + codeDiscountNodeByCode: shopify.codes.has(variables.code) + ? { id: "gid://shopify/DiscountCodeNode/1" } + : null, + }, + }); + const code = variables.discount.code; + if (shopify.codes.has(code)) + return Response.json({ + data: { + discountCodeBasicCreate: { + codeDiscountNode: null, + userErrors: [{ code: "TAKEN" }], + }, + }, + }); + shopify.codes.add(code); + if (shopify.loseReply) return new Response("timeout", { status: 504 }); + return Response.json({ + data: { + discountCodeBasicCreate: { + codeDiscountNode: { id: "gid://shopify/DiscountCodeNode/2" }, + userErrors: [], + }, + }, + }); +} const defaults = { discovery: "GitHub", role: "Developer or engineer", @@ -28,7 +81,11 @@ const defaults = { const enabled = { COMMUNITY_FORMS_ENABLED: "true", COMMUNITY_FORM_RATE_LIMITER: { limit: async () => ({ success: true }) }, - COMMUNITY_STICKER_CLAIM_URL: claimBase, + COMMUNITY_SHOPIFY_STORE: "test-shop.myshopify.com", + COMMUNITY_STICKER_VARIANT_ID: "123", + COMMUNITY_STICKER_LIMIT: "1000", + SHOPIFY_CLIENT_ID: "client", + SHOPIFY_CLIENT_SECRET: "secret", }; const request = (body: string, headers: Record = {}) => new Request(`${origin}/api/community/survey`, { @@ -60,7 +117,7 @@ const post = async ( }; }; const codeFrom = (body: SurveyReply) => - body.result?.claimLink?.match(/\?discount=([A-Z0-9]+)/)?.[1]; + body.result?.claimLink?.match(/\?discount=(BB-[A-Z2-7]{12})/)?.[1]; const count = (sql: string) => db.prepare(`SELECT COUNT(*) AS n FROM ${sql}`).first("n"); beforeAll(async () => { @@ -77,11 +134,17 @@ beforeEach(async () => { await db.batch([ db.prepare("DELETE FROM community_survey_response"), db.prepare("DELETE FROM community_survey_email"), - db.prepare("DELETE FROM community_gift_code"), - db.prepare( - "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 10) INSERT INTO community_gift_code (code) SELECT printf('CODE%04d', i) FROM n", - ), + db.prepare("DELETE FROM community_sticker_claim"), ]); + Object.assign(shopify, { tokenRequests: 0, down: false, loseReply: false }); + shopify.codes.clear(); + resetShopifyTokenCache(); + vi.stubGlobal("fetch", fakeShopify); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); afterAll(async () => { await runtime?.dispose(); @@ -193,11 +256,14 @@ describe("survey submissions", () => { ]), ), ); + expect(shopify.codes).toEqual(new Set([codeFrom(body)])); expect((await post(fields, {}, id)).body).toEqual({ result: { duplicate: true, claimLink: null, claimNote: null }, }); expect(await count("community_survey_response")).toBe(1); - expect(await count("community_gift_code WHERE claimed = 1")).toBe(1); + expect( + await count("community_sticker_claim WHERE status = 'created'"), + ).toBe(1); }); it("gives each email one code, ignoring case and +tags", async () => { @@ -214,45 +280,76 @@ describe("survey submissions", () => { codeFrom((await post({ email: "grace@example.test" })).body), ).toBeDefined(); expect(await count("community_survey_response")).toBe(4); - expect(await count("community_gift_code WHERE claimed = 1")).toBe(2); + expect(await count("community_sticker_claim")).toBe(2); + expect(shopify.tokenRequests).toBe(1); expect(emailKey(" Ada+Tag@Example.TEST ")).toBe("ada@example.test"); }); - it("hands out the last code once and still saves answers when codes run out", async () => { - await db - .prepare( - "UPDATE community_gift_code SET claimed = 1 WHERE code != 'CODE0001'", - ) - .run(); + it("reserves the last claim once and still saves answers at the limit", async () => { const replies = await Promise.all( - [0, 1, 2, 3].map((index) => post({ email: `ada${index}@example.test` })), + [0, 1, 2, 3].map((index) => + post( + { email: `ada${index}@example.test` }, + { COMMUNITY_STICKER_LIMIT: "1" }, + ), + ), ); - expect(replies.map(({ body }) => codeFrom(body)).filter(Boolean)).toEqual([ - "CODE0001", - ]); + expect( + replies.map(({ body }) => codeFrom(body)).filter(Boolean), + ).toHaveLength(1); expect( replies.filter(({ body }) => body.result?.claimNote === SOLD_OUT), ).toHaveLength(3); expect(await count("community_survey_response")).toBe(4); + expect(await count("community_sticker_claim")).toBe(1); + }); + + it("keeps an email eligible while claims are closed", async () => { + const closed = await post({}, { SHOPIFY_CLIENT_SECRET: "" }); + expect(closed.body.result?.claimNote).toBe(CLAIM_NOT_OPEN); + expect(await count("community_sticker_claim")).toBe(0); + expect(await count("community_survey_email")).toBe(0); + expect(codeFrom((await post()).body)).toBeDefined(); }); - it("keeps codes while claiming is off", async () => { - const { body } = await post({}, { COMMUNITY_STICKER_CLAIM_URL: "" }); - expect(body.result?.claimNote).toBe(CLAIM_NOT_OPEN); - expect(await count("community_gift_code WHERE claimed = 1")).toBe(0); + it("keeps the claim pending when Shopify is unavailable", async () => { + shopify.down = true; + const { body } = await post(); + expect(body.result).toEqual({ + duplicate: false, + claimLink: null, + claimNote: CLAIM_PENDING, + }); + expect( + await count( + "community_sticker_claim WHERE status = 'pending' AND attempts = 1", + ), + ).toBe(1); + expect(shopify.codes.size).toBe(0); + }); + + it("treats a code Shopify created before losing the reply as created", async () => { + shopify.loseReply = true; + const { body } = await post(); + expect(codeFrom(body)).toBeDefined(); + expect(shopify.codes.size).toBe(1); + expect( + await count("community_sticker_claim WHERE status = 'created'"), + ).toBe(1); }); - it("rolls back answers when claiming a code fails", async () => { + it("rolls back answers when reserving a claim fails", async () => { await db .prepare( - "CREATE TRIGGER reject_code BEFORE UPDATE ON community_gift_code BEGIN SELECT RAISE(ABORT, 'unavailable'); END", + "CREATE TRIGGER reject_claim BEFORE INSERT ON community_sticker_claim BEGIN SELECT RAISE(ABORT, 'unavailable'); END", ) .run(); try { expect((await post()).status).toBe(503); expect(await count("community_survey_response")).toBe(0); + expect(shopify.codes.size).toBe(0); } finally { - await db.prepare("DROP TRIGGER reject_code").run(); + await db.prepare("DROP TRIGGER reject_claim").run(); } }); diff --git a/apps/web/src/community/handler.ts b/apps/web/src/community/handler.ts index c5b07577fd9..ea450a18658 100644 --- a/apps/web/src/community/handler.ts +++ b/apps/web/src/community/handler.ts @@ -1,9 +1,17 @@ import { sha256Hex } from "@bb/connect-db"; import { z } from "zod"; import type { Env } from "../server/env"; +import { + claimUrl, + createStickerDiscount, + newStickerCode, + shopifyConfig, + type ShopifyEnv, +} from "./shopify"; import { ALREADY_CLAIMED, CLAIM_NOT_OPEN, + CLAIM_PENDING, SOLD_OUT, surveyAnswers, surveyFields, @@ -11,13 +19,14 @@ import { type SurveyReply, } from "./survey"; -type CommunityEnv = Pick< - Env, - | "DB" - | "COMMUNITY_FORMS_ENABLED" - | "COMMUNITY_STICKER_CLAIM_URL" - | "COMMUNITY_FORM_RATE_LIMITER" ->; +type CommunityEnv = ShopifyEnv & + Pick< + Env, + | "DB" + | "COMMUNITY_FORMS_ENABLED" + | "COMMUNITY_STICKER_LIMIT" + | "COMMUNITY_FORM_RATE_LIMITER" + >; const MAX_BODY_BYTES = 16384; const submission = z.object({ @@ -35,13 +44,6 @@ export function emailKey(email: string): string { return `${normalized.slice(0, at).split("+")[0]}${normalized.slice(at)}`; } -function claimUrl(base: string, code: string, email: string): string { - const url = new URL(base); - url.searchParams.set("discount", code); - url.searchParams.set("checkout[email]", email); - return url.href; -} - export function rateLimitKey(ip: string | null): string { if (!ip) return "local-preview"; if (!ip.includes(":")) return ip; @@ -149,35 +151,60 @@ export async function handleSurveySubmission( const errors = validateSurvey(values); if (Object.keys(errors).length) return reply({ errors }, 400); const email = values.email?.[0] ?? ""; - const claimBase = env.COMMUNITY_STICKER_CLAIM_URL; + const config = shopifyConfig(env); + const stickerLimit = + Number.parseInt(env.COMMUNITY_STICKER_LIMIT ?? "", 10) || 0; + const emailHash = await sha256Hex(emailKey(email)); + const code = newStickerCode(); + const now = Date.now(); try { - const [response, emailRow, allocation] = await env.DB.batch([ + const [existing, response, claim] = await env.DB.batch([ + env.DB.prepare( + "SELECT EXISTS(SELECT 1 FROM community_survey_email WHERE email_hash = ?) AS claimed", + ).bind(emailHash), env.DB.prepare( "INSERT INTO community_survey_response (id, created_at, answers) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING", - ).bind(id, Date.now(), JSON.stringify(surveyAnswers(values))), + ).bind(id, now, JSON.stringify(surveyAnswers(values))), env.DB.prepare( - "INSERT INTO community_survey_email (email_hash) SELECT ? WHERE changes() = 1 ON CONFLICT(email_hash) DO NOTHING", - ).bind(await sha256Hex(emailKey(email))), + "INSERT INTO community_sticker_claim (code, submission_id, status, created_at) SELECT ?, ?, 'pending', ? WHERE changes() = 1 AND ? AND NOT EXISTS (SELECT 1 FROM community_survey_email WHERE email_hash = ?) AND (SELECT COUNT(*) FROM community_sticker_claim) < ?", + ).bind(code, id, now, config ? 1 : 0, emailHash, stickerLimit), env.DB.prepare( - "UPDATE community_gift_code SET claimed = 1 WHERE code = (SELECT code FROM community_gift_code WHERE claimed = 0 ORDER BY random() LIMIT 1) AND changes() = 1 AND ? RETURNING code", - ).bind(claimBase ? 1 : 0), + "INSERT INTO community_survey_email (email_hash) SELECT ? WHERE changes() = 1 ON CONFLICT(email_hash) DO NOTHING", + ).bind(emailHash), ]); const duplicate = !response?.meta.changes; - const code = z - .object({ code: z.string() }) - .safeParse(allocation?.results[0]); + const reserved = config !== null && claim?.meta.changes === 1; + const created = reserved + ? await createStickerDiscount(config, code, id, now) + : null; + if (created) + await env.DB.prepare( + "UPDATE community_sticker_claim SET status = ?, attempts = 1, last_attempt_at = ? WHERE code = ?", + ) + .bind(created.ok ? "created" : "pending", now, code) + .run(); + if (created && !created.ok) + console.error( + JSON.stringify({ + event: "community_sticker_claim_failed", + reason: created.reason, + }), + ); const claimLink = - claimBase && code.success - ? claimUrl(claimBase, code.data.code, email) - : null; + config && created?.ok ? claimUrl(config, code, email) : null; + const alreadyClaimed = z + .object({ claimed: z.literal(1) }) + .safeParse(existing?.results[0]).success; const claimNote = duplicate || claimLink ? null - : !emailRow?.meta.changes - ? ALREADY_CLAIMED - : claimBase - ? SOLD_OUT - : CLAIM_NOT_OPEN; + : reserved + ? CLAIM_PENDING + : alreadyClaimed + ? ALREADY_CLAIMED + : config + ? SOLD_OUT + : CLAIM_NOT_OPEN; return reply({ result: { duplicate, claimLink, claimNote } }); } catch { return reply( diff --git a/apps/web/src/community/shopify.ts b/apps/web/src/community/shopify.ts new file mode 100644 index 00000000000..ccd5c0d951a --- /dev/null +++ b/apps/web/src/community/shopify.ts @@ -0,0 +1,206 @@ +import { z } from "zod"; +import type { Env } from "../server/env"; + +export type ShopifyEnv = Pick< + Env, + | "COMMUNITY_SHOPIFY_STORE" + | "COMMUNITY_STICKER_VARIANT_ID" + | "SHOPIFY_CLIENT_ID" + | "SHOPIFY_CLIENT_SECRET" +>; + +export type ShopifyConfig = { + store: string; + variantId: string; + clientId: string; + clientSecret: string; +}; + +export type CreateResult = { ok: true } | { ok: false; reason: string }; + +const API_VERSION = "2026-10"; +const TIMEOUT_MS = 5000; +const CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +const tokenResponse = z.object({ + access_token: z.string(), + expires_in: z.number(), +}); +const userErrors = z.array(z.object({ code: z.string().nullable() })); +const createResponse = z.object({ + data: z.object({ + discountCodeBasicCreate: z.object({ + codeDiscountNode: z.object({ id: z.string() }).nullable(), + userErrors, + }), + }), +}); +const lookupResponse = z.object({ + data: z.object({ + codeDiscountNodeByCode: z.object({ id: z.string() }).nullable(), + }), +}); + +let cachedToken: { key: string; token: string; expiresAt: number } | null = + null; + +export function shopifyConfig(env: ShopifyEnv): ShopifyConfig | null { + const store = env.COMMUNITY_SHOPIFY_STORE ?? ""; + const variantId = env.COMMUNITY_STICKER_VARIANT_ID ?? ""; + if ( + !/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(store) || + !/^\d+$/.test(variantId) || + !env.SHOPIFY_CLIENT_ID || + !env.SHOPIFY_CLIENT_SECRET + ) + return null; + return { + store, + variantId, + clientId: env.SHOPIFY_CLIENT_ID, + clientSecret: env.SHOPIFY_CLIENT_SECRET, + }; +} + +export function newStickerCode(): string { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + return `BB-${[...bytes].map((byte) => CODE_ALPHABET[byte % 32]).join("")}`; +} + +export function claimUrl( + config: ShopifyConfig, + code: string, + email: string, +): string { + const url = new URL(`https://${config.store}/cart/${config.variantId}:1`); + url.searchParams.set("discount", code); + url.searchParams.set("checkout[email]", email); + return url.href; +} + +export function resetShopifyTokenCache(): void { + cachedToken = null; +} + +async function accessToken(config: ShopifyConfig, now: number) { + const key = `${config.store}:${config.clientId}`; + if (cachedToken?.key === key && cachedToken.expiresAt - 60_000 > now) + return cachedToken.token; + const response = await fetch( + `https://${config.store}/admin/oauth/access_token`, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: config.clientId, + client_secret: config.clientSecret, + }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }, + ); + if (!response.ok) throw new Error(`token_${response.status}`); + const body = tokenResponse.parse(await response.json()); + cachedToken = { + key, + token: body.access_token, + expiresAt: now + body.expires_in * 1000, + }; + return body.access_token; +} + +async function graphql( + config: ShopifyConfig, + now: number, + query: string, + variables: Record, +): Promise { + const response = await fetch( + `https://${config.store}/admin/api/${API_VERSION}/graphql.json`, + { + method: "POST", + headers: { + "content-type": "application/json", + "X-Shopify-Access-Token": await accessToken(config, now), + }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }, + ); + if (response.status === 401 || response.status === 403) cachedToken = null; + if (!response.ok) throw new Error(`graphql_${response.status}`); + return response.json(); +} + +async function codeExists(config: ShopifyConfig, now: number, code: string) { + const body = lookupResponse.parse( + await graphql( + config, + now, + "query StickerCode($code: String!) { codeDiscountNodeByCode(code: $code) { id } }", + { code }, + ), + ); + return body.data.codeDiscountNodeByCode !== null; +} + +export async function createStickerDiscount( + config: ShopifyConfig, + code: string, + submissionId: string, + now: number, +): Promise { + try { + const body = createResponse.parse( + await graphql( + config, + now, + "mutation StickerCode($discount: DiscountCodeBasicInput!) { discountCodeBasicCreate(basicCodeDiscount: $discount) { codeDiscountNode { id } userErrors { code } } }", + { + discount: { + title: `Survey stickers – ${submissionId.slice(0, 8)}`, + code, + startsAt: new Date(now - 5 * 60_000).toISOString(), + context: { all: "ALL" }, + customerGets: { + value: { + discountAmount: { amount: "3.00", appliesOnEachItem: false }, + }, + items: { + products: { + productVariantsToAdd: [ + `gid://shopify/ProductVariant/${config.variantId}`, + ], + }, + }, + }, + usageLimit: 1, + appliesOncePerCustomer: false, + combinesWith: { + orderDiscounts: false, + productDiscounts: false, + shippingDiscounts: false, + }, + tags: ["survey"], + }, + }, + ), + ); + const result = body.data.discountCodeBasicCreate; + if (result.codeDiscountNode && !result.userErrors.length) + return { ok: true }; + if (await codeExists(config, now, code)) return { ok: true }; + return { + ok: false, + reason: result.userErrors.map((error) => error.code).join(",") || "none", + }; + } catch (error) { + try { + if (await codeExists(config, now, code)) return { ok: true }; + } catch {} + return { + ok: false, + reason: error instanceof Error ? error.message : "unknown", + }; + } +} diff --git a/apps/web/src/community/survey.ts b/apps/web/src/community/survey.ts index 1d776b452d4..a4490088a71 100644 --- a/apps/web/src/community/survey.ts +++ b/apps/web/src/community/survey.ts @@ -18,6 +18,7 @@ export const SOLD_OUT = "We've given out all the stickers for now. Thank you for answering."; export const CLAIM_NOT_OPEN = "Sticker claims aren't open yet. We'll post in Discord when they are."; +export const CLAIM_PENDING = "We'll email you your sticker link shortly."; export const ALREADY_CLAIMED = "This email already has a sticker code. Thank you for answering again!"; diff --git a/apps/web/src/server/env.ts b/apps/web/src/server/env.ts index e07932e9ab8..797f8d20099 100644 --- a/apps/web/src/server/env.ts +++ b/apps/web/src/server/env.ts @@ -3,7 +3,11 @@ import { env as workerEnv } from "cloudflare:workers"; export interface Env { DB: D1Database; COMMUNITY_FORMS_ENABLED?: string; - COMMUNITY_STICKER_CLAIM_URL?: string; + COMMUNITY_SHOPIFY_STORE?: string; + COMMUNITY_STICKER_VARIANT_ID?: string; + COMMUNITY_STICKER_LIMIT?: string; + SHOPIFY_CLIENT_ID?: string; + SHOPIFY_CLIENT_SECRET?: string; COMMUNITY_FORM_RATE_LIMITER?: RateLimit; TUNNEL_DO: DurableObjectNamespace; LINK_START_RATE_LIMITER: RateLimit; diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 3bc3fb8a802..0debe2e185f 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -37,7 +37,9 @@ "RESEND_AUDIENCE_ID": "299db4ee-f3db-476d-9847-0bf4344d9c44", "PRESENCE_WRITES": "on-change", "COMMUNITY_FORMS_ENABLED": "false", - "COMMUNITY_STICKER_CLAIM_URL": "", + "COMMUNITY_SHOPIFY_STORE": "", + "COMMUNITY_STICKER_VARIANT_ID": "", + "COMMUNITY_STICKER_LIMIT": "1000", "NEXT_BASE_DOMAIN": "next.vibecodethis.site", "GATE_PUBLIC_KEYS": "{\"keys\":[{\"kid\":\"staging-2026-10-20\",\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"R5uinj0FFjpFtS9QHKb7LY7wsPo9RZkoRRsxegheihw\"}]}", }, @@ -115,7 +117,9 @@ "APP_URL": "https://getbb.app", "PRESENCE_WRITES": "on-change", "COMMUNITY_FORMS_ENABLED": "false", - "COMMUNITY_STICKER_CLAIM_URL": "https://y1f5zj-bk.myshopify.com/cart/67856686448953:1", + "COMMUNITY_SHOPIFY_STORE": "y1f5zj-bk.myshopify.com", + "COMMUNITY_STICKER_VARIANT_ID": "67856686448953", + "COMMUNITY_STICKER_LIMIT": "1000", "NEXT_BASE_DOMAIN": "next.getbb.app", "GATE_PUBLIC_KEYS": "{\"keys\":[{\"kid\":\"prod-2026-10-21\",\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"x\":\"sSHHgGnUQNXtsJ2_4EDXj4ZpIq9y45fZDvMGkKsWnGU\"}]}", "RESEND_AUDIENCE_ID": "299db4ee-f3db-476d-9847-0bf4344d9c44", diff --git a/docs/community/forms.md b/docs/community/forms.md index 2e46c882bce..b9c7a830614 100644 --- a/docs/community/forms.md +++ b/docs/community/forms.md @@ -6,7 +6,7 @@ Collection defaults to off in both environments. `COMMUNITY_FORMS_ENABLED=true` enables survey submissions. Keep it off until the copy, data handling and exact build pass review. Apply the generated connect-db migrations before enabling collection. -`COMMUNITY_STICKER_CLAIM_URL` is empty in staging and set to the store's sticker-pack cart permalink in production. Before enabling collection, load the unused Shopify codes with `INSERT INTO community_gift_code (code) VALUES (...)` through `wrangler d1 execute DB --remote --file `, keeping the SQL file outside the repository, and leave out any code already used in a test order. +Sticker claims need four Shopify settings. `COMMUNITY_SHOPIFY_STORE` (the `*.myshopify.com` host) and `COMMUNITY_STICKER_VARIANT_ID` (the numeric sticker-pack variant) are vars: empty in staging, set in production. `SHOPIFY_CLIENT_ID` and `SHOPIFY_CLIENT_SECRET` are Worker secrets from a Dev Dashboard app installed on the store with only the `write_discounts` scope; `deploy-web.yml` syncs them from GitHub secrets and skips them while they are empty. `COMMUNITY_STICKER_LIMIT` caps how many claims are reserved (1000). Until all four Shopify settings exist, finishers see “Sticker claims aren't open yet.” Nothing is loaded into the database. The shared review preview keeps collection off. Local synthetic QA may override these values against a fresh local D1 database; never use production bindings or real addresses for QA. @@ -28,16 +28,18 @@ Requests must be JSON, which cross-site forms can't send without a CORS prefligh ## Gifts and sticker codes -The gift is two bb stickers, claimed through a Shopify store. `COMMUNITY_STICKER_CLAIM_URL` is the store's cart permalink for one sticker pack, for example `https://.myshopify.com/cart/:1`. While it is empty, finishers see “Sticker claims aren't open yet.” instead of a link. +The gift is two bb stickers, claimed through a Shopify store. Each claim gets its own Shopify discount, created when the claim is made: no code list is stored or loaded. -`community_gift_code` holds one-time Shopify discount codes that each make one pack free. The codes are generated in Shopify and loaded into the production database out of band; never commit them. A successful survey save atomically marks one random unclaimed code as claimed, at most one per email. Email is compared lowercased and trimmed with any `+tag` removed; a repeat email saves its answers and gets “This email already has a sticker code.” as `claimNote`. `community_survey_email` keeps only a SHA-256 of that normalized email. When no unclaimed code remains, `claimNote` is “We've given out all the stickers for now. Thank you for answering.” A duplicate submission UUID cannot claim another code. +The save is one D1 batch: it checks whether the normalized email already has a claim, inserts the response, reserves a claim (`community_sticker_claim`, with a random `BB-` code of 12 base32 characters) only when the response is new, claims are configured, the email has no claim and fewer than `COMMUNITY_STICKER_LIMIT` claims exist, and records the email's SHA-256 in `community_survey_email` only when the claim was reserved. Email is compared lowercased and trimmed with any `+tag` removed. Someone who finishes while claims are closed or sold out can still claim later with the same email. -`claimLink` is the claim URL with `discount=` and `checkout[email]=`, so checkout opens with the pack, the code applied, a $0 total and the email filled in. The thank-you view shows it once. +After the batch, the worker gets a client-credentials token (cached per worker instance until a minute before it expires) and calls `discountCodeBasicCreate` (Admin GraphQL 2026-10) with the stored code: $3.00 off the sticker variant, usage limit 1, no combining, tagged `survey`, titled `Survey stickers – `. Each Shopify request times out after 5 seconds. A duplicate-code error or an ambiguous failure is resolved by looking the code up with `codeDiscountNodeByCode`; the stored code is reused for every attempt, so retries never create a second discount. On success the claim becomes `created`; otherwise it stays `pending` and a `community_sticker_claim_failed` log line records the reason. + +`claimLink` is `https:///cart/:1` with `discount=` and `checkout[email]=`, so checkout opens with the pack, the code applied, a $0 total and the email filled in. The thank-you view shows it once. `claimNote` is “We'll email you your sticker link shortly.” for a pending claim, “This email already has a sticker code. Thank you for answering again!” when the email already claimed, “We've given out all the stickers for now. Thank you for answering.” at the limit, and “Sticker claims aren't open yet. We'll post in Discord when they are.” without Shopify settings. A duplicate submission UUID cannot claim again. The store must not accept payments, so a cart without a code or with more than one pack cannot be completed. Shopify keeps each order's name and mailing address; fulfill orders from Shopify admin and mark them fulfilled there. bb never stores addresses. ## Privacy -Survey responses contain a random submission ID, creation time and answers, including email. Email is used for stickers and results, never a marketing list. `community_gift_code` contains only a code and a claimed flag: no survey ID, answer, contact detail or timestamp. Do not add request-body logging, analytics, or a join between these datasets. Infrastructure may still handle request metadata; review logging before launch. IP addresses are used by the short-lived rate limiter, not stored with answers. +Survey responses contain a random submission ID, creation time and answers, including email. Email is used for stickers and results, never a marketing list. A claim links a response to its code through `submission_id` only until the claim is delivered or expires; the email hash holds no answers. Do not add request-body logging or analytics. Infrastructure may still handle request metadata; review logging before launch. IP addresses are used by the short-lived rate limiter, not stored with answers. Choose a survey closing date and response-deletion date before launch. Delete raw survey responses when no longer needed and retain anonymous aggregate findings. Database backups can retain deleted survey answers under the provider's recovery policy. From 5c10fad34005356fbac52ad627a565400f289b86 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:16:19 +0000 Subject: [PATCH 3/4] Fail Shopify calls fast and keep a saved claim saved Fetch the token once per create and skip the lookup when the token itself failed, refuse redirects so credentials never follow one, surface GraphQL error codes as the failure reason, default the pack limit to 1000, and don't report a saved survey as failed when the follow-up status write fails. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/community/handler.test.ts | 33 +++++++++++++++- apps/web/src/community/handler.ts | 5 ++- apps/web/src/community/shopify.ts | 54 +++++++++++++++++++------- docs/community/forms.md | 2 +- 4 files changed, 75 insertions(+), 19 deletions(-) diff --git a/apps/web/src/community/handler.test.ts b/apps/web/src/community/handler.test.ts index d9d0b877e10..a6965d61967 100644 --- a/apps/web/src/community/handler.test.ts +++ b/apps/web/src/community/handler.test.ts @@ -30,12 +30,21 @@ const shopify = { tokenRequests: 0, down: false, loseReply: false, + taken: false, + tokenStatus: 200, + graphqlRequests: 0, }; async function fakeShopify(url: string, init: RequestInit) { if (url.endsWith("/admin/oauth/access_token")) { shopify.tokenRequests++; + if (shopify.tokenStatus !== 200) + return Response.json( + { error: "application_cannot_be_found" }, + { status: shopify.tokenStatus }, + ); return Response.json({ access_token: "token", expires_in: 86399 }); } + shopify.graphqlRequests++; if (shopify.down) return new Response("unavailable", { status: 503 }); const { query, variables } = JSON.parse(String(init.body)); if (query.includes("codeDiscountNodeByCode")) @@ -47,6 +56,7 @@ async function fakeShopify(url: string, init: RequestInit) { }, }); const code = variables.discount.code; + if (shopify.taken) shopify.codes.add(code); if (shopify.codes.has(code)) return Response.json({ data: { @@ -136,7 +146,14 @@ beforeEach(async () => { db.prepare("DELETE FROM community_survey_email"), db.prepare("DELETE FROM community_sticker_claim"), ]); - Object.assign(shopify, { tokenRequests: 0, down: false, loseReply: false }); + Object.assign(shopify, { + tokenRequests: 0, + down: false, + loseReply: false, + taken: false, + tokenStatus: 200, + graphqlRequests: 0, + }); shopify.codes.clear(); resetShopifyTokenCache(); vi.stubGlobal("fetch", fakeShopify); @@ -328,6 +345,20 @@ describe("survey submissions", () => { expect(shopify.codes.size).toBe(0); }); + it("treats a duplicate-code error for its own code as created", async () => { + shopify.taken = true; + const { body } = await post(); + expect(codeFrom(body)).toBeDefined(); + expect(shopify.codes.size).toBe(1); + }); + + it("stops at a failed token request without retrying it", async () => { + shopify.tokenStatus = 400; + const { body } = await post(); + expect(body.result?.claimLink).toBeNull(); + expect([shopify.tokenRequests, shopify.graphqlRequests]).toEqual([1, 0]); + }); + it("treats a code Shopify created before losing the reply as created", async () => { shopify.loseReply = true; const { body } = await post(); diff --git a/apps/web/src/community/handler.ts b/apps/web/src/community/handler.ts index ea450a18658..1ae6614fede 100644 --- a/apps/web/src/community/handler.ts +++ b/apps/web/src/community/handler.ts @@ -153,7 +153,7 @@ export async function handleSurveySubmission( const email = values.email?.[0] ?? ""; const config = shopifyConfig(env); const stickerLimit = - Number.parseInt(env.COMMUNITY_STICKER_LIMIT ?? "", 10) || 0; + Number.parseInt(env.COMMUNITY_STICKER_LIMIT ?? "", 10) || 1000; const emailHash = await sha256Hex(emailKey(email)); const code = newStickerCode(); const now = Date.now(); @@ -182,7 +182,8 @@ export async function handleSurveySubmission( "UPDATE community_sticker_claim SET status = ?, attempts = 1, last_attempt_at = ? WHERE code = ?", ) .bind(created.ok ? "created" : "pending", now, code) - .run(); + .run() + .catch(() => undefined); if (created && !created.ok) console.error( JSON.stringify({ diff --git a/apps/web/src/community/shopify.ts b/apps/web/src/community/shopify.ts index ccd5c0d951a..17f7273431d 100644 --- a/apps/web/src/community/shopify.ts +++ b/apps/web/src/community/shopify.ts @@ -9,14 +9,14 @@ export type ShopifyEnv = Pick< | "SHOPIFY_CLIENT_SECRET" >; -export type ShopifyConfig = { +type ShopifyConfig = { store: string; variantId: string; clientId: string; clientSecret: string; }; -export type CreateResult = { ok: true } | { ok: false; reason: string }; +type CreateResult = { ok: true } | { ok: false; reason: string }; const API_VERSION = "2026-10"; const TIMEOUT_MS = 5000; @@ -35,6 +35,11 @@ const createResponse = z.object({ }), }), }); +const graphqlErrors = z.object({ + errors: z.array( + z.object({ extensions: z.object({ code: z.string() }).optional() }), + ), +}); const lookupResponse = z.object({ data: z.object({ codeDiscountNodeByCode: z.object({ id: z.string() }).nullable(), @@ -82,8 +87,9 @@ export function resetShopifyTokenCache(): void { cachedToken = null; } -async function accessToken(config: ShopifyConfig, now: number) { +async function accessToken(config: ShopifyConfig) { const key = `${config.store}:${config.clientId}`; + const now = Date.now(); if (cachedToken?.key === key && cachedToken.expiresAt - 60_000 > now) return cachedToken.token; const response = await fetch( @@ -97,6 +103,7 @@ async function accessToken(config: ShopifyConfig, now: number) { client_secret: config.clientSecret, }), signal: AbortSignal.timeout(TIMEOUT_MS), + redirect: "error", }, ); if (!response.ok) throw new Error(`token_${response.status}`); @@ -110,8 +117,8 @@ async function accessToken(config: ShopifyConfig, now: number) { } async function graphql( + token: string, config: ShopifyConfig, - now: number, query: string, variables: Record, ): Promise { @@ -121,22 +128,29 @@ async function graphql( method: "POST", headers: { "content-type": "application/json", - "X-Shopify-Access-Token": await accessToken(config, now), + "X-Shopify-Access-Token": token, }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(TIMEOUT_MS), + redirect: "error", }, ); if (response.status === 401 || response.status === 403) cachedToken = null; if (!response.ok) throw new Error(`graphql_${response.status}`); - return response.json(); + const body: unknown = await response.json(); + const failure = graphqlErrors.safeParse(body); + if (failure.success && failure.data.errors.length) + throw new Error( + `graphql_${failure.data.errors[0]?.extensions?.code ?? "error"}`, + ); + return body; } -async function codeExists(config: ShopifyConfig, now: number, code: string) { +async function codeExists(token: string, config: ShopifyConfig, code: string) { const body = lookupResponse.parse( await graphql( + token, config, - now, "query StickerCode($code: String!) { codeDiscountNodeByCode(code: $code) { id } }", { code }, ), @@ -150,11 +164,17 @@ export async function createStickerDiscount( submissionId: string, now: number, ): Promise { + let token: string; + try { + token = await accessToken(config); + } catch (error) { + return { ok: false, reason: reasonOf(error) }; + } try { const body = createResponse.parse( await graphql( + token, config, - now, "mutation StickerCode($discount: DiscountCodeBasicInput!) { discountCodeBasicCreate(basicCodeDiscount: $discount) { codeDiscountNode { id } userErrors { code } } }", { discount: { @@ -189,18 +209,22 @@ export async function createStickerDiscount( const result = body.data.discountCodeBasicCreate; if (result.codeDiscountNode && !result.userErrors.length) return { ok: true }; - if (await codeExists(config, now, code)) return { ok: true }; + if (await codeExists(token, config, code)) return { ok: true }; return { ok: false, reason: result.userErrors.map((error) => error.code).join(",") || "none", }; } catch (error) { try { - if (await codeExists(config, now, code)) return { ok: true }; + if (await codeExists(token, config, code)) return { ok: true }; } catch {} - return { - ok: false, - reason: error instanceof Error ? error.message : "unknown", - }; + return { ok: false, reason: reasonOf(error) }; } } + +function reasonOf(error: unknown): string { + if (error instanceof Error && /^(token|graphql)_/.test(error.message)) + return error.message; + if (error instanceof Error && error.name === "TimeoutError") return "timeout"; + return "unavailable"; +} diff --git a/docs/community/forms.md b/docs/community/forms.md index b9c7a830614..3931c6b86f9 100644 --- a/docs/community/forms.md +++ b/docs/community/forms.md @@ -6,7 +6,7 @@ Collection defaults to off in both environments. `COMMUNITY_FORMS_ENABLED=true` enables survey submissions. Keep it off until the copy, data handling and exact build pass review. Apply the generated connect-db migrations before enabling collection. -Sticker claims need four Shopify settings. `COMMUNITY_SHOPIFY_STORE` (the `*.myshopify.com` host) and `COMMUNITY_STICKER_VARIANT_ID` (the numeric sticker-pack variant) are vars: empty in staging, set in production. `SHOPIFY_CLIENT_ID` and `SHOPIFY_CLIENT_SECRET` are Worker secrets from a Dev Dashboard app installed on the store with only the `write_discounts` scope; `deploy-web.yml` syncs them from GitHub secrets and skips them while they are empty. `COMMUNITY_STICKER_LIMIT` caps how many claims are reserved (1000). Until all four Shopify settings exist, finishers see “Sticker claims aren't open yet.” Nothing is loaded into the database. +Sticker claims need four Shopify settings. `COMMUNITY_SHOPIFY_STORE` (the `*.myshopify.com` host) and `COMMUNITY_STICKER_VARIANT_ID` (the numeric sticker-pack variant) are vars: empty in staging, set in production. `SHOPIFY_CLIENT_ID` and `SHOPIFY_CLIENT_SECRET` are Worker secrets from a Dev Dashboard app installed on the store with only the `write_discounts` scope; `deploy-web.yml` syncs them from GitHub secrets and skips them while they are empty. `COMMUNITY_STICKER_LIMIT` caps how many claims are reserved (1000). Until all four Shopify settings exist, finishers see “Sticker claims aren't open yet.” Nothing is loaded into the database. The Shopify secrets aren't in `secrets.required`, so deploys succeed before setup; for local QA, set them as vars, because local dev loads only the listed secrets from `.dev.vars`. The shared review preview keeps collection off. Local synthetic QA may override these values against a fresh local D1 database; never use production bindings or real addresses for QA. From 7721475fe49147d40ea5a89d1d692c0e86ff450c Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:57:02 +0000 Subject: [PATCH 4/4] Give sticker claims one module and simplify the Shopify client claims.ts now owns the save batch, the Shopify step and the claim messages, so the handler only checks the request and replies. The Shopify client fetches a token per create instead of caching one, reports failures as auth, unreachable or rejected, and the tests use one Shopify mode per case. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/community/claims.ts | 106 +++++++++++++++++ apps/web/src/community/handler.test.ts | 143 ++++++++++------------- apps/web/src/community/handler.ts | 100 +++------------- apps/web/src/community/shopify.ts | 152 +++++++++++-------------- apps/web/src/community/survey.ts | 8 -- docs/community/forms.md | 2 +- 6 files changed, 247 insertions(+), 264 deletions(-) create mode 100644 apps/web/src/community/claims.ts diff --git a/apps/web/src/community/claims.ts b/apps/web/src/community/claims.ts new file mode 100644 index 00000000000..fe85d00b8d6 --- /dev/null +++ b/apps/web/src/community/claims.ts @@ -0,0 +1,106 @@ +import { sha256Hex } from "@bb/connect-db"; +import { z } from "zod"; +import type { Env } from "../server/env"; +import { + claimUrl, + createStickerDiscount, + newStickerCode, + shopifyConfig, + type CreateResult, + type ShopifyEnv, +} from "./shopify"; +import { surveyAnswers, type FormValues } from "./survey"; + +export const SOLD_OUT = + "We've given out all the stickers for now. Thank you for answering."; +export const CLAIM_NOT_OPEN = + "Sticker claims aren't open yet. We'll post in Discord when they are."; +export const CLAIM_PENDING = "We'll email you your sticker link shortly."; +export const ALREADY_CLAIMED = + "This email already has a sticker code. Thank you for answering again!"; + +export type ClaimsEnv = ShopifyEnv & + Pick; + +export type Claim = { code: string; submissionId: string; email: string }; + +export type SavedResponse = { + duplicate: boolean; + claim: Claim | null; + note: string | null; +}; + +export function emailKey(email: string): string { + const normalized = email.trim().toLowerCase(); + const at = normalized.lastIndexOf("@"); + return `${normalized.slice(0, at).split("+")[0]}${normalized.slice(at)}`; +} + +export async function saveResponse( + env: ClaimsEnv, + id: string, + values: FormValues, + now: number, +): Promise { + const config = shopifyConfig(env); + const limit = Number.parseInt(env.COMMUNITY_STICKER_LIMIT ?? "", 10) || 1000; + const email = values.email?.[0] ?? ""; + const emailHash = await sha256Hex(emailKey(email)); + const code = newStickerCode(); + const [existing, response, reserved] = await env.DB.batch([ + env.DB.prepare( + "SELECT EXISTS(SELECT 1 FROM community_survey_email WHERE email_hash = ?) AS claimed", + ).bind(emailHash), + env.DB.prepare( + "INSERT INTO community_survey_response (id, created_at, answers) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING", + ).bind(id, now, JSON.stringify(surveyAnswers(values))), + env.DB.prepare( + "INSERT INTO community_sticker_claim (code, submission_id, status, created_at) SELECT ?, ?, 'pending', ? WHERE changes() = 1 AND ? AND NOT EXISTS (SELECT 1 FROM community_survey_email WHERE email_hash = ?) AND (SELECT COUNT(*) FROM community_sticker_claim) < ?", + ).bind(code, id, now, config ? 1 : 0, emailHash, limit), + env.DB.prepare( + "INSERT INTO community_survey_email (email_hash) SELECT ? WHERE changes() = 1 ON CONFLICT(email_hash) DO NOTHING", + ).bind(emailHash), + ]); + const duplicate = !response?.meta.changes; + if (reserved?.meta.changes === 1) + return { duplicate, claim: { code, submissionId: id, email }, note: null }; + const alreadyClaimed = z + .object({ claimed: z.literal(1) }) + .safeParse(existing?.results[0]).success; + const note = duplicate + ? null + : alreadyClaimed + ? ALREADY_CLAIMED + : config + ? SOLD_OUT + : CLAIM_NOT_OPEN; + return { duplicate, claim: null, note }; +} + +export type Advanced = + | { ok: true; claimLink: string } + | Extract; + +export async function advanceClaim( + env: ClaimsEnv, + claim: Claim, + now: number, +): Promise { + const config = shopifyConfig(env); + if (!config) return { ok: false, kind: "auth" }; + const created = await createStickerDiscount( + config, + claim.code, + claim.submissionId, + now, + ); + await env.DB.prepare( + "UPDATE community_sticker_claim SET status = ?, attempts = attempts + 1, last_attempt_at = ? WHERE code = ? AND status = 'pending'", + ) + .bind(created.ok ? "created" : "pending", now, claim.code) + .run() + .catch(() => undefined); + return created.ok + ? { ok: true, claimLink: claimUrl(config, claim.code, claim.email) } + : created; +} diff --git a/apps/web/src/community/handler.test.ts b/apps/web/src/community/handler.test.ts index a6965d61967..0da55a35093 100644 --- a/apps/web/src/community/handler.test.ts +++ b/apps/web/src/community/handler.test.ts @@ -10,42 +10,34 @@ import { vi, } from "vitest"; import { applyConnectDbMigrationsToD1 } from "@bb/connect-db/testing"; -import { emailKey, handleSurveySubmission, rateLimitKey } from "./handler"; -import { resetShopifyTokenCache } from "./shopify"; import { ALREADY_CLAIMED, CLAIM_NOT_OPEN, CLAIM_PENDING, + emailKey, SOLD_OUT, - surveyReply, - type SurveyReply, -} from "./survey"; +} from "./claims"; +import { handleSurveySubmission, rateLimitKey } from "./handler"; +import { surveyReply, type SurveyReply } from "./survey"; let runtime: Miniflare; let db: D1Database; const origin = "https://community.test"; const claimBase = "https://test-shop.myshopify.com/cart/123:1"; +type ShopifyMode = "ok" | "down" | "tokenDenied" | "loseReply" | "taken"; const shopify = { + mode: "ok" as ShopifyMode, codes: new Set(), - tokenRequests: 0, - down: false, - loseReply: false, - taken: false, - tokenStatus: 200, - graphqlRequests: 0, + requests: 0, }; async function fakeShopify(url: string, init: RequestInit) { - if (url.endsWith("/admin/oauth/access_token")) { - shopify.tokenRequests++; - if (shopify.tokenStatus !== 200) - return Response.json( - { error: "application_cannot_be_found" }, - { status: shopify.tokenStatus }, - ); - return Response.json({ access_token: "token", expires_in: 86399 }); - } - shopify.graphqlRequests++; - if (shopify.down) return new Response("unavailable", { status: 503 }); + shopify.requests++; + if (url.endsWith("/admin/oauth/access_token")) + return shopify.mode === "tokenDenied" + ? Response.json({ error: "invalid_client" }, { status: 400 }) + : Response.json({ access_token: "token", expires_in: 86399 }); + if (shopify.mode === "down") + return new Response("unavailable", { status: 503 }); const { query, variables } = JSON.parse(String(init.body)); if (query.includes("codeDiscountNodeByCode")) return Response.json({ @@ -56,24 +48,18 @@ async function fakeShopify(url: string, init: RequestInit) { }, }); const code = variables.discount.code; - if (shopify.taken) shopify.codes.add(code); - if (shopify.codes.has(code)) - return Response.json({ - data: { - discountCodeBasicCreate: { - codeDiscountNode: null, - userErrors: [{ code: "TAKEN" }], - }, - }, - }); + const existed = shopify.codes.has(code) || shopify.mode === "taken"; shopify.codes.add(code); - if (shopify.loseReply) return new Response("timeout", { status: 504 }); + if (shopify.mode === "loseReply") + return new Response("timeout", { status: 504 }); return Response.json({ data: { - discountCodeBasicCreate: { - codeDiscountNode: { id: "gid://shopify/DiscountCodeNode/2" }, - userErrors: [], - }, + discountCodeBasicCreate: existed + ? { codeDiscountNode: null, userErrors: [{ code: "TAKEN" }] } + : { + codeDiscountNode: { id: "gid://shopify/DiscountCodeNode/2" }, + userErrors: [], + }, }, }); } @@ -146,16 +132,9 @@ beforeEach(async () => { db.prepare("DELETE FROM community_survey_email"), db.prepare("DELETE FROM community_sticker_claim"), ]); - Object.assign(shopify, { - tokenRequests: 0, - down: false, - loseReply: false, - taken: false, - tokenStatus: 200, - graphqlRequests: 0, - }); + shopify.mode = "ok"; + shopify.requests = 0; shopify.codes.clear(); - resetShopifyTokenCache(); vi.stubGlobal("fetch", fakeShopify); vi.spyOn(console, "error").mockImplementation(() => {}); }); @@ -298,7 +277,6 @@ describe("survey submissions", () => { ).toBeDefined(); expect(await count("community_survey_response")).toBe(4); expect(await count("community_sticker_claim")).toBe(2); - expect(shopify.tokenRequests).toBe(1); expect(emailKey(" Ada+Tag@Example.TEST ")).toBe("ada@example.test"); }); @@ -329,45 +307,40 @@ describe("survey submissions", () => { expect(codeFrom((await post()).body)).toBeDefined(); }); - it("keeps the claim pending when Shopify is unavailable", async () => { - shopify.down = true; - const { body } = await post(); - expect(body.result).toEqual({ - duplicate: false, - claimLink: null, - claimNote: CLAIM_PENDING, - }); - expect( - await count( - "community_sticker_claim WHERE status = 'pending' AND attempts = 1", - ), - ).toBe(1); - expect(shopify.codes.size).toBe(0); - }); - - it("treats a duplicate-code error for its own code as created", async () => { - shopify.taken = true; - const { body } = await post(); - expect(codeFrom(body)).toBeDefined(); - expect(shopify.codes.size).toBe(1); - }); - - it("stops at a failed token request without retrying it", async () => { - shopify.tokenStatus = 400; - const { body } = await post(); - expect(body.result?.claimLink).toBeNull(); - expect([shopify.tokenRequests, shopify.graphqlRequests]).toEqual([1, 0]); - }); + it.each([ + ["down", 3], + ["tokenDenied", 1], + ] as const)( + "keeps the claim pending when Shopify is %s", + async (mode, requests) => { + shopify.mode = mode; + const { body } = await post(); + expect(body.result).toEqual({ + duplicate: false, + claimLink: null, + claimNote: CLAIM_PENDING, + }); + expect( + await count( + "community_sticker_claim WHERE status = 'pending' AND attempts = 1", + ), + ).toBe(1); + expect([shopify.codes.size, shopify.requests]).toEqual([0, requests]); + }, + ); - it("treats a code Shopify created before losing the reply as created", async () => { - shopify.loseReply = true; - const { body } = await post(); - expect(codeFrom(body)).toBeDefined(); - expect(shopify.codes.size).toBe(1); - expect( - await count("community_sticker_claim WHERE status = 'created'"), - ).toBe(1); - }); + it.each(["taken", "loseReply"] as const)( + "treats its own code as created after a %s reply", + async (mode) => { + shopify.mode = mode; + const { body } = await post(); + expect(codeFrom(body)).toBeDefined(); + expect(shopify.codes.size).toBe(1); + expect( + await count("community_sticker_claim WHERE status = 'created'"), + ).toBe(1); + }, + ); it("rolls back answers when reserving a claim fails", async () => { await db diff --git a/apps/web/src/community/handler.ts b/apps/web/src/community/handler.ts index 1ae6614fede..fdb1bfa9782 100644 --- a/apps/web/src/community/handler.ts +++ b/apps/web/src/community/handler.ts @@ -1,32 +1,15 @@ -import { sha256Hex } from "@bb/connect-db"; import { z } from "zod"; import type { Env } from "../server/env"; import { - claimUrl, - createStickerDiscount, - newStickerCode, - shopifyConfig, - type ShopifyEnv, -} from "./shopify"; -import { - ALREADY_CLAIMED, - CLAIM_NOT_OPEN, + advanceClaim, CLAIM_PENDING, - SOLD_OUT, - surveyAnswers, - surveyFields, - validateSurvey, - type SurveyReply, -} from "./survey"; + saveResponse, + type ClaimsEnv, +} from "./claims"; +import { surveyFields, validateSurvey, type SurveyReply } from "./survey"; -type CommunityEnv = ShopifyEnv & - Pick< - Env, - | "DB" - | "COMMUNITY_FORMS_ENABLED" - | "COMMUNITY_STICKER_LIMIT" - | "COMMUNITY_FORM_RATE_LIMITER" - >; +type CommunityEnv = ClaimsEnv & + Pick; const MAX_BODY_BYTES = 16384; const submission = z.object({ @@ -38,12 +21,6 @@ const submission = z.object({ ), }); -export function emailKey(email: string): string { - const normalized = email.trim().toLowerCase(); - const at = normalized.lastIndexOf("@"); - return `${normalized.slice(0, at).split("+")[0]}${normalized.slice(at)}`; -} - export function rateLimitKey(ip: string | null): string { if (!ip) return "local-preview"; if (!ip.includes(":")) return ip; @@ -150,63 +127,18 @@ export async function handleSurveySubmission( const { submission_id: id, answers: values } = read; const errors = validateSurvey(values); if (Object.keys(errors).length) return reply({ errors }, 400); - const email = values.email?.[0] ?? ""; - const config = shopifyConfig(env); - const stickerLimit = - Number.parseInt(env.COMMUNITY_STICKER_LIMIT ?? "", 10) || 1000; - const emailHash = await sha256Hex(emailKey(email)); - const code = newStickerCode(); - const now = Date.now(); try { - const [existing, response, claim] = await env.DB.batch([ - env.DB.prepare( - "SELECT EXISTS(SELECT 1 FROM community_survey_email WHERE email_hash = ?) AS claimed", - ).bind(emailHash), - env.DB.prepare( - "INSERT INTO community_survey_response (id, created_at, answers) VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING", - ).bind(id, now, JSON.stringify(surveyAnswers(values))), - env.DB.prepare( - "INSERT INTO community_sticker_claim (code, submission_id, status, created_at) SELECT ?, ?, 'pending', ? WHERE changes() = 1 AND ? AND NOT EXISTS (SELECT 1 FROM community_survey_email WHERE email_hash = ?) AND (SELECT COUNT(*) FROM community_sticker_claim) < ?", - ).bind(code, id, now, config ? 1 : 0, emailHash, stickerLimit), - env.DB.prepare( - "INSERT INTO community_survey_email (email_hash) SELECT ? WHERE changes() = 1 ON CONFLICT(email_hash) DO NOTHING", - ).bind(emailHash), - ]); - const duplicate = !response?.meta.changes; - const reserved = config !== null && claim?.meta.changes === 1; - const created = reserved - ? await createStickerDiscount(config, code, id, now) + const now = Date.now(); + const saved = await saveResponse(env, id, values, now); + const advanced = saved.claim + ? await advanceClaim(env, saved.claim, now) : null; - if (created) - await env.DB.prepare( - "UPDATE community_sticker_claim SET status = ?, attempts = 1, last_attempt_at = ? WHERE code = ?", - ) - .bind(created.ok ? "created" : "pending", now, code) - .run() - .catch(() => undefined); - if (created && !created.ok) - console.error( - JSON.stringify({ - event: "community_sticker_claim_failed", - reason: created.reason, - }), - ); - const claimLink = - config && created?.ok ? claimUrl(config, code, email) : null; - const alreadyClaimed = z - .object({ claimed: z.literal(1) }) - .safeParse(existing?.results[0]).success; + const claimLink = advanced?.ok ? advanced.claimLink : null; const claimNote = - duplicate || claimLink - ? null - : reserved - ? CLAIM_PENDING - : alreadyClaimed - ? ALREADY_CLAIMED - : config - ? SOLD_OUT - : CLAIM_NOT_OPEN; - return reply({ result: { duplicate, claimLink, claimNote } }); + saved.note ?? (saved.claim && !claimLink ? CLAIM_PENDING : null); + return reply({ + result: { duplicate: saved.duplicate, claimLink, claimNote }, + }); } catch { return reply( { error: "We couldn't save your response. Please try again." }, diff --git a/apps/web/src/community/shopify.ts b/apps/web/src/community/shopify.ts index 17f7273431d..c7a16adb179 100644 --- a/apps/web/src/community/shopify.ts +++ b/apps/web/src/community/shopify.ts @@ -9,45 +9,41 @@ export type ShopifyEnv = Pick< | "SHOPIFY_CLIENT_SECRET" >; -type ShopifyConfig = { +export type ShopifyConfig = { store: string; variantId: string; clientId: string; clientSecret: string; }; -type CreateResult = { ok: true } | { ok: false; reason: string }; +export type CreateResult = + | { ok: true } + | { ok: false; kind: "auth" | "unreachable" | "rejected" }; const API_VERSION = "2026-10"; const TIMEOUT_MS = 5000; const CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; -const tokenResponse = z.object({ - access_token: z.string(), - expires_in: z.number(), -}); -const userErrors = z.array(z.object({ code: z.string().nullable() })); +const tokenResponse = z.object({ access_token: z.string() }); const createResponse = z.object({ data: z.object({ discountCodeBasicCreate: z.object({ codeDiscountNode: z.object({ id: z.string() }).nullable(), - userErrors, + userErrors: z.array(z.unknown()), }), }), }); -const graphqlErrors = z.object({ - errors: z.array( - z.object({ extensions: z.object({ code: z.string() }).optional() }), - ), -}); const lookupResponse = z.object({ data: z.object({ codeDiscountNodeByCode: z.object({ id: z.string() }).nullable(), }), }); -let cachedToken: { key: string; token: string; expiresAt: number } | null = - null; +class ShopifyError extends Error { + constructor(readonly kind: "auth" | "unreachable" | "rejected") { + super(kind); + } +} export function shopifyConfig(env: ShopifyEnv): ShopifyConfig | null { const store = env.COMMUNITY_SHOPIFY_STORE ?? ""; @@ -73,7 +69,7 @@ export function newStickerCode(): string { } export function claimUrl( - config: ShopifyConfig, + config: Pick, code: string, email: string, ): string { @@ -83,79 +79,68 @@ export function claimUrl( return url.href; } -export function resetShopifyTokenCache(): void { - cachedToken = null; +async function post(url: string, init: RequestInit): Promise { + let response: Response; + try { + response = await fetch(url, { + ...init, + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch { + throw new ShopifyError("unreachable"); + } + if (response.status >= 500 || response.status === 429) + throw new ShopifyError("unreachable"); + if (!response.ok) throw new ShopifyError("auth"); + return response.json(); } -async function accessToken(config: ShopifyConfig) { - const key = `${config.store}:${config.clientId}`; - const now = Date.now(); - if (cachedToken?.key === key && cachedToken.expiresAt - 60_000 > now) - return cachedToken.token; - const response = await fetch( - `https://${config.store}/admin/oauth/access_token`, - { - method: "POST", +async function accessToken(config: ShopifyConfig): Promise { + const body = tokenResponse.safeParse( + await post(`https://${config.store}/admin/oauth/access_token`, { headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: config.clientId, client_secret: config.clientSecret, }), - signal: AbortSignal.timeout(TIMEOUT_MS), - redirect: "error", - }, + }), ); - if (!response.ok) throw new Error(`token_${response.status}`); - const body = tokenResponse.parse(await response.json()); - cachedToken = { - key, - token: body.access_token, - expiresAt: now + body.expires_in * 1000, - }; - return body.access_token; + if (!body.success) throw new ShopifyError("auth"); + return body.data.access_token; } -async function graphql( - token: string, +function graphql( config: ShopifyConfig, + token: string, query: string, variables: Record, ): Promise { - const response = await fetch( - `https://${config.store}/admin/api/${API_VERSION}/graphql.json`, - { - method: "POST", - headers: { - "content-type": "application/json", - "X-Shopify-Access-Token": token, - }, - body: JSON.stringify({ query, variables }), - signal: AbortSignal.timeout(TIMEOUT_MS), - redirect: "error", + return post(`https://${config.store}/admin/api/${API_VERSION}/graphql.json`, { + headers: { + "content-type": "application/json", + "X-Shopify-Access-Token": token, }, - ); - if (response.status === 401 || response.status === 403) cachedToken = null; - if (!response.ok) throw new Error(`graphql_${response.status}`); - const body: unknown = await response.json(); - const failure = graphqlErrors.safeParse(body); - if (failure.success && failure.data.errors.length) - throw new Error( - `graphql_${failure.data.errors[0]?.extensions?.code ?? "error"}`, - ); - return body; + body: JSON.stringify({ query, variables }), + }); } -async function codeExists(token: string, config: ShopifyConfig, code: string) { - const body = lookupResponse.parse( +async function codeExists( + config: ShopifyConfig, + token: string, + code: string, +): Promise { + const body = lookupResponse.safeParse( await graphql( - token, config, + token, "query StickerCode($code: String!) { codeDiscountNodeByCode(code: $code) { id } }", { code }, ), ); - return body.data.codeDiscountNodeByCode !== null; + return body.success && body.data.data.codeDiscountNodeByCode !== null; } export async function createStickerDiscount( @@ -168,13 +153,17 @@ export async function createStickerDiscount( try { token = await accessToken(config); } catch (error) { - return { ok: false, reason: reasonOf(error) }; + return { + ok: false, + kind: error instanceof ShopifyError ? error.kind : "unreachable", + }; } + let failure: ShopifyError = new ShopifyError("rejected"); try { - const body = createResponse.parse( + const body = createResponse.safeParse( await graphql( - token, config, + token, "mutation StickerCode($discount: DiscountCodeBasicInput!) { discountCodeBasicCreate(basicCodeDiscount: $discount) { codeDiscountNode { id } userErrors { code } } }", { discount: { @@ -206,25 +195,16 @@ export async function createStickerDiscount( }, ), ); - const result = body.data.discountCodeBasicCreate; - if (result.codeDiscountNode && !result.userErrors.length) + const created = body.success + ? body.data.data.discountCodeBasicCreate + : null; + if (created?.codeDiscountNode && !created.userErrors.length) return { ok: true }; - if (await codeExists(token, config, code)) return { ok: true }; - return { - ok: false, - reason: result.userErrors.map((error) => error.code).join(",") || "none", - }; } catch (error) { - try { - if (await codeExists(token, config, code)) return { ok: true }; - } catch {} - return { ok: false, reason: reasonOf(error) }; + if (error instanceof ShopifyError) failure = error; } -} - -function reasonOf(error: unknown): string { - if (error instanceof Error && /^(token|graphql)_/.test(error.message)) - return error.message; - if (error instanceof Error && error.name === "TimeoutError") return "timeout"; - return "unavailable"; + try { + if (await codeExists(config, token, code)) return { ok: true }; + } catch {} + return { ok: false, kind: failure.kind }; } diff --git a/apps/web/src/community/survey.ts b/apps/web/src/community/survey.ts index a4490088a71..5392e058568 100644 --- a/apps/web/src/community/survey.ts +++ b/apps/web/src/community/survey.ts @@ -14,14 +14,6 @@ export function questionVisible(name: string, values: FormValues): boolean { return true; } -export const SOLD_OUT = - "We've given out all the stickers for now. Thank you for answering."; -export const CLAIM_NOT_OPEN = - "Sticker claims aren't open yet. We'll post in Discord when they are."; -export const CLAIM_PENDING = "We'll email you your sticker link shortly."; -export const ALREADY_CLAIMED = - "This email already has a sticker code. Thank you for answering again!"; - type Question = { name: string; label: string; diff --git a/docs/community/forms.md b/docs/community/forms.md index 3931c6b86f9..e4ef5590679 100644 --- a/docs/community/forms.md +++ b/docs/community/forms.md @@ -32,7 +32,7 @@ The gift is two bb stickers, claimed through a Shopify store. Each claim gets it The save is one D1 batch: it checks whether the normalized email already has a claim, inserts the response, reserves a claim (`community_sticker_claim`, with a random `BB-` code of 12 base32 characters) only when the response is new, claims are configured, the email has no claim and fewer than `COMMUNITY_STICKER_LIMIT` claims exist, and records the email's SHA-256 in `community_survey_email` only when the claim was reserved. Email is compared lowercased and trimmed with any `+tag` removed. Someone who finishes while claims are closed or sold out can still claim later with the same email. -After the batch, the worker gets a client-credentials token (cached per worker instance until a minute before it expires) and calls `discountCodeBasicCreate` (Admin GraphQL 2026-10) with the stored code: $3.00 off the sticker variant, usage limit 1, no combining, tagged `survey`, titled `Survey stickers – `. Each Shopify request times out after 5 seconds. A duplicate-code error or an ambiguous failure is resolved by looking the code up with `codeDiscountNodeByCode`; the stored code is reused for every attempt, so retries never create a second discount. On success the claim becomes `created`; otherwise it stays `pending` and a `community_sticker_claim_failed` log line records the reason. +After the batch, the worker gets a client-credentials token and calls `discountCodeBasicCreate` (Admin GraphQL 2026-10) with the stored code: $3.00 off the sticker variant, usage limit 1, no combining, tagged `survey`, titled `Survey stickers – `. Each Shopify request times out after 5 seconds and refuses redirects. A duplicate-code error or an ambiguous failure is resolved by looking the code up with `codeDiscountNodeByCode`; the stored code is reused for every attempt, so retries never create a second discount. On success the claim becomes `created`; otherwise it stays `pending`. Pending claims are retried, and their links emailed, by the results-email layer; don't enable collection without it. `claims.ts` owns the whole claim: the save batch, the Shopify step and the messages. `claimLink` is `https:///cart/:1` with `discount=` and `checkout[email]=`, so checkout opens with the pack, the code applied, a $0 total and the email filled in. The thank-you view shows it once. `claimNote` is “We'll email you your sticker link shortly.” for a pending claim, “This email already has a sticker code. Thank you for answering again!” when the email already claimed, “We've given out all the stickers for now. Thank you for answering.” at the limit, and “Sticker claims aren't open yet. We'll post in Discord when they are.” without Shopify settings. A duplicate submission UUID cannot claim again.