diff --git a/packages/nodes-base/credentials/HarvestApi.credentials.ts b/packages/nodes-base/credentials/HarvestApi.credentials.ts new file mode 100644 index 000000000..14a5ffac9 --- /dev/null +++ b/packages/nodes-base/credentials/HarvestApi.credentials.ts @@ -0,0 +1,25 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class HarvestApi implements ICredentialType { + name = 'harvestApi'; + displayName = 'Harvest API'; + properties = [ + { + displayName: 'Account ID', + name: 'accountId', + type: 'string' as NodePropertyTypes, + default: '', + description: 'Visit your account details page, and grab the Account ID. See Harvest Personal Access Tokens.' + }, + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + description: 'Visit your account details page, and grab the Access Token. See Harvest Personal Access Tokens.' + }, + ]; +} diff --git a/packages/nodes-base/nodes/Harvest/ClientDescription.ts b/packages/nodes-base/nodes/Harvest/ClientDescription.ts new file mode 100644 index 000000000..259ed3987 --- /dev/null +++ b/packages/nodes-base/nodes/Harvest/ClientDescription.ts @@ -0,0 +1,9 @@ +import { INodeProperties } from "n8n-workflow"; + +export const clientOperations = [ + +] as INodeProperties[]; + +export const clientFields = [ + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Harvest/GenericFunctions.ts b/packages/nodes-base/nodes/Harvest/GenericFunctions.ts new file mode 100644 index 000000000..c465197c0 --- /dev/null +++ b/packages/nodes-base/nodes/Harvest/GenericFunctions.ts @@ -0,0 +1,104 @@ +import { OptionsWithUri } from 'request'; +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; +import { IDataObject } from 'n8n-workflow'; + +export async function harvestApiRequest( + this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: string, + qs: IDataObject = {}, + uri?: string, + body: IDataObject = {}, + option: IDataObject = {}, + ): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('harvestApi') as IDataObject; + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + qs.access_token = credentials.accessToken; + qs.account_id = credentials.accountId; + // Convert to query string into a format the API can read + const queryStringElements: string[] = []; + for (const key of Object.keys(qs)) { + if (Array.isArray(qs[key])) { + (qs[key] as string[]).forEach(value => { + queryStringElements.push(`${key}=${value}`); + }); + } else { + queryStringElements.push(`${key}=${qs[key]}`); + } + } + + let options: OptionsWithUri = { + method, + body, + uri: `https://api.harvestapp.com/v2/${uri}?${queryStringElements.join('&')}`, + json: true, + headers: { + "User-Agent": "Harvest API" + } + }; + console.log({options}) + + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0) { + delete options.body; + } + try { + const result = await this.helpers.request!(options); + console.log(result); + return result; + } catch (error) { + if (error.statusCode === 401) { + // Return a clear error + throw new Error('The Harvest credentials are not valid!'); + } + + if (error.error && error.error.error_summary) { + // Try to return the error prettier + throw new Error(`Harvest error response [${error.statusCode}]: ${error.error.error_summary}`); + } + + // If that data does not exist for some reason return the actual error + throw error; + } +} + +/** + * Make an API request to paginated flow endpoint + * and return all results + */ +export async function harvestApiRequestAllItems( + this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, + method: string, + qs: IDataObject = {}, + uri?: string, + body: IDataObject = {}, + option: IDataObject = {}, + ): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + try { + do { + responseData = await harvestApiRequest.call(this, method, qs, uri, body, option); + qs.cursor = responseData.cursor.id; + returnData.push.apply(returnData, responseData.response); + } while ( + responseData.cursor.more === true && + responseData.cursor.hasNext === true + ); + return returnData; + } catch(error) { + throw error; + } +} diff --git a/packages/nodes-base/nodes/Harvest/Harvest.node.ts b/packages/nodes-base/nodes/Harvest/Harvest.node.ts new file mode 100644 index 000000000..76123e957 --- /dev/null +++ b/packages/nodes-base/nodes/Harvest/Harvest.node.ts @@ -0,0 +1,248 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; + +import { harvestApiRequest } from './GenericFunctions'; +import { timeEntryOperations, timeEntryFields } from './TimeEntryDescription'; + + +export class Harvest implements INodeType { + description: INodeTypeDescription = { + displayName: 'Harvest', + name: 'harvest', + icon: 'file:harvest.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Access data on Harvest', + defaults: { + name: 'Harvest', + color: '#22BB44', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'harvestApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Time Entries', + value: 'timeEntry', + }, + ], + default: 'timeEntry', + description: 'The resource to operate on.', + }, + + // operations + ...timeEntryOperations, + + // fields + ...timeEntryFields + ] + }; + + 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 endpoint = ''; + let requestMethod = ''; + let body: IDataObject | Buffer; + let qs: IDataObject; + + + for (let i = 0; i < items.length; i++) { + body = {}; + qs = {}; + + if (resource === 'timeEntry') { + if (operation === 'get') { + // ---------------------------------- + // get + // ---------------------------------- + + requestMethod = 'GET'; + const id = this.getNodeParameter('id', i) as string; + + endpoint = `time_entries/${id}`; + + try { + const responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push(responseData); + } catch (error) { + throw error; + } + + } else if (operation === 'getAll') { + // ---------------------------------- + // getAll + // ---------------------------------- + + requestMethod = 'GET'; + + endpoint = 'time_entries'; + + const additionalFields = this.getNodeParameter('filters', i) as IDataObject; + const limit = this.getNodeParameter('limit', i) as string; + qs.per_page = limit; + Object.assign(qs, additionalFields); + + try { + let responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push.apply(returnData, responseData.time_entries as IDataObject[]); + } catch (error) { + throw error; + } + + } else if (operation === 'createByStartEnd') { + // ---------------------------------- + // createByStartEnd + // ---------------------------------- + + requestMethod = 'POST'; + endpoint = 'time_entries'; + + const createFields = this.getNodeParameter('createFields', i) as IDataObject; + + Object.assign(qs, createFields); + + try { + let responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push(responseData); + } catch (error) { + throw error; + } + + } else if (operation === 'createByDuration') { + // ---------------------------------- + // createByDuration + // ---------------------------------- + + requestMethod = 'POST'; + endpoint = 'time_entries'; + + const createFields = this.getNodeParameter('createFields', i) as IDataObject; + + Object.assign(qs, createFields); + + try { + let responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push(responseData); + } catch (error) { + throw error; + } + + } else if (operation === 'delete') { + // ---------------------------------- + // delete + // ---------------------------------- + + requestMethod = 'DELETE'; + const id = this.getNodeParameter('id', i) as string; + endpoint = `time_entries/${id}`; + + try { + let responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push(responseData); + } catch (error) { + throw error; + } + } else if (operation === 'deleteExternal') { + // ---------------------------------- + // deleteExternal + // ---------------------------------- + + requestMethod = 'DELETE'; + const id = this.getNodeParameter('id', i) as string; + endpoint = `time_entries/${id}/external_reference`; + + try { + let responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push(responseData); + } catch (error) { + throw error; + } + + } else if (operation === 'restartTime') { + // ---------------------------------- + // restartTime + // ---------------------------------- + + requestMethod = 'PATCH'; + const id = this.getNodeParameter('id', i) as string; + endpoint = `time_entries/${id}/restart`; + + try { + let responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push(responseData); + } catch (error) { + throw error; + } + + } else if (operation === 'stopTime') { + // ---------------------------------- + // stopTime + // ---------------------------------- + + requestMethod = 'PATCH'; + const id = this.getNodeParameter('id', i) as string; + endpoint = `time_entries/${id}/stop`; + + try { + let responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push(responseData); + } catch (error) { + throw error; + } + + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + requestMethod = 'PATCH'; + const id = this.getNodeParameter('id', i) as string; + endpoint = `time_entries/${id}`; + + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + Object.assign(qs, updateFields); + try { + let responseData = await harvestApiRequest.call(this, requestMethod, qs, endpoint); + returnData.push(responseData); + } catch (error) { + throw error; + } + + } else { + throw new Error(`The operation "${operation}" is not known!`); + } + + } else { + throw new Error(`The resource "${resource}" is not known!`); + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } + +} diff --git a/packages/nodes-base/nodes/Harvest/TimeEntryDescription.ts b/packages/nodes-base/nodes/Harvest/TimeEntryDescription.ts new file mode 100644 index 000000000..39b3e07ca --- /dev/null +++ b/packages/nodes-base/nodes/Harvest/TimeEntryDescription.ts @@ -0,0 +1,528 @@ +import { INodeProperties } from "n8n-workflow"; + +export const timeEntryOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'timeEntry', + ], + }, + }, + options: [ + { + name: 'Create via Duration', + value: 'createByDuration', + description: 'Create a time entry via duration', + }, + { + name: 'Create via Start and End Time', + value: 'createByStartEnd', + description: 'Create a time entry via start and end time', + }, + { + name: 'Delete', + value: 'delete', + description: `Delete a time entry`, + }, + { + name: 'Delete External Reference', + value: 'deleteExternal', + description: `Delete a time entry’s external reference.`, + }, + { + name: 'Get', + value: 'get', + description: 'Get data of a time entry', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get data of all time entries', + }, + { + name: 'Restart', + value: 'restartTime', + description: 'Restart a time entry', + }, + { + name: 'Stop', + value: 'stopTime', + description: 'Stop a time entry', + }, + { + name: 'Update', + value: 'update', + description: 'Update a time entry', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const timeEntryFields = [ +/* -------------------------------------------------------------------------- */ +/* timeEntry:getAll */ +/* -------------------------------------------------------------------------- */ + +{ + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'timeEntry', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'Returns a list of your time entries.', +}, +{ + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'timeEntry', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', +}, +{ + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'timeEntry', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'User ID', + name: 'user_id', + type: 'string', + default: '', + description: 'Only return time entries belonging to the user with the given ID.', + }, + { + displayName: 'Client ID', + name: 'client_id', + type: 'string', + default: '', + description: 'Only return time entries belonging to the client with the given ID.', + }, + { + displayName: 'Is Billed', + name: 'is_billed', + type: 'boolean', + default: '', + description: 'Pass true to only return time entries that have been invoiced and false to return time entries that have not been invoiced.', + }, + { + displayName: 'Is Running', + name: 'is_running', + type: 'string', + default: '', + description: 'Pass true to only return running time entries and false to return non-running time entries.', + }, + { + displayName: 'Updated Since', + name: 'updated_since', + type: 'string', + default: '', + description: 'Only return time entries that have been updated since the given date and time.', + }, + { + displayName: 'From', + name: 'from', + type: 'dateTime', + default: '', + description: 'Only return time entries with a spent_date on or after the given date.', + }, + { + displayName: 'To', + name: 'to', + type: 'dateTime', + default: '', + description: 'Only return time entries with a spent_date on or before the given date.', + }, + { + displayName: 'Page', + name: 'page', + type: 'string', + default: '', + description: 'The page number to use in pagination. For instance, if you make a list request and receive 100 records, your subsequent call can include page=2 to retrieve the next page of the list. (Default: 1)', + } + ] +}, + +/* -------------------------------------------------------------------------- */ +/* timeEntry:get */ +/* -------------------------------------------------------------------------- */ +{ + displayName: 'Time Entry Id', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'timeEntry', + ], + }, + }, + description: 'The ID of the time entry you are retrieving.', +}, + +/* -------------------------------------------------------------------------- */ +/* timeEntry:delete */ +/* -------------------------------------------------------------------------- */ +{ + displayName: 'Time Entry Id', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'delete', + ], + resource: [ + 'timeEntry', + ], + }, + }, + description: 'The ID of the time entry you are deleting.', +}, + +/* -------------------------------------------------------------------------- */ +/* timeEntry:deleteExternal */ +/* -------------------------------------------------------------------------- */ +{ + displayName: 'Time Entry Id', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'deleteExternal', + ], + resource: [ + 'timeEntry', + ], + }, + }, + description: 'The ID of the time entry whose external reference you are deleting.', +}, + +/* -------------------------------------------------------------------------- */ +/* timeEntry:stopTime */ +/* -------------------------------------------------------------------------- */ +{ + displayName: 'Time Entry Id', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'stopTime', + ], + resource: [ + 'timeEntry', + ], + }, + }, + description: 'Stop a running time entry. Stopping a time entry is only possible if it’s currently running.', +}, + +/* -------------------------------------------------------------------------- */ +/* timeEntry:restartTime */ +/* -------------------------------------------------------------------------- */ +{ + displayName: 'Time Entry Id', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'restartTime', + ], + resource: [ + 'timeEntry', + ], + }, + }, + description: 'Restart a stopped time entry. Restarting a time entry is only possible if it isn’t currently running.', +}, + +/* -------------------------------------------------------------------------- */ +/* timeEntry:update */ +/* -------------------------------------------------------------------------- */ +{ + displayName: 'Time Entry Id', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'timeEntry', + ], + }, + }, + description: 'The ID of the time entry to update.', +}, +{ + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'timeEntry', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Project Id', + name: 'projectId', + type: 'string', + default: '', + description: 'The ID of the project to associate with the time entry.', + }, + { + displayName: 'Task Id', + name: 'taskId', + type: 'string', + default: '', + description: 'The ID of the task to associate with the time entry.', + }, + { + displayName: 'Spent Date', + name: 'spentDate', + type: 'dateTime', + default: '', + description: 'The ISO 8601 formatted date the time entry was spent.', + }, + { + displayName: 'Started Time', + name: 'startedTime', + type: 'string', + default: '', + description: 'The time the entry started. Defaults to the current time. Example: “8:00am”.', + }, + { + displayName: 'Ended Time', + name: 'endedTime', + type: 'string', + default: '', + description: 'The time the entry ended.', + }, + { + displayName: 'Hours', + name: 'hours', + type: 'string', + default: '', + description: 'The current amount of time tracked.', + }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + default: '', + description: 'These are notes about the time entry..', + } + ], +}, + +/* -------------------------------------------------------------------------- */ +/* timeEntry:createByDuration */ +/* -------------------------------------------------------------------------- */ +{ + displayName: 'Create Fields', + name: 'createFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'createByDuration', + ], + resource: [ + 'timeEntry', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Project Id', + name: 'projectId', + type: 'string', + default: '', + description: 'The ID of the project to associate with the time entry.', + }, + { + displayName: 'Task Id', + name: 'taskId', + type: 'string', + default: '', + description: 'The ID of the task to associate with the time entry.', + }, + { + displayName: 'Spent Date', + name: 'spentDate', + type: 'dateTime', + default: '', + description: 'The ISO 8601 formatted date the time entry was spent.', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + description: 'The ID of the user to associate with the time entry. Defaults to the currently authenticated user’s ID.', + }, + { + displayName: 'Hours', + name: 'hours', + type: 'string', + default: '', + description: 'The current amount of time tracked.', + }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + default: '', + description: 'These are notes about the time entry..', + } + ], +}, + +/* -------------------------------------------------------------------------- */ +/* timeEntry:createByStartEnd */ +/* -------------------------------------------------------------------------- */ +{ + displayName: 'Create Fields', + name: 'createFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'createByStartEnd', + ], + resource: [ + 'timeEntry', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Project Id', + name: 'projectId', + type: 'string', + default: '', + description: 'The ID of the project to associate with the time entry.', + }, + { + displayName: 'Task Id', + name: 'taskId', + type: 'string', + default: '', + description: 'The ID of the task to associate with the time entry.', + }, + { + displayName: 'Spent Date', + name: 'spentDate', + type: 'dateTime', + default: '', + description: 'The ISO 8601 formatted date the time entry was spent.', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + description: 'The ID of the user to associate with the time entry. Defaults to the currently authenticated user’s ID.', + }, + { + displayName: 'Started Time', + name: 'startedTime', + type: 'string', + default: '', + description: 'The time the entry started. Defaults to the current time. Example: “8:00am”.', + }, + { + displayName: 'Ended Time', + name: 'endedTime', + type: 'string', + default: '', + description: 'The time the entry ended.', + }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + default: '', + description: 'These are notes about the time entry..', + } + ], +}, + + + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Harvest/harvest.png b/packages/nodes-base/nodes/Harvest/harvest.png new file mode 100644 index 000000000..5a6713b91 Binary files /dev/null and b/packages/nodes-base/nodes/Harvest/harvest.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 2c40a4d07..59bc31254 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -48,6 +48,7 @@ "dist/credentials/GitlabApi.credentials.js", "dist/credentials/GoogleApi.credentials.js", "dist/credentials/GumroadApi.credentials.js", + "dist/credentials/HarvestApi.credentials.js", "dist/credentials/HttpBasicAuth.credentials.js", "dist/credentials/HttpDigestAuth.credentials.js", "dist/credentials/HttpHeaderAuth.credentials.js", @@ -134,6 +135,7 @@ "dist/nodes/Google/GoogleSheets.node.js", "dist/nodes/GraphQL/GraphQL.node.js", "dist/nodes/Gumroad/GumroadTrigger.node.js", + "dist/nodes/Harvest/Harvest.node.js", "dist/nodes/HtmlExtract/HtmlExtract.node.js", "dist/nodes/HttpRequest.node.js", "dist/nodes/Hubspot/Hubspot.node.js",