fix(twenty-partners): coerce null fields in TFT opportunity import (#22017)

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22017?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-23 17:37:41 +04:00
committed by GitHub
parent c52c983b90
commit 4f1ffa0a96
3 changed files with 39 additions and 3 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "1.1.1",
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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');
});
});
@@ -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