Skip to content

Commit 09c7664

Browse files
committed
feat(convex/customers): add normalizedEmail field + index for broadcast dedup
Required pre-work for the PRO-launch broadcast. Without this, the audience-build dedup query (registrations − emailSuppressions − paying customers) is a full `customers` table scan, AND the join would silently fail across case/whitespace differences between `registrations.normalizedEmail` (lowercased+trimmed) and `customers.email` (raw) — leaking paid users into the "buy PRO!" send. Mirrors the existing convention from `registrations` and `emailSuppressions`, both of which already store + index `normalizedEmail`. - schema.ts: customers.normalizedEmail (v.optional, backward-compatible) + by_normalized_email index - subscriptionHelpers.ts: populate normalizedEmail at both write sites in handleSubscriptionActive (insert + patch); single derivation reused - backfillCustomerNormalizedEmail.ts (new): idempotent paginated internalMutation + countPending diagnostic query for existing rows Operational sequence after deploy: npx convex run payments/backfillCustomerNormalizedEmail:countPending npx convex run payments/backfillCustomerNormalizedEmail:backfill # rerun backfill until done:true; verify with countPending → pending:0
1 parent 6c28e93 commit 09c7664

3 files changed

Lines changed: 88 additions & 1 deletion

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* One-time backfill: populate `customers.normalizedEmail` on rows
3+
* that predate the field's introduction.
4+
*
5+
* Required before the PRO-launch broadcast — the dedup query
6+
* (`registrations` − `emailSuppressions` − paying-customers) joins
7+
* on `normalizedEmail`, and rows missing the field would otherwise
8+
* fall through and receive a "buy PRO!" email despite already paying.
9+
*
10+
* Idempotent: skips rows that already have a non-empty `normalizedEmail`.
11+
* Paginated: pass a `batchSize` (default 500). Re-run until `done: true`.
12+
*
13+
* Usage:
14+
* npx convex run payments/backfillCustomerNormalizedEmail:backfill
15+
* npx convex run payments/backfillCustomerNormalizedEmail:backfill '{"batchSize":1000}'
16+
*/
17+
import { v } from "convex/values";
18+
import { internalMutation, query } from "../_generated/server";
19+
20+
export const backfill = internalMutation({
21+
args: {
22+
batchSize: v.optional(v.number()),
23+
},
24+
handler: async (ctx, { batchSize }) => {
25+
const limit = batchSize ?? 500;
26+
const all = await ctx.db.query("customers").collect();
27+
28+
let scanned = 0;
29+
let patched = 0;
30+
let alreadySet = 0;
31+
let emptyEmail = 0;
32+
33+
for (const row of all) {
34+
scanned++;
35+
36+
if (row.normalizedEmail && row.normalizedEmail.length > 0) {
37+
alreadySet++;
38+
continue;
39+
}
40+
41+
const computed = (row.email ?? "").trim().toLowerCase();
42+
if (computed.length === 0) {
43+
emptyEmail++;
44+
await ctx.db.patch(row._id, { normalizedEmail: "" });
45+
continue;
46+
}
47+
48+
await ctx.db.patch(row._id, { normalizedEmail: computed });
49+
patched++;
50+
if (patched >= limit) break;
51+
}
52+
53+
const remaining = all.length - scanned;
54+
const done = remaining === 0;
55+
return { total: all.length, scanned, patched, alreadySet, emptyEmail, remaining, done };
56+
},
57+
});
58+
59+
/**
60+
* Diagnostic: how many customer rows still need backfilling?
61+
* Returns an exact count (full scan — only run from CLI, not hot paths).
62+
*/
63+
export const countPending = query({
64+
args: {},
65+
handler: async (ctx) => {
66+
const all = await ctx.db.query("customers").collect();
67+
let pending = 0;
68+
let withEmail = 0;
69+
let total = all.length;
70+
for (const row of all) {
71+
if (!row.normalizedEmail || row.normalizedEmail.length === 0) pending++;
72+
if (row.email && row.email.length > 0) withEmail++;
73+
}
74+
return { total, pending, withEmail };
75+
},
76+
});

convex/payments/subscriptionHelpers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ export async function handleSubscriptionActive(
333333
// Upsert customer record so portal session creation can find dodoCustomerId
334334
const dodoCustomerId = data.customer?.customer_id;
335335
const email = data.customer?.email ?? "";
336+
const normalizedEmail = email.trim().toLowerCase();
336337

337338
if (dodoCustomerId) {
338339
const existingCustomer = await ctx.db
@@ -346,13 +347,15 @@ export async function handleSubscriptionActive(
346347
await ctx.db.patch(existingCustomer._id, {
347348
userId,
348349
email,
350+
normalizedEmail,
349351
updatedAt: eventTimestamp,
350352
});
351353
} else {
352354
await ctx.db.insert("customers", {
353355
userId,
354356
dodoCustomerId,
355357
email,
358+
normalizedEmail,
356359
createdAt: eventTimestamp,
357360
updatedAt: eventTimestamp,
358361
});

convex/schema.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,19 @@ export default defineSchema({
223223
userId: v.string(),
224224
dodoCustomerId: v.optional(v.string()),
225225
email: v.string(),
226+
// Lowercased + trimmed mirror of `email`. Required for O(1) joins from
227+
// `registrations`/`emailSuppressions` (both keyed on `normalizedEmail`)
228+
// when building broadcast audiences — without this, dedup is a full
229+
// table scan and paid users can leak into "buy PRO!" sends.
230+
// Optional so existing rows pass schema validation; backfilled via
231+
// `npx convex run payments/backfillCustomerNormalizedEmail:backfill`.
232+
normalizedEmail: v.optional(v.string()),
226233
createdAt: v.number(),
227234
updatedAt: v.number(),
228235
})
229236
.index("by_userId", ["userId"])
230-
.index("by_dodoCustomerId", ["dodoCustomerId"]),
237+
.index("by_dodoCustomerId", ["dodoCustomerId"])
238+
.index("by_normalized_email", ["normalizedEmail"]),
231239

232240
webhookEvents: defineTable({
233241
webhookId: v.string(),

0 commit comments

Comments
 (0)