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",