From 4f1ffa0a96403dc9a03a3b7f4b4e26baba81519d Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:37:41 +0400 Subject: [PATCH] fix(twenty-partners): coerce null fields in TFT opportunity import (#22017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The TFT `HTTP Request` action POSTs `null` for empty fields (e.g. `amountMicros:null`, `closeDate:null`). The import schema typed those as `z.number()/z.string().optional()`, which reject `null` (it is not `undefined`), so the endpoint returned `ok:false / invalid_input` before any API call. ## Fix A `dropNulls` preprocessor on the request schema converts `null` (top-level or nested) to "field absent" before validation. Null optional fields are simply omitted from the created opportunity; required `name` still fails correctly if null. No schema-shape or behaviour-contract change. ## Tests Added a case feeding the failing payload shape (`amountMicros:null`, `closeDate:null`) → `created:true` with `amount`/`closeDate` omitted. 42/42 unit pass, lint clean. Patch bump `1.1.1 → 1.1.2`. Review in cubic --- .../internal/twenty-partners/package.json | 2 +- .../import-opportunity-from-tft.test.ts | 24 +++++++++++++++++++ ...ort-opportunity-from-tft.logic-function.ts | 16 +++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index ca7ec99a77..739f9f9e0a 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": "1.1.1", + "version": "1.1.2", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts index defa896f31..bb735762d6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/import-opportunity-from-tft.test.ts @@ -95,4 +95,28 @@ describe('import-opportunity-from-tft handler', () => { pointOfContactId: 'person-1', }); }); + + it('drops null amountMicros/closeDate instead of failing validation', async () => { + queryMock + .mockResolvedValueOnce({ opportunities: { edges: [] } }) + .mockResolvedValueOnce({ companies: { edges: [] } }) + .mockResolvedValueOnce({ people: { edges: [] } }); + mutationMock + .mockResolvedValueOnce({ createCompany: { id: 'company-1' } }) + .mockResolvedValueOnce({ createPerson: { id: 'person-1' } }) + .mockResolvedValueOnce({ createOpportunity: { id: 'opp-1' } }); + + const body = { ...baseInput(), amountMicros: null, closeDate: null }; + const result = await handler({ + body, + headers: { 'x-application-secret': SECRET }, + }); + + expect(result).toEqual({ ok: true, created: true, id: 'opp-1' }); + const data = mutationMock.mock.calls.find( + ([arg]) => 'createOpportunity' in arg, + )?.[0].createOpportunity.__args.data; + expect(data).not.toHaveProperty('amount'); + expect(data).not.toHaveProperty('closeDate'); + }); }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts index 8d864bda95..aa82dd4da4 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/import-opportunity-from-tft.logic-function.ts @@ -12,8 +12,20 @@ export const IMPORT_OPPORTUNITY_FROM_TFT_LOGIC_FUNCTION_ID = const APPLICATION_SECRET_HEADER = 'x-application-secret'; +// TFT POSTs `null` for empty fields; treat null as "field absent". +const dropNulls = (value: unknown): unknown => + value === null + ? undefined + : Array.isArray(value) + ? value.map(dropNulls) + : typeof value === 'object' + ? Object.fromEntries( + Object.entries(value).map(([key, val]) => [key, dropNulls(val)]), + ) + : value; + // Request contract — the JSON the TFT workflow POSTs. -export const importOpportunityFromTftSchema = z.object({ +export const importOpportunityFromTftSchema = z.preprocess(dropNulls, z.object({ tftOpportunityId: z.string().optional(), name: z.string().trim().min(1), amountMicros: z.number().optional(), @@ -33,7 +45,7 @@ export const importOpportunityFromTftSchema = z.object({ lastName: z.string().optional(), }) .optional(), -}); +})); export type ImportOpportunityFromTftInput = z.infer< typeof importOpportunityFromTftSchema