From b127fcc2750cd3b2a6c61cec89b7e251ee7f5cd8 Mon Sep 17 00:00:00 2001 From: Abdulmumin Yaqeen Date: Wed, 8 Jul 2026 09:49:00 +0100 Subject: [PATCH] fix(api): make customer auto-create alias-safe --- apps/api/src/lib/customers.ts | 67 ++++++++++++++++- .../runtime/customers-auto-create.test.ts | 74 +++++++++++++++++++ apps/api/test/runtime/helpers/sqlite-d1.ts | 4 + .../0011_customer_alias_uniqueness.sql | 2 + packages/db/src/schema/billing.ts | 7 ++ 5 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 apps/api/test/runtime/customers-auto-create.test.ts create mode 100644 packages/db/migrations/0011_customer_alias_uniqueness.sql diff --git a/apps/api/src/lib/customers.ts b/apps/api/src/lib/customers.ts index d83f02f..3cebd97 100644 --- a/apps/api/src/lib/customers.ts +++ b/apps/api/src/lib/customers.ts @@ -1,10 +1,48 @@ import { eq } from "drizzle-orm"; import { schema, createDb } from "@owostack/db"; import { EntitlementCache } from "./cache"; -import { resolveCustomerByIdentifier } from "./customer-resolution"; +import { + resolveCustomerByEmail, + resolveCustomerByIdentifier, +} from "./customer-resolution"; import { autoAssignPlansToNewCustomer } from "./customer-auto-plans"; type DB = ReturnType; +type Customer = typeof schema.customers.$inferSelect; + +function isUniqueConstraintError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const message = "message" in error ? String(error.message) : ""; + return /unique constraint failed|constraint failed/i.test(message); +} + +async function resolveExistingCustomerAfterCreateConflict(opts: { + db: DB; + organizationId: string; + customerId: string; + email: string; + cache?: EntitlementCache | null; + waitUntil?: (promise: Promise) => void; +}): Promise { + const byIdentifier = await resolveCustomerByIdentifier({ + db: opts.db, + organizationId: opts.organizationId, + customerId: opts.customerId, + cache: opts.cache, + waitUntil: opts.waitUntil, + }); + if (byIdentifier?.customer) return byIdentifier.customer; + + const byEmail = await resolveCustomerByEmail({ + db: opts.db, + organizationId: opts.organizationId, + email: opts.email, + cache: opts.cache, + waitUntil: opts.waitUntil, + }); + + return byEmail?.customer ?? null; +} export interface CustomerData { email: string; @@ -133,10 +171,31 @@ export async function resolveOrCreateCustomer( updatedAt: now, }; - await db.insert(schema.customers).values(newCustomer); - customer = newCustomer as unknown as typeof schema.customers.$inferSelect; + let createdCustomer = false; + try { + await db.insert(schema.customers).values(newCustomer); + customer = newCustomer as unknown as typeof schema.customers.$inferSelect; + createdCustomer = true; + } catch (error) { + if (!isUniqueConstraintError(error)) { + throw error; + } + + customer = await resolveExistingCustomerAfterCreateConflict({ + db, + organizationId, + customerId, + email, + cache, + waitUntil: opts.waitUntil, + }); + + if (!customer) { + throw error; + } + } - if (opts.autoApplyPlansOnCreate) { + if (createdCustomer && opts.autoApplyPlansOnCreate) { await autoAssignPlansToNewCustomer({ db, organizationId, diff --git a/apps/api/test/runtime/customers-auto-create.test.ts b/apps/api/test/runtime/customers-auto-create.test.ts new file mode 100644 index 0000000..ae39553 --- /dev/null +++ b/apps/api/test/runtime/customers-auto-create.test.ts @@ -0,0 +1,74 @@ +import { and, eq } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { schema } from "@owostack/db"; +import { resolveOrCreateCustomer } from "../../src/lib/customers"; +import { createRuntimeBusinessDb } from "./helpers/business-db"; +import { insertOrganization } from "./helpers/workflow-runtime"; + +describe("resolveOrCreateCustomer runtime integration", () => { + let businessDb: ReturnType; + + beforeEach(async () => { + businessDb = createRuntimeBusinessDb(); + await insertOrganization(businessDb.d1, { id: "org_customer_create" }); + }); + + afterEach(() => { + businessDb.close(); + }); + + it("coalesces concurrent auto-create requests for the same organization-scoped aliases", async () => { + const created = await Promise.all( + Array.from({ length: 12 }, () => + resolveOrCreateCustomer({ + db: businessDb.db, + organizationId: "org_customer_create", + customerId: "external_user_123", + customerData: { + email: "USER@example.com", + name: "Runtime User", + }, + }), + ), + ); + + const customerIds = new Set(created.map((customer) => customer?.id)); + expect(customerIds.size).toBe(1); + + const rows = await businessDb.db.query.customers.findMany({ + where: and( + eq(schema.customers.organizationId, "org_customer_create"), + eq(schema.customers.email, "user@example.com"), + ), + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + organizationId: "org_customer_create", + externalId: "external_user_123", + email: "user@example.com", + name: "Runtime User", + }); + }); + + it("keeps the same email reusable across different organizations", async () => { + await insertOrganization(businessDb.d1, { id: "org_customer_create_2" }); + + const first = await resolveOrCreateCustomer({ + db: businessDb.db, + organizationId: "org_customer_create", + customerId: "shared_user", + customerData: { email: "shared@example.com" }, + }); + const second = await resolveOrCreateCustomer({ + db: businessDb.db, + organizationId: "org_customer_create_2", + customerId: "shared_user", + customerData: { email: "shared@example.com" }, + }); + + expect(first?.id).toBeTruthy(); + expect(second?.id).toBeTruthy(); + expect(first?.id).not.toBe(second?.id); + }); +}); diff --git a/apps/api/test/runtime/helpers/sqlite-d1.ts b/apps/api/test/runtime/helpers/sqlite-d1.ts index c1cb712..9a3eeb6 100644 --- a/apps/api/test/runtime/helpers/sqlite-d1.ts +++ b/apps/api/test/runtime/helpers/sqlite-d1.ts @@ -42,6 +42,10 @@ const MIGRATION_FILES = [ "../../../../../packages/db/migrations/0010_single_default_payment_method.sql", import.meta.url, ), + new URL( + "../../../../../packages/db/migrations/0011_customer_alias_uniqueness.sql", + import.meta.url, + ), ]; type SqliteRunResult = { diff --git a/packages/db/migrations/0011_customer_alias_uniqueness.sql b/packages/db/migrations/0011_customer_alias_uniqueness.sql new file mode 100644 index 0000000..341ea2d --- /dev/null +++ b/packages/db/migrations/0011_customer_alias_uniqueness.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX `customers_org_email_uniq` ON `customers` (`organization_id`, `email`);--> statement-breakpoint +CREATE UNIQUE INDEX `customers_org_external_uniq` ON `customers` (`organization_id`, `external_id`) WHERE `external_id` IS NOT NULL; diff --git a/packages/db/src/schema/billing.ts b/packages/db/src/schema/billing.ts index 243b35d..b096feb 100644 --- a/packages/db/src/schema/billing.ts +++ b/packages/db/src/schema/billing.ts @@ -46,6 +46,13 @@ export const customers = sqliteTable( index("customers_org_idx").on(table.organizationId), index("customers_email_idx").on(table.email), index("customers_external_idx").on(table.externalId), + uniqueIndex("customers_org_email_uniq").on( + table.organizationId, + table.email, + ), + uniqueIndex("customers_org_external_uniq") + .on(table.organizationId, table.externalId) + .where(sql`${table.externalId} is not null`), ], );