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: {