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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+46
@@ -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);
|
||||
|
||||
+68
-11
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user