diff --git a/packages/nodes-base/credentials/GoogleAnalyticsOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleAnalyticsOAuth2Api.credentials.ts new file mode 100644 index 0000000000..0f76ff76b3 --- /dev/null +++ b/packages/nodes-base/credentials/GoogleAnalyticsOAuth2Api.credentials.ts @@ -0,0 +1,27 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'https://www.googleapis.com/auth/analytics', + 'https://www.googleapis.com/auth/analytics.readonly', +]; + + +export class GoogleAnalyticsOAuth2Api implements ICredentialType { + name = 'googleAnalyticsOAuth2'; + extends = [ + 'googleOAuth2Api', + ]; + displayName = 'Google Analytics OAuth2 API'; + documentationUrl = 'google'; + properties = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + ]; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Analytics/GenericFunctions.ts new file mode 100644 index 0000000000..ed2da34c56 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/GenericFunctions.ts @@ -0,0 +1,103 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function googleApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, + endpoint: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + let options: OptionsWithUri = { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: uri || `https://analyticsreporting.googleapis.com${endpoint}`, + json: true, + }; + + options = Object.assign({}, options, option); + + try { + if (Object.keys(body).length === 0) { + delete options.body; + } + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'googleAnalyticsOAuth2', options); + + } catch (error) { + if (error.response && error.response.body && error.response.body.error) { + + let errorMessages; + + if (error.response.body.error.errors) { + // Try to return the error prettier + errorMessages = error.response.body.error.errors; + + errorMessages = errorMessages.map((errorItem: IDataObject) => errorItem.message); + + errorMessages = errorMessages.join('|'); + + } else if (error.response.body.error.message) { + errorMessages = error.response.body.error.message; + } + + throw new Error(`Google Analytics error response [${error.statusCode}]: ${errorMessages}`); + } + throw error; + } +} + +export async function googleApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}, uri?: string): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + body.pageSize = 100; + + do { + responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri); + if (body.reportRequests && Array.isArray(body.reportRequests)) { + body.reportRequests[0].pageToken = responseData['nextPageToken']; + } else { + body.pageToken = responseData['nextPageToken']; + } + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + (responseData['nextPageToken'] !== undefined && + responseData['nextPageToken'] !== '') || + (responseData['reports'] && + responseData['reports'][0].nextPageToken && + responseData['reports'][0].nextPageToken !== undefined) + ); + + return returnData; +} + +export function simplify(responseData: any) { // tslint:disable-line:no-any + const { columnHeader: { dimensions }, data: { rows } } = responseData[0]; + responseData = []; + for (const row of rows) { + const data: IDataObject = {}; + if (dimensions) { + for (let i = 0; i < dimensions.length; i++) { + data[dimensions[i]] = row.dimensions[i]; + data['total'] = row.metrics[0].values.join(','); + } + } else { + data['total'] = row.metrics[0].values.join(','); + } + responseData.push(data); + } + return responseData; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/GoogleAnalytics.node.ts b/packages/nodes-base/nodes/Google/Analytics/GoogleAnalytics.node.ts new file mode 100644 index 0000000000..54ef98af43 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/GoogleAnalytics.node.ts @@ -0,0 +1,261 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + reportFields, + reportOperations, +} from './ReportDescription'; + +import { + userActivityFields, + userActivityOperations, +} from './UserActivityDescription'; + +import { + googleApiRequest, + googleApiRequestAllItems, + simplify, +} from './GenericFunctions'; + +import * as moment from 'moment-timezone'; + +import { + IData +} from './Interfaces'; + +export class GoogleAnalytics implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google Analytics', + name: 'googleAnalytics', + icon: 'file:analytics.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Use the Google Analytics API', + defaults: { + name: 'Google Analytics', + color: '#772244', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleAnalyticsOAuth2', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Report', + value: 'report', + }, + { + name: 'User Activity', + value: 'userActivity', + }, + ], + default:'report', + }, + //------------------------------- + // Reports Operations + //------------------------------- + ...reportOperations, + ...reportFields, + + //------------------------------- + // User Activity Operations + //------------------------------- + ...userActivityOperations, + ...userActivityFields, + ], + }; + + methods = { + loadOptions: { + // Get all the dimensions to display them to user so that he can + // select them easily + async getDimensions( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + const { items: dimensions } = await googleApiRequest.call( + this, + 'GET', + '', + {}, + {}, + 'https://www.googleapis.com/analytics/v3/metadata/ga/columns', + ); + + for (const dimesion of dimensions) { + if (dimesion.attributes.type === 'DIMENSION' && dimesion.attributes.status !== 'DEPRECATED') { + returnData.push({ + name: dimesion.attributes.uiName, + value: dimesion.id, + description: dimesion.attributes.description, + }); + } + } + return returnData; + }, + // Get all the views to display them to user so that he can + // select them easily + async getViews( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + const { items } = await googleApiRequest.call( + this, + 'GET', + '', + {}, + {}, + 'https://www.googleapis.com/analytics/v3/management/accounts/~all/webproperties/~all/profiles', + ); + + for (const item of items) { + returnData.push({ + name: item.name, + value: item.id, + description: item.websiteUrl, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + let method = ''; + const qs: IDataObject = {}; + let endpoint = ''; + let responseData; + for (let i = 0; i < items.length; i++) { + if(resource === 'report') { + if(operation === 'get') { + //https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet + method = 'POST'; + endpoint = '/v4/reports:batchGet'; + const viewId = this.getNodeParameter('viewId', i) as string; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i, + ) as IDataObject; + const simple = this.getNodeParameter('simple', i) as boolean; + + const body: IData = { + viewId, + }; + + if(additionalFields.useResourceQuotas){ + qs.useResourceQuotas = additionalFields.useResourceQuotas; + } + if(additionalFields.dateRangesUi){ + const dateValues = (additionalFields.dateRangesUi as IDataObject).dateRanges as IDataObject; + if(dateValues){ + const start = dateValues.startDate as string; + const end = dateValues.endDate as string; + Object.assign( + body, + { + dateRanges: + [ + { + startDate: moment(start).utc().format('YYYY-MM-DD'), + endDate: moment(end).utc().format('YYYY-MM-DD'), + }, + ], + }, + ); + } + } + + if(additionalFields.metricsUi) { + const metrics = (additionalFields.metricsUi as IDataObject).metricValues as IDataObject[]; + body.metrics = metrics; + } + if(additionalFields.dimensionUi){ + const dimensions = (additionalFields.dimensionUi as IDataObject).dimensionValues as IDataObject[]; + if (dimensions) { + body.dimensions = dimensions; + } + } + if(additionalFields.includeEmptyRows){ + Object.assign(body, { includeEmptyRows: additionalFields.includeEmptyRows }); + } + if(additionalFields.hideTotals){ + Object.assign(body, { hideTotals: additionalFields.hideTotals }); + } + if(additionalFields.hideValueRanges){ + Object.assign(body, { hideTotals: additionalFields.hideTotals }); + } + + responseData = await googleApiRequest.call(this, method, endpoint, { reportRequests: [body] }, qs); + responseData = responseData.reports; + + if (simple === true) { + responseData = simplify(responseData); + } + } + } + if(resource === 'userActivity') { + if(operation === 'search') { + // https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/userActivity/search + method = 'POST'; + endpoint = '/v4/userActivity:search'; + const viewId = this.getNodeParameter('viewId', i); + const userId = this.getNodeParameter('userId', i); + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const additionalFields = this.getNodeParameter( + 'additionalFields', + i, + ) as IDataObject; + const body: IDataObject = { + viewId, + user: { + userId, + }, + }; + if(additionalFields.activityTypes) { + Object.assign(body, { activityTypes: additionalFields.activityTypes }); + } + + if (returnAll) { + responseData = await googleApiRequestAllItems.call(this, 'sessions', method, endpoint, body); + } else { + body.pageSize = this.getNodeParameter('limit', 0) as number; + responseData = await googleApiRequest.call(this, method, endpoint, body); + responseData = responseData.sessions; + } + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Google/Analytics/Interfaces.ts b/packages/nodes-base/nodes/Google/Analytics/Interfaces.ts new file mode 100644 index 0000000000..5a54a9acab --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/Interfaces.ts @@ -0,0 +1,17 @@ +export interface IData { + viewId: string; + dimensions?: IDimension[]; + pageSize?: number; + metrics?: IMetric[]; +} + +export interface IDimension { + name?: string; + histogramBuckets?: string[]; +} + +export interface IMetric { + expression?: string; + alias?: string; + formattingType?: string; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/ReportDescription.ts b/packages/nodes-base/nodes/Google/Analytics/ReportDescription.ts new file mode 100644 index 0000000000..2428b67b4a --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/ReportDescription.ts @@ -0,0 +1,241 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const reportOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'report', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Return the analytics data', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const reportFields = [ + { + displayName: 'View ID', + name: 'viewId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getViews', + }, + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'report', + ], + operation: [ + 'get', + ], + }, + }, + placeholder: '123456', + description: 'The View ID of Google Analytics', + }, + { + displayName: 'Simple', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'report', + ], + }, + }, + default: true, + description: 'When set to true a simplify version of the response will be used else the raw data.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'report', + ], + operation: [ + 'get', + ], + }, + }, + options: [ + { + displayName: 'Date Ranges', + name: 'dateRangesUi', + placeholder: 'Add Date Range', + type: 'fixedCollection', + default: {}, + description: 'Date ranges in the request', + options: [ + { + displayName: 'Date Range', + name: 'dateRanges', + values: [ + { + displayName: 'Start Date', + name: 'startDate', + type: 'dateTime', + default: '', + description: 'Start date', + }, + { + displayName: 'End Date', + name: 'endDate', + type: 'dateTime', + default: '', + description: 'End date', + }, + ], + }, + ], + }, + { + displayName: 'Dimensions', + name: 'dimensionUi', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Dimension', + description: 'Dimensions are attributes of your data. For example, the dimension ga:city indicates the city, for example, "Paris" or "New York", from which a session originates.', + options: [ + { + displayName: 'Dimension', + name: 'dimensionValues', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDimensions', + }, + default: '', + description: 'Name of the dimension to fetch, for example ga:browser.', + }, + ], + }, + ], + }, + { + displayName: 'Hide Totals', + name: 'hideTotals', + type: 'boolean', + default: false, + description: 'If set to true, hides the total of all metrics for all the matching rows, for every date range.', + }, + { + displayName: 'Hide Value Ranges', + name: 'hideValueRanges', + type: 'boolean', + default: false, + description: 'If set to true, hides the minimum and maximum across all matching rows.', + }, + { + displayName: 'Include Empty Rows', + name: 'includeEmptyRows', + type: 'boolean', + default: false, + description: 'If set to false, the response exclude rows if all the retrieved metrics are equal to zero.', + }, + { + displayName: 'Metrics', + name: 'metricsUi', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Metrics', + description: 'Metrics in the request', + options: [ + { + displayName: 'Metric', + name: 'metricValues', + values: [ + { + displayName: 'Alias', + name: 'alias', + type: 'string', + default: '', + description: 'An alias for the metric expression is an alternate name for the expression. The alias can be used for filtering and sorting.', + }, + { + displayName: 'Expression', + name: 'expression', + type: 'string', + default: 'ga:newUsers', + description: `A metric expression in the request. An expression is constructed from one or more metrics and numbers.
+ Accepted operators include: Plus (+), Minus (-), Negation (Unary -), Divided by (/), Multiplied by (*), Parenthesis,
+ Positive cardinal numbers (0-9), can include decimals and is limited to 1024 characters. Example ga:totalRefunds/ga:users,
+ in most cases the metric expression is just a single metric name like ga:users. Adding mixed MetricType (E.g., CURRENCY + PERCENTAGE)
+ metrics will result in unexpected results.`, + }, + { + displayName: 'Formatting Type', + name: 'formattingType', + type: 'options', + default: 'INTEGER', + description: 'Specifies how the metric expression should be formatted.', + options: [ + { + name: 'Currency', + value: 'CURRENCY', + }, + { + name: 'Float', + value: 'FLOAT', + }, + { + name: 'Integer', + value: 'INTEGER', + }, + { + name: 'Percent', + value: 'PERCENT', + }, + { + name: 'Time', + value: 'TIME', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Use Resource Quotas', + name: 'useResourceQuotas', + type: 'boolean', + default: false, + description: 'Enables resource based quotas.', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Google/Analytics/UserActivityDescription.ts b/packages/nodes-base/nodes/Google/Analytics/UserActivityDescription.ts new file mode 100644 index 0000000000..4201b900cb --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/UserActivityDescription.ts @@ -0,0 +1,160 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const userActivityOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'userActivity', + ], + }, + }, + options: [ + { + name: 'Search', + value: 'search', + description: 'Return user activity data.', + }, + ], + default: 'search', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const userActivityFields = [ + { + displayName: 'View ID', + name: 'viewId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getViews', + }, + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'userActivity', + ], + operation: [ + 'search', + ], + }, + }, + placeholder: '123456', + description: 'The View ID of Google Analytics.', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'userActivity', + ], + operation: [ + 'search', + ], + }, + }, + placeholder: '123456', + description: 'ID of a user.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'userActivity', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'userActivity', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'userActivity', + ], + }, + }, + options: [ + { + displayName: 'Activity Types', + name: 'activityTypes', + type: 'multiOptions', + options: [ + { + name: 'Ecommerce', + value: 'ECOMMERCE', + }, + { + name: 'Event', + value: 'EVENT', + }, + { + name: 'Goal', + value: 'GOAL', + }, + { + name: 'Pageview', + value: 'PAGEVIEW', + }, + { + name: 'Screenview', + value: 'SCREENVIEW', + }, + ], + description: 'Type of activites requested.', + default: [], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Google/Analytics/analytics.svg b/packages/nodes-base/nodes/Google/Analytics/analytics.svg new file mode 100644 index 0000000000..bc9386a1af --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/analytics.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 297aa4c09e..d47a1890d3 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -83,6 +83,7 @@ "dist/credentials/GitlabApi.credentials.js", "dist/credentials/GitlabOAuth2Api.credentials.js", "dist/credentials/GmailOAuth2Api.credentials.js", + "dist/credentials/GoogleAnalyticsOAuth2Api.credentials.js", "dist/credentials/GoogleApi.credentials.js", "dist/credentials/GoogleBooksOAuth2Api.credentials.js", "dist/credentials/GoogleCalendarOAuth2Api.credentials.js", @@ -317,6 +318,7 @@ "dist/nodes/Gitlab/Gitlab.node.js", "dist/nodes/Gitlab/GitlabTrigger.node.js", "dist/nodes/Google/Books/GoogleBooks.node.js", + "dist/nodes/Google/Analytics/GoogleAnalytics.node.js", "dist/nodes/Google/Calendar/GoogleCalendar.node.js", "dist/nodes/Google/CloudNaturalLanguage/GoogleCloudNaturalLanguage.node.js", "dist/nodes/Google/Contacts/GoogleContacts.node.js",