diff --git a/packages/nodes-base/credentials/GoogleMyBusinessOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleMyBusinessOAuth2Api.credentials.ts new file mode 100644 index 00000000000..b837303ca3a --- /dev/null +++ b/packages/nodes-base/credentials/GoogleMyBusinessOAuth2Api.credentials.ts @@ -0,0 +1,29 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +const scopes = ['https://www.googleapis.com/auth/business.manage']; + +export class GoogleMyBusinessOAuth2Api implements ICredentialType { + name = 'googleMyBusinessOAuth2Api'; + + extends = ['googleOAuth2Api']; + + displayName = 'Google My Business OAuth2 API'; + + documentationUrl = 'google/oauth-single-service'; + + properties: INodeProperties[] = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: scopes.join(' '), + }, + { + displayName: + 'Make sure that you have fulfilled the prerequisites and requested access to Google My Business API. More info. Also, make sure that you have enabled the following APIs & Services in the Google Cloud Console: Google My Business API, Google My Business Management API. More info.', + name: 'notice', + type: 'notice', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts b/packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts new file mode 100644 index 00000000000..9813b3a3e83 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts @@ -0,0 +1,520 @@ +import { + NodeApiError, + NodeOperationError, + type DeclarativeRestApiSettings, + type IDataObject, + type IExecutePaginationFunctions, + type IExecuteSingleFunctions, + type IHttpRequestMethods, + type IHttpRequestOptions, + type ILoadOptionsFunctions, + type IN8nHttpFullResponse, + type INodeExecutionData, + type INodeListSearchItems, + type INodeListSearchResult, + type IPollFunctions, + type JsonObject, +} from 'n8n-workflow'; + +import type { ITimeInterval } from './Interfaces'; + +const addOptName = 'additionalOptions'; +const possibleRootProperties = ['localPosts', 'reviews']; + +const getAllParams = (execFns: IExecuteSingleFunctions): Record => { + const params = execFns.getNode().parameters; + const additionalOptions = execFns.getNodeParameter(addOptName, {}) as Record; + + // Merge standard parameters with additional options from the node parameters + return { ...params, ...additionalOptions }; +}; + +/* Helper function to adjust date-time parameters for API requests */ +export async function handleDatesPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const params = getAllParams(this); + const body = Object.assign({}, opts.body) as IDataObject; + const event = (body.event as IDataObject) ?? ({} as IDataObject); + + if (!params.startDateTime && !params.startDate && !params.endDateTime && !params.endDate) { + return opts; + } + + const createDateTimeObject = (dateString: string) => { + const date = new Date(dateString); + return { + date: { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + }, + time: dateString.includes('T') + ? { + hours: date.getUTCHours(), + minutes: date.getUTCMinutes(), + seconds: date.getUTCSeconds(), + nanos: 0, + } + : undefined, + }; + }; + + // Convert start and end date-time parameters if provided + const startDateTime = + params.startDateTime || params.startDate + ? createDateTimeObject((params.startDateTime || params.startDate) as string) + : null; + const endDateTime = + params.endDateTime || params.endDate + ? createDateTimeObject((params.endDateTime || params.endDate) as string) + : null; + + const schedule: Partial = { + startDate: startDateTime?.date, + endDate: endDateTime?.date, + startTime: startDateTime?.time, + endTime: endDateTime?.time, + }; + + event.schedule = schedule; + Object.assign(body, { event }); + opts.body = body; + return opts; +} + +/* Helper function adding update mask to the request */ +export async function addUpdateMaskPresend( + this: IExecuteSingleFunctions, + opts: IHttpRequestOptions, +): Promise { + const additionalOptions = this.getNodeParameter('additionalOptions') as IDataObject; + const propertyMapping: { [key: string]: string } = { + postType: 'topicType', + actionType: 'actionType', + callToActionType: 'callToAction.actionType', + url: 'callToAction.url', + startDateTime: 'event.schedule.startDate,event.schedule.startTime', + endDateTime: 'event.schedule.endDate,event.schedule.endTime', + title: 'event.title', + startDate: 'event.schedule.startDate', + endDate: 'event.schedule.endDate', + couponCode: 'offer.couponCode', + redeemOnlineUrl: 'offer.redeemOnlineUrl', + termsAndConditions: 'offer.termsAndConditions', + }; + + if (Object.keys(additionalOptions).length) { + const updateMask = Object.keys(additionalOptions) + .map((key) => propertyMapping[key] || key) + .join(','); + opts.qs = { + ...opts.qs, + updateMask, + }; + } + + return opts; +} + +/* Helper function to handle pagination */ +export async function handlePagination( + this: IExecutePaginationFunctions, + resultOptions: DeclarativeRestApiSettings.ResultOptions, +): Promise { + const aggregatedResult: IDataObject[] = []; + let nextPageToken: string | undefined; + const returnAll = this.getNodeParameter('returnAll') as boolean; + let limit = 100; + if (!returnAll) { + limit = this.getNodeParameter('limit') as number; + resultOptions.maxResults = limit; + } + resultOptions.paginate = true; + + do { + if (nextPageToken) { + resultOptions.options.qs = { ...resultOptions.options.qs, pageToken: nextPageToken }; + } + + const responseData = await this.makeRoutingRequest(resultOptions); + + for (const page of responseData) { + for (const prop of possibleRootProperties) { + if (page.json[prop]) { + const currentData = page.json[prop] as IDataObject[]; + aggregatedResult.push(...currentData); + } + } + + if (!returnAll && aggregatedResult.length >= limit) { + return aggregatedResult.slice(0, limit).map((item) => ({ json: item })); + } + + nextPageToken = page.json.nextPageToken as string | undefined; + } + } while (nextPageToken); + + return aggregatedResult.map((item) => ({ json: item })); +} + +/* Helper functions to handle errors */ + +export async function handleErrorsDeletePost( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const post = this.getNodeParameter('post', undefined) as IDataObject; + + // Provide a better error message + if (post && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The post you are deleting could not be found. Adjust the "post" parameter setting to delete the post correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsGetPost( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const post = this.getNodeParameter('post', undefined) as IDataObject; + + // Provide a better error message + if (post && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The post you are requesting could not be found. Adjust the "post" parameter setting to retrieve the post correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsUpdatePost( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const post = this.getNodeParameter('post') as IDataObject; + const additionalOptions = this.getNodeParameter('additionalOptions') as IDataObject; + + // Provide a better error message + if (post && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The post you are updating could not be found. Adjust the "post" parameter setting to update the post correctly.', + ); + } + + // Do not throw an error if the user didn't set additional options (a hint will be shown) + if (response.statusCode === 400 && Object.keys(additionalOptions).length === 0) { + return [{ json: { success: true } }]; + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsDeleteReply( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const review = this.getNodeParameter('review', undefined) as IDataObject; + + // Provide a better error message + if (review && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The review you are deleting could not be found. Adjust the "review" parameter setting to update the review correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsGetReview( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const review = this.getNodeParameter('review', undefined) as IDataObject; + + // Provide a better error message + if (review && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The review you are requesting could not be found. Adjust the "review" parameter setting to update the review correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +export async function handleErrorsReplyToReview( + this: IExecuteSingleFunctions, + data: INodeExecutionData[], + response: IN8nHttpFullResponse, +): Promise { + if (response.statusCode < 200 || response.statusCode >= 300) { + const review = this.getNodeParameter('review', undefined) as IDataObject; + + // Provide a better error message + if (review && response.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The review you are replying to could not be found. Adjust the "review" parameter setting to reply to the review correctly.', + ); + } + + throw new NodeApiError(this.getNode(), response.body as JsonObject, { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }); + } + return data; +} + +/* Helper function used in listSearch methods */ +export async function googleApiRequest( + this: ILoadOptionsFunctions | IPollFunctions, + method: IHttpRequestMethods, + resource: string, + body: IDataObject = {}, + qs: IDataObject = {}, + url?: string, +): Promise { + const options: IHttpRequestOptions = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + url: url ?? `https://mybusiness.googleapis.com/v4${resource}`, + json: true, + }; + try { + if (Object.keys(body).length === 0) { + delete options.body; + } + + return (await this.helpers.httpRequestWithAuthentication.call( + this, + 'googleMyBusinessOAuth2Api', + options, + )) as IDataObject; + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +/* listSearch methods */ + +export async function searchAccounts( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + // Docs for this API call can be found here: + // https://developers.google.com/my-business/reference/accountmanagement/rest/v1/accounts/list + const query: IDataObject = {}; + if (paginationToken) { + query.pageToken = paginationToken; + } + + const responseData: IDataObject = await googleApiRequest.call( + this, + 'GET', + '', + {}, + { + pageSize: 20, + ...query, + }, + 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts', + ); + + const accounts = responseData.accounts as Array<{ name: string; accountName: string }>; + + const results: INodeListSearchItems[] = accounts + .map((a) => ({ + name: a.accountName, + value: a.name, + })) + .filter( + (a) => + !filter || + a.name.toLowerCase().includes(filter.toLowerCase()) || + a.value.toLowerCase().includes(filter.toLowerCase()), + ) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.nextPageToken }; +} + +export async function searchLocations( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + // Docs for this API call can be found here: + // https://developers.google.com/my-business/reference/businessinformation/rest/v1/accounts.locations/list + const query: IDataObject = {}; + if (paginationToken) { + query.pageToken = paginationToken; + } + + const account = (this.getNodeParameter('account') as IDataObject).value as string; + + const responseData: IDataObject = await googleApiRequest.call( + this, + 'GET', + '', + {}, + { + readMask: 'name', + pageSize: 100, + ...query, + }, + `https://mybusinessbusinessinformation.googleapis.com/v1/${account}/locations`, + ); + + const locations = responseData.locations as Array<{ name: string }>; + + const results: INodeListSearchItems[] = locations + .map((a) => ({ + name: a.name, + value: a.name, + })) + .filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase())) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.nextPageToken }; +} + +export async function searchReviews( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const query: IDataObject = {}; + if (paginationToken) { + query.pageToken = paginationToken; + } + + const account = (this.getNodeParameter('account') as IDataObject).value as string; + const location = (this.getNodeParameter('location') as IDataObject).value as string; + + const responseData: IDataObject = await googleApiRequest.call( + this, + 'GET', + `/${account}/${location}/reviews`, + {}, + { + pageSize: 50, + ...query, + }, + ); + + const reviews = responseData.reviews as Array<{ name: string; comment: string }>; + + const results: INodeListSearchItems[] = reviews + .map((a) => ({ + name: a.comment, + value: a.name, + })) + .filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase())) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.nextPageToken }; +} + +export async function searchPosts( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const query: IDataObject = {}; + if (paginationToken) { + query.pageToken = paginationToken; + } + + const account = (this.getNodeParameter('account') as IDataObject).value as string; + const location = (this.getNodeParameter('location') as IDataObject).value as string; + + const responseData: IDataObject = await googleApiRequest.call( + this, + 'GET', + `/${account}/${location}/localPosts`, + {}, + { + pageSize: 100, + ...query, + }, + ); + + const localPosts = responseData.localPosts as Array<{ name: string; summary: string }>; + + const results: INodeListSearchItems[] = localPosts + .map((a) => ({ + name: a.summary, + value: a.name, + })) + .filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase())) + .sort((a, b) => { + if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; + return 0; + }); + + return { results, paginationToken: responseData.nextPageToken }; +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.json b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.json new file mode 100644 index 00000000000..aeff09c9e96 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.googleMyBusiness", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Marketing", "Productivity"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlemybusiness/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.ts b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.ts new file mode 100644 index 00000000000..5cafd943754 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusiness.node.ts @@ -0,0 +1,77 @@ +import { NodeConnectionType, type INodeType, type INodeTypeDescription } from 'n8n-workflow'; + +import { searchAccounts, searchLocations, searchPosts, searchReviews } from './GenericFunctions'; +import { postFields, postOperations } from './PostDescription'; +import { reviewFields, reviewOperations } from './ReviewDescription'; + +export class GoogleMyBusiness implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google My Business', + name: 'googleMyBusiness', + icon: 'file:googleMyBusines.svg', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Google My Business API', + defaults: { + name: 'Google My Business', + }, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + hints: [ + { + message: 'Please select a parameter in the options to modify the post', + displayCondition: + '={{$parameter["resource"] === "post" && $parameter["operation"] === "update" && Object.keys($parameter["additionalOptions"]).length === 0}}', + whenToDisplay: 'always', + location: 'outputPane', + type: 'warning', + }, + ], + credentials: [ + { + name: 'googleMyBusinessOAuth2Api', + required: true, + }, + ], + requestDefaults: { + baseURL: 'https://mybusiness.googleapis.com/v4', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Post', + value: 'post', + }, + { + name: 'Review', + value: 'review', + }, + ], + default: 'post', + }, + ...postOperations, + ...postFields, + ...reviewOperations, + ...reviewFields, + ], + }; + + methods = { + listSearch: { + searchAccounts, + searchLocations, + searchReviews, + searchPosts, + }, + }; +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.json b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.json new file mode 100644 index 00000000000..a456fdd38cf --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.googleMyBusinessTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Communication"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.googlemybusinesstrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.ts b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.ts new file mode 100644 index 00000000000..07333d377be --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.ts @@ -0,0 +1,192 @@ +import { + NodeApiError, + NodeConnectionType, + type IPollFunctions, + type IDataObject, + type INodeExecutionData, + type INodeType, + type INodeTypeDescription, +} from 'n8n-workflow'; + +import { googleApiRequest, searchAccounts, searchLocations } from './GenericFunctions'; + +export class GoogleMyBusinessTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google My Business Trigger', + name: 'googleMyBusinessTrigger', + icon: 'file:googleMyBusines.svg', + group: ['trigger'], + version: 1, + description: + 'Fetches reviews from Google My Business and starts the workflow on specified polling intervals.', + subtitle: '={{"Google My Business Trigger"}}', + defaults: { + name: 'Google My Business Trigger', + }, + credentials: [ + { + name: 'googleMyBusinessOAuth2Api', + required: true, + }, + ], + polling: true, + inputs: [], + outputs: [NodeConnectionType.Main], + properties: [ + { + displayName: 'Event', + name: 'event', + required: true, + type: 'options', + noDataExpression: true, + default: 'reviewAdded', + options: [ + { + name: 'Review Added', + value: 'reviewAdded', + }, + ], + }, + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { event: ['reviewAdded'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { event: ['reviewAdded'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + ], + }; + + methods = { + listSearch: { + searchAccounts, + searchLocations, + }, + }; + + async poll(this: IPollFunctions): Promise { + const nodeStaticData = this.getWorkflowStaticData('node'); + let responseData; + const qs: IDataObject = {}; + + const account = (this.getNodeParameter('account') as { value: string; mode: string }).value; + const location = (this.getNodeParameter('location') as { value: string; mode: string }).value; + + const manualMode = this.getMode() === 'manual'; + if (manualMode) { + qs.pageSize = 1; // In manual mode we only want to fetch the latest review + } else { + qs.pageSize = 50; // Maximal page size for the get reviews endpoint + } + + try { + responseData = (await googleApiRequest.call( + this, + 'GET', + `/${account}/${location}/reviews`, + {}, + qs, + )) as { reviews: IDataObject[]; totalReviewCount: number; nextPageToken?: string }; + + if (manualMode) { + responseData = responseData.reviews; + } else { + // During the first execution there is no delta + if (!nodeStaticData.totalReviewCountLastTimeChecked) { + nodeStaticData.totalReviewCountLastTimeChecked = responseData.totalReviewCount; + return null; + } + + // When count did't change the node shouldn't trigger + if ( + !responseData?.reviews?.length || + nodeStaticData?.totalReviewCountLastTimeChecked === responseData?.totalReviewCount + ) { + return null; + } + + const numNewReviews = + // @ts-ignore + responseData.totalReviewCount - nodeStaticData.totalReviewCountLastTimeChecked; + nodeStaticData.totalReviewCountLastTimeChecked = responseData.totalReviewCount; + + // By default the reviews will be sorted by updateTime in descending order + // Return only the delta reviews since last pooling + responseData = responseData.reviews.slice(0, numNewReviews); + } + + if (Array.isArray(responseData) && responseData.length) { + return [this.helpers.returnJsonArray(responseData)]; + } + + return null; + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } + } +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts b/packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts new file mode 100644 index 00000000000..481fcb9bc6c --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts @@ -0,0 +1,23 @@ +interface IDate { + year: number; + month: number; + day: number; +} + +interface ITimeOfDay { + hours: number; + minutes: number; + seconds: number; + nanos: number; +} + +export interface ITimeInterval { + startDate: IDate; + startTime: ITimeOfDay; + endDate: IDate; + endTime: ITimeOfDay; +} + +export interface IReviewReply { + comment: string; +} diff --git a/packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts b/packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts new file mode 100644 index 00000000000..b6b250728fa --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts @@ -0,0 +1,1000 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { + addUpdateMaskPresend, + handleDatesPresend, + handleErrorsDeletePost, + handleErrorsGetPost, + handleErrorsUpdatePost, + handlePagination, +} from './GenericFunctions'; + +export const postOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'create', + noDataExpression: true, + displayOptions: { show: { resource: ['post'] } }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create post', + description: 'Create a new post on Google My Business', + routing: { + send: { preSend: [handleDatesPresend] }, + request: { + method: 'POST', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/localPosts', + }, + }, + }, + { + name: 'Delete', + value: 'delete', + action: 'Delete post', + description: 'Delete an existing post', + routing: { + request: { + method: 'DELETE', + url: '=/{{$parameter["post"]}}', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsDeletePost], + }, + }, + }, + { + name: 'Get', + value: 'get', + action: 'Get post', + description: 'Retrieve details of a specific post', + routing: { + request: { + method: 'GET', + url: '=/{{$parameter["post"]}}', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsGetPost], + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many posts', + description: 'Retrieve multiple posts', + routing: { + send: { paginate: true }, + operations: { pagination: handlePagination }, + request: { + method: 'GET', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/localPosts', + qs: { + pageSize: + '={{ $parameter["limit"] ? ($parameter["limit"] < 100 ? $parameter["limit"] : 100) : 100 }}', // Google allows maximum 100 results per page + }, + }, + }, + }, + { + name: 'Update', + value: 'update', + action: 'Update a post', + description: 'Update an existing post', + routing: { + send: { + preSend: [handleDatesPresend, addUpdateMaskPresend], + }, + request: { + method: 'PATCH', + url: '=/{{$parameter["post"]}}', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsUpdatePost], + }, + }, + }, + ], + }, +]; + +export const postFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* post:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Post Type', + name: 'postType', + required: true, + type: 'options', + default: 'STANDARD', + description: 'The type of post to create (standard, event, offer, or alert)', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + routing: { send: { type: 'body', property: 'topicType' } }, + options: [ + { + name: 'Standard', + value: 'STANDARD', + }, + { + name: 'Event', + value: 'EVENT', + }, + { + name: 'Offer', + value: 'OFFER', + }, + { + name: 'Alert', + value: 'ALERT', + }, + ], + }, + { + displayName: 'Summary', + name: 'summary', + required: true, + type: 'string', + default: '', + description: 'The main text of the post', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + routing: { send: { type: 'body', property: 'summary' } }, + }, + { + displayName: 'Title', + name: 'title', + required: true, + type: 'string', + default: '', + description: 'E.g. Sales this week.', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['EVENT'] } }, + routing: { send: { type: 'body', property: 'event.title' } }, + }, + { + displayName: 'Start Date and Time', + name: 'startDateTime', + required: true, + type: 'dateTime', + default: '', + description: 'The start date and time of the event', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['EVENT'] } }, + }, + { + displayName: 'End Date and Time', + name: 'endDateTime', + required: true, + type: 'dateTime', + default: '', + description: 'The end date and time of the event', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['EVENT'] } }, + }, + { + displayName: 'Title', + name: 'title', + required: true, + type: 'string', + default: '', + description: 'E.g. 20% off in store or online.', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['OFFER'] } }, + routing: { send: { type: 'body', property: 'event.title' } }, + }, + { + displayName: 'Start Date', + name: 'startDate', + required: true, + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'The start date of the offer', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['OFFER'] } }, + }, + { + displayName: 'End Date', + name: 'endDate', + required: true, + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'The end date of the offer', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['OFFER'] } }, + }, + { + displayName: 'Alert Type', + name: 'alertType', + required: true, + type: 'options', + default: 'COVID_19', + description: 'The sub-type of the alert', + displayOptions: { show: { resource: ['post'], operation: ['create'], postType: ['ALERT'] } }, + routing: { + send: { type: 'body', property: 'alertType' }, + }, + options: [ + { + name: 'Covid 19', + value: 'COVID_19', + description: 'This alert is related to the 2019 Coronavirus Disease pandemic', + }, + ], + }, + { + displayName: 'Options', + name: 'additionalOptions', + type: 'collection', + default: {}, + placeholder: 'Add Option', + displayOptions: { show: { resource: ['post'], operation: ['create'] } }, + options: [ + { + displayName: 'Language', + name: 'languageCode', + type: 'string', + default: '', + placeholder: 'e.g. en', + description: + 'The language code of the post content. More info.', + routing: { send: { type: 'body', property: 'languageCode' } }, + }, + { + displayName: 'Call to Action Type', + name: 'callToActionType', + type: 'options', + default: 'ACTION_TYPE_UNSPECIFIED', + description: 'The type of call to action', + displayOptions: { show: { '/postType': ['STANDARD', 'EVENT', 'ALERT'] } }, + routing: { + send: { type: 'body', property: 'callToAction.actionType' }, + }, + options: [ + { + name: 'Action Type Unspecified', + value: 'ACTION_TYPE_UNSPECIFIED', + description: 'Type unspecified', + }, + { + name: 'Book', + value: 'BOOK', + description: 'This post wants a user to book an appointment/table/etc', + }, + { + name: 'Call', + value: 'CALL', + description: 'This post wants a user to call the business', + }, + { + name: 'Learn More', + value: 'LEARN_MORE', + description: 'This post wants a user to learn more (at their website)', + }, + { + name: 'Order', + value: 'ORDER', + description: 'This post wants a user to order something', + }, + { + name: 'Shop', + value: 'SHOP', + description: 'This post wants a user to browse a product catalog', + }, + { + name: 'Sign Up', + value: 'SIGN_UP', + description: 'This post wants a user to register/sign up/join something', + }, + ], + }, + { + displayName: 'Call to Action Url', + name: 'url', + type: 'string', + default: '', + description: 'The URL that users are sent to when clicking through the promotion', + displayOptions: { show: { '/postType': ['STANDARD', 'EVENT', 'ALERT'] } }, + routing: { + send: { type: 'body', property: 'callToAction.url' }, + }, + }, + { + displayName: 'Coupon Code', + name: 'couponCode', + type: 'string', + default: '', + description: 'The coupon code for the offer', + displayOptions: { show: { '/postType': ['OFFER'] } }, + routing: { + send: { type: 'body', property: 'offer.couponCode' }, + }, + }, + { + displayName: 'Redeem Online Url', + name: 'redeemOnlineUrl', + type: 'string', + default: '', + description: 'Link to redeem the offer', + displayOptions: { show: { '/postType': ['OFFER'] } }, + routing: { + send: { type: 'body', property: 'offer.redeemOnlineUrl' }, + }, + }, + { + displayName: 'Terms and Conditions', + name: 'termsConditions', + type: 'string', + default: '', + description: 'The terms and conditions of the offer', + displayOptions: { show: { '/postType': ['OFFER'] } }, + routing: { + send: { type: 'body', property: 'offer.termsConditions' }, + }, + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* post:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Post', + name: 'post', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the post to retrieve its details', + displayOptions: { show: { resource: ['post'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchPosts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/localPosts/[0-9]+', + errorMessage: + 'The name must be in the format "accounts/123/locations/123/localPosts/123"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/localPosts/123', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* post:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Post', + name: 'post', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the post to retrieve its details', + displayOptions: { show: { resource: ['post'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchPosts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/localPosts/[0-9]+', + errorMessage: + 'The name must be in the format "accounts/123/locations/123/localPosts/123"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/localPosts/123', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* post:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['getAll'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['getAll'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { show: { resource: ['post'], operation: ['getAll'] } }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 20, + description: 'Max number of results to return', + displayOptions: { show: { resource: ['post'], operation: ['getAll'], returnAll: [false] } }, + }, + + /* -------------------------------------------------------------------------- */ + /* post:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Post', + name: 'post', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the post to retrieve its details', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchPosts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/localPosts/[0-9]+', + errorMessage: + 'The name must be in the format "accounts/123/locations/123/localPosts/123"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/localPosts/123', + }, + ], + }, + { + displayName: + "Make sure that the updated options are supported by the post type. More info.", + name: 'notice', + type: 'notice', + default: '', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + }, + { + displayName: 'Options', + name: 'additionalOptions', + type: 'collection', + default: {}, + placeholder: 'Add Option', + displayOptions: { show: { resource: ['post'], operation: ['update'] } }, + options: [ + { + displayName: 'Summary', + name: 'summary', + type: 'string', + default: '', + description: 'The main text of the post', + routing: { send: { type: 'body', property: 'summary' } }, + }, + { + displayName: 'Language', + name: 'languageCode', + type: 'string', + default: '', + placeholder: 'e.g. en', + description: + 'The language code of the post content. More info.', + routing: { send: { type: 'body', property: 'languageCode' } }, + }, + { + displayName: 'Call to Action Type', + name: 'callToActionType', + type: 'options', + default: 'ACTION_TYPE_UNSPECIFIED', + description: 'The type of call to action', + routing: { + send: { type: 'body', property: 'callToAction.actionType' }, + }, + options: [ + { + name: 'Action Type Unspecified', + value: 'ACTION_TYPE_UNSPECIFIED', + description: 'Type unspecified', + }, + { + name: 'Book', + value: 'BOOK', + description: 'This post wants a user to book an appointment/table/etc', + }, + { + name: 'Get Offer', + value: 'GET_OFFER', + description: + 'Deprecated. Use OFFER in LocalPostTopicType to create a post with offer content.', + }, + { + name: 'Learn More', + value: 'LEARN_MORE', + description: 'This post wants a user to learn more (at their website)', + }, + { + name: 'Order', + value: 'ORDER', + description: 'This post wants a user to order something', + }, + { + name: 'Shop', + value: 'SHOP', + description: 'This post wants a user to browse a product catalog', + }, + { + name: 'Sign Up', + value: 'SIGN_UP', + description: 'This post wants a user to register/sign up/join something', + }, + ], + }, + { + displayName: 'Call to Action Url', + name: 'url', + type: 'string', + default: '', + description: 'The URL that users are sent to when clicking through the promotion', + routing: { + send: { type: 'body', property: 'callToAction.url' }, + }, + }, + { + displayName: 'Start Date and Time', + name: 'startDateTime', + type: 'dateTime', + default: '', + description: 'The start date and time of the event', + }, + { + displayName: 'End Date and Time', + name: 'endDateTime', + type: 'dateTime', + default: '', + description: 'The end date and time of the event', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'E.g. 20% off in store or online.', + routing: { send: { type: 'body', property: 'event.title' } }, + }, + { + displayName: 'Start Date', + name: 'startDate', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'The start date of the offer', + }, + { + displayName: 'End Date', + name: 'endDate', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'The end date of the offer', + }, + { + displayName: 'Coupon Code', + name: 'couponCode', + type: 'string', + default: '', + description: 'The coupon code for the offer', + routing: { + send: { type: 'body', property: 'offer.couponCode' }, + }, + }, + { + displayName: 'Redeem Online Url', + name: 'redeemOnlineUrl', + type: 'string', + default: '', + description: 'Link to redeem the offer', + routing: { + send: { type: 'body', property: 'offer.redeemOnlineUrl' }, + }, + }, + { + displayName: 'Terms and Conditions', + name: 'termsConditions', + type: 'string', + default: '', + description: 'The terms and conditions of the offer', + routing: { + send: { type: 'body', property: 'offer.termsConditions' }, + }, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts b/packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts new file mode 100644 index 00000000000..7d3430ffad3 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts @@ -0,0 +1,574 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import { + handleErrorsDeleteReply, + handleErrorsGetReview, + handleErrorsReplyToReview, + handlePagination, +} from './GenericFunctions'; + +export const reviewOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'get', + noDataExpression: true, + displayOptions: { show: { resource: ['review'] } }, + options: [ + { + name: 'Delete Reply', + value: 'delete', + action: 'Delete a reply to a review', + description: 'Delete a reply to a review', + routing: { + request: { + method: 'DELETE', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}/reply', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsDeleteReply], + }, + }, + }, + { + name: 'Get', + value: 'get', + action: 'Get review', + description: 'Retrieve details of a specific review on Google My Business', + routing: { + request: { + method: 'GET', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}', + ignoreHttpStatusErrors: true, + }, + + output: { + postReceive: [handleErrorsGetReview], + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many reviews', + description: 'Retrieve multiple reviews', + routing: { + send: { paginate: true }, + operations: { pagination: handlePagination }, + request: { + method: 'GET', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews', + qs: { + pageSize: + '={{ $parameter["limit"] ? ($parameter["limit"] < 50 ? $parameter["limit"] : 50) : 50 }}', // Google allows maximum 50 results per page + }, + }, + }, + }, + { + name: 'Reply', + value: 'reply', + action: 'Reply to review', + description: 'Reply to a review', + routing: { + request: { + method: 'PUT', + url: '=/{{$parameter["account"]}}/{{$parameter["location"]}}/reviews/{{$parameter["review"].split("reviews/").pop().split("/reply")[0]}}/reply', + ignoreHttpStatusErrors: true, + }, + output: { + postReceive: [handleErrorsReplyToReview], + }, + }, + }, + ], + }, +]; + +export const reviewFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* review:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['review'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['review'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Review', + name: 'review', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the review to retrieve its details', + displayOptions: { show: { resource: ['review'], operation: ['get'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchReviews', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*', + errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. ABC123_review-ID_456xyz', + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$', + errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* review:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['review'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['review'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Review', + name: 'review', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the review to retrieve its details', + displayOptions: { show: { resource: ['review'], operation: ['delete'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchReviews', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*', + errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. ABC123_review-ID_456xyz', + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$', + errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* review:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['review'], operation: ['getAll'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['review'], operation: ['getAll'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { show: { resource: ['review'], operation: ['getAll'] } }, + type: 'boolean', + }, + { + displayName: 'Limit', + name: 'limit', + required: true, + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 20, + description: 'Max number of results to return', + displayOptions: { show: { resource: ['review'], operation: ['getAll'], returnAll: [false] } }, + }, + + /* -------------------------------------------------------------------------- */ + /* review:reply */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Account', + name: 'account', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The Google My Business account', + displayOptions: { show: { resource: ['review'], operation: ['reply'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchAccounts', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the account name', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+', + errorMessage: 'The name must start with "accounts/"', + }, + }, + ], + placeholder: 'e.g. accounts/0123456789', + }, + ], + }, + { + displayName: 'Location', + name: 'location', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'The specific location or business associated with the account', + displayOptions: { show: { resource: ['review'], operation: ['reply'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchLocations', + searchable: true, + }, + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + hint: 'Enter the location name', + validation: [ + { + type: 'regex', + properties: { + regex: 'locations/[0-9]+', + errorMessage: 'The name must start with "locations/"', + }, + }, + ], + placeholder: 'e.g. locations/0123456789', + }, + ], + }, + { + displayName: 'Review', + name: 'review', + required: true, + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Select the review to retrieve its details', + displayOptions: { show: { resource: ['review'], operation: ['reply'] } }, + modes: [ + { + displayName: 'From list', + name: 'list', + type: 'list', + typeOptions: { + searchListMethod: 'searchReviews', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '^(?!accounts/[0-9]+/locations/[0-9]+/reviews/).*', + errorMessage: 'The name must not start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. ABC123_review-ID_456xyz', + }, + { + displayName: 'By name', + name: 'name', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: 'accounts/[0-9]+/locations/[0-9]+/reviews/.*$', + errorMessage: 'The name must start with "accounts/123/locations/123/reviews/"', + }, + }, + ], + placeholder: 'e.g. accounts/123/locations/123/reviews/ABC123_review-ID_456xyz', + }, + ], + }, + { + displayName: 'Reply', + name: 'reply', + type: 'string', + default: '', + description: 'The body of the reply (up to 4096 characters)', + displayOptions: { show: { resource: ['review'], operation: ['reply'] } }, + typeOptions: { rows: 5 }, + routing: { send: { type: 'body', property: 'comment' } }, + }, +]; diff --git a/packages/nodes-base/nodes/Google/MyBusiness/googleMyBusines.svg b/packages/nodes-base/nodes/Google/MyBusiness/googleMyBusines.svg new file mode 100644 index 00000000000..c42fe669069 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/googleMyBusines.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/AddUpdateMask.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/AddUpdateMask.test.ts new file mode 100644 index 00000000000..94f37b9034c --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/AddUpdateMask.test.ts @@ -0,0 +1,84 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { addUpdateMaskPresend } from '../GenericFunctions'; + +describe('GenericFunctions - addUpdateMask', () => { + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + getNodeParameter: mockGetNodeParameter, + } as unknown as IExecuteSingleFunctions; + + beforeEach(() => { + mockGetNodeParameter.mockClear(); + }); + + it('should add updateMask with mapped properties to the query string', async () => { + mockGetNodeParameter.mockReturnValue({ + postType: 'postTypeValue', + url: 'https://example.com', + startDateTime: '2023-09-15T10:00:00.000Z', + couponCode: 'DISCOUNT123', + }); + + const opts: Partial = { + qs: {}, + }; + + const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.qs).toEqual({ + updateMask: + 'topicType,callToAction.url,event.schedule.startDate,event.schedule.startTime,offer.couponCode', + }); + }); + + it('should handle empty additionalOptions and not add updateMask', async () => { + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + qs: {}, + }; + + const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.qs).toEqual({}); + }); + + it('should include unmapped properties in the updateMask', async () => { + mockGetNodeParameter.mockReturnValue({ + postType: 'postTypeValue', + unmappedProperty: 'someValue', + }); + + const opts: Partial = { + qs: {}, + }; + + const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.qs).toEqual({ + updateMask: 'topicType,unmappedProperty', + }); + }); + + it('should merge updateMask with existing query string', async () => { + mockGetNodeParameter.mockReturnValue({ + postType: 'postTypeValue', + redeemOnlineUrl: 'https://google.example.com', + }); + + const opts: Partial = { + qs: { + existingQuery: 'existingValue', + }, + }; + + const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.qs).toEqual({ + existingQuery: 'existingValue', + updateMask: 'topicType,offer.redeemOnlineUrl', + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/GoogleApiRequest.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/GoogleApiRequest.test.ts new file mode 100644 index 00000000000..6e56b1ab65f --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/GoogleApiRequest.test.ts @@ -0,0 +1,84 @@ +import { NodeApiError, type ILoadOptionsFunctions, type IPollFunctions } from 'n8n-workflow'; + +import { googleApiRequest } from '../GenericFunctions'; + +describe('googleApiRequest', () => { + const mockHttpRequestWithAuthentication = jest.fn(); + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockHttpRequestWithAuthentication, + }, + getNode: jest.fn(), + } as unknown as ILoadOptionsFunctions | IPollFunctions; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should make a GET request and return data', async () => { + const mockResponse = { success: true }; + mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse); + + const result = await googleApiRequest.call(mockContext, 'GET', '/test-resource'); + + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( + 'googleMyBusinessOAuth2Api', + expect.objectContaining({ + method: 'GET', + url: 'https://mybusiness.googleapis.com/v4/test-resource', + qs: {}, + json: true, + }), + ); + + expect(result).toEqual(mockResponse); + }); + + it('should make a POST request with body and return data', async () => { + const mockResponse = { success: true }; + mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse); + + const requestBody = { key: 'value' }; + const result = await googleApiRequest.call(mockContext, 'POST', '/test-resource', requestBody); + + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( + 'googleMyBusinessOAuth2Api', + expect.objectContaining({ + method: 'POST', + body: requestBody, + url: 'https://mybusiness.googleapis.com/v4/test-resource', + qs: {}, + json: true, + }), + ); + + expect(result).toEqual(mockResponse); + }); + + it('should remove the body for GET requests', async () => { + const mockResponse = { success: true }; + mockHttpRequestWithAuthentication.mockResolvedValue(mockResponse); + + const result = await googleApiRequest.call(mockContext, 'GET', '/test-resource', {}); + + expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith( + 'googleMyBusinessOAuth2Api', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.not.objectContaining({ body: expect.anything() }), + ); + + expect(result).toEqual(mockResponse); + }); + + it('should throw NodeApiError on API failure', async () => { + const mockError = new Error('API request failed'); + mockHttpRequestWithAuthentication.mockRejectedValue(mockError); + + await expect(googleApiRequest.call(mockContext, 'GET', '/test-resource')).rejects.toThrow( + NodeApiError, + ); + + expect(mockContext.getNode).toHaveBeenCalled(); + expect(mockHttpRequestWithAuthentication).toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/HandleDatesPresend.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/HandleDatesPresend.test.ts new file mode 100644 index 00000000000..6ef15ab8c6b --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/HandleDatesPresend.test.ts @@ -0,0 +1,131 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { handleDatesPresend } from '../GenericFunctions'; + +describe('GenericFunctions - handleDatesPresend', () => { + const mockGetNode = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + getNode: mockGetNode, + getNodeParameter: mockGetNodeParameter, + } as unknown as IExecuteSingleFunctions; + + beforeEach(() => { + mockGetNode.mockClear(); + mockGetNodeParameter.mockClear(); + }); + + it('should return options unchanged if no date-time parameters are provided', async () => { + mockGetNode.mockReturnValue({ + parameters: {}, + }); + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + body: {}, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result).toEqual(opts); + }); + + it('should merge startDateTime parameter into event schedule', async () => { + mockGetNode.mockReturnValue({ + parameters: { + startDateTime: '2023-09-15T10:00:00.000Z', + }, + }); + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + body: {}, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.body).toEqual({ + event: { + schedule: { + startDate: { year: 2023, month: 9, day: 15 }, + startTime: { hours: 10, minutes: 0, seconds: 0, nanos: 0 }, + }, + }, + }); + }); + + it('should merge startDate and endDateTime parameters into event schedule', async () => { + mockGetNode.mockReturnValue({ + parameters: { + startDate: '2023-09-15', + endDateTime: '2023-09-16T12:30:00.000Z', + }, + }); + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + body: {}, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.body).toEqual({ + event: { + schedule: { + startDate: { year: 2023, month: 9, day: 15 }, + endDate: { year: 2023, month: 9, day: 16 }, + endTime: { hours: 12, minutes: 30, seconds: 0, nanos: 0 }, + }, + }, + }); + }); + + it('should merge additional options into event schedule', async () => { + mockGetNode.mockReturnValue({ + parameters: { + startDate: '2023-09-15', + }, + }); + mockGetNodeParameter.mockReturnValue({ + additionalOption: 'someValue', + }); + + const opts: Partial = { + body: {}, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.body).toEqual({ + event: { + schedule: { + startDate: { year: 2023, month: 9, day: 15 }, + }, + }, + }); + }); + + it('should modify the body with event schedule containing only date', async () => { + mockGetNode.mockReturnValue({ + parameters: { + startDate: '2023-09-15', + }, + }); + mockGetNodeParameter.mockReturnValue({}); + + const opts: Partial = { + body: { event: {} }, + }; + + const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions); + + expect(result.body).toEqual({ + event: { + schedule: { + startDate: { year: 2023, month: 9, day: 15 }, + }, + }, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/HandlePagination.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/HandlePagination.test.ts new file mode 100644 index 00000000000..ad9b0755a51 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/HandlePagination.test.ts @@ -0,0 +1,123 @@ +import type { DeclarativeRestApiSettings, IExecutePaginationFunctions } from 'n8n-workflow'; + +import { handlePagination } from '../GenericFunctions'; + +describe('GenericFunctions - handlePagination', () => { + const mockMakeRoutingRequest = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + makeRoutingRequest: mockMakeRoutingRequest, + getNodeParameter: mockGetNodeParameter, + } as unknown as IExecutePaginationFunctions; + + beforeEach(() => { + mockMakeRoutingRequest.mockClear(); + mockGetNodeParameter.mockClear(); + }); + + it('should stop fetching when the limit is reached and returnAll is false', async () => { + mockMakeRoutingRequest + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 1 }, { id: 2 }], + nextPageToken: 'nextToken1', + }, + }, + ]) + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 3 }, { id: 4 }], + }, + }, + ]); + + mockGetNodeParameter.mockReturnValueOnce(false); + mockGetNodeParameter.mockReturnValueOnce(3); + + const requestOptions = { + options: { + qs: {}, + }, + } as unknown as DeclarativeRestApiSettings.ResultOptions; + + const result = await handlePagination.call(mockContext, requestOptions); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }, { json: { id: 3 } }]); + }); + + it('should handle empty results', async () => { + mockMakeRoutingRequest.mockResolvedValueOnce([ + { + json: { + localPosts: [], + }, + }, + ]); + + mockGetNodeParameter.mockReturnValueOnce(false); + mockGetNodeParameter.mockReturnValueOnce(5); + + const requestOptions = { + options: { + qs: {}, + }, + } as unknown as DeclarativeRestApiSettings.ResultOptions; + + const result = await handlePagination.call(mockContext, requestOptions); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1); + + expect(result).toEqual([]); + }); + + it('should fetch all items when returnAll is true', async () => { + mockMakeRoutingRequest + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 1 }, { id: 2 }], + nextPageToken: 'nextToken1', + }, + }, + ]) + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 3 }, { id: 4 }], + nextPageToken: 'nextToken2', + }, + }, + ]) + .mockResolvedValueOnce([ + { + json: { + localPosts: [{ id: 5 }], + }, + }, + ]); + + mockGetNodeParameter.mockReturnValueOnce(true); + + const requestOptions = { + options: { + qs: {}, + }, + } as unknown as DeclarativeRestApiSettings.ResultOptions; + + const result = await handlePagination.call(mockContext, requestOptions); + + expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(3); + + expect(result).toEqual([ + { json: { id: 1 } }, + { json: { id: 2 } }, + { json: { id: 3 } }, + { json: { id: 4 } }, + { json: { id: 5 } }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/SearchAccounts.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchAccounts.test.ts new file mode 100644 index 00000000000..01e08f3732f --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchAccounts.test.ts @@ -0,0 +1,65 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchAccounts } from '../GenericFunctions'; + +describe('GenericFunctions - searchAccounts', () => { + const mockGoogleApiRequest = jest.fn(); + + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockGoogleApiRequest, + }, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockGoogleApiRequest.mockClear(); + }); + + it('should return accounts with filtering', async () => { + mockGoogleApiRequest.mockResolvedValue({ + accounts: [ + { name: 'accounts/123', accountName: 'Test Account 1' }, + { name: 'accounts/234', accountName: 'Test Account 2' }, + ], + }); + + const filter = '123'; + const result = await searchAccounts.call(mockContext, filter); + + expect(result).toEqual({ + results: [{ name: 'Test Account 1', value: 'accounts/123' }], + paginationToken: undefined, + }); + }); + + it('should handle empty results', async () => { + mockGoogleApiRequest.mockResolvedValue({ accounts: [] }); + + const result = await searchAccounts.call(mockContext); + + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should handle pagination', async () => { + mockGoogleApiRequest.mockResolvedValue({ + accounts: [{ name: 'accounts/123', accountName: 'Test Account 1' }], + nextPageToken: 'nextToken1', + }); + mockGoogleApiRequest.mockResolvedValue({ + accounts: [{ name: 'accounts/234', accountName: 'Test Account 2' }], + nextPageToken: 'nextToken2', + }); + mockGoogleApiRequest.mockResolvedValue({ + accounts: [{ name: 'accounts/345', accountName: 'Test Account 3' }], + }); + + const result = await searchAccounts.call(mockContext); + + // The request would only return the last result + // N8N handles the pagination and adds the previous results to the results array + expect(result).toEqual({ + results: [{ name: 'Test Account 3', value: 'accounts/345' }], + paginationToken: undefined, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/SearchLocations.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchLocations.test.ts new file mode 100644 index 00000000000..2279840d269 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchLocations.test.ts @@ -0,0 +1,68 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchLocations } from '../GenericFunctions'; + +describe('GenericFunctions - searchLocations', () => { + const mockGoogleApiRequest = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockGoogleApiRequest, + }, + getNodeParameter: mockGetNodeParameter, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockGoogleApiRequest.mockClear(); + mockGetNodeParameter.mockClear(); + mockGetNodeParameter.mockReturnValue('parameterValue'); + }); + + it('should return locations with filtering', async () => { + mockGoogleApiRequest.mockResolvedValue({ + locations: [{ name: 'locations/123' }, { name: 'locations/234' }], + }); + + const filter = '123'; + const result = await searchLocations.call(mockContext, filter); + + expect(result).toEqual({ + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + results: [{ name: 'locations/123', value: 'locations/123' }], + paginationToken: undefined, + }); + }); + + it('should handle empty results', async () => { + mockGoogleApiRequest.mockResolvedValue({ locations: [] }); + + const result = await searchLocations.call(mockContext); + + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should handle pagination', async () => { + mockGoogleApiRequest.mockResolvedValue({ + locations: [{ name: 'locations/123' }], + nextPageToken: 'nextToken1', + }); + mockGoogleApiRequest.mockResolvedValue({ + locations: [{ name: 'locations/234' }], + nextPageToken: 'nextToken2', + }); + mockGoogleApiRequest.mockResolvedValue({ + locations: [{ name: 'locations/345' }], + }); + + const result = await searchLocations.call(mockContext); + + // The request would only return the last result + // N8N handles the pagination and adds the previous results to the results array + expect(result).toEqual({ + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + results: [{ name: 'locations/345', value: 'locations/345' }], + paginationToken: undefined, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/SearchPosts.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchPosts.test.ts new file mode 100644 index 00000000000..9a542d721c9 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchPosts.test.ts @@ -0,0 +1,72 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchPosts } from '../GenericFunctions'; + +describe('GenericFunctions - searchPosts', () => { + const mockGoogleApiRequest = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockGoogleApiRequest, + }, + getNodeParameter: mockGetNodeParameter, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockGoogleApiRequest.mockClear(); + mockGetNodeParameter.mockClear(); + mockGetNodeParameter.mockReturnValue('parameterValue'); + }); + + it('should return posts with filtering', async () => { + mockGoogleApiRequest.mockResolvedValue({ + localPosts: [ + { name: 'accounts/123/locations/123/localPosts/123', summary: 'First Post' }, + { name: 'accounts/123/locations/123/localPosts/234', summary: 'Second Post' }, + ], + }); + + const filter = 'First'; + const result = await searchPosts.call(mockContext, filter); + + expect(result).toEqual({ + results: [ + { + name: 'First Post', + value: 'accounts/123/locations/123/localPosts/123', + }, + ], + paginationToken: undefined, + }); + }); + + it('should handle empty results', async () => { + mockGoogleApiRequest.mockResolvedValue({ localPosts: [] }); + + const result = await searchPosts.call(mockContext); + + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should handle pagination', async () => { + mockGoogleApiRequest.mockResolvedValue({ + localPosts: [{ name: 'accounts/123/locations/123/localPosts/123', summary: 'First Post' }], + nextPageToken: 'nextToken1', + }); + mockGoogleApiRequest.mockResolvedValue({ + localPosts: [{ name: 'accounts/123/locations/123/localPosts/234', summary: 'Second Post' }], + nextPageToken: 'nextToken2', + }); + mockGoogleApiRequest.mockResolvedValue({ + localPosts: [{ name: 'accounts/123/locations/123/localPosts/345', summary: 'Third Post' }], + }); + + const result = await searchPosts.call(mockContext); + + expect(result).toEqual({ + results: [{ name: 'Third Post', value: 'accounts/123/locations/123/localPosts/345' }], + paginationToken: undefined, + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/MyBusiness/test/SearchReviews.test.ts b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchReviews.test.ts new file mode 100644 index 00000000000..6a5b8e3daf6 --- /dev/null +++ b/packages/nodes-base/nodes/Google/MyBusiness/test/SearchReviews.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchReviews } from '../GenericFunctions'; + +describe('GenericFunctions - searchReviews', () => { + const mockGoogleApiRequest = jest.fn(); + const mockGetNodeParameter = jest.fn(); + + const mockContext = { + helpers: { + httpRequestWithAuthentication: mockGoogleApiRequest, + }, + getNodeParameter: mockGetNodeParameter, + } as unknown as ILoadOptionsFunctions; + + beforeEach(() => { + mockGoogleApiRequest.mockClear(); + mockGetNodeParameter.mockClear(); + mockGetNodeParameter.mockReturnValue('parameterValue'); + }); + + it('should return reviews with filtering', async () => { + mockGoogleApiRequest.mockResolvedValue({ + reviews: [ + { name: 'accounts/123/locations/123/reviews/123', comment: 'Great service!' }, + { name: 'accounts/123/locations/123/reviews/234', comment: 'Good experience.' }, + ], + }); + + const filter = 'Great'; + const result = await searchReviews.call(mockContext, filter); + + expect(result).toEqual({ + results: [ + { + name: 'Great service!', + value: 'accounts/123/locations/123/reviews/123', + }, + ], + paginationToken: undefined, + }); + }); + + it('should handle empty results', async () => { + mockGoogleApiRequest.mockResolvedValue({ reviews: [] }); + + const result = await searchReviews.call(mockContext); + + expect(result).toEqual({ results: [], paginationToken: undefined }); + }); + + it('should handle pagination', async () => { + mockGoogleApiRequest.mockResolvedValue({ + reviews: [{ name: 'accounts/123/locations/123/reviews/123', comment: 'First Review' }], + nextPageToken: 'nextToken1', + }); + mockGoogleApiRequest.mockResolvedValue({ + reviews: [{ name: 'accounts/123/locations/123/reviews/234', comment: 'Second Review' }], + nextPageToken: 'nextToken2', + }); + mockGoogleApiRequest.mockResolvedValue({ + reviews: [{ name: 'accounts/123/locations/123/reviews/345', comment: 'Third Review' }], + }); + + const result = await searchReviews.call(mockContext); + + expect(result).toEqual({ + results: [{ name: 'Third Review', value: 'accounts/123/locations/123/reviews/345' }], + paginationToken: undefined, + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 93a4c6c056f..a40f7f2cfa6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -140,6 +140,7 @@ "dist/credentials/GoogleDriveOAuth2Api.credentials.js", "dist/credentials/GoogleFirebaseCloudFirestoreOAuth2Api.credentials.js", "dist/credentials/GoogleFirebaseRealtimeDatabaseOAuth2Api.credentials.js", + "dist/credentials/GoogleMyBusinessOAuth2Api.credentials.js", "dist/credentials/GoogleOAuth2Api.credentials.js", "dist/credentials/GooglePerspectiveOAuth2Api.credentials.js", "dist/credentials/GoogleSheetsOAuth2Api.credentials.js", @@ -538,6 +539,8 @@ "dist/nodes/Google/Gmail/Gmail.node.js", "dist/nodes/Google/Gmail/GmailTrigger.node.js", "dist/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.js", + "dist/nodes/Google/MyBusiness/GoogleMyBusiness.node.js", + "dist/nodes/Google/MyBusiness/GoogleMyBusinessTrigger.node.js", "dist/nodes/Google/Perspective/GooglePerspective.node.js", "dist/nodes/Google/Sheet/GoogleSheets.node.js", "dist/nodes/Google/Sheet/GoogleSheetsTrigger.node.js",