From 065b6efe11637c4c31dcebb1a7f83926e76ef65a Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:17:29 +0400 Subject: [PATCH] fix(twenty-partners): reuse existing company by domain in partner-application handler (#21615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Partner applications **502** for any applicant whose company is already in the CRM. The `submit-partner-application` logic function dedupes applicants **only by person email**. When no person matches that email, it takes the create path and calls `createCompany` unconditionally. But `Company.domainName` has a **UNIQUE index**, so whenever a company with the applicant's domain already exists — which is common, since the **TFT import seeds companies** — the mutation throws `"duplicate entry"`. The handler's `catch` returns `{ ok: false }`, and the website `/api/partner-application` route surfaces it as a **502**. The applicant can never be submitted. Real case that surfaced this: an applicant whose company (`BKG Integration UG`, domain `bkg-integration.de`) was already present from the TFT import with no Partner/Person attached. ## Fix Extract `findOrCreateCompanyId`: - Look the company up by **exact domain** (`domainName.primaryLinkUrl eq`) and **reuse** it when found. - Only `createCompany` when no domain matches. - The matched company is **never renamed** — the existing CRM name wins over the applicant's free-text `companyName`. Person-email dedup is unchanged (already handled upstream in the handler). ### Known limitation Matches **active** rows only. A *soft-deleted* company still holds the unique index and would re-collide; clear those with `yarn purge:prod`. Noted inline. ## Tests Adds an integration test: pre-seed a company by domain → submit an application with the same domain → assert the partner reuses the same company id and the company name is untouched. ## Version `twenty-partners` 0.5.1 → **0.5.2** (patch: bug fix, no schema change). Review in cubic --- .../internal/twenty-partners/package.json | 2 +- ...it-partner-application.integration-test.ts | 46 +++++++++++ ...bmit-partner-application.logic-function.ts | 79 ++++++++++++++++--- 3 files changed, 115 insertions(+), 12 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 6036f458cf..e62aee3657 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -1,6 +1,6 @@ { "name": "twenty-partners", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.integration-test.ts index 4e35e19385..9cef48aa9f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/submit-partner-application.integration-test.ts @@ -122,6 +122,52 @@ describe('submit-partner-application handler — upsert', () => { expect(personNode?.name?.lastName).toBe('Lovelace'); }); + it('reuses an existing company for a protocol/www/case domain variant instead of failing on the unique domain index', async () => { + // A company with this domain already exists (e.g. seeded by the TFT import, + // with no Partner/Person attached). Company.domainName is uniquely indexed, + // so a blind createCompany would throw "duplicate entry" and the whole + // submission would 502. The applicant submits a www/case/trailing-slash + // variant of the same real domain — the handler must still reuse the + // existing company via normalized-host matching. + const storedDomain = 'https://reuse-domain-case.example.com'; + const submittedVariant = 'https://www.Reuse-Domain-Case.example.com/'; + const created = await client.mutation({ + createCompany: { + __args: { data: { name: 'Pre-existing Co', domainName: { primaryLinkUrl: storedDomain } } }, + id: true, + }, + }); + const existingCompanyId = created.createCompany?.id; + expect(existingCompanyId).toBeDefined(); + if (existingCompanyId === undefined) return; + createdCompanyIds.push(existingCompanyId); + + const result = await handler( + authedEvent( + baseInput({ + email: 'reuse.domain@example.com', + companyName: 'Applicant Co', + domainName: submittedVariant, + }), + ), + ); + await trackCreated(result); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.created).toBe(true); + + const partner = await client.query({ + partner: { + __args: { filter: { id: { eq: result.partnerId } } }, + company: { id: true, name: true }, + }, + }); + // Reused the existing company (same id) and left its name untouched. + expect(partner.partner?.company?.id).toBe(existingCompanyId); + expect(partner.partner?.company?.name).toBe('Pre-existing Co'); + }); + it('returns created: false and updates fields on resubmission for the same email', async () => { const first = await handler(authedEvent(baseInput({ email: 'update.case@example.com', city: 'London' }))); await trackCreated(first); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-application.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-application.logic-function.ts index 03a84b3ebb..f43e15f5b6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-application.logic-function.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/submit-partner-application.logic-function.ts @@ -106,6 +106,73 @@ function buildPartnerFields(input: SubmitPartnerApplicationInput): PartnerFields return fields; } +function normalizeDomainHost( + value: string | null | undefined, +): string | undefined { + if (!isNonEmptyString(value)) return undefined; + const host = value + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/^www\./, '') + .replace(/[/:?#].*$/, ''); + return host.length > 0 ? host : undefined; +} + +// ponytail: matches active rows only — soft-deleted companies still hold the unique index; clear those with `yarn purge:prod`. +async function findOrCreateCompanyId( + client: CoreApiClient, + input: SubmitPartnerApplicationInput, +): Promise { + const domain = isNonEmptyString(input.domainName) + ? input.domainName.trim() + : undefined; + const host = normalizeDomainHost(domain); + + if (host !== undefined) { + // Broad ilike catches every stored URL form (bare, any protocol, paths, www). + // Paginate to exhaustion so the real match is never paged out; client-side + // normalization rejects false positives on each page. + let cursor: string | null = null; + do { + const existing = await client.query({ + companies: { + __args: { + filter: { domainName: { primaryLinkUrl: { ilike: `%${host}%` } } }, + first: 20, + ...(cursor !== null ? { after: cursor } : {}), + }, + pageInfo: { hasNextPage: true, endCursor: true }, + edges: { node: { id: true, domainName: { primaryLinkUrl: true } } }, + }, + }); + const match = existing.companies?.edges?.find( + (edge) => normalizeDomainHost(edge.node.domainName?.primaryLinkUrl) === host, + ); + if (match !== undefined) { + return match.node.id; + } + const pageInfo = existing.companies?.pageInfo; + cursor = pageInfo?.hasNextPage ? (pageInfo.endCursor ?? null) : null; + } while (cursor !== null); + } + + const companyData: CoreSchema.CompanyCreateInput = { + name: input.companyName.trim(), + }; + if (domain !== undefined) { + companyData.domainName = { primaryLinkUrl: domain }; + } + const companyResult = await client.mutation({ + createCompany: { __args: { data: companyData }, id: true }, + }); + const companyId = companyResult.createCompany?.id; + if (companyId === undefined) { + throw new Error('createCompany did not return an id'); + } + return companyId; +} + type SubmitPartnerApplicationEvent = { headers?: Record; body?: unknown; @@ -191,17 +258,7 @@ export const handler = async ( return { ok: true, created: false, partnerId }; } - const companyData: CoreSchema.CompanyCreateInput = { name: input.companyName.trim() }; - if (isNonEmptyString(input.domainName)) { - companyData.domainName = { primaryLinkUrl: input.domainName.trim() }; - } - const companyResult = await client.mutation({ - createCompany: { __args: { data: companyData }, id: true }, - }); - const companyId = companyResult.createCompany?.id; - if (companyId === undefined) { - throw new Error('createCompany did not return an id'); - } + const companyId = await findOrCreateCompanyId(client, input); const partnerResult = await client.mutation({ createPartner: {