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).
---
.../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: {