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/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 4bbf225b087..0da55a35093 100644 --- a/apps/web/src/community/handler.test.ts +++ b/apps/web/src/community/handler.test.ts @@ -1,12 +1,68 @@ 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 { + ALREADY_CLAIMED, + CLAIM_NOT_OPEN, + CLAIM_PENDING, + emailKey, + SOLD_OUT, +} from "./claims"; import { handleSurveySubmission, rateLimitKey } from "./handler"; -import { surveyReply } from "./survey"; +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(), + requests: 0, +}; +async function fakeShopify(url: string, init: RequestInit) { + 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({ + data: { + codeDiscountNodeByCode: shopify.codes.has(variables.code) + ? { id: "gid://shopify/DiscountCodeNode/1" } + : null, + }, + }); + const code = variables.discount.code; + const existed = shopify.codes.has(code) || shopify.mode === "taken"; + shopify.codes.add(code); + if (shopify.mode === "loseReply") + return new Response("timeout", { status: 504 }); + return Response.json({ + data: { + discountCodeBasicCreate: existed + ? { codeDiscountNode: null, userErrors: [{ code: "TAKEN" }] } + : { + codeDiscountNode: { id: "gid://shopify/DiscountCodeNode/2" }, + userErrors: [], + }, + }, + }); +} const defaults = { discovery: "GitHub", role: "Developer or engineer", @@ -21,6 +77,11 @@ const defaults = { const enabled = { COMMUNITY_FORMS_ENABLED: "true", COMMUNITY_FORM_RATE_LIMITER: { limit: async () => ({ success: true }) }, + 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`, { @@ -51,6 +112,8 @@ const post = async ( body: surveyReply.parse(await response.json()), }; }; +const codeFrom = (body: SurveyReply) => + 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 () => { @@ -64,7 +127,20 @@ 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_sticker_claim"), + ]); + shopify.mode = "ok"; + shopify.requests = 0; + shopify.codes.clear(); + vi.stubGlobal("fetch", fakeShopify); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); afterAll(async () => { await runtime?.dispose(); @@ -143,7 +219,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 +231,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) @@ -175,24 +252,108 @@ describe("survey submissions", () => { ]), ), ); + expect(shopify.codes).toEqual(new Set([codeFrom(body)])); 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_sticker_claim WHERE status = 'created'"), + ).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_sticker_claim")).toBe(2); + expect(emailKey(" Ada+Tag@Example.TEST ")).toBe("ada@example.test"); }); - it("reports a save failure so the page can keep the answers", async () => { + 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` }, + { COMMUNITY_STICKER_LIMIT: "1" }, + ), + ), + ); + 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.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.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 .prepare( - "CREATE TRIGGER reject_response BEFORE INSERT ON community_survey_response BEGIN SELECT RAISE(ABORT, 'unavailable'); END", + "CREATE TRIGGER reject_claim BEFORE INSERT ON community_sticker_claim 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); + expect(shopify.codes.size).toBe(0); } finally { - await db.prepare("DROP TRIGGER reject_response").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 941ba893f41..fdb1bfa9782 100644 --- a/apps/web/src/community/handler.ts +++ b/apps/web/src/community/handler.ts @@ -1,16 +1,15 @@ import { z } from "zod"; import type { Env } from "../server/env"; import { - surveyAnswers, - surveyFields, - validateSurvey, - type SurveyReply, -} from "./survey"; + advanceClaim, + CLAIM_PENDING, + saveResponse, + type ClaimsEnv, +} from "./claims"; +import { surveyFields, validateSurvey, type SurveyReply } from "./survey"; -type CommunityEnv = Pick< - Env, - "DB" | "COMMUNITY_FORMS_ENABLED" | "COMMUNITY_FORM_RATE_LIMITER" ->; +type CommunityEnv = ClaimsEnv & + Pick; const MAX_BODY_BYTES = 16384; const submission = z.object({ @@ -129,12 +128,17 @@ export async function handleSurveySubmission( const errors = validateSurvey(values); if (Object.keys(errors).length) return reply({ errors }, 400); 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 now = Date.now(); + const saved = await saveResponse(env, id, values, now); + const advanced = saved.claim + ? await advanceClaim(env, saved.claim, now) + : null; + const claimLink = advanced?.ok ? advanced.claimLink : null; + const 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 new file mode 100644 index 00000000000..c7a16adb179 --- /dev/null +++ b/apps/web/src/community/shopify.ts @@ -0,0 +1,210 @@ +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; 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() }); +const createResponse = z.object({ + data: z.object({ + discountCodeBasicCreate: z.object({ + codeDiscountNode: z.object({ id: z.string() }).nullable(), + userErrors: z.array(z.unknown()), + }), + }), +}); +const lookupResponse = z.object({ + data: z.object({ + codeDiscountNodeByCode: z.object({ id: z.string() }).nullable(), + }), +}); + +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 ?? ""; + 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: Pick, + 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; +} + +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): 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, + }), + }), + ); + if (!body.success) throw new ShopifyError("auth"); + return body.data.access_token; +} + +function graphql( + config: ShopifyConfig, + token: string, + query: string, + variables: Record, +): Promise { + return post(`https://${config.store}/admin/api/${API_VERSION}/graphql.json`, { + headers: { + "content-type": "application/json", + "X-Shopify-Access-Token": token, + }, + body: JSON.stringify({ query, variables }), + }); +} + +async function codeExists( + config: ShopifyConfig, + token: string, + code: string, +): Promise { + const body = lookupResponse.safeParse( + await graphql( + config, + token, + "query StickerCode($code: String!) { codeDiscountNodeByCode(code: $code) { id } }", + { code }, + ), + ); + return body.success && body.data.data.codeDiscountNodeByCode !== null; +} + +export async function createStickerDiscount( + config: ShopifyConfig, + code: string, + submissionId: string, + now: number, +): Promise { + let token: string; + try { + token = await accessToken(config); + } catch (error) { + return { + ok: false, + kind: error instanceof ShopifyError ? error.kind : "unreachable", + }; + } + let failure: ShopifyError = new ShopifyError("rejected"); + try { + const body = createResponse.safeParse( + await graphql( + config, + token, + "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 created = body.success + ? body.data.data.discountCodeBasicCreate + : null; + if (created?.codeDiscountNode && !created.userErrors.length) + return { ok: true }; + } catch (error) { + if (error instanceof ShopifyError) failure = error; + } + 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-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..5392e058568 100644 --- a/apps/web/src/community/survey.ts +++ b/apps/web/src/community/survey.ts @@ -143,7 +143,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..797f8d20099 100644 --- a/apps/web/src/server/env.ts +++ b/apps/web/src/server/env.ts @@ -3,6 +3,11 @@ import { env as workerEnv } from "cloudflare:workers"; export interface Env { DB: D1Database; COMMUNITY_FORMS_ENABLED?: 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 8e6592f9cd7..0debe2e185f 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -37,6 +37,9 @@ "RESEND_AUDIENCE_ID": "299db4ee-f3db-476d-9847-0bf4344d9c44", "PRESENCE_WRITES": "on-change", "COMMUNITY_FORMS_ENABLED": "false", + "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\"}]}", }, @@ -114,6 +117,9 @@ "APP_URL": "https://getbb.app", "PRESENCE_WRITES": "on-change", "COMMUNITY_FORMS_ENABLED": "false", + "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 2907cbbe519..e4ef5590679 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. +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. ## Survey submission contract @@ -22,10 +24,22 @@ 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. Each claim gets its own Shopify discount, created when the claim is made: no code list is stored or loaded. + +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 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. + +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. 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.