fix(Pipedrive Node): Format date-only fields as YYYY-MM-DD (#30891)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
This commit is contained in:
Alexander Gekov 2026-05-22 16:42:25 +03:00 committed by GitHub
parent 1864916665
commit ca74a8367d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 364 additions and 15 deletions

View File

@ -2,7 +2,12 @@ import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import { encodeCustomFieldsV2, resolveCustomFieldsV2 } from '../../v2/helpers/customFields';
import { addFieldsToBody } from '../../v2/helpers/fields';
import { coerceToBoolean, coerceToNumber, toRfc3339 } from '../../v2/helpers/typeCoercion';
import {
coerceToBoolean,
coerceToNumber,
toDateOnly,
toRfc3339,
} from '../../v2/helpers/typeCoercion';
import type { ICustomProperties } from '../../v2/transport';
const customProperties: ICustomProperties = {
@ -388,4 +393,30 @@ describe('Pipedrive v2 Type Coercion', () => {
expect(toRfc3339('2024-01-15')).toBe('2024-01-15');
});
});
describe('toDateOnly', () => {
it('should pass through YYYY-MM-DD unchanged', () => {
expect(toDateOnly('2024-01-15')).toBe('2024-01-15');
});
it('should strip the time component from an ISO datetime', () => {
expect(toDateOnly('2024-01-15T00:00:00.000Z')).toBe('2024-01-15');
expect(toDateOnly('2024-01-15T14:30:00Z')).toBe('2024-01-15');
});
it('should strip the time component from a space-separated datetime', () => {
expect(toDateOnly('2024-01-15 14:30:00')).toBe('2024-01-15');
});
it('should return an empty input unchanged', () => {
expect(toDateOnly('')).toBe('');
});
it('should return the original value when it cannot be parsed', () => {
expect(toDateOnly('not-a-date')).toBe('not-a-date');
});
it('should return non-standard inputs verbatim instead of shifting the calendar day through UTC', () => {
// Parsing 'Jan 15, 2024' as a local-time Date and converting via
// toISOString() can yield 2024-01-14 in timezones east of UTC.
// We deliberately avoid that conversion, so the input is passed
// through and Pipedrive surfaces the real validation error.
expect(toDateOnly('Jan 15, 2024')).toBe('Jan 15, 2024');
});
});
});

View File

@ -0,0 +1,124 @@
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
import { execute as activityCreateExecute } from '../../v2/actions/activity/create.operation';
import { execute as activityUpdateExecute } from '../../v2/actions/activity/update.operation';
import { execute as dealCreateExecute } from '../../v2/actions/deal/create.operation';
import { execute as dealUpdateExecute } from '../../v2/actions/deal/update.operation';
import { pipedriveApiRequest, pipedriveGetCustomProperties } from '../../v2/transport';
jest.mock('../../v2/transport', () => ({
pipedriveApiRequest: { call: jest.fn() },
pipedriveApiRequestAllItemsCursor: { call: jest.fn() },
pipedriveApiRequestAllItemsOffset: { call: jest.fn() },
pipedriveGetCustomProperties: { call: jest.fn() },
}));
const mockApiRequest = pipedriveApiRequest as unknown as { call: jest.Mock };
const mockGetCustomProperties = pipedriveGetCustomProperties as unknown as { call: jest.Mock };
function buildContext(params: Record<string, unknown>): IExecuteFunctions {
return {
getInputData: jest.fn(() => [{ json: {} }]),
getNodeParameter: jest.fn((name: string, _i?: number, defaultValue?: unknown) => {
if (Object.prototype.hasOwnProperty.call(params, name)) return params[name];
return defaultValue;
}),
continueOnFail: jest.fn(() => false),
helpers: {
returnJsonArray: jest.fn((data: unknown) => (Array.isArray(data) ? data : [data])),
constructExecutionMetaData: jest.fn((items: unknown) => items),
},
getNode: jest.fn(() => ({})),
} as unknown as IExecuteFunctions;
}
describe('Pipedrive v2 normalises ISO date inputs to YYYY-MM-DD on outgoing requests', () => {
beforeEach(() => {
mockApiRequest.call.mockReset().mockResolvedValue({ data: {}, additionalData: {} });
mockGetCustomProperties.call.mockReset().mockResolvedValue({});
});
it('deal/create strips the time component from expected_close_date', async () => {
const ctx = buildContext({
rawCustomFieldKeys: true,
title: 'Test Deal',
associateWith: 'organization',
org_id: 7,
additionalFields: { expected_close_date: '2026-04-13T00:00:00.000Z' },
});
await dealCreateExecute.call(ctx);
const [, method, endpoint, body] = mockApiRequest.call.mock.calls[0] as [
unknown,
string,
string,
IDataObject,
];
expect(method).toBe('POST');
expect(endpoint).toBe('/deals');
expect(body.expected_close_date).toBe('2026-04-13');
});
it('deal/update strips the time component from expected_close_date', async () => {
const ctx = buildContext({
rawCustomFieldKeys: true,
dealId: 10,
updateFields: { expected_close_date: '2026-04-13T00:00:00.000Z' },
});
await dealUpdateExecute.call(ctx);
const [, method, endpoint, body] = mockApiRequest.call.mock.calls[0] as [
unknown,
string,
string,
IDataObject,
];
expect(method).toBe('PATCH');
expect(endpoint).toBe('/deals/10');
expect(body.expected_close_date).toBe('2026-04-13');
});
it('activity/create strips the time component from due_date', async () => {
const ctx = buildContext({
rawCustomFieldKeys: true,
subject: 'Call client',
done: false,
type: 'call',
additionalFields: { due_date: '2026-04-01T00:00:00.000Z' },
});
await activityCreateExecute.call(ctx);
const [, method, endpoint, body] = mockApiRequest.call.mock.calls[0] as [
unknown,
string,
string,
IDataObject,
];
expect(method).toBe('POST');
expect(endpoint).toBe('/activities');
expect(body.due_date).toBe('2026-04-01');
});
it('activity/update strips the time component from due_date', async () => {
const ctx = buildContext({
rawCustomFieldKeys: true,
activityId: 10,
updateFields: { due_date: '2026-04-02T00:00:00.000Z' },
});
await activityUpdateExecute.call(ctx);
const [, method, endpoint, body] = mockApiRequest.call.mock.calls[0] as [
unknown,
string,
string,
IDataObject,
];
expect(method).toBe('PATCH');
expect(endpoint).toBe('/activities/10');
expect(body.due_date).toBe('2026-04-02');
});
});

View File

@ -15,6 +15,7 @@ describe('Test PipedriveV2, activity => create', () => {
deal_id: 8,
person_id: 10,
org_id: 7,
due_date: '2026-04-01',
})
.reply(200, {
success: true,

View File

@ -20,7 +20,8 @@
"additionalFields": {
"deal_id": 8,
"person_id": 10,
"org_id": 7
"org_id": 7,
"due_date": "2026-04-01T00:00:00.000Z"
}
},
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",

View File

@ -11,6 +11,7 @@ describe('Test PipedriveV2, activity => update', () => {
.patch('/activities/10', {
subject: 'Updated call',
done: true,
due_date: '2026-04-02',
})
.reply(200, {
success: true,

View File

@ -17,7 +17,8 @@
"activityId": 10,
"updateFields": {
"subject": "Updated call",
"done": true
"done": true,
"due_date": "2026-04-02T00:00:00.000Z"
}
},
"id": "22222222-3333-4444-5555-666666666666",

View File

@ -0,0 +1,66 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import { credentials } from '../credentials';
import { mockFieldsApi } from '../fieldsApiMock';
describe('Test PipedriveV2, deal => create (date-only normalisation)', () => {
mockFieldsApi('deal');
nock('https://api.pipedrive.com/api/v2')
.post('/deals', {
title: 'Test Deal',
org_id: 7,
expected_close_date: '2026-04-13',
})
.reply(200, {
success: true,
data: {
id: 9,
title: 'Test Deal',
creator_user_id: 25455458,
value: null,
person_id: null,
org_id: 7,
stage_id: 6,
currency: 'USD',
add_time: '2026-04-01T22:03:27Z',
update_time: null,
status: 'open',
probability: null,
lost_reason: null,
visible_to: 3,
close_time: null,
pipeline_id: 2,
won_time: null,
lost_time: null,
stage_change_time: null,
local_won_date: null,
local_lost_date: null,
local_close_date: null,
expected_close_date: '2026-04-13',
custom_fields: {
test_string: null,
test_enum: null,
test_multi: null,
},
owner_id: 25455458,
label_ids: [],
is_deleted: false,
origin: 'API',
origin_id: null,
channel: null,
channel_id: null,
acv: null,
arr: null,
mrr: null,
is_archived: false,
archive_time: null,
},
});
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['createWithDate.workflow.json'],
});
});

View File

@ -0,0 +1,101 @@
{
"name": "Pipedrive v2 deal create with date test",
"nodes": [
{
"parameters": {},
"id": "aa111111-bb22-cc33-dd44-ee5555555555",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"authentication": "apiToken",
"resource": "deal",
"operation": "create",
"title": "Test Deal",
"associateWith": "organization",
"org_id": 7,
"additionalFields": {
"expected_close_date": "2026-04-13T00:00:00.000Z"
}
},
"id": "bb222222-cc33-dd44-ee55-ff6666666666",
"name": "Pipedrive",
"type": "n8n-nodes-base.pipedrive",
"typeVersion": 2,
"position": [200, 0],
"credentials": {
"pipedriveApi": {
"id": "cred-1",
"name": "Pipedrive API"
}
}
},
{
"parameters": {},
"id": "cc333333-dd44-ee55-ff66-aa7777777777",
"name": "No Operation, do nothing",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [400, 0]
}
],
"pinData": {
"No Operation, do nothing": [
{
"json": {
"id": 9,
"title": "Test Deal",
"creator_user_id": 25455458,
"value": null,
"person_id": null,
"org_id": 7,
"stage_id": 6,
"currency": "USD",
"add_time": "2026-04-01T22:03:27Z",
"update_time": null,
"status": "open",
"probability": null,
"lost_reason": null,
"visible_to": 3,
"close_time": null,
"pipeline_id": 2,
"won_time": null,
"lost_time": null,
"stage_change_time": null,
"local_won_date": null,
"local_lost_date": null,
"local_close_date": null,
"expected_close_date": "2026-04-13",
"custom_fields": {
"test_string": null,
"test_enum": null,
"test_multi": null
},
"owner_id": 25455458,
"label_ids": [],
"is_deleted": false,
"origin": "API",
"origin_id": null,
"channel": null,
"channel_id": null,
"acv": null,
"arr": null,
"mrr": null,
"is_archived": false,
"archive_time": null
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [[{ "node": "Pipedrive", "type": "main", "index": 0 }]]
},
"Pipedrive": {
"main": [[{ "node": "No Operation, do nothing", "type": "main", "index": 0 }]]
}
}
}

View File

@ -11,6 +11,7 @@ describe('Test PipedriveV2, deal => update', () => {
.patch('/deals/10', {
title: 'Updated Deal',
value: 7500,
expected_close_date: '2026-04-13',
})
.reply(200, {
success: true,
@ -37,7 +38,7 @@ describe('Test PipedriveV2, deal => update', () => {
local_won_date: null,
local_lost_date: null,
local_close_date: null,
expected_close_date: null,
expected_close_date: '2026-04-13',
custom_fields: {
f5ed368466cf0477371c6ee076252f49a188848e: null,
'48233ee3e9d505bbcca8e7e05d9b8df4231021bc': null,

View File

@ -17,7 +17,8 @@
"dealId": 10,
"updateFields": {
"title": "Updated Deal",
"value": 7500
"value": 7500,
"expected_close_date": "2026-04-13T00:00:00.000Z"
}
},
"id": "b8b8b8b8-1111-2222-3333-444444444444",
@ -67,7 +68,7 @@
"local_won_date": null,
"local_lost_date": null,
"local_close_date": null,
"expected_close_date": null,
"expected_close_date": "2026-04-13",
"custom_fields": {
"test_string": null,
"test_enum": null,

View File

@ -11,7 +11,7 @@ import {
encodeCustomFieldsV2,
resolveCustomFieldsV2,
coerceToBoolean,
toRfc3339,
toDateOnly,
addFieldsToBody,
} from '../../helpers';
import { customFieldsCollection, rawCustomFieldKeysOption } from '../common.description';
@ -138,7 +138,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
addFieldsToBody(body, additionalFields);
if (body.due_date) {
body.due_date = toRfc3339(body.due_date as string);
body.due_date = toDateOnly(body.due_date as string);
}
if (customProperties) {

View File

@ -11,7 +11,7 @@ import {
encodeCustomFieldsV2,
resolveCustomFieldsV2,
coerceToBoolean,
toRfc3339,
toDateOnly,
addFieldsToBody,
} from '../../helpers';
import { customFieldsCollection, rawCustomFieldKeysOption } from '../common.description';
@ -161,7 +161,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
}
if (body.due_date) {
body.due_date = toRfc3339(body.due_date as string);
body.due_date = toDateOnly(body.due_date as string);
}
if (customProperties) {

View File

@ -11,7 +11,7 @@ import {
encodeCustomFieldsV2,
resolveCustomFieldsV2,
coerceToNumber,
toRfc3339,
toDateOnly,
addFieldsToBody,
} from '../../helpers';
import {
@ -247,7 +247,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
addFieldsToBody(body, additionalFields);
if (body.expected_close_date) {
body.expected_close_date = toRfc3339(body.expected_close_date as string);
body.expected_close_date = toDateOnly(body.expected_close_date as string);
}
if (body.value !== undefined) {
body.value = coerceToNumber(body.value);

View File

@ -11,7 +11,7 @@ import {
encodeCustomFieldsV2,
resolveCustomFieldsV2,
coerceToNumber,
toRfc3339,
toDateOnly,
addFieldsToBody,
} from '../../helpers';
import {
@ -200,7 +200,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
}
if (body.expected_close_date) {
body.expected_close_date = toRfc3339(body.expected_close_date as string);
body.expected_close_date = toDateOnly(body.expected_close_date as string);
}
if (body.value !== undefined) {
body.value = coerceToNumber(body.value);

View File

@ -1,6 +1,6 @@
export { encodeCustomFieldsV2, resolveCustomFieldsV2 } from './customFields';
export { coerceToBoolean, coerceToNumber, toRfc3339 } from './typeCoercion';
export { coerceToBoolean, coerceToNumber, toDateOnly, toRfc3339 } from './typeCoercion';
export { addFieldsToBody } from './fields';

View File

@ -38,3 +38,24 @@ export function toRfc3339(value: string): string {
if (Number.isNaN(date.getTime())) return value;
return date.toISOString();
}
/**
* Reduces any supported date/datetime input to a YYYY-MM-DD string for
* Pipedrive fields that only accept a calendar date (e.g. deal
* `expected_close_date`, activity `due_date`). The dateTime widget emits
* ISO 8601 strings with a time component, which Pipedrive rejects for
* these fields.
*
* Inputs that don't match the expected shapes are returned verbatim so
* Pipedrive can surface the real validation error we deliberately avoid
* `new Date(value).toISOString()` here because that converts through UTC
* and can shift the calendar day for timezone-less inputs parsed in a
* non-UTC server timezone.
*/
export function toDateOnly(value: string): string {
if (!value) return value;
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
const datePart = value.split('T')[0].split(' ')[0];
if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return datePart;
return value;
}