From d5f12f9648e2cae374d024d3f6587eb08b11e631 Mon Sep 17 00:00:00 2001 From: Joe Birch Date: Mon, 27 Jul 2026 10:44:30 +0100 Subject: [PATCH] Add backwards compatibility for legacy GBP fields --- .../Buffer/__tests__/google-business.test.ts | 31 +++++ nodes/Buffer/__tests__/test-helpers.ts | 8 +- .../actions/post/networks/google-business.ts | 66 +++++++---- nodes/Buffer/descriptions/index.ts | 5 +- nodes/Buffer/descriptions/post.description.ts | 109 ++++++++++-------- 5 files changed, 141 insertions(+), 78 deletions(-) diff --git a/nodes/Buffer/__tests__/google-business.test.ts b/nodes/Buffer/__tests__/google-business.test.ts index 9b49e6e..5d242ec 100644 --- a/nodes/Buffer/__tests__/google-business.test.ts +++ b/nodes/Buffer/__tests__/google-business.test.ts @@ -253,6 +253,37 @@ describe('Buffer Node - Create Post', () => { }), ).rejects.toThrow('Google Business posts do not support video attachments'); }); + + // Workflows saved before "Specify Event Time" replaced the old "Full Day + // Event" toggle still have a `googleEventIsFullDay` value stored on the node, + // even though it's no longer part of this node's UI. These tests simulate + // that legacy saved shape to make sure old timed events don't silently + // become all-day events after upgrading. + describe('legacy googleEventIsFullDay workflows', () => { + it('should send the full start/end date-time and keep isFullDayEvent false for a legacy timed event', async () => { + const input = await executePostCreate({ + ...googleEventDefaults, + googleEventIsFullDay: false, + }); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsEvent as IDataObject; + expect(details.startDate).toBe('2026-07-01T10:00:00.000Z'); + expect(details.endDate).toBe('2026-07-01T18:00:00.000Z'); + expect(details.isFullDayEvent).toBe(false); + }); + + it('should send the full start/end date-time and keep isFullDayEvent true for a legacy all-day event', async () => { + const input = await executePostCreate({ + ...googleEventDefaults, + googleEventIsFullDay: true, + }); + const meta = (input.metadata as IDataObject).google as IDataObject; + const details = meta.detailsEvent as IDataObject; + expect(details.startDate).toBe('2026-07-01T10:00:00.000Z'); + expect(details.endDate).toBe('2026-07-01T18:00:00.000Z'); + expect(details.isFullDayEvent).toBe(true); + }); + }); }); // ------------------------------------------------------- diff --git a/nodes/Buffer/__tests__/test-helpers.ts b/nodes/Buffer/__tests__/test-helpers.ts index bf8c1c4..b066c1c 100644 --- a/nodes/Buffer/__tests__/test-helpers.ts +++ b/nodes/Buffer/__tests__/test-helpers.ts @@ -23,9 +23,9 @@ export async function executePostCreate(params: Record) { const mockContext = { getInputData: () => [{ json: {} }], - getNodeParameter: (name: string) => { + getNodeParameter: (name: string, _itemIndex: number, fallback?: unknown) => { if (!(name in defaults)) { - return ''; + return fallback !== undefined ? fallback : ''; } return defaults[name]; }, @@ -103,10 +103,10 @@ export async function runExecute( const mockContext = { getInputData: () => itemsDefaults.map(() => ({ json: {} })), - getNodeParameter: (name: string, itemIndex: number) => { + getNodeParameter: (name: string, itemIndex: number, fallback?: unknown) => { const defaults = itemsDefaults[itemIndex]; if (!defaults || !(name in defaults)) { - return ''; + return fallback !== undefined ? fallback : ''; } return defaults[name]; }, diff --git a/nodes/Buffer/actions/post/networks/google-business.ts b/nodes/Buffer/actions/post/networks/google-business.ts index 5b76e3d..23d4d00 100644 --- a/nodes/Buffer/actions/post/networks/google-business.ts +++ b/nodes/Buffer/actions/post/networks/google-business.ts @@ -46,34 +46,54 @@ export function buildGoogleBusinessMetadata(ctx: IExecuteFunctions, itemIndex: n const title = ctx.getNodeParameter('googleEventTitle', itemIndex) as string; const startDate = ctx.getNodeParameter('googleEventStartDate', itemIndex) as string; const endDate = ctx.getNodeParameter('googleEventEndDate', itemIndex) as string; - const hasTime = ctx.getNodeParameter('googleEventHasTime', itemIndex) as boolean; const button = ctx.getNodeParameter('googleEventButton', itemIndex) as string; const link = ctx.getNodeParameter('googleEventLink', itemIndex) as string; + // Workflows saved before "Specify Event Time" replaced the old "Full Day Event" + // toggle still carry a `googleEventIsFullDay` value in their stored parameters, + // even though that field no longer exists in this node's UI. The old behavior + // always sent the full start/end date-time and used this flag only to set + // isFullDayEvent, so replicate that exactly here rather than falling through to + // the new hasTime logic - otherwise old timed events would silently become + // all-day events (isFullDayEvent would flip from false to true) after upgrading. + const legacyIsFullDay = ctx.getNodeParameter('googleEventIsFullDay', itemIndex, null) as + | boolean + | null; + let eventStartDate: string; let eventEndDate: string; - if (hasTime) { - const startTime = ctx.getNodeParameter('googleEventStartTime', itemIndex) as string; - const endTime = ctx.getNodeParameter('googleEventEndTime', itemIndex) as string; - if (!startTime || !endTime) { - throw new NodeOperationError( - ctx.getNode(), - 'Please provide both an "Event Start Time" and "Event End Time", or disable "Specify Event Time" to create an all-day event.', - { itemIndex }, - ); - } - if (!TIME_FORMAT_REGEX.test(startTime) || !TIME_FORMAT_REGEX.test(endTime)) { - throw new NodeOperationError( - ctx.getNode(), - 'Event times must be in 24-hour HH:mm format, e.g. 14:30.', - { itemIndex }, - ); - } - eventStartDate = combineDateAndTime(startDate, startTime); - eventEndDate = combineDateAndTime(endDate, endTime); + let isFullDayEvent: boolean; + + if (legacyIsFullDay !== null) { + eventStartDate = new Date(startDate).toISOString(); + eventEndDate = new Date(endDate).toISOString(); + isFullDayEvent = legacyIsFullDay; } else { - eventStartDate = toDateOnlyIso(startDate); - eventEndDate = toDateOnlyIso(endDate); + const hasTime = ctx.getNodeParameter('googleEventHasTime', itemIndex) as boolean; + if (hasTime) { + const startTime = ctx.getNodeParameter('googleEventStartTime', itemIndex) as string; + const endTime = ctx.getNodeParameter('googleEventEndTime', itemIndex) as string; + if (!startTime || !endTime) { + throw new NodeOperationError( + ctx.getNode(), + 'Please provide both an "Event Start Time" and "Event End Time", or disable "Specify Event Time" to create an all-day event.', + { itemIndex }, + ); + } + if (!TIME_FORMAT_REGEX.test(startTime) || !TIME_FORMAT_REGEX.test(endTime)) { + throw new NodeOperationError( + ctx.getNode(), + 'Event times must be in 24-hour HH:mm format, e.g. 14:30.', + { itemIndex }, + ); + } + eventStartDate = combineDateAndTime(startDate, startTime); + eventEndDate = combineDateAndTime(endDate, endTime); + } else { + eventStartDate = toDateOnlyIso(startDate); + eventEndDate = toDateOnlyIso(endDate); + } + isFullDayEvent = !hasTime; } if (title) googleMeta.title = title; @@ -81,7 +101,7 @@ export function buildGoogleBusinessMetadata(ctx: IExecuteFunctions, itemIndex: n title, startDate: eventStartDate, endDate: eventEndDate, - isFullDayEvent: !hasTime, + isFullDayEvent, }; if (button) details.button = button; if (link) details.link = link; diff --git a/nodes/Buffer/descriptions/index.ts b/nodes/Buffer/descriptions/index.ts index 47f8809..19868f7 100644 --- a/nodes/Buffer/descriptions/index.ts +++ b/nodes/Buffer/descriptions/index.ts @@ -1,6 +1,6 @@ import type { INodeProperties } from 'n8n-workflow'; import { ideaProperties } from './idea.description'; -import { postCoreProperties, postAttachmentProperties, postAssetProperties } from './post.description'; +import { resourceProperty, postCoreProperties, postAttachmentProperties, postAssetProperties } from './post.description'; import { googleBusinessProperties } from './google-business.description'; import { pinterestProperties } from './pinterest.description'; import { facebookProperties } from './facebook.description'; @@ -9,7 +9,10 @@ import { youtubeProperties } from './youtube.description'; // Concatenated in the same order the fields originally appeared in Buffer.node.ts's `properties` // array, so the field order in the n8n UI is unchanged by the split into per-resource/network files. +// The Resource selector must come first, since the Idea/Post fields that follow are conditionally +// shown based on its value. export const properties: INodeProperties[] = [ + ...resourceProperty, ...ideaProperties, ...postCoreProperties, ...googleBusinessProperties, diff --git a/nodes/Buffer/descriptions/post.description.ts b/nodes/Buffer/descriptions/post.description.ts index d7f1803..579101c 100644 --- a/nodes/Buffer/descriptions/post.description.ts +++ b/nodes/Buffer/descriptions/post.description.ts @@ -1,11 +1,10 @@ import type { INodeProperties } from 'n8n-workflow'; -// Resource, Post's Operation field, core post fields (channel/text/scheduling), and both -// schedulingType field variants (see the comment on the field below for why there are two). -export const postCoreProperties: INodeProperties[] = [ - // ---------------------------------- - // Resources - // ---------------------------------- +// The Resource selector, kept as its own export so it can be placed first in the concatenated +// properties array (see descriptions/index.ts) — it must come before the Idea/Post-specific +// fields it controls, otherwise those fields render above it once "Idea" (the default) is +// selected, pushing the selector out of its expected position in the n8n UI. +export const resourceProperty: INodeProperties[] = [ { displayName: 'Resource', name: 'resource', @@ -23,6 +22,11 @@ export const postCoreProperties: INodeProperties[] = [ ], default: 'idea', }, +]; + +// Post's Operation field, core post fields (channel/text/scheduling), and both schedulingType +// field variants (see the comment on the field below for why there are two). +export const postCoreProperties: INodeProperties[] = [ // ---------------------------------- // Operations // ---------------------------------- @@ -236,30 +240,20 @@ export const postCoreProperties: INodeProperties[] = [ }, ]; -// The four attachmentType field variants: generic (hidden for YouTube/Google Business/Pinterest), -// YouTube-only (forces 'video'), Google Business-only (no video option), and Pinterest-only -// (forces 'image'). +// The four attachmentType field variants: YouTube-only (forces 'video'), Google Business-only +// (no video option), Pinterest-only (forces 'image'), and generic (hidden for those three +// channels). The generic variant is deliberately listed LAST: n8n resolves a duplicate-named +// field's initial default (before an org/channel has been selected, so channelService is empty +// and no displayOptions match yet) from the LAST matching property definition in the array, +// not by evaluating displayOptions. Listing the generic 'none' variant last ensures a freshly +// added node defaults to "No Attachment" instead of whichever network-specific variant happens +// to be last. export const postAttachmentProperties: INodeProperties[] = [ { - // YouTube posts are always videos, Google Business does not support video, and - // Pinterest posts are always images, so this field is hidden for those channels and - // replaced with the network-specific fields below (see the Scheduling Mode fields - // above for why this uses field-level displayOptions rather than per-option - // displayOptions). displayName: 'Attachment Type', name: 'attachmentType', type: 'options', options: [ - { - name: 'No Attachment', - value: 'none', - description: 'Create a text-only post', - }, - { - name: 'Image', - value: 'image', - description: 'Create a post with an image', - }, { name: 'Video', value: 'video', @@ -270,17 +264,11 @@ export const postAttachmentProperties: INodeProperties[] = [ show: { resource: ['post'], operation: ['create'], - }, - hide: { - channelService: [ - 'youtube', 'YouTube', 'YOUTUBE', - 'google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS', - 'pinterest', 'Pinterest', 'PINTEREST', - ], + channelService: ['youtube', 'YouTube', 'YOUTUBE'], }, }, - default: 'none', - description: 'Type of attachment to add to the post', + default: 'video', + description: 'YouTube posts only support video attachments', }, { displayName: 'Attachment Type', @@ -288,31 +276,31 @@ export const postAttachmentProperties: INodeProperties[] = [ type: 'options', options: [ { - name: 'Video', - value: 'video', - description: 'Create a post with a video', + name: 'No Attachment', + value: 'none', + description: 'Create a text-only post', + }, + { + name: 'Image', + value: 'image', + description: 'Create a post with an image', }, ], displayOptions: { show: { resource: ['post'], operation: ['create'], - channelService: ['youtube', 'YouTube', 'YOUTUBE'], + channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], }, }, - default: 'video', - description: 'YouTube posts only support video attachments', + default: 'none', + description: 'Google Business posts do not support video attachments', }, { displayName: 'Attachment Type', name: 'attachmentType', type: 'options', options: [ - { - name: 'No Attachment', - value: 'none', - description: 'Create a text-only post', - }, { name: 'Image', value: 'image', @@ -323,32 +311,53 @@ export const postAttachmentProperties: INodeProperties[] = [ show: { resource: ['post'], operation: ['create'], - channelService: ['google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS'], + channelService: ['pinterest', 'Pinterest', 'PINTEREST'], }, }, - default: 'none', - description: 'Google Business posts do not support video attachments', + default: 'image', + description: 'Pinterest posts always require an image attachment', }, { + // YouTube posts are always videos, Google Business does not support video, and + // Pinterest posts are always images, so this field is hidden for those channels and + // replaced with the network-specific fields above (see the Scheduling Mode fields + // above for why this uses field-level displayOptions rather than per-option + // displayOptions). displayName: 'Attachment Type', name: 'attachmentType', type: 'options', options: [ + { + name: 'No Attachment', + value: 'none', + description: 'Create a text-only post', + }, { name: 'Image', value: 'image', description: 'Create a post with an image', }, + { + name: 'Video', + value: 'video', + description: 'Create a post with a video', + }, ], displayOptions: { show: { resource: ['post'], operation: ['create'], - channelService: ['pinterest', 'Pinterest', 'PINTEREST'], + }, + hide: { + channelService: [ + 'youtube', 'YouTube', 'YOUTUBE', + 'google', 'Google', 'GOOGLE', 'googlebusiness', 'GoogleBusiness', 'GOOGLEBUSINESS', 'google_business', 'Google_Business', 'GOOGLE_BUSINESS', + 'pinterest', 'Pinterest', 'PINTEREST', + ], }, }, - default: 'image', - description: 'Pinterest posts always require an image attachment', + default: 'none', + description: 'Type of attachment to add to the post', }, ];