diff --git a/packages/nodes-base/nodes/Google/Analytics/GoogleAnalytics.node.ts b/packages/nodes-base/nodes/Google/Analytics/GoogleAnalytics.node.ts index dd7e26e057..5746647cb9 100644 --- a/packages/nodes-base/nodes/Google/Analytics/GoogleAnalytics.node.ts +++ b/packages/nodes-base/nodes/Google/Analytics/GoogleAnalytics.node.ts @@ -1,299 +1,25 @@ -import { IExecuteFunctions } from 'n8n-core'; +import { INodeTypeBaseDescription, IVersionedNodeType, VersionedNodeType } from 'n8n-workflow'; -import { - IDataObject, - ILoadOptionsFunctions, - INodeExecutionData, - INodePropertyOptions, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; +import { GoogleAnalyticsV1 } from './v1/GoogleAnalyticsV1.node'; +import { GoogleAnalyticsV2 } from './v2/GoogleAnalyticsV2.node'; -import { reportFields, reportOperations } from './ReportDescription'; +export class GoogleAnalytics extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Google Analytics', + name: 'googleAnalytics', + icon: 'file:analytics.svg', + group: ['transform'], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Use the Google Analytics API', + defaultVersion: 2, + }; -import { userActivityFields, userActivityOperations } from './UserActivityDescription'; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new GoogleAnalyticsV1(baseDescription), + 2: new GoogleAnalyticsV2(baseDescription), + }; -import { googleApiRequest, googleApiRequestAllItems, merge, simplify } from './GenericFunctions'; - -import 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', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'googleAnalyticsOAuth2', - required: true, - }, - ], - properties: [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - 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, - }); - } - } - - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { - return -1; - } - if (aName > bName) { - return 1; - } - return 0; - }); - - 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: INodeExecutionData[] = []; - const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0); - - let method = ''; - const qs: IDataObject = {}; - let endpoint = ''; - let responseData; - for (let i = 0; i < items.length; i++) { - try { - 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 returnAll = this.getNodeParameter('returnAll', 0); - const additionalFields = this.getNodeParameter('additionalFields', i); - 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.dimensionFiltersUi) { - const dimensionFilters = (additionalFields.dimensionFiltersUi as IDataObject) - .filterValues as IDataObject[]; - if (dimensionFilters) { - dimensionFilters.forEach((filter) => (filter.expressions = [filter.expressions])); - body.dimensionFilterClauses = { filters: dimensionFilters }; - } - } - - 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 }); - } - - if (returnAll) { - responseData = await googleApiRequestAllItems.call( - this, - 'reports', - method, - endpoint, - { reportRequests: [body] }, - qs, - ); - } else { - responseData = await googleApiRequest.call( - this, - method, - endpoint, - { reportRequests: [body] }, - qs, - ); - responseData = responseData.reports; - } - - if (simple) { - responseData = simplify(responseData); - } else if (returnAll && responseData.length > 1) { - responseData = merge(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); - const additionalFields = this.getNodeParameter('additionalFields', i); - 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); - responseData = await googleApiRequest.call(this, method, endpoint, body); - responseData = responseData.sessions; - } - } - } - - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(responseData), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } catch (error) { - if (this.continueOnFail()) { - const executionErrorData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray({ error: error.message }), - { itemData: { item: i } }, - ); - returnData.push(...executionErrorData); - continue; - } - throw error; - } - } - return this.prepareOutputData(returnData); + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Google/Analytics/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Analytics/v1/GenericFunctions.ts similarity index 68% rename from packages/nodes-base/nodes/Google/Analytics/GenericFunctions.ts rename to packages/nodes-base/nodes/Google/Analytics/v1/GenericFunctions.ts index 04a73fe63e..f81493d629 100644 --- a/packages/nodes-base/nodes/Google/Analytics/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Analytics/v1/GenericFunctions.ts @@ -1,19 +1,18 @@ import { OptionsWithUri } from 'request'; - import { IExecuteFunctions, IExecuteSingleFunctions, ILoadOptionsFunctions } from 'n8n-core'; - import { IDataObject, NodeApiError } from 'n8n-workflow'; export async function googleApiRequest( this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, - - body: any = {}, + body: IDataObject = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}, -): Promise { +) { + const baseURL = 'https://analyticsreporting.googleapis.com'; + let options: OptionsWithUri = { headers: { Accept: 'application/json', @@ -22,11 +21,12 @@ export async function googleApiRequest( method, body, qs, - uri: uri || `https://analyticsreporting.googleapis.com${endpoint}`, + uri: uri || `${baseURL}${endpoint}`, json: true, }; options = Object.assign({}, options, option); + try { if (Object.keys(body).length === 0) { delete options.body; @@ -34,10 +34,17 @@ export async function googleApiRequest( if (Object.keys(qs).length === 0) { delete options.qs; } - //@ts-ignore return await this.helpers.requestOAuth2.call(this, 'googleAnalyticsOAuth2', options); } catch (error) { - throw new NodeApiError(this.getNode(), error); + const errorData = (error.message || '').split(' - ')[1] as string; + if (errorData) { + const parsedError = JSON.parse(errorData.trim()); + const [message, ...rest] = parsedError.error.message.split('\n'); + const description = rest.join('\n'); + const httpCode = parsedError.error.code; + throw new NodeApiError(this.getNode(), error, { message, description, httpCode }); + } + throw new NodeApiError(this.getNode(), error, { message: error.message }); } } @@ -46,19 +53,18 @@ export async function googleApiRequestAllItems( propertyName: string, method: string, endpoint: string, - - body: any = {}, + body: IDataObject = {}, query: IDataObject = {}, uri?: string, -): Promise { +) { const returnData: IDataObject[] = []; - let responseData; do { responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri); if (body.reportRequests && Array.isArray(body.reportRequests)) { - body.reportRequests[0].pageToken = responseData[propertyName][0].nextPageToken; + (body.reportRequests as IDataObject[])[0].pageToken = + responseData[propertyName][0].nextPageToken; } else { body.pageToken = responseData.nextPageToken; } @@ -74,26 +80,32 @@ export async function googleApiRequestAllItems( export function simplify(responseData: any | [any]) { const response = []; for (const { - columnHeader: { dimensions }, + columnHeader: { dimensions, metricHeader }, data: { rows }, } of responseData) { if (rows === undefined) { // Do not error if there is no data continue; } + const metrics = metricHeader.metricHeaderEntries.map((entry: { name: string }) => entry.name); 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(','); + for (const [index, metric] of metrics.entries()) { + data[metric] = row.metrics[0].values[index]; + } } } else { - data.total = row.metrics[0].values.join(','); + for (const [index, metric] of metrics.entries()) { + data[metric] = row.metrics[0].values[index]; + } } response.push(data); } } + return response; } diff --git a/packages/nodes-base/nodes/Google/Analytics/v1/GoogleAnalyticsV1.node.ts b/packages/nodes-base/nodes/Google/Analytics/v1/GoogleAnalyticsV1.node.ts new file mode 100644 index 0000000000..a753191f94 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v1/GoogleAnalyticsV1.node.ts @@ -0,0 +1,305 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { IExecuteFunctions } from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { reportFields, reportOperations } from './ReportDescription'; +import { userActivityFields, userActivityOperations } from './UserActivityDescription'; +import { googleApiRequest, googleApiRequestAllItems, merge, simplify } from './GenericFunctions'; +import moment from 'moment-timezone'; +import { IData } from './Interfaces'; + +const versionDescription: 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', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleAnalyticsOAuth2', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Report', + value: 'report', + }, + { + name: 'User Activity', + value: 'userActivity', + }, + ], + default: 'report', + }, + //------------------------------- + // Reports Operations + //------------------------------- + ...reportOperations, + ...reportFields, + + //------------------------------- + // User Activity Operations + //------------------------------- + ...userActivityOperations, + ...userActivityFields, + ], +}; + +export class GoogleAnalyticsV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + 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, + }); + } + } + + returnData.sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + if (aName < bName) { + return -1; + } + if (aName > bName) { + return 1; + } + return 0; + }); + + 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: INodeExecutionData[] = []; + const resource = this.getNodeParameter('resource', 0); + const operation = this.getNodeParameter('operation', 0); + + let method = ''; + const qs: IDataObject = {}; + let endpoint = ''; + let responseData; + for (let i = 0; i < items.length; i++) { + try { + 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 returnAll = this.getNodeParameter('returnAll', 0); + const additionalFields = this.getNodeParameter('additionalFields', i); + 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.dimensionFiltersUi) { + const dimensionFilters = (additionalFields.dimensionFiltersUi as IDataObject) + .filterValues as IDataObject[]; + if (dimensionFilters) { + dimensionFilters.forEach((filter) => (filter.expressions = [filter.expressions])); + body.dimensionFilterClauses = { filters: dimensionFilters }; + } + } + + 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 }); + } + + if (returnAll) { + responseData = await googleApiRequestAllItems.call( + this, + 'reports', + method, + endpoint, + { reportRequests: [body] }, + qs, + ); + } else { + responseData = await googleApiRequest.call( + this, + method, + endpoint, + { reportRequests: [body] }, + qs, + ); + responseData = responseData.reports; + } + + if (simple) { + responseData = simplify(responseData); + } else if (returnAll && responseData.length > 1) { + responseData = merge(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); + const additionalFields = this.getNodeParameter('additionalFields', i); + 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); + responseData = await googleApiRequest.call(this, method, endpoint, body); + responseData = responseData.sessions; + } + } + } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionErrorData); + continue; + } + throw error; + } + } + return this.prepareOutputData(returnData); + } +} diff --git a/packages/nodes-base/nodes/Google/Analytics/Interfaces.ts b/packages/nodes-base/nodes/Google/Analytics/v1/Interfaces.ts similarity index 86% rename from packages/nodes-base/nodes/Google/Analytics/Interfaces.ts rename to packages/nodes-base/nodes/Google/Analytics/v1/Interfaces.ts index e997bbd1f5..cf21a4afde 100644 --- a/packages/nodes-base/nodes/Google/Analytics/Interfaces.ts +++ b/packages/nodes-base/nodes/Google/Analytics/v1/Interfaces.ts @@ -1,3 +1,5 @@ +import { IDataObject } from 'n8n-workflow'; + export interface IData { viewId: string; dimensions?: IDimension[]; @@ -6,6 +8,7 @@ export interface IData { }; pageSize?: number; metrics?: IMetric[]; + dateRanges?: IDataObject[]; } export interface IDimension { diff --git a/packages/nodes-base/nodes/Google/Analytics/ReportDescription.ts b/packages/nodes-base/nodes/Google/Analytics/v1/ReportDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Google/Analytics/ReportDescription.ts rename to packages/nodes-base/nodes/Google/Analytics/v1/ReportDescription.ts diff --git a/packages/nodes-base/nodes/Google/Analytics/UserActivityDescription.ts b/packages/nodes-base/nodes/Google/Analytics/v1/UserActivityDescription.ts similarity index 100% rename from packages/nodes-base/nodes/Google/Analytics/UserActivityDescription.ts rename to packages/nodes-base/nodes/Google/Analytics/v1/UserActivityDescription.ts diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/GoogleAnalyticsV2.node.ts b/packages/nodes-base/nodes/Google/Analytics/v2/GoogleAnalyticsV2.node.ts new file mode 100644 index 0000000000..b6c129da8a --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/GoogleAnalyticsV2.node.ts @@ -0,0 +1,28 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { IExecuteFunctions } from 'n8n-core'; +import { + INodeExecutionData, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, +} from 'n8n-workflow'; +import { listSearch, loadOptions } from './methods'; +import { router } from './actions/router'; +import { versionDescription } from './actions/versionDescription'; + +export class GoogleAnalyticsV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { loadOptions, listSearch }; + + async execute(this: IExecuteFunctions): Promise { + return router.call(this); + } +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/node.type.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/node.type.ts new file mode 100644 index 0000000000..66e788fe41 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/node.type.ts @@ -0,0 +1,13 @@ +import { AllEntities, Entity } from 'n8n-workflow'; + +type GoogleAnalyticsMap = { + userActivity: 'search'; + report: ReportBasedOnProperty; +}; + +export type GoogleAnalytics = AllEntities; + +export type GoogleAnalyticsUserActivity = Entity; +export type GoogleAnalyticReport = Entity; + +export type ReportBasedOnProperty = 'getga4' | 'getuniversal'; diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/FiltersDescription.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/FiltersDescription.ts new file mode 100644 index 0000000000..2fe75ef2a6 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/FiltersDescription.ts @@ -0,0 +1,488 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const dimensionDropdown: INodeProperties[] = [ + { + displayName: 'Dimension', + name: 'listName', + type: 'options', + default: 'date', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Browser', + value: 'browser', + }, + { + name: 'Campaign', + value: 'campaignName', + }, + { + name: 'City', + value: 'city', + }, + { + name: 'Country', + value: 'country', + }, + { + name: 'Date', + value: 'date', + }, + { + name: 'Device Category', + value: 'deviceCategory', + }, + { + name: 'Item Name', + value: 'itemName', + }, + { + name: 'Language', + value: 'language', + }, + { + name: 'Page Location', + value: 'pageLocation', + }, + { + name: 'Source / Medium', + value: 'sourceMedium', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'Other dimensions…', + value: 'other', + }, + ], + }, + { + displayName: 'Name or ID', + name: 'name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDimensionsGA4', + loadOptionsDependsOn: ['propertyId.value'], + }, + default: 'date', + description: + 'The name of the dimension. Choose from the list, or specify an ID using an expression.', + displayOptions: { + show: { + listName: ['other'], + }, + }, + }, +]; + +export const metricDropdown: INodeProperties[] = [ + { + displayName: 'Metric', + name: 'listName', + type: 'options', + default: 'totalUsers', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: '1 Day Active Users', + value: 'active1DayUsers', + }, + { + name: '28 Day Active Users', + value: 'active28DayUsers', + }, + { + name: '7 Day Active Users', + value: 'active7DayUsers', + }, + { + name: 'Checkouts', + value: 'checkouts', + }, + { + name: 'Events', + value: 'eventCount', + }, + { + name: 'Page Views', + value: 'screenPageViews', + }, + { + name: 'Session Duration', + value: 'userEngagementDuration', + }, + { + name: 'Sessions', + value: 'sessions', + }, + { + name: 'Sessions per User', + value: 'sessionsPerUser', + }, + { + name: 'Total Users', + value: 'totalUsers', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'Other metrics…', + value: 'other', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'Custom metric…', + value: 'custom', + }, + ], + }, + { + displayName: 'Name or ID', + name: 'name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getMetricsGA4', + loadOptionsDependsOn: ['propertyId.value'], + }, + default: 'totalUsers', + hint: 'If expression is specified, name can be any string that you would like', + description: + 'The name of the metric. Choose from the list, or specify an ID using an expression.', + displayOptions: { + show: { + listName: ['other'], + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: 'custom_metric', + displayOptions: { + show: { + listName: ['custom'], + }, + }, + }, +]; + +const dimensionsFilterExpressions: INodeProperties[] = [ + { + displayName: 'Expression', + name: 'expression', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + placeholder: 'Add Expression', + options: [ + { + displayName: 'String Filter', + name: 'stringFilter', + values: [ + ...dimensionDropdown, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + { + displayName: 'Case Sensitive', + name: 'caseSensitive', + type: 'boolean', + default: true, + }, + { + displayName: 'Match Type', + name: 'matchType', + type: 'options', + default: 'EXACT', + options: [ + { + name: 'Begins With', + value: 'BEGINS_WITH', + }, + { + name: 'Contains Value', + value: 'CONTAINS', + }, + { + name: 'Ends With', + value: 'ENDS_WITH', + }, + { + name: 'Exact Match', + value: 'EXACT', + }, + { + name: 'Full Match for the Regular Expression', + value: 'FULL_REGEXP', + }, + { + name: 'Partial Match for the Regular Expression', + value: 'PARTIAL_REGEXP', + }, + ], + }, + ], + }, + { + displayName: 'In List Filter', + name: 'inListFilter', + values: [ + ...dimensionDropdown, + { + displayName: 'Values', + name: 'values', + type: 'string', + default: '', + hint: 'Comma separated list of values. Must be non-empty.', + }, + { + displayName: 'Case Sensitive', + name: 'caseSensitive', + type: 'boolean', + default: true, + }, + ], + }, + { + displayName: 'Numeric Filter', + name: 'numericFilter', + values: [ + ...dimensionDropdown, + { + displayName: 'Value Type', + name: 'valueType', + type: 'options', + default: 'doubleValue', + options: [ + { + name: 'Double Value', + value: 'doubleValue', + }, + { + name: 'Integer Value', + value: 'int64Value', + }, + ], + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'EQUAL', + options: [ + { + name: 'Equal', + value: 'EQUAL', + }, + { + name: 'Greater Than', + value: 'GREATER_THAN', + }, + { + name: 'Greater than or Equal', + value: 'GREATER_THAN_OR_EQUAL', + }, + { + name: 'Less Than', + value: 'LESS_THAN', + }, + { + name: 'Less than or Equal', + value: 'LESS_THAN_OR_EQUAL', + }, + ], + }, + ], + }, + ], + }, +]; + +export const dimensionFilterField: INodeProperties[] = [ + { + displayName: 'Dimensions Filters', + name: 'dimensionFiltersUI', + type: 'fixedCollection', + default: {}, + placeholder: 'Add Filter', + options: [ + { + displayName: 'Filter Expressions', + name: 'filterExpressions', + values: [ + { + displayName: 'Filter Expression Type', + name: 'filterExpressionType', + type: 'options', + default: 'andGroup', + options: [ + { + name: 'And Group', + value: 'andGroup', + }, + { + name: 'Or Group', + value: 'orGroup', + }, + ], + }, + ...dimensionsFilterExpressions, + ], + }, + ], + }, +]; + +const metricsFilterExpressions: INodeProperties[] = [ + { + displayName: 'Expression', + name: 'expression', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + placeholder: 'Add Expression', + options: [ + { + displayName: 'Between Filter', + name: 'betweenFilter', + values: [ + ...metricDropdown, + { + displayName: 'Value Type', + name: 'valueType', + type: 'options', + default: 'doubleValue', + options: [ + { + name: 'Double Value', + value: 'doubleValue', + }, + { + name: 'Integer Value', + value: 'int64Value', + }, + ], + }, + { + displayName: 'From Value', + name: 'fromValue', + type: 'string', + default: '', + }, + { + displayName: 'To Value', + name: 'toValue', + type: 'string', + default: '', + }, + ], + }, + { + displayName: 'Numeric Filter', + name: 'numericFilter', + values: [ + ...metricDropdown, + { + displayName: 'Value Type', + name: 'valueType', + type: 'options', + default: 'doubleValue', + options: [ + { + name: 'Double Value', + value: 'doubleValue', + }, + { + name: 'Integer Value', + value: 'int64Value', + }, + ], + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'EQUAL', + options: [ + { + name: 'Equal', + value: 'EQUAL', + }, + { + name: 'Greater Than', + value: 'GREATER_THAN', + }, + { + name: 'Greater than or Equal', + value: 'GREATER_THAN_OR_EQUAL', + }, + { + name: 'Less Than', + value: 'LESS_THAN', + }, + { + name: 'Less than or Equal', + value: 'LESS_THAN_OR_EQUAL', + }, + ], + }, + ], + }, + ], + }, +]; + +export const metricsFilterField: INodeProperties[] = [ + { + displayName: 'Metrics Filters', + name: 'metricsFiltersUI', + type: 'fixedCollection', + default: {}, + placeholder: 'Add Filter', + options: [ + { + displayName: 'Filter Expressions', + name: 'filterExpressions', + values: [ + { + displayName: 'Filter Expression Type', + name: 'filterExpressionType', + type: 'options', + default: 'andGroup', + options: [ + { + name: 'And Group', + value: 'andGroup', + }, + { + name: 'Or Group', + value: 'orGroup', + }, + ], + }, + ...metricsFilterExpressions, + ], + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/Report.resource.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/Report.resource.ts new file mode 100644 index 0000000000..a5e7df62f4 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/Report.resource.ts @@ -0,0 +1,55 @@ +import { INodeProperties } from 'n8n-workflow'; +import * as getga4 from './get.ga4.operation'; +import * as getuniversal from './get.universal.operation'; + +export { getga4, getuniversal }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['report'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Return the analytics data', + action: 'Get a report', + }, + ], + default: 'get', + }, + { + displayName: 'Property Type', + name: 'propertyType', + type: 'options', + noDataExpression: true, + description: + 'Google Analytics 4 is the latest version. Universal Analytics is an older version that is not fully functional after the end of June 2023.', + options: [ + { + name: 'Google Analytics 4', + value: 'ga4', + }, + { + name: 'Universal Analytics', + value: 'universal', + }, + ], + default: 'ga4', + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + }, + }, + }, + ...getga4.description, + ...getuniversal.description, +]; diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/get.ga4.operation.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/get.ga4.operation.ts new file mode 100644 index 0000000000..e644db4712 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/get.ga4.operation.ts @@ -0,0 +1,620 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { + checkDuplicates, + defaultEndDate, + defaultStartDate, + prepareDateRange, + processFilters, + simplifyGA4, +} from '../../helpers/utils'; +import { googleApiRequest, googleApiRequestAllItems } from '../../transport'; +import { + dimensionDropdown, + dimensionFilterField, + metricDropdown, + metricsFilterField, +} from './FiltersDescription'; + +export const description: INodeProperties[] = [ + { + displayName: 'Property', + name: 'propertyId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'The Property of Google Analytics', + hint: "If this doesn't work, try changing the 'Property Type' field above", + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a property...', + typeOptions: { + searchListMethod: 'searchProperties', + searchFilterRequired: false, + searchable: false, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://analytics.google.com/analytics/...', + validation: [ + { + type: 'regex', + properties: { + regex: '.*analytics\\.google\\.com\\/analytics.*\\/p([0-9]{1,})(?:\\/.*|)*', + errorMessage: 'Not a valid Google Analytics URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '.*analytics\\.google\\.com\\/analytics.*\\/p([0-9]{1,})(?:\\/.*|)', + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '123456', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]{1,}', + errorMessage: 'Not a valid Google Analytics Property ID', + }, + }, + ], + url: '=https://analytics.google.com/analytics/web/#/p{{$value}}/', + }, + ], + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['ga4'], + }, + }, + }, + { + displayName: 'Date Range', + name: 'dateRange', + type: 'options', + required: true, + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Last 7 Days', + value: 'last7days', + }, + { + name: 'Last 30 Days', + value: 'last30days', + }, + { + name: 'Today', + value: 'today', + }, + { + name: 'Yesterday', + value: 'yesterday', + }, + { + name: 'Last Complete Calendar Week', + value: 'lastCalendarWeek', + }, + { + name: 'Last Complete Calendar Month', + value: 'lastCalendarMonth', + }, + { + name: 'Custom', + value: 'custom', + }, + ], + default: 'last7days', + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['ga4'], + }, + }, + }, + { + displayName: 'Start', + name: 'startDate', + type: 'dateTime', + required: true, + default: defaultStartDate(), + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + dateRange: ['custom'], + propertyType: ['ga4'], + }, + }, + }, + { + displayName: 'End', + name: 'endDate', + type: 'dateTime', + required: true, + default: defaultEndDate(), + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + dateRange: ['custom'], + propertyType: ['ga4'], + }, + }, + }, + { + displayName: 'Metrics', + name: 'metricsGA4', + type: 'fixedCollection', + default: { metricValues: [{ listName: 'totalUsers' }] }, + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Metric', + description: + 'The quantitative measurements of a report. For example, the metric eventCount is the total number of events. Requests are allowed up to 10 metrics.', + options: [ + { + displayName: 'Values', + name: 'metricValues', + values: [ + ...metricDropdown, + { + displayName: 'Expression', + name: 'expression', + type: 'string', + default: '', + description: + 'A mathematical expression for derived metrics. For example, the metric Event count per user is eventCount/totalUsers.', + placeholder: 'e.g. eventCount/totalUsers', + displayOptions: { + show: { + listName: ['custom'], + }, + }, + }, + { + displayName: 'Invisible', + name: 'invisible', + type: 'boolean', + default: false, + displayOptions: { + show: { + listName: ['custom'], + }, + }, + description: + 'Whether a metric is invisible in the report response. If a metric is invisible, the metric will not produce a column in the response, but can be used in metricFilter, orderBys, or a metric expression.', + }, + ], + }, + ], + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['ga4'], + }, + }, + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + displayName: 'Dimensions to split by', + name: 'dimensionsGA4', + type: 'fixedCollection', + default: { dimensionValues: [{ listName: 'date' }] }, + // default: {}, + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Dimension', + description: + 'Dimensions are attributes of your data. For example, the dimension city indicates the city from which an event originates. Dimension values in report responses are strings; for example, the city could be "Paris" or "New York". Requests are allowed up to 9 dimensions.', + options: [ + { + displayName: 'Values', + name: 'dimensionValues', + values: [...dimensionDropdown], + }, + ], + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['ga4'], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: ['get'], + resource: ['report'], + propertyType: ['ga4'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: ['get'], + resource: ['report'], + propertyType: ['ga4'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 50, + description: 'Max number of results to return', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-simplify + displayName: 'Simplify Output', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + operation: ['get'], + resource: ['report'], + propertyType: ['ga4'], + }, + }, + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['ga4'], + }, + }, + options: [ + { + displayName: 'Currency Code', + name: 'currencyCode', + type: 'string', + default: '', + description: + 'A currency code in ISO4217 format, such as "AED", "USD", "JPY". If the field is empty, the report uses the property\'s default currency.', + }, + ...dimensionFilterField, + { + displayName: 'Metric Aggregation', + name: 'metricAggregations', + type: 'multiOptions', + default: [], + options: [ + { + name: 'MAXIMUM', + value: 'MAXIMUM', + }, + { + name: 'MINIMUM', + value: 'MINIMUM', + }, + { + name: 'TOTAL', + value: 'TOTAL', + }, + ], + displayOptions: { + show: { + '/simple': [false], + }, + }, + }, + ...metricsFilterField, + { + displayName: 'Keep Empty Rows', + name: 'keepEmptyRows', + type: 'boolean', + default: false, + description: + 'Whether false or unspecified, each row with all metrics equal to 0 will not be returned. If true, these rows will be returned if they are not separately removed by a filter.', + }, + { + displayName: 'Order By', + name: 'orderByUI', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Order', + description: 'Specifies how rows are ordered in the response', + options: [ + { + displayName: 'Metric Order By', + name: 'metricOrderBy', + values: [ + { + displayName: 'Descending', + name: 'desc', + type: 'boolean', + default: false, + description: 'Whether true, sorts by descending order', + }, + { + displayName: 'Metric Name or ID', + name: 'metricName', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getMetricsGA4', + loadOptionsDependsOn: ['propertyId.value'], + }, + default: '', + description: + 'Sorts by metric values. Choose from the list, or specify an ID using an expression.', + }, + ], + }, + { + displayName: 'Dimmension Order By', + name: 'dimmensionOrderBy', + values: [ + { + displayName: 'Descending', + name: 'desc', + type: 'boolean', + default: false, + description: 'Whether true, sorts by descending order', + }, + { + displayName: 'Dimmension Name or ID', + name: 'dimensionName', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDimensionsGA4', + loadOptionsDependsOn: ['propertyId.value'], + }, + default: '', + description: + 'Sorts by metric values. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Order Type', + name: 'orderType', + type: 'options', + default: 'ORDER_TYPE_UNSPECIFIED', + options: [ + { + name: 'Alphanumeric', + value: 'ALPHANUMERIC', + description: 'Alphanumeric sort by Unicode code point', + }, + { + name: 'Case Insensitive Alphanumeric', + value: 'CASE_INSENSITIVE_ALPHANUMERIC', + description: + 'Case insensitive alphanumeric sort by lower case Unicode code point', + }, + { + name: 'Numeric', + value: 'NUMERIC', + description: 'Dimension values are converted to numbers before sorting', + }, + { + name: 'Unspecified', + value: 'ORDER_TYPE_UNSPECIFIED', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Return Property Quota', + name: 'returnPropertyQuota', + type: 'boolean', + default: false, + description: + "Whether to return the current state of this Analytics Property's quota. Quota is returned in PropertyQuota.", + displayOptions: { + show: { + '/simple': [false], + }, + }, + }, + ], + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + //migration guide: https://developers.google.com/analytics/devguides/migration/api/reporting-ua-to-ga4#core_reporting + const propertyId = this.getNodeParameter('propertyId', index, undefined, { + extractValue: true, + }) as string; + + const returnAll = this.getNodeParameter('returnAll', 0); + const additionalFields = this.getNodeParameter('additionalFields', index); + const dateRange = this.getNodeParameter('dateRange', index) as string; + const metricsGA4 = this.getNodeParameter('metricsGA4', index, {}) as IDataObject; + const dimensionsGA4 = this.getNodeParameter('dimensionsGA4', index, {}) as IDataObject; + const simple = this.getNodeParameter('simple', index) as boolean; + + let responseData: IDataObject[] = []; + + const qs: IDataObject = {}; + const body: IDataObject = { + dateRanges: prepareDateRange.call(this, dateRange, index), + }; + + if (metricsGA4.metricValues) { + const metrics = (metricsGA4.metricValues as IDataObject[]).map((metric) => { + switch (metric.listName) { + case 'other': + return { name: metric.name }; + case 'custom': + const newMetric = { + name: metric.name, + expression: metric.expression, + invisible: metric.invisible, + }; + + if (newMetric.invisible === false) { + delete newMetric.invisible; + } + + if (newMetric.expression === '') { + delete newMetric.expression; + } + + return newMetric; + default: + return { name: metric.listName }; + } + }); + if (metrics.length) { + checkDuplicates.call(this, metrics, 'name', 'metrics'); + body.metrics = metrics; + } + } + + if (dimensionsGA4.dimensionValues) { + const dimensions = (dimensionsGA4.dimensionValues as IDataObject[]).map((dimension) => { + switch (dimension.listName) { + case 'other': + return { name: dimension.name }; + default: + return { name: dimension.listName }; + } + }); + if (dimensions.length) { + checkDuplicates.call(this, dimensions, 'name', 'dimensions'); + body.dimensions = dimensions; + } + } + + if (additionalFields.currencyCode) { + body.currencyCode = additionalFields.currencyCode; + } + + if (additionalFields.dimensionFiltersUI) { + const { filterExpressionType, expression } = ( + additionalFields.dimensionFiltersUI as IDataObject + ).filterExpressions as IDataObject; + if (expression) { + body.dimensionFilter = { + [filterExpressionType as string]: { + expressions: processFilters(expression as IDataObject), + }, + }; + } + } + + if (additionalFields.metricsFiltersUI) { + const { filterExpressionType, expression } = (additionalFields.metricsFiltersUI as IDataObject) + .filterExpressions as IDataObject; + if (expression) { + body.metricFilter = { + [filterExpressionType as string]: { + expressions: processFilters(expression as IDataObject), + }, + }; + } + } + + if (additionalFields.metricAggregations) { + body.metricAggregations = additionalFields.metricAggregations; + } + + if (additionalFields.keepEmptyRows) { + body.keepEmptyRows = additionalFields.keepEmptyRows; + } + + if (additionalFields.orderByUI) { + let orderBys: IDataObject[] = []; + const metricOrderBy = (additionalFields.orderByUI as IDataObject) + .metricOrderBy as IDataObject[]; + const dimmensionOrderBy = (additionalFields.orderByUI as IDataObject) + .dimmensionOrderBy as IDataObject[]; + if (metricOrderBy) { + orderBys = orderBys.concat( + metricOrderBy.map((order) => { + return { + desc: order.desc, + metric: { + metricName: order.metricName, + }, + }; + }), + ); + } + if (dimmensionOrderBy) { + orderBys = orderBys.concat( + dimmensionOrderBy.map((order) => { + return { + desc: order.desc, + dimension: { + dimensionName: order.dimensionName, + orderType: order.orderType, + }, + }; + }), + ); + } + body.orderBys = orderBys; + } + + if (additionalFields.returnPropertyQuota) { + body.returnPropertyQuota = additionalFields.returnPropertyQuota; + } + + const method = 'POST'; + const endpoint = `/v1beta/properties/${propertyId}:runReport`; + + if (returnAll) { + responseData = await googleApiRequestAllItems.call(this, '', method, endpoint, body, qs); + } else { + body.limit = this.getNodeParameter('limit', 0); + responseData = [await googleApiRequest.call(this, method, endpoint, body, qs)]; + } + + if (responseData?.length && simple) { + responseData = simplifyGA4(responseData[0]); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: { item: index } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/get.universal.operation.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/get.universal.operation.ts new file mode 100644 index 0000000000..1b07d1d174 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/report/get.universal.operation.ts @@ -0,0 +1,725 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { IData, IDimension, IMetric } from '../../helpers/Interfaces'; +import { + checkDuplicates, + defaultEndDate, + defaultStartDate, + merge, + prepareDateRange, + simplify, +} from '../../helpers/utils'; +import { googleApiRequest, googleApiRequestAllItems } from '../../transport'; + +const dimensionDropdown: INodeProperties[] = [ + { + displayName: 'Dimension', + name: 'listName', + type: 'options', + default: 'ga:date', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Browser', + value: 'ga:browser', + }, + { + name: 'Campaign', + value: 'ga:campaign', + }, + { + name: 'City', + value: 'ga:city', + }, + { + name: 'Country', + value: 'ga:country', + }, + { + name: 'Date', + value: 'ga:date', + }, + { + name: 'Device Category', + value: 'ga:deviceCategory', + }, + { + name: 'Item Name', + value: 'ga:productName', + }, + { + name: 'Language', + value: 'ga:language', + }, + { + name: 'Page', + value: 'ga:pagePath', + }, + { + name: 'Source / Medium', + value: 'ga:sourceMedium', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'Other dimensions…', + value: 'other', + }, + ], + }, + { + displayName: 'Name or ID', + name: 'name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDimensions', + loadOptionsDependsOn: ['viewId.value'], + }, + default: 'ga:date', + description: + 'Name of the dimension to fetch, for example ga:browser. Choose from the list, or specify an ID using an expression.', + displayOptions: { + show: { + listName: ['other'], + }, + }, + }, +]; + +export const description: INodeProperties[] = [ + { + displayName: 'View', + name: 'viewId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + description: 'The View of Google Analytics', + hint: "If this doesn't work, try changing the 'Property Type' field above", + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a view...', + typeOptions: { + searchListMethod: 'searchViews', + searchFilterRequired: false, + searchable: false, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://analytics.google.com/analytics/...', + validation: [ + { + type: 'regex', + properties: { + regex: '.*analytics.google.com/analytics.*p[0-9]{1,}.*', + errorMessage: 'Not a valid Google Analytics URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '.*analytics.google.com/analytics.*p([0-9]{1,})', + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '123456', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]{1,}', + errorMessage: 'Not a valid Google Analytics View ID', + }, + }, + ], + }, + ], + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['universal'], + }, + }, + }, + { + displayName: 'Date Range', + name: 'dateRange', + type: 'options', + required: true, + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Last 7 Days', + value: 'last7days', + }, + { + name: 'Last 30 Days', + value: 'last30days', + }, + { + name: 'Today', + value: 'today', + }, + { + name: 'Yesterday', + value: 'yesterday', + }, + { + name: 'Last Complete Calendar Week', + value: 'lastCalendarWeek', + }, + { + name: 'Last Complete Calendar Month', + value: 'lastCalendarMonth', + }, + { + name: 'Custom', + value: 'custom', + }, + ], + default: 'last7days', + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['universal'], + }, + }, + }, + { + displayName: 'Start', + name: 'startDate', + type: 'dateTime', + required: true, + default: defaultStartDate(), + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['universal'], + dateRange: ['custom'], + }, + }, + }, + { + displayName: 'End', + name: 'endDate', + type: 'dateTime', + required: true, + default: defaultEndDate(), + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['universal'], + dateRange: ['custom'], + }, + }, + }, + { + displayName: 'Metrics', + name: 'metricsUA', + type: 'fixedCollection', + default: { metricValues: [{ listName: 'ga:users' }] }, + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add metric', + description: 'Metrics in the request', + options: [ + { + displayName: 'Metric', + name: 'metricValues', + values: [ + { + displayName: 'Metric', + name: 'listName', + type: 'options', + default: 'ga:users', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Checkouts', + value: 'ga:productCheckouts', + }, + { + name: 'Events', + value: 'ga:totalEvents', + }, + { + name: 'Page Views', + value: 'ga:pageviews', + }, + { + name: 'Session Duration', + value: 'ga:sessionDuration', + }, + { + name: 'Sessions', + value: 'ga:sessions', + }, + { + name: 'Sessions per User', + value: 'ga:sessionsPerUser', + }, + { + name: 'Total Users', + value: 'ga:users', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'Other metrics…', + value: 'other', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'Custom metric…', + value: 'custom', + }, + ], + }, + { + displayName: 'Name or ID', + name: 'name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getMetrics', + loadOptionsDependsOn: ['viewId.value'], + }, + default: 'ga:users', + hint: 'If expression is specified, name can be any string that you would like', + description: + 'The name of the metric. Choose from the list, or specify an ID using an expression.', + displayOptions: { + show: { + listName: ['other'], + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: 'custom_metric', + displayOptions: { + show: { + listName: ['custom'], + }, + }, + }, + { + displayName: 'Expression', + name: 'expression', + type: 'string', + default: '', + placeholder: 'e.g. ga:totalRefunds/ga:users', + description: + 'Learn more about Google Analytics metric expressions', + displayOptions: { + show: { + listName: ['custom'], + }, + }, + }, + { + 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', + }, + ], + displayOptions: { + show: { + listName: ['custom'], + }, + }, + }, + ], + }, + ], + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['universal'], + }, + }, + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + displayName: 'Dimensions to split by', + name: 'dimensionsUA', + type: 'fixedCollection', + default: { dimensionValues: [{ listName: 'ga:date' }] }, + // 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: 'Values', + name: 'dimensionValues', + values: [...dimensionDropdown], + }, + ], + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['universal'], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: ['get'], + resource: ['report'], + propertyType: ['universal'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: ['get'], + resource: ['report'], + propertyType: ['universal'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 50, + description: 'Max number of results to return', + }, + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-simplify + displayName: 'Simplify Output', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + operation: ['get'], + resource: ['report'], + propertyType: ['universal'], + }, + }, + default: true, + description: 'Whether to return a simplified version of the response instead of the raw data', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['report'], + operation: ['get'], + propertyType: ['universal'], + }, + }, + options: [ + { + displayName: 'Dimension Filters', + name: 'dimensionFiltersUi', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Dimension Filter', + description: 'Dimension Filters in the request', + options: [ + { + displayName: 'Filters', + name: 'filterValues', + values: [ + ...dimensionDropdown, + // https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet#Operator + { + displayName: 'Operator', + name: 'operator', + type: 'options', + default: 'EXACT', + description: 'Operator to use in combination with value', + options: [ + { + name: 'Begins With', + value: 'BEGINS_WITH', + }, + { + name: 'Ends With', + value: 'ENDS_WITH', + }, + { + name: 'Equals (Number)', + value: 'NUMERIC_EQUAL', + }, + { + name: 'Exactly Matches', + value: 'EXACT', + }, + { + name: 'Greater Than (Number)', + value: 'NUMERIC_GREATER_THAN', + }, + { + name: 'Less Than (Number)', + value: 'NUMERIC_LESS_THAN', + }, + { + name: 'Partly Matches', + value: 'PARTIAL', + }, + { + name: 'Regular Expression', + value: 'REGEXP', + }, + ], + }, + { + displayName: 'Value', + name: 'expressions', + type: 'string', + default: '', + placeholder: '', + description: + 'String or regular expression to match against', + }, + ], + }, + ], + }, + { + displayName: 'Hide Totals', + name: 'hideTotals', + type: 'boolean', + default: false, + description: + 'Whether to hide the total of all metrics for all the matching rows, for every date range', + displayOptions: { + show: { + '/simple': [false], + }, + }, + }, + { + displayName: 'Hide Value Ranges', + name: 'hideValueRanges', + type: 'boolean', + default: false, + description: 'Whether to hide the minimum and maximum across all matching rows', + displayOptions: { + show: { + '/simple': [false], + }, + }, + }, + { + displayName: 'Include Empty Rows', + name: 'includeEmptyRows', + type: 'boolean', + default: false, + description: + 'Whether the response exclude rows if all the retrieved metrics are equal to zero', + }, + { + displayName: 'Use Resource Quotas', + name: 'useResourceQuotas', + type: 'boolean', + default: false, + description: 'Whether to enable resource based quotas', + displayOptions: { + show: { + '/simple': [false], + }, + }, + }, + ], + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + //https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/reports/batchGet + // const viewId = this.getNodeParameter('viewId', index) as string; + const viewId = this.getNodeParameter('viewId', index, undefined, { + extractValue: true, + }) as string; + const returnAll = this.getNodeParameter('returnAll', 0); + const dateRange = this.getNodeParameter('dateRange', index) as string; + const metricsUA = this.getNodeParameter('metricsUA', index) as IDataObject; + const dimensionsUA = this.getNodeParameter('dimensionsUA', index) as IDataObject; + const additionalFields = this.getNodeParameter('additionalFields', index); + const simple = this.getNodeParameter('simple', index) as boolean; + + let responseData; + + const qs: IDataObject = {}; + const body: IData = { + viewId, + dateRanges: prepareDateRange.call(this, dateRange, index), + }; + + if (metricsUA.metricValues) { + const metrics = (metricsUA.metricValues as IDataObject[]).map((metric) => { + switch (metric.listName) { + case 'other': + return { + alias: metric.name, + expression: metric.name, + }; + case 'custom': + const newMetric = { + alias: metric.name, + expression: metric.expression, + formattingType: metric.formattingType, + }; + return newMetric; + default: + return { + alias: metric.listName, + expression: metric.listName, + }; + } + }); + if (metrics.length) { + checkDuplicates.call(this, metrics, 'alias', 'metrics'); + body.metrics = metrics as IMetric[]; + } + } + + if (dimensionsUA.dimensionValues) { + const dimensions = (dimensionsUA.dimensionValues as IDataObject[]).map((dimension) => { + switch (dimension.listName) { + case 'other': + return { name: dimension.name }; + default: + return { name: dimension.listName }; + } + }); + if (dimensions.length) { + checkDuplicates.call(this, dimensions, 'name', 'dimensions'); + body.dimensions = dimensions as IDimension[]; + } + } + + if (additionalFields.useResourceQuotas) { + qs.useResourceQuotas = additionalFields.useResourceQuotas; + } + + if (additionalFields.dimensionFiltersUi) { + const dimensionFilters = (additionalFields.dimensionFiltersUi as IDataObject) + .filterValues as IDataObject[]; + if (dimensionFilters) { + dimensionFilters.forEach((filter) => { + filter.expressions = [filter.expressions]; + switch (filter.listName) { + case 'other': + filter.dimensionName = filter.name; + delete filter.name; + delete filter.listName; + break; + default: + filter.dimensionName = filter.listName; + delete filter.listName; + } + }); + body.dimensionFilterClauses = { filters: dimensionFilters }; + } + } + + 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 }); + } + + const method = 'POST'; + const endpoint = '/v4/reports:batchGet'; + + if (returnAll) { + responseData = await googleApiRequestAllItems.call( + this, + 'reports', + method, + endpoint, + { reportRequests: [body] }, + qs, + ); + } else { + body.pageSize = this.getNodeParameter('limit', 0); + responseData = await googleApiRequest.call( + this, + method, + endpoint, + { reportRequests: [body] }, + qs, + ); + responseData = responseData.reports; + } + + if (simple) { + responseData = simplify(responseData); + } else if (returnAll && responseData.length > 1) { + responseData = merge(responseData); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: { item: index } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/router.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/router.ts new file mode 100644 index 0000000000..bdac877673 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/router.ts @@ -0,0 +1,51 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { INodeExecutionData, NodeOperationError } from 'n8n-workflow'; + +import { GoogleAnalytics, ReportBasedOnProperty } from './node.type'; +import * as userActivity from './userActivity/UserActivity.resource'; +import * as report from './report/Report.resource'; + +export async function router(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0); + + let responseData; + + const googleAnalytics = { + resource, + operation, + } as GoogleAnalytics; + + for (let i = 0; i < items.length; i++) { + try { + switch (googleAnalytics.resource) { + case 'report': + const propertyType = this.getNodeParameter('propertyType', 0) as string; + const operationBasedOnProperty = + `${googleAnalytics.operation}${propertyType}` as ReportBasedOnProperty; + responseData = await report[operationBasedOnProperty].execute.call(this, i); + break; + case 'userActivity': + responseData = await userActivity[googleAnalytics.operation].execute.call(this, i); + break; + default: + throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not known`); + } + + returnData.push(...responseData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionErrorData); + continue; + } + throw error; + } + } + return this.prepareOutputData(returnData); +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/userActivity/UserActivity.resource.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/userActivity/UserActivity.resource.ts new file mode 100644 index 0000000000..32cd4a50ed --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/userActivity/UserActivity.resource.ts @@ -0,0 +1,28 @@ +import { INodeProperties } from 'n8n-workflow'; +import * as search from './search.operation'; + +export { search }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['userActivity'], + }, + }, + options: [ + { + name: 'Search', + value: 'search', + description: 'Return user activity data', + action: 'Search user activity data', + }, + ], + default: 'search', + }, + ...search.description, +]; diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/userActivity/search.operation.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/userActivity/search.operation.ts new file mode 100644 index 0000000000..ecd9ff711e --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/userActivity/search.operation.ts @@ -0,0 +1,158 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { IDataObject, INodeExecutionData, INodeProperties } from 'n8n-workflow'; +import { googleApiRequest, googleApiRequestAllItems } from '../../transport'; + +export const description: INodeProperties[] = [ + { + displayName: 'View Name or ID', + name: 'viewId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getViews', + }, + default: '', + required: true, + displayOptions: { + show: { + resource: ['userActivity'], + operation: ['search'], + }, + }, + placeholder: '123456', + description: + 'The view from Google Analytics. Choose from the list, or specify an ID using an expression.', + hint: "If there's nothing here, try changing the 'Property type' field above", + }, + { + 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: 'Whether to return all results 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: 'Max number of 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: [], + }, + ], + }, +]; + +export async function execute( + this: IExecuteFunctions, + index: number, +): Promise { + //https://developers.google.com/analytics/devguides/reporting/core/v4/rest/v4/userActivity/search + const viewId = this.getNodeParameter('viewId', index); + const userId = this.getNodeParameter('userId', index); + const returnAll = this.getNodeParameter('returnAll', 0); + const additionalFields = this.getNodeParameter('additionalFields', index); + + let responseData; + + const body: IDataObject = { + viewId, + user: { + userId, + }, + }; + + if (additionalFields.activityTypes) { + Object.assign(body, { activityTypes: additionalFields.activityTypes }); + } + + const method = 'POST'; + const endpoint = '/v4/userActivity:search'; + + if (returnAll) { + responseData = await googleApiRequestAllItems.call(this, 'sessions', method, endpoint, body); + } else { + body.pageSize = this.getNodeParameter('limit', 0); + responseData = await googleApiRequest.call(this, method, endpoint, body); + responseData = responseData.sessions; + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: { item: index } }, + ); + + return executionData; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Google/Analytics/v2/actions/versionDescription.ts new file mode 100644 index 0000000000..da8b5a3089 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/actions/versionDescription.ts @@ -0,0 +1,46 @@ +/* eslint-disable n8n-nodes-base/node-filename-against-convention */ +import { INodeTypeDescription } from 'n8n-workflow'; +import * as userActivity from './userActivity/UserActivity.resource'; +import * as report from './report/Report.resource'; + +export const versionDescription: INodeTypeDescription = { + displayName: 'Google Analytics', + name: 'googleAnalytics', + icon: 'file:analytics.svg', + group: ['transform'], + version: 2, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Use the Google Analytics API', + defaults: { + name: 'Google Analytics', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleAnalyticsOAuth2', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Report', + value: 'report', + }, + { + name: 'User Activity', + value: 'userActivity', + }, + ], + default: 'report', + }, + ...report.description, + ...userActivity.description, + ], +}; diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/helpers/Interfaces.ts b/packages/nodes-base/nodes/Google/Analytics/v2/helpers/Interfaces.ts new file mode 100644 index 0000000000..cf21a4afde --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/helpers/Interfaces.ts @@ -0,0 +1,28 @@ +import { IDataObject } from 'n8n-workflow'; + +export interface IData { + viewId: string; + dimensions?: IDimension[]; + dimensionFilterClauses?: { + filters: IDimensionFilter[]; + }; + pageSize?: number; + metrics?: IMetric[]; + dateRanges?: IDataObject[]; +} + +export interface IDimension { + name?: string; + histogramBuckets?: string[]; +} + +export interface IDimensionFilter { + dimensionName?: string; + operator?: string; + expressions?: string[]; +} +export interface IMetric { + expression?: string; + alias?: string; + formattingType?: string; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/helpers/utils.ts b/packages/nodes-base/nodes/Google/Analytics/v2/helpers/utils.ts new file mode 100644 index 0000000000..8b954c7768 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/helpers/utils.ts @@ -0,0 +1,255 @@ +import { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-core'; +import { + IDataObject, + INodeListSearchItems, + INodePropertyOptions, + NodeOperationError, +} from 'n8n-workflow'; +import { DateTime } from 'luxon'; + +// tslint:disable-next-line:no-any +export function simplify(responseData: any | [any]) { + const returnData = []; + for (const { + columnHeader: { dimensions, metricHeader }, + data: { rows }, + } of responseData) { + if (rows === undefined) { + // Do not error if there is no data + continue; + } + const metrics = metricHeader.metricHeaderEntries.map((entry: { name: string }) => entry.name); + for (const row of rows) { + const rowDimensions: IDataObject = {}; + const rowMetrics: IDataObject = {}; + if (dimensions) { + for (let i = 0; i < dimensions.length; i++) { + rowDimensions[dimensions[i]] = row.dimensions[i]; + for (const [index, metric] of metrics.entries()) { + rowMetrics[metric] = row.metrics[0].values[index]; + } + } + } else { + for (const [index, metric] of metrics.entries()) { + rowMetrics[metric] = row.metrics[0].values[index]; + } + } + returnData.push({ ...rowDimensions, ...rowMetrics }); + } + } + + return returnData; +} + +// tslint:disable-next-line:no-any +export function merge(responseData: [any]) { + const response: { columnHeader: IDataObject; data: { rows: [] } } = { + columnHeader: responseData[0].columnHeader, + data: responseData[0].data, + }; + const allRows = []; + for (const { + data: { rows }, + } of responseData) { + allRows.push(...rows); + } + response.data.rows = allRows as []; + return [response]; +} + +export function simplifyGA4(response: IDataObject) { + if (!response.rows) return []; + const dimensionHeaders = ((response.dimensionHeaders as IDataObject[]) || []).map( + (header) => header.name as string, + ); + const metricHeaders = ((response.metricHeaders as IDataObject[]) || []).map( + (header) => header.name as string, + ); + const returnData: IDataObject[] = []; + + (response.rows as IDataObject[]).forEach((row) => { + if (!row) return; + const rowDimensions: IDataObject = {}; + const rowMetrics: IDataObject = {}; + dimensionHeaders.forEach((dimension, index) => { + rowDimensions[dimension] = (row.dimensionValues as IDataObject[])[index].value; + }); + metricHeaders.forEach((metric, index) => { + rowMetrics[metric] = (row.metricValues as IDataObject[])[index].value; + }); + returnData.push({ ...rowDimensions, ...rowMetrics }); + }); + + return returnData; +} + +export function processFilters(expression: IDataObject): IDataObject[] { + const processedFilters: IDataObject[] = []; + + Object.entries(expression).forEach((entry) => { + const [filterType, filters] = entry; + + (filters as IDataObject[]).forEach((filter) => { + let fieldName = ''; + switch (filter.listName) { + case 'other': + fieldName = filter.name as string; + delete filter.name; + break; + case 'custom': + fieldName = filter.name as string; + delete filter.name; + break; + default: + fieldName = filter.listName as string; + } + delete filter.listName; + + if (filterType === 'inListFilter') { + filter.values = (filter.values as string).split(','); + } + + if (filterType === 'numericFilter') { + filter.value = { + [filter.valueType as string]: filter.value, + }; + delete filter.valueType; + } + + if (filterType === 'betweenFilter') { + filter.fromValue = { + [filter.valueType as string]: filter.fromValue, + }; + filter.toValue = { + [filter.valueType as string]: filter.toValue, + }; + delete filter.valueType; + } + + processedFilters.push({ + filter: { + fieldName, + [filterType]: filter, + }, + }); + }); + }); + + return processedFilters; +} + +export function prepareDateRange( + this: IExecuteFunctions | ILoadOptionsFunctions, + period: string, + itemIndex: number, +) { + const dateRanges: IDataObject[] = []; + + switch (period) { + case 'today': + dateRanges.push({ + startDate: DateTime.local().startOf('day').toISODate(), + endDate: DateTime.now().toISODate(), + }); + break; + case 'yesterday': + dateRanges.push({ + startDate: DateTime.local().startOf('day').minus({ days: 1 }).toISODate(), + endDate: DateTime.local().endOf('day').minus({ days: 1 }).toISODate(), + }); + break; + case 'lastCalendarWeek': + const begginingOfLastWeek = DateTime.local().startOf('week').minus({ weeks: 1 }).toISODate(); + const endOfLastWeek = DateTime.local().endOf('week').minus({ weeks: 1 }).toISODate(); + dateRanges.push({ + startDate: begginingOfLastWeek, + endDate: endOfLastWeek, + }); + break; + case 'lastCalendarMonth': + const begginingOfLastMonth = DateTime.local() + .startOf('month') + .minus({ months: 1 }) + .toISODate(); + const endOfLastMonth = DateTime.local().endOf('month').minus({ months: 1 }).toISODate(); + dateRanges.push({ + startDate: begginingOfLastMonth, + endDate: endOfLastMonth, + }); + break; + case 'last7days': + dateRanges.push({ + startDate: DateTime.now().minus({ days: 7 }).toISODate(), + endDate: DateTime.now().toISODate(), + }); + break; + case 'last30days': + dateRanges.push({ + startDate: DateTime.now().minus({ days: 30 }).toISODate(), + endDate: DateTime.now().toISODate(), + }); + break; + case 'custom': + const start = DateTime.fromISO(this.getNodeParameter('startDate', itemIndex, '') as string); + const end = DateTime.fromISO(this.getNodeParameter('endDate', itemIndex, '') as string); + + if (start > end) { + throw new NodeOperationError( + this.getNode(), + `Parameter Start: ${start.toISO()} cannot be after End: ${end.toISO()}`, + ); + } + + dateRanges.push({ + startDate: start.toISODate(), + endDate: end.toISODate(), + }); + + break; + default: + throw new NodeOperationError( + this.getNode(), + `The period '${period}' is not supported, to specify own period use 'custom' option`, + ); + } + + return dateRanges; +} + +export const defaultStartDate = () => DateTime.now().startOf('day').minus({ days: 8 }).toISO(); + +export const defaultEndDate = () => DateTime.now().startOf('day').minus({ days: 1 }).toISO(); + +export function checkDuplicates( + this: IExecuteFunctions, + data: IDataObject[], + key: string, + type: string, +) { + const fields = data.map((item) => item[key] as string); + const duplicates = fields.filter((field, i) => fields.indexOf(field) !== i); + const unique = Array.from(new Set(duplicates)); + if (unique.length) { + throw new NodeOperationError( + this.getNode(), + `A ${type} is specified more than once (${unique.join(', ')})`, + ); + } +} + +export function sortLoadOptions(data: INodePropertyOptions[] | INodeListSearchItems[]) { + const returnData = [...data]; + returnData.sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + if (aName < bName) { + return -1; + } + if (aName > bName) { + return 1; + } + return 0; + }); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/methods/index.ts b/packages/nodes-base/nodes/Google/Analytics/v2/methods/index.ts new file mode 100644 index 0000000000..a5508a3e0f --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/methods/index.ts @@ -0,0 +1,2 @@ +export * as loadOptions from './loadOptions'; +export * as listSearch from './listSearch'; diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/methods/listSearch.ts b/packages/nodes-base/nodes/Google/Analytics/v2/methods/listSearch.ts new file mode 100644 index 0000000000..415021062e --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/methods/listSearch.ts @@ -0,0 +1,65 @@ +import { ILoadOptionsFunctions, INodeListSearchItems, INodeListSearchResult } from 'n8n-workflow'; +import { sortLoadOptions } from '../helpers/utils'; +import { googleApiRequest } from '../transport'; + +export async function searchProperties( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodeListSearchItems[] = []; + + const { accounts } = await googleApiRequest.call( + this, + 'GET', + '', + {}, + {}, + 'https://analyticsadmin.googleapis.com/v1alpha/accounts', + ); + + for (const acount of accounts || []) { + const { properties } = await googleApiRequest.call( + this, + 'GET', + '', + {}, + { filter: `parent:${acount.name}` }, + 'https://analyticsadmin.googleapis.com/v1alpha/properties', + ); + + if (properties && properties.length > 0) { + for (const property of properties) { + const name = property.displayName; + const value = property.name.split('/')[1] || property.name; + const url = `https://analytics.google.com/analytics/web/#/p${value}/`; + returnData.push({ name, value, url }); + } + } + } + return { + results: sortLoadOptions(returnData), + }; +} + +export async function searchViews(this: ILoadOptionsFunctions): Promise { + const returnData: INodeListSearchItems[] = []; + 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} [${item.websiteUrl}]`, + value: item.id, + url: `https://analytics.google.com/analytics/web/#/report-home/a${item.accountId}w${item.internalWebPropertyId}p${item.id}`, + }); + } + + return { + results: sortLoadOptions(returnData), + }; +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/methods/loadOptions.ts b/packages/nodes-base/nodes/Google/Analytics/v2/methods/loadOptions.ts new file mode 100644 index 0000000000..24f48be7e1 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/methods/loadOptions.ts @@ -0,0 +1,152 @@ +import { ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; +import { sortLoadOptions } from '../helpers/utils'; +import { googleApiRequest } from '../transport'; + +export async function 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 sortLoadOptions(returnData); +} + +export async function getMetrics(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { items: metrics } = await googleApiRequest.call( + this, + 'GET', + '', + {}, + {}, + 'https://www.googleapis.com/analytics/v3/metadata/ga/columns', + ); + + for (const metric of metrics) { + if (metric.attributes.type === 'METRIC' && metric.attributes.status !== 'DEPRECATED') { + returnData.push({ + name: metric.attributes.uiName, + value: metric.id, + description: metric.attributes.description, + }); + } + } + return sortLoadOptions(returnData); +} + +export async function 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 sortLoadOptions(returnData); +} + +export async function getProperties(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + + const { accounts } = await googleApiRequest.call( + this, + 'GET', + '', + {}, + {}, + 'https://analyticsadmin.googleapis.com/v1alpha/accounts', + ); + + for (const acount of accounts || []) { + const { properties } = await googleApiRequest.call( + this, + 'GET', + '', + {}, + { filter: `parent:${acount.name}` }, + 'https://analyticsadmin.googleapis.com/v1alpha/properties', + ); + + if (properties && properties.length > 0) { + for (const property of properties) { + const name = property.displayName; + const value = property.name.split('/')[1] || property.name; + returnData.push({ name, value }); + } + } + } + return sortLoadOptions(returnData); +} + +export async function getDimensionsGA4( + this: ILoadOptionsFunctions, +): Promise { + const returnData: INodePropertyOptions[] = []; + const propertyId = this.getNodeParameter('propertyId', undefined, { + extractValue: true, + }) as string; + const { dimensions } = await googleApiRequest.call( + this, + 'GET', + `/v1beta/properties/${propertyId}/metadata`, + {}, + { fields: 'dimensions' }, + ); + + for (const dimesion of dimensions) { + returnData.push({ + name: dimesion.uiName as string, + value: dimesion.apiName as string, + description: dimesion.description as string, + }); + } + return sortLoadOptions(returnData); +} + +export async function getMetricsGA4(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const propertyId = this.getNodeParameter('propertyId', undefined, { + extractValue: true, + }) as string; + const { metrics } = await googleApiRequest.call( + this, + 'GET', + `/v1beta/properties/${propertyId}/metadata`, + {}, + { fields: 'metrics' }, + ); + + for (const metric of metrics) { + returnData.push({ + name: metric.uiName as string, + value: metric.apiName as string, + description: metric.description as string, + }); + } + return sortLoadOptions(returnData); +} diff --git a/packages/nodes-base/nodes/Google/Analytics/v2/transport/index.ts b/packages/nodes-base/nodes/Google/Analytics/v2/transport/index.ts new file mode 100644 index 0000000000..2e1fe65c45 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Analytics/v2/transport/index.ts @@ -0,0 +1,104 @@ +import { OptionsWithUri } from 'request'; +import { IExecuteFunctions, IExecuteSingleFunctions, ILoadOptionsFunctions } from 'n8n-core'; +import { IDataObject, NodeApiError } from 'n8n-workflow'; + +export async function googleApiRequest( + this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + qs: IDataObject = {}, + uri?: string, + option: IDataObject = {}, +) { + const propertyType = this.getNodeParameter('propertyType', 0, 'universal') as string; + const baseURL = + propertyType === 'ga4' + ? 'https://analyticsdata.googleapis.com' + : 'https://analyticsreporting.googleapis.com'; + + let options: OptionsWithUri = { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: uri ?? `${baseURL}${endpoint}`, + json: true, + }; + + options = Object.assign({}, options, option); + + try { + if (Object.keys(body).length === 0) { + delete options.body; + } + if (Object.keys(qs).length === 0) { + delete options.qs; + } + return await this.helpers.requestOAuth2.call(this, 'googleAnalyticsOAuth2', options); + } catch (error) { + const errorData = (error.message || '').split(' - ')[1] as string; + if (errorData) { + const parsedError = JSON.parse(errorData.trim()); + if (parsedError.error?.message) { + const [message, ...rest] = parsedError.error.message.split('\n'); + const description = rest.join('\n'); + const httpCode = parsedError.error.code; + throw new NodeApiError(this.getNode(), error, { message, description, httpCode }); + } + } + throw new NodeApiError(this.getNode(), error, { message: error.message }); + } +} + +export async function googleApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + propertyName: string, + method: string, + endpoint: string, + body: IDataObject = {}, + query: IDataObject = {}, + uri?: string, +) { + const propertyType = this.getNodeParameter('propertyType', 0, 'universal') as string; + const returnData: IDataObject[] = []; + + let responseData; + + if (propertyType === 'ga4') { + let rows: IDataObject[] = []; + query.limit = 100000; + query.offset = 0; + + responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri); + rows = rows.concat(responseData.rows); + query.offset = rows.length; + + while (responseData.rowCount > rows.length) { + responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri); + rows = rows.concat(responseData.rows); + query.offset = rows.length; + } + responseData.rows = rows; + returnData.push(responseData); + } else { + do { + responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri); + if (body.reportRequests && Array.isArray(body.reportRequests)) { + (body.reportRequests as IDataObject[])[0].pageToken = + responseData[propertyName][0].nextPageToken; + } else { + body.pageToken = responseData.nextPageToken; + } + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + (responseData.nextPageToken !== undefined && responseData.nextPageToken !== '') || + responseData[propertyName]?.[0].nextPageToken !== undefined + ); + } + + return returnData; +}