Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions apps/web/src/community/claims.ts
Original file line number Diff line number Diff line change
@@ -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<Env, "DB" | "COMMUNITY_STICKER_LIMIT">;

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<SavedResponse> {
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<CreateResult, { ok: false }>;

export async function advanceClaim(
env: ClaimsEnv,
claim: Claim,
now: number,
): Promise<Advanced> {
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;
}
189 changes: 175 additions & 14 deletions apps/web/src/community/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(),
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",
Expand All @@ -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<string, string> = {}) =>
new Request(`${origin}/api/community/survey`, {
Expand Down Expand Up @@ -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<number>("n");
beforeAll(async () => {
Expand All @@ -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();
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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();
}
});

Expand Down
Loading
Loading