fix(twenty-partners): reuse existing company by domain in partner-application handler (#21615)

## 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).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21615?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Rashad Karanouh
2026-06-16 11:17:29 +04:00
committed by GitHub
parent 504eaa5600
commit 065b6efe11
3 changed files with 115 additions and 12 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "0.5.1",
"version": "0.5.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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);
@@ -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<string> {
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<string, string | undefined>;
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: {