Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(n8n Google My Business Node): New node #10504

Merged
merged 44 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
fd4252e
WIP: Add Google My Business node
valentina98 Aug 19, 2024
860c3e9
WIP: Fixing descriptions
valentina98 Aug 21, 2024
d898564
More fixes of the descriptions and add preSend/postReceive functions
valentina98 Aug 21, 2024
a123c39
Add the authentication and request defaults
valentina98 Aug 21, 2024
84b3d07
Remove unneeded preAuthentication
valentina98 Aug 26, 2024
fcdeafb
Remove unneeded Authorization
valentina98 Aug 26, 2024
5097157
Use routing where possible and fix descriptions
valentina98 Aug 26, 2024
e705c80
Refactor presend and postreceive
valentina98 Aug 28, 2024
891e455
Fix review endpoints
valentina98 Aug 28, 2024
eaffaac
Remove deprecated option
valentina98 Aug 29, 2024
726ee81
Fix loadOptions
valentina98 Aug 29, 2024
6cd0ad7
Fix property name
valentina98 Sep 1, 2024
589649c
Implement pagination
valentina98 Sep 2, 2024
22ea05a
Remove unused properties
valentina98 Sep 4, 2024
a02f915
Remove simplify
valentina98 Sep 4, 2024
23aaecb
Add missing parameter for loadoptions
valentina98 Sep 12, 2024
075edaa
Use resourceLocator instead of loadOptions; adjust descriptions
valentina98 Sep 16, 2024
c2f0b0f
Add a delete reply endpoint
valentina98 Sep 16, 2024
d4d8eaf
Add maximum page sizes to listSearch methods, add links to documentat…
valentina98 Sep 16, 2024
88db747
Fix regex, urls, and add update mask
valentina98 Sep 17, 2024
c420c05
Add a trigger node
valentina98 Sep 17, 2024
27facee
Add unit tests
valentina98 Sep 20, 2024
0e282e3
Change "name" to "id"
valentina98 Sep 20, 2024
e08f9d7
Change back "id" to "name"
valentina98 Sep 23, 2024
6600b4d
Fix resource locators and language option according to feedback
valentina98 Sep 24, 2024
f85fa2b
Fix "By ID", add hint and return all, adjust pagination and descriptions
valentina98 Sep 26, 2024
23053c3
Add a workaround for a routing bug
valentina98 Sep 26, 2024
b1513ab
Return the last review when triggered manually
valentina98 Sep 26, 2024
8f167da
Fix errors
valentina98 Sep 27, 2024
51d57ed
Fix parameter
valentina98 Sep 27, 2024
df7e5d1
WIP: Issue with post properties
valentina98 Sep 27, 2024
034f4a5
Fix required fields for post create
valentina98 Sep 27, 2024
093fd02
Adjust descriptions, add notice, clean up
valentina98 Sep 27, 2024
663338e
Update tests, fix pagination and displayOptions
valentina98 Sep 27, 2024
4a719f0
Fix format check issues
valentina98 Sep 28, 2024
8691a99
Hide limit when returnAll, add ellipsis
valentina98 Oct 8, 2024
48749af
Merge remote-tracking branch 'upstream/master' into node-google-my-bu…
valentina98 Oct 8, 2024
fae0a26
Fix ts and eslint issues
valentina98 Oct 8, 2024
3757203
Remove unspecified alert type
valentina98 Oct 8, 2024
e6c96a5
Fix test after hiding "limit"
valentina98 Oct 8, 2024
fc38cd0
Remove unused function
valentina98 Oct 8, 2024
c6314ac
Remove elipsis and clipping (handled by CSS)
valentina98 Oct 9, 2024
c2c1895
Fixes after code review
valentina98 Oct 16, 2024
787d98c
Merge remote-tracking branch 'upstream/master' into node-google-my-bu…
valentina98 Oct 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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: '',
},
];
}
365 changes: 365 additions & 0 deletions packages/nodes-base/nodes/Google/MyBusiness/GenericFunctions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
import {
NodeApiError,
type DeclarativeRestApiSettings,
type IDataObject,
type IExecutePaginationFunctions,
type IExecuteSingleFunctions,
type IHttpRequestMethods,
type IHttpRequestOptions,
type ILoadOptionsFunctions,
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 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 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 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 };
}
Loading