mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
feat(n8n Google My Business Node): New node (#10504)
This commit is contained in:
parent
c090fcb340
commit
bf28fbefe5
|
@ -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. <a href="https://developers.google.com/my-business/content/prereqs" target="_blank">More info</a>. 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. <a href="https://docs.n8n.io/integrations/builtin/credentials/google/oauth-generic/#scopes" target="_blank">More info</a>.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
];
|
||||
}
|
520
packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts
Normal file
520
packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts
Normal file
|
@ -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<string, unknown> => {
|
||||
const params = execFns.getNode().parameters;
|
||||
const additionalOptions = execFns.getNodeParameter(addOptName, {}) as Record<string, unknown>;
|
||||
|
||||
// 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<IHttpRequestOptions> {
|
||||
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<ITimeInterval> = {
|
||||
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<IHttpRequestOptions> {
|
||||
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<INodeExecutionData[]> {
|
||||
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<INodeExecutionData[]> {
|
||||
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<INodeExecutionData[]> {
|
||||
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<INodeExecutionData[]> {
|
||||
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<INodeExecutionData[]> {
|
||||
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<INodeExecutionData[]> {
|
||||
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<INodeExecutionData[]> {
|
||||
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<IDataObject> {
|
||||
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<INodeListSearchResult> {
|
||||
// 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<INodeListSearchResult> {
|
||||
// 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<INodeListSearchResult> {
|
||||
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<INodeListSearchResult> {
|
||||
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 };
|
||||
}
|
|
@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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<INodeExecutionData[][] | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
23
packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts
Normal file
23
packages/nodes-base/nodes/Google/MyBusiness/Interfaces.ts
Normal file
|
@ -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;
|
||||
}
|
1000
packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts
Normal file
1000
packages/nodes-base/nodes/Google/MyBusiness/PostDescription.ts
Normal file
File diff suppressed because it is too large
Load diff
574
packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts
Normal file
574
packages/nodes-base/nodes/Google/MyBusiness/ReviewDescription.ts
Normal file
|
@ -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' } },
|
||||
},
|
||||
];
|
|
@ -0,0 +1 @@
|
|||
<svg height="2185" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0.43 1064 928.69"><linearGradient id="a" x1="0%" x2="99.999%" y1="49.999%" y2="49.999%"><stop offset=".03" stop-color="#4079d8"/><stop offset="1" stop-color="#4989f5"/></linearGradient><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><rect fill="#4989f5" height="696.14" rx="36.88" width="931" x="53.45" y="232.98"/><path d="M936.81 227.75H100.06c-25.92 0-46.09 200.6-46.09 226.52L512.2 929.12h424.61c26-.071 47.059-21.13 47.13-47.13V274.87c-.077-25.996-21.134-47.049-47.13-47.12z" fill="url(#a)"/><path d="M266.03 349.56h266V.44H305.86z" fill="#3c4ba6"/><path d="M798.03 349.56h-266V.44H758.2zM984.45 66.62l.33 1.19c-.08-.42-.24-.81-.33-1.19z" fill="#7babf7"/><path d="M984.78 67.8l-.33-1.19C976.017 27.993 941.837.455 902.31.43H758.2L798 349.56h266z" fill="#3f51b5"/><path d="M79.61 66.62l-.33 1.19c.08-.42.24-.81.33-1.19z" fill="#7babf7"/><path d="M79.27 67.8l.33-1.19C88.033 27.993 122.213.455 161.74.43h144.12L266 349.56H0z" fill="#7babf7"/></g><path d="M266.48 349.47c0 73.412-59.513 132.925-132.925 132.925S.63 422.882.63 349.47z" fill="#709be0"/><path d="M532.33 349.47c0 73.412-59.513 132.925-132.925 132.925S266.48 422.882 266.48 349.47z" fill="#3c4ba6"/><path d="M798.18 349.47c0 73.412-59.513 132.925-132.925 132.925S532.33 422.882 532.33 349.47z" fill="#709be0"/><path d="M1064 349.47c0 73.412-59.513 132.925-132.925 132.925S798.15 422.882 798.15 349.47z" fill="#3c4ba6"/><path d="M931.08 709.6c-.47-6.33-1.25-12.11-2.36-19.49h-145c0 20.28 0 42.41-.08 62.7h84a73.05 73.05 0 0 1-30.75 46.89s0-.35-.06-.36a88 88 0 0 1-34 13.27 99.85 99.85 0 0 1-36.79-.16 91.9 91.9 0 0 1-34.31-14.87 95.72 95.72 0 0 1-33.73-43.1c-.52-1.35-1-2.71-1.49-4.09v-.15l.13-.1a93 93 0 0 1-.05-59.84A96.27 96.27 0 0 1 718.9 654c23.587-24.399 58.829-33.576 91.32-23.78a83 83 0 0 1 33.23 19.56l28.34-28.34c5-5.05 10.19-9.94 15-15.16a149.78 149.78 0 0 0-49.64-30.74 156.08 156.08 0 0 0-103.83-.91c-1.173.4-2.34.817-3.5 1.25A155.18 155.18 0 0 0 646 651a152.61 152.61 0 0 0-13.42 38.78c-16.052 79.772 32.623 158.294 111.21 179.4 25.69 6.88 53 6.71 78.89.83a139.88 139.88 0 0 0 63.14-32.81c18.64-17.15 32-40 39-64.27a179 179 0 0 0 6.26-63.33z" fill="#fff" fill-rule="nonzero"/></g></svg>
|
After Width: | Height: | Size: 2.2 KiB |
|
@ -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<IHttpRequestOptions> = {
|
||||
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<IHttpRequestOptions> = {
|
||||
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<IHttpRequestOptions> = {
|
||||
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<IHttpRequestOptions> = {
|
||||
qs: {
|
||||
existingQuery: 'existingValue',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await addUpdateMaskPresend.call(mockContext, opts as IHttpRequestOptions);
|
||||
|
||||
expect(result.qs).toEqual({
|
||||
existingQuery: 'existingValue',
|
||||
updateMask: 'topicType,offer.redeemOnlineUrl',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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<IHttpRequestOptions> = {
|
||||
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<IHttpRequestOptions> = {
|
||||
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<IHttpRequestOptions> = {
|
||||
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<IHttpRequestOptions> = {
|
||||
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<IHttpRequestOptions> = {
|
||||
body: { event: {} },
|
||||
};
|
||||
|
||||
const result = await handleDatesPresend.call(mockContext, opts as IHttpRequestOptions);
|
||||
|
||||
expect(result.body).toEqual({
|
||||
event: {
|
||||
schedule: {
|
||||
startDate: { year: 2023, month: 9, day: 15 },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 } },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue