diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 5539f8d8..5c380dbc 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -26,3 +26,10 @@ updates: - dependency-name: '@types/*' schedule: interval: 'weekly' + + - package-ecosystem: 'npm' + directory: '/nodes/moco' + ignore: + - dependency-name: '@types/*' + schedule: + interval: 'weekly' diff --git a/.github/workflows/publish-package.yaml b/.github/workflows/publish-package.yaml index 4bb402b7..179f5802 100644 --- a/.github/workflows/publish-package.yaml +++ b/.github/workflows/publish-package.yaml @@ -5,6 +5,7 @@ on: tags: - 'clockify-enhanced-*' - 'google-enhanced-*' + - 'moco-*' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index ce26c18b..8573649e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This is a mono repository with many different community nodes. Please take a closer look at the detailed instructions for the individual nodes: - [Enhanced Clockify community nodes](nodes/clockify-enhanced/README.md) +- [MOCO community nodes](nodes/moco/README.md) ## Resources diff --git a/nodes/moco/.eslintrc.json b/nodes/moco/.eslintrc.json new file mode 100644 index 00000000..b31cf71b --- /dev/null +++ b/nodes/moco/.eslintrc.json @@ -0,0 +1,30 @@ +{ + "extends": ["../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredDependencies": ["express", "jest-mock-extended"] + } + ] + } + } + ] +} diff --git a/nodes/moco/README.md b/nodes/moco/README.md new file mode 100644 index 00000000..629988a3 --- /dev/null +++ b/nodes/moco/README.md @@ -0,0 +1,65 @@ +# @skriptfabrik/n8n-nodes-moco + +[![NPM Version](https://img.shields.io/npm/v/@skriptfabrik/n8n-nodes-moco)](https://www.npmjs.com/package/@skriptfabrik/n8n-nodes-moco) +[![NPM Downloads](https://img.shields.io/npm/dt/@skriptfabrik/n8n-nodes-moco)](https://www.npmjs.com/package/@skriptfabrik/n8n-nodes-moco) + +> MOCO community nodes for your [n8n](https://n8n.io/) workflows + +This is an n8n community node. It lets you use [MOCO](https://www.mocoapp.com/) in your n8n workflows. + +MOCO is an ERP agency software with the following features: + +- Time recording +- Billing +- Customer acquisition +- Capacity planning +- Personnel & contacts +- Expenses + +[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform. + +[Installation](#installation) +[Operations](#operations) +[Credentials](#credentials) +[Compatibility](#compatibility) +[Resources](#resources) + +## Installation + +Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community +nodes documentation. + +1. Go to **Settings > Community Nodes**. +2. Select **Install**. +3. Enter `@skriptfabrik/n8n-nodes-moco` in **Enter npm package name**. +4. Agree to the [risks](https://docs.n8n.io/integrations/community-nodes/risks/) of using community nodes: select + **I understand the risks of installing unverified code from a public source**. +5. Select **Install**. + +After installing the node, you can use it like any other node. n8n displays the node in search results in the **Nodes** panel. + +## Operations + +It supports these operations: + +- Create, delete, get, list and update activities +- Create, delete, get, list and update project +- Create, delete, get, list and update users + +## Credentials + +Create a free MOCO account [here](https://www.mocoapp.com/anmeldung/start) which allows you to test it for 30 days +without obligation. + +- Remember the Sub-Domain +- Generate an API Key here: Settings -> Extensions -> API & WebHooks -> API Keys +- Find the Web Hook Secret here: Settings -> Extensions -> API & WebHooks -> WebHooks + +## Compatibility + +Tested against n8n version 1.0+. + +## Resources + +- [n8n community nodes documentation](https://docs.n8n.io/integrations/community-nodes/) +- [MOCO API Documentation](https://hundertzehn.github.io/mocoapp-api-docs/) diff --git a/nodes/moco/jest.config.ts b/nodes/moco/jest.config.ts new file mode 100644 index 00000000..08c87caa --- /dev/null +++ b/nodes/moco/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'n8n-nodes-moco', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/nodes/moco', +}; diff --git a/nodes/moco/package.json b/nodes/moco/package.json new file mode 100644 index 00000000..a65ac88a --- /dev/null +++ b/nodes/moco/package.json @@ -0,0 +1,44 @@ +{ + "name": "@skriptfabrik/n8n-nodes-moco", + "version": "0.1.0", + "description": "MOCO community nodes for n8n", + "keywords": [ + "moco", + "n8n", + "n8n-community-node", + "n8n-community-node-package" + ], + "license": "MIT", + "homepage": "https://github.com/skriptfabrik/n8n-nodes/blob/main/nodes/moco/README.md", + "author": { + "name": "skriptfabrik", + "email": "info@skriptfabrik.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/skriptfabrik/n8n-nodes.git" + }, + "main": "./src/index.js", + "typings": "./src/index.d.ts", + "type": "commonjs", + "n8n": { + "n8nNodesApiVersion": 1, + "credentials": [ + "src/credentials/MocoApi.credentials.js" + ], + "nodes": [ + "src/nodes/Moco/Moco.node.js", + "src/nodes/Moco/MocoTrigger.node.js" + ] + }, + "dependencies": { + "moment-timezone": "^0.5.28", + "tslib": "^2.6.2" + }, + "peerDependencies": { + "n8n-workflow": "^1.29.1" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org" + } +} diff --git a/nodes/moco/project.json b/nodes/moco/project.json new file mode 100644 index 00000000..a06ecb3c --- /dev/null +++ b/nodes/moco/project.json @@ -0,0 +1,43 @@ +{ + "name": "n8n-nodes-moco", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "nodes/moco/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/nodes/moco", + "tsConfig": "nodes/moco/tsconfig.lib.json", + "packageJson": "nodes/moco/package.json", + "main": "nodes/moco/src/index.ts", + "assets": [ + "nodes/moco/src/nodes/*/*.svg", + "nodes/moco/src/nodes/*/*.json", + "nodes/moco/*.md" + ] + } + }, + "link": { + "executor": "nx:run-commands", + "options": { + "cwd": "dist/nodes/moco", + "command": "npm link --no-audit" + }, + "dependsOn": ["build"] + }, + "install": { + "executor": "nx:run-commands", + "options": { + "command": "node tools/scripts/install.mjs n8n-nodes-moco" + }, + "dependsOn": ["link"] + }, + "publish": { + "command": "node tools/scripts/publish.mjs n8n-nodes-moco {args.ver} {args.tag}", + "dependsOn": ["build"] + } + }, + "tags": [] +} diff --git a/nodes/moco/src/api.d.ts b/nodes/moco/src/api.d.ts new file mode 100644 index 00000000..708cd939 --- /dev/null +++ b/nodes/moco/src/api.d.ts @@ -0,0 +1,342 @@ +export type Activity = { + id: number; + date: string; + hours: number; + seconds: number; + description: string; + billed: boolean; + invoice_id: string; + billable: boolean; + tag: string; + remote_service: string; + remote_id: string; + remote_url: string; + project: { + id: number; + name: string; + billable: boolean; + }; + task: { + id: number; + name: string; + billable: boolean; + }; + customer: { + id: number; + name: string; + }; + user: { + id: number; + firstname: string; + lastname: string; + }; + hourly_rate: number; + timer_started_at: string; + created_at: string; + updated_at: string; +}; + +export type ActivityParameters = { + date: string; + project_id: number; + task_id: number; + seconds?: number; + description?: string; + billable?: boolean; + tag?: string; + remote_service?: + | 'trello' + | 'jira' + | 'asana' + | 'basecamp' + | 'wunderlist' + | 'basecamp2' + | 'basecamp3' + | 'toggl' + | 'mite' + | 'github' + | 'youtrack'; + remote_id?: string; + remote_url?: string; +}; + +export type ActivityFilters = GlobalFilters & { + from?: string; + to?: string; + user_id?: string; + project_id?: string; + task_id?: string; + company_id?: string; + term?: string; +}; + +export type Company = { + id: number; + type: 'customer' | 'supplier' | 'organization'; + name: string; + website: string; + email: string; + billing_email_cc: string; + phone: string; + fax: string; + address: string; + tags: string[]; + user: { + id: number; + firstname: string; + lastname: string; + }; + info: string; + custom_properties: { + UID: string; + }; + identifier: string; + intern: boolean; + billing_tax: number; + customer_vat: { + tax: number; + reverse_charge: boolean; + intra_eu: boolean; + active: boolean; + print_gross_total: boolean; + notice_tax_exemption: string; + notice_tax_exemption_alt: string; + }; // for customers only + supplier_vat: { + tax: number; + reverse_charge: boolean; + intra_eu: boolean; + active: boolean; + }; // for suppliers only + currency: string; + custom_rates: boolean; + include_time_report: boolean; + billing_notes: string; + default_discount: number; + default_cash_discount: number; + default_cash_discount_days: number; + country_code: string; + vat_identifier: string; + alternative_correspondence_language: boolean; + default_invoice_due_days: number; + footer: string; + projects: { + id: number; + identifier: string; + name: string; + active: boolean; + billable: boolean; + }[]; + created_at: string; + updated_at: string; + debit_number: number; +}; + +export type Deal = { + id: number; + name: string; + status: string; + reminder_date: string; + closed_on: string; + money: number; + currency: string; + info: string; + custom_properties: Record; + user: { + id: number; + firstname: string; + lastname: string; + }; + company: { + id: number; + name: string; + type: 'customer' | 'supplier' | 'organization'; + }; + person: { + id: number; + name: string; + }; + category: { + id: number; + name: string; + probability: number; + }; + service_period_from: string; + service_period_to: string; + created_at: string; + updated_at: string; +}; + +export type GlobalFilters = { + limit?: number; + ids?: string; + updated_after?: string; +}; + +export type Project = { + id: number; + identifier: string; + name: string; + active: boolean; + billable: boolean; + fixed_price: boolean; + retainer: boolean; + start_date: string; + finish_date: string; + color: string; + currency: string; + billing_variant: string; + billing_address: string; + billing_email_to: string; + billing_email_cc: string; + billing_notes: string; + setting_include_time_report: boolean; + budget: number; + budget_monthly: number; + budget_expenses: number; + hourly_rate: number; + info: string; + tags: string[]; + custom_properties: Record; + leader: { + id: number; + firstname: string; + lastname: string; + }; + co_leader: string; + customer: { + id: number; + name: string; + }; + deal: { + id: number; + name: string; + }; + tasks: { + id: number; + name: string; + billable: boolean; + active: boolean; + budget: number; + hourly_rate: number; + }[]; + contracts: { + id: number; + user_id: number; + firstname: string; + lastname: string; + billable: boolean; + active: boolean; + budget: number; + hourly_rate: number; + }[]; + project_group: { + id: number; + name: string; + }; + billing_contact: { + id: number; + firstname: string; + lastname: string; + }; + created_at: string; + updated_at: string; +}; + +export type ProjectParameters = { + name: string; + currency: string; + start_date: string; + finish_date: string; + fixed_price: boolean; + retainer: boolean; + leader_id: number; + co_leader_id?: number; + customer_id: number; + deal_id?: number; + identifier: string; + billing_address?: string; + billing_email_to?: string; + billing_email_cc?: string; + billing_notes?: string; + setting_include_time_report?: boolean; + billing_variant?: string; + hourly_rate?: number; + budget?: number; + budget_monthly?: number; + budget_expenses?: number; + tags?: string[]; + custom_properties?: Record; + info?: string; +}; + +export type ProjectTask = { + id: number; + name: string; + billable: boolean; + active: boolean; + budget: number; + hourly_rate: number; + created_at: string; + updated_at: string; +}; + +export type Unit = { + id: number; + name: string; + users: { + id: number; + firstname: string; + lastname: string; + email: string; + }[]; + created_at: string; + updated_at: string; +}; + +export type User = { + id: number; + firstname: string; + lastname: string; + active: boolean; + extern: boolean; + email: string; + mobile_phone: string; + work_phone: string; + home_address: string; + info: string; + birthday: string; + iban: string; + avatar_url: string; + tags: string[]; + custom_properties: Record; + unit: { + id: number; + name: string; + }; + created_at: string; + updated_at: string; +}; + +export type UserParameters = { + firstname: string; + lastname: string; + email: string; + password: string; + unit_id: number; + active?: boolean; + external?: boolean; + language?: string; + mobile_phone?: string; + work_phone?: string; + home_address?: string; + bday?: string; + iban?: string; + tags?: string[]; + custom_properties?: Record; + info?: string; +}; + +export type UserFilters = GlobalFilters & { + include_archived?: boolean; +}; diff --git a/nodes/moco/src/credentials/MocoApi.credentials.spec.ts b/nodes/moco/src/credentials/MocoApi.credentials.spec.ts new file mode 100644 index 00000000..2c64347c --- /dev/null +++ b/nodes/moco/src/credentials/MocoApi.credentials.spec.ts @@ -0,0 +1,13 @@ +import { MocoApi } from './MocoApi.credentials'; + +describe('MocoApi', () => { + let api: MocoApi; + + beforeEach(() => { + api = new MocoApi(); + }); + + it('should be defined', () => { + expect(api).toBeDefined(); + }); +}); diff --git a/nodes/moco/src/credentials/MocoApi.credentials.ts b/nodes/moco/src/credentials/MocoApi.credentials.ts new file mode 100644 index 00000000..78af8e50 --- /dev/null +++ b/nodes/moco/src/credentials/MocoApi.credentials.ts @@ -0,0 +1,65 @@ +import type { + IAuthenticateGeneric, + ICredentialDataDecryptedObject, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export interface CredentialData extends ICredentialDataDecryptedObject { + subDomain: string; + apiKey: string; + webhookSecret: string; +} + +export class MocoApi implements ICredentialType { + name = 'mocoApi'; + + displayName = 'MOCO API'; + + documentationUrl = + 'https://github.com/skriptfabrik/n8n-nodes/blob/main/nodes/moco/README.md'; + + properties: INodeProperties[] = [ + { + displayName: 'Sub-Domain', + name: 'subDomain', + type: 'string', + default: '', + }, + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + { + displayName: 'Web Hook Secret', + name: 'webhookSecret', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + authorization: '=Token token={{$credentials.apiKey}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: '=https://{{$credentials.subDomain}}.mocoapp.com/api/v1', + url: '/session', + }, + }; +} diff --git a/nodes/moco/src/index.ts b/nodes/moco/src/index.ts new file mode 100644 index 00000000..9f92f540 --- /dev/null +++ b/nodes/moco/src/index.ts @@ -0,0 +1,3 @@ +export * from './credentials/MocoApi.credentials'; +export * from './nodes/Moco/Moco.node'; +export * from './nodes/Moco/MocoTrigger.node'; diff --git a/nodes/moco/src/nodes/Moco/ActivityDescription.ts b/nodes/moco/src/nodes/Moco/ActivityDescription.ts new file mode 100644 index 00000000..60fbec6d --- /dev/null +++ b/nodes/moco/src/nodes/Moco/ActivityDescription.ts @@ -0,0 +1,579 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const activityOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['activity'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an activity', + action: 'Create an activity', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an activity', + action: 'Delete an activity', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a single activity', + action: 'Retrieve a single activity', + }, + { + name: 'List', + value: 'list', + description: 'Retrieve all activities', + action: 'Retrieve all activities', + }, + { + name: 'Update', + value: 'update', + description: 'Update an activity', + action: 'Update an activity', + }, + ], + default: 'create', + }, +]; + +export const activityFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* activity:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Impersonate User Name or ID', + name: 'impersonateUserId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listUsers', + }, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['activity'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Date', + name: 'date', + type: 'string', + default: '', + required: true, + description: 'Date of activity being created (format: YYYY-MM-DD)', + displayOptions: { + show: { + resource: ['activity'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Project Name or ID', + name: 'projectId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listProjects', + }, + required: true, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['activity'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Task Name or ID', + name: 'taskId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listProjectTasks', + loadOptionsDependsOn: ['projectId'], + }, + required: true, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['activity'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Seconds', + name: 'seconds', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + }, + description: 'Seconds spent on the activity', + displayOptions: { + show: { + resource: ['activity'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'Description of the activity being created', + displayOptions: { + show: { + resource: ['activity'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + placeholder: 'Add Field', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['activity'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Billable', + name: 'billable', + type: 'boolean', + default: true, + description: 'Whether the activity is billable or not', + }, + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + description: 'Tag for the activity being created', + }, + { + displayName: 'Remote Service Name or ID', + name: 'remoteService', + type: 'options', + default: 'asana', + options: [ + { + name: 'ASANA', + value: 'asana', + }, + { + name: 'Basecamp', + value: 'basecamp', + }, + { + name: 'Basecamp 2', + value: 'basecamp2', + }, + { + name: 'Basecamp 3', + value: 'basecamp3', + }, + { + name: 'GitHub', + value: 'github', + }, + { + name: 'JIRA', + value: 'jira', + }, + { + name: 'Mite', + value: 'mite', + }, + { + name: 'Toggl', + value: 'toggl', + }, + { + name: 'Trello', + value: 'trello', + }, + { + name: 'Wunderlist', + value: 'wunderlist', + }, + { + name: 'YouTrack', + value: 'youtrack', + }, + ], + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Remote ID', + name: 'remoteId', + type: 'string', + default: '', + description: 'Remote ID for the activity being created', + }, + { + displayName: 'Remote URL', + name: 'remoteUrl', + type: 'string', + default: '', + description: 'Remote URL for the activity being created', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* activity:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Activity ID', + name: 'activityId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['activity'], + operation: ['delete'], + }, + }, + }, + /* -------------------------------------------------------------------------- */ + /* activity:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Activity ID', + name: 'activityId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['activity'], + operation: ['get'], + }, + }, + }, + /* -------------------------------------------------------------------------- */ + /* activity:list */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: ['activity'], + operation: ['list'], + }, + }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: ['activity'], + operation: ['list'], + returnAll: [false], + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'IDs', + name: 'ids', + type: 'string', + default: '', + displayOptions: { + show: { + resource: ['activity'], + operation: ['list'], + returnAll: [false], + }, + }, + description: + 'Allows you to filter by IDs and fetch multiple entities comma-separated', + }, + { + displayName: 'Updated After', + name: 'updatedAfter', + type: 'dateTime', + default: '', + displayOptions: { + show: { + resource: ['activity'], + operation: ['list'], + returnAll: [false], + }, + }, + description: + 'Enables you to give a timestamp for all entities that are created or updated after this timestamp', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + placeholder: 'Add Field', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['activity'], + operation: ['list'], + }, + }, + options: [ + { + displayName: 'From', + name: 'from', + type: 'string', + default: '', + description: + 'From date for the activities being listed (format: YYYY-MM-DD)', + }, + { + displayName: 'To', + name: 'to', + type: 'string', + default: '', + description: + 'To date for the activities being listed (format: YYYY-MM-DD)', + }, + { + displayName: 'User Name or ID', + name: 'userId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listUsers', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Project Name or ID', + name: 'projectId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listProjects', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Task Name or ID', + name: 'taskId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listProjectTasks', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Company Name or ID', + name: 'companyId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listCompanies', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Term', + name: 'term', + type: 'string', + default: '', + description: 'Search term for the activities being listed', + }, + { + displayName: 'Sort By', + name: 'sortBy', + type: 'string', + default: '', + description: 'The field to sort the results by', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* activity:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Activity ID', + name: 'activityId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['activity'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Date', + name: 'date', + type: 'string', + default: '', + description: 'Date of activity being created (format: YYYY-MM-DD)', + displayOptions: { + show: { + resource: ['activity'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Project Name or ID', + name: 'projectId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listProjects', + }, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['activity'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Task Name or ID', + name: 'taskId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listProjectTasks', + loadOptionsDependsOn: ['projectId'], + }, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['activity'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Seconds', + name: 'seconds', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + }, + description: 'Seconds spent on the activity', + displayOptions: { + show: { + resource: ['activity'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'Description of the activity being created', + displayOptions: { + show: { + resource: ['activity'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: ['activity'], + operation: ['update'], + }, + }, + default: {}, + options: [ + { + displayName: 'Billable', + name: 'billable', + type: 'boolean', + default: true, + description: 'Whether the activity is billable or not', + }, + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + description: 'Tag for the activity being created', + }, + { + displayName: 'Remote Service Name or ID', + name: 'remoteService', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listRemoteServices', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Remote ID', + name: 'remoteId', + type: 'string', + default: '', + description: 'Remote ID for the activity being created', + }, + { + displayName: 'Remote URL', + name: 'remoteUrl', + type: 'string', + default: '', + description: 'Remote URL for the activity being created', + }, + ], + }, +]; diff --git a/nodes/moco/src/nodes/Moco/GenericFunctions.spec.ts b/nodes/moco/src/nodes/Moco/GenericFunctions.spec.ts new file mode 100644 index 00000000..ff94a804 --- /dev/null +++ b/nodes/moco/src/nodes/Moco/GenericFunctions.spec.ts @@ -0,0 +1,313 @@ +import { mockClear, mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeApiError, sleep } from 'n8n-workflow'; +import { + createParametersFromNodeParameter, + createUTCStringFromNodeParameter, + mocoApiRequest, + mocoApiRequestAllItems, +} from './GenericFunctions'; + +jest.mock('n8n-workflow'); + +describe('GenericFunctions', () => { + const executeFunctions = mockDeep(); + const mockedSleep = jest.mocked(sleep); + + afterEach(() => { + mockClear(executeFunctions); + mockClear(mockedSleep); + }); + + it('should create UTC string from node parameter', () => { + const updatedAfter = '2022-01-01T00:00:00'; + + executeFunctions.getNodeParameter + .calledWith('updatedAfter', 0) + .mockReturnValue(updatedAfter); + executeFunctions.getTimezone.mockReturnValue('Europe/Berlin'); + + expect( + createUTCStringFromNodeParameter.call( + executeFunctions, + 'updatedAfter', + 0, + ), + ).toEqual('2021-12-31T23:00:00Z'); + }); + + it('should not create UTC string from node parameter', () => { + executeFunctions.getNodeParameter + .calledWith('updatedAfter', 0) + .mockReturnValue(''); + + expect( + createUTCStringFromNodeParameter.call( + executeFunctions, + 'updatedAfter', + 0, + ), + ).toBeUndefined(); + }); + + it('should create parameters from node parameter', () => { + const additionalFields = { + unknownValue: '__unknown_value__', + stringValue: '__string_value__', + trueValue: true, + falseValue: false, + emptyValue: '', + }; + + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue(additionalFields); + + expect( + createParametersFromNodeParameter.call( + executeFunctions, + 'additionalFields', + 0, + ['stringValue', 'trueValue', 'falseValue', 'emptyValue'], + ), + ).toEqual({ + string_value: '__string_value__', + true_value: 'true', + false_value: 'false', + }); + }); + + it('should make an impersonated API request to create one item', () => { + const impersonateUserId = '123'; + const body = { + firstname: 'Max', + lastname: 'Muster', + email: 'max.muster@beispiel.de', + password: '123456', + mobile_phone: '+49 177 123 45 67', + work_phone: '+49 40 123 45 67', + home_address: '', + info: '', + birthday: '1970-01-01', + iban: 'CH3181239000001245689', + avatar_url: 'https//meinefirma.mocoapp.com/.../profil.jpg', + tags: ['Deutschland'], + custom_properties: { + 'Starting Month': 'January 2015', + }, + unit_id: 456, + }; + const responseData = { + id: 123, + firstname: 'Max', + lastname: 'Muster', + active: true, + extern: false, + email: 'max.muster@beispiel.de', + mobile_phone: '+49 177 123 45 67', + work_phone: '+49 40 123 45 67', + home_address: '', + info: '', + birthday: '1970-01-01', + iban: 'CH3181239000001245689', + avatar_url: 'https//meinefirma.mocoapp.com/.../profil.jpg', + tags: ['Deutschland'], + custom_properties: { + 'Starting Month': 'January 2015', + }, + unit: { + id: 456, + name: 'Geschäftsleitung', + }, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }; + + executeFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + subDomain: '__sub_domain__', + apiKey: '__api_key__', + webhookSecret: '__secret__', + }); + executeFunctions.helpers.httpRequestWithAuthentication + .calledWith('mocoApi', expect.any(Object)) + .mockResolvedValueOnce({ body: responseData, statusCode: 200 }); + + expect( + mocoApiRequest.call(executeFunctions, 0, 'POST', '/users', { + impersonateUserId, + body, + }), + ).resolves.toEqual(responseData); + }); + + it('should make an API request to get one item', () => { + const responseData = { + id: 123, + firstname: 'Max', + lastname: 'Muster', + active: true, + extern: false, + email: 'max.muster@beispiel.de', + mobile_phone: '+49 177 123 45 67', + work_phone: '+49 40 123 45 67', + home_address: '', + info: '', + birthday: '1970-01-01', + iban: 'CH3181239000001245689', + avatar_url: 'https//meinefirma.mocoapp.com/.../profil.jpg', + tags: ['Deutschland'], + custom_properties: { + 'Starting Month': 'January 2015', + }, + unit: { + id: 456, + name: 'Geschäftsleitung', + }, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }; + + executeFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + subDomain: '__sub_domain__', + apiKey: '__api_key__', + webhookSecret: '__secret__', + }); + executeFunctions.helpers.httpRequestWithAuthentication + .calledWith('mocoApi', expect.any(Object)) + .mockResolvedValueOnce({ body: responseData, statusCode: 200 }); + + expect( + mocoApiRequest.call(executeFunctions, 0, 'GET', '/users/123'), + ).resolves.toEqual(responseData); + }); + + it('should make an API request to get one item which gets rejected', () => { + executeFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + subDomain: '__sub_domain__', + apiKey: '__api_key__', + webhookSecret: '__secret__', + }); + executeFunctions.helpers.httpRequestWithAuthentication + .calledWith('mocoApi', expect.any(Object)) + .mockResolvedValue({ body: 'Too many requests.', statusCode: 429 }); + + expect( + mocoApiRequest.call(executeFunctions, 0, 'GET', '/users/123'), + ).rejects.toBeInstanceOf(NodeApiError); + }); + + it('should make an API request to get all items', () => { + const responseData = [ + { + id: 123, + firstname: 'Max', + lastname: 'Muster', + active: true, + extern: false, + email: 'max.muster@beispiel.de', + mobile_phone: '+49 177 123 45 67', + work_phone: '+49 40 123 45 67', + home_address: '', + info: '', + birthday: '1970-01-01', + iban: 'CH3181239000001245689', + avatar_url: 'https//meinefirma.mocoapp.com/.../profil.jpg', + tags: ['Deutschland'], + custom_properties: { + 'Starting Month': 'January 2015', + }, + unit: { + id: 456, + name: 'Geschäftsleitung', + }, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }, + ]; + + executeFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + subDomain: '__sub_domain__', + apiKey: '__api_key__', + webhookSecret: '__secret__', + }); + executeFunctions.helpers.httpRequestWithAuthentication + .calledWith('mocoApi', expect.any(Object)) + .mockResolvedValueOnce({ body: responseData, statusCode: 200 }) + .mockResolvedValueOnce({ body: [], statusCode: 200 }); + + expect( + mocoApiRequestAllItems.call(executeFunctions, 0, 'GET', '/users'), + ).resolves.toEqual(responseData); + }); + + it('should make an API request to get some items', () => { + const responseData = [ + { + id: 123, + firstname: 'Max', + lastname: 'Muster', + active: true, + extern: false, + email: 'max.muster@beispiel.de', + mobile_phone: '+49 177 123 45 67', + work_phone: '+49 40 123 45 67', + home_address: '', + info: '', + birthday: '1970-01-01', + iban: 'CH3181239000001245689', + avatar_url: 'https//meinefirma.mocoapp.com/.../profil.jpg', + tags: ['Deutschland'], + custom_properties: { + 'Starting Month': 'January 2015', + }, + unit: { + id: 456, + name: 'Geschäftsleitung', + }, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }, + { + id: 456, + firstname: 'Maxi', + lastname: 'Muster', + active: true, + extern: false, + email: 'maxi.muster@beispiel.de', + mobile_phone: '+49 177 123 45 68', + work_phone: '+49 40 123 45 68', + home_address: '', + info: '', + birthday: '2000-01-01', + iban: 'DE3181239000001245689', + avatar_url: 'https//meinefirma.mocoapp.com/.../profil.jpg', + tags: ['Deutschland'], + custom_properties: { + 'Starting Month': 'January 2015', + }, + unit: { + id: 789, + name: 'Entwicklung', + }, + created_at: '2018-10-10T07:35:13Z', + updated_at: '2018-10-10T07:35:13Z', + }, + ]; + + executeFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + subDomain: '__sub_domain__', + apiKey: '__api_key__', + webhookSecret: '__secret__', + }); + executeFunctions.helpers.httpRequestWithAuthentication + .calledWith('mocoApi', expect.any(Object)) + .mockResolvedValueOnce({ body: responseData, statusCode: 200 }) + .mockResolvedValueOnce({ body: [], statusCode: 200 }); + + expect( + mocoApiRequestAllItems.call(executeFunctions, 0, 'GET', '/users', { + qs: { limit: 1 }, + }), + ).resolves.toEqual([responseData[0]]); + }); +}); diff --git a/nodes/moco/src/nodes/Moco/GenericFunctions.ts b/nodes/moco/src/nodes/Moco/GenericFunctions.ts new file mode 100644 index 00000000..aba8b4c9 --- /dev/null +++ b/nodes/moco/src/nodes/Moco/GenericFunctions.ts @@ -0,0 +1,180 @@ +import moment from 'moment-timezone'; +import type { + IExecuteFunctions, + ILoadOptionsFunctions, + IPollFunctions, + IDataObject, + IHttpRequestMethods, + IHttpRequestOptions, +} from 'n8n-workflow'; +import { NodeApiError, sleep } from 'n8n-workflow'; +import { CredentialData } from '../../credentials/MocoApi.credentials'; + +export function createUTCStringFromNodeParameter( + this: IExecuteFunctions | IPollFunctions | ILoadOptionsFunctions, + parameterName: string, + itemIndex?: number, +): string | undefined { + const dateTime = this.getNodeParameter(parameterName, itemIndex) as string; + + if (dateTime === '') { + return undefined; + } + + const timezone = this.getTimezone(); + + return moment.tz(dateTime, timezone).utc().format(); +} + +export function createParametersFromNodeParameter( + this: IExecuteFunctions | IPollFunctions | ILoadOptionsFunctions, + parameterName: string, + itemIndex: number | undefined, + fields: string[], +): IDataObject { + const additionalFieldsData = this.getNodeParameter( + parameterName, + itemIndex, + ) as IDataObject; + + const returnData: IDataObject = {}; + + for (const field of fields) { + if ( + additionalFieldsData[field] === undefined || + additionalFieldsData[field] === '' + ) { + continue; + } + + const name = field.replace( + /[A-Z]/g, + (letter) => `_${letter.toLowerCase()}`, + ); + + returnData[name] = additionalFieldsData[field]; + + if (typeof returnData[name] === 'boolean') { + returnData[name] = Boolean(returnData[name]).toString(); + } + } + + return returnData; +} + +export async function mocoApiRequest( + this: ILoadOptionsFunctions | IPollFunctions | IExecuteFunctions, + itemIndex: number | undefined, + method: IHttpRequestMethods, + url: string, + options?: { + impersonateUserId?: string; + body?: IDataObject; + qs?: IDataObject; + }, +): Promise { + const credentials = (await this.getCredentials( + 'mocoApi', + itemIndex, + )) as CredentialData; + + const { impersonateUserId, body, qs } = options || {}; + + const requestOptions: IHttpRequestOptions = { + url, + baseURL: `https://${credentials.subDomain}.mocoapp.com/api/v1`, + headers: { + accept: 'application/json', + ...(body ? { 'content-type': 'application/json' } : {}), + ...(impersonateUserId + ? { 'x-impersonate-user-id': impersonateUserId } + : {}), + }, + method, + body, + qs, + returnFullResponse: true, + ignoreHttpStatusErrors: true, + json: true, + }; + + let response: { + body: IDataObject | string; + headers: Record; + statusCode: number; + statusMessage: string; + }; + + const retries = 5; + + let remainingRetries = retries; + + do { + response = await this.helpers.httpRequestWithAuthentication.call( + this, + 'mocoApi', + requestOptions, + ); + + if (response.statusCode === 429) { + await sleep(Math.pow(2, retries - remainingRetries) * 1000); + remainingRetries = remainingRetries - 1; + } else { + remainingRetries = 0; + } + } while (remainingRetries > 0); + + if (response.statusCode >= 400) { + throw new NodeApiError( + this.getNode(), + {}, + { + message: response.statusMessage, + httpCode: response.statusCode.toString(), + }, + ); + } + + return response.body as IDataObject; +} + +export async function mocoApiRequestAllItems( + this: IExecuteFunctions | IPollFunctions | ILoadOptionsFunctions, + itemIndex: number | undefined, + method: IHttpRequestMethods, + url: string, + options?: { + impersonateUserId?: string; + body?: IDataObject; + qs?: IDataObject; + }, +): Promise { + const returnData: IDataObject[] = []; + + const qs = options?.qs || {}; + + const { limit }: { limit?: number } = qs; + + let responseData; + + qs['page'] = 1; + + delete qs['limit']; + + do { + responseData = (await mocoApiRequest.call(this, itemIndex, method, url, { + ...options, + qs, + })) as IDataObject[]; + + returnData.push(...responseData); + + if (limit !== undefined && returnData.length >= limit) { + return returnData.splice(0, limit); + } + + qs['page']++; + } while (responseData.length > 0); + + return returnData; +} diff --git a/nodes/moco/src/nodes/Moco/Moco.node.json b/nodes/moco/src/nodes/Moco/Moco.node.json new file mode 100644 index 00000000..e1324c57 --- /dev/null +++ b/nodes/moco/src/nodes/Moco/Moco.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.moco", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Productivity"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://github.com/skriptfabrik/n8n-nodes/blob/main/nodes/moco/README.md" + } + ], + "primaryDocumentation": [ + { + "url": "https://github.com/skriptfabrik/n8n-nodes/blob/main/nodes/moco/README.md" + } + ] + } +} diff --git a/nodes/moco/src/nodes/Moco/Moco.node.spec.ts b/nodes/moco/src/nodes/Moco/Moco.node.spec.ts new file mode 100644 index 00000000..321a6b81 --- /dev/null +++ b/nodes/moco/src/nodes/Moco/Moco.node.spec.ts @@ -0,0 +1,1805 @@ +import { mockClear, mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions, ILoadOptionsFunctions } from 'n8n-workflow'; +import { Moco } from './Moco.node'; +import { mocoApiRequest, mocoApiRequestAllItems } from './GenericFunctions'; +import { + Activity, + ActivityParameters, + Project, + ProjectParameters, + User, + UserParameters, +} from '../../api'; + +jest.mock('./GenericFunctions'); + +describe('Moco', () => { + const loadOptionsFunctions = mockDeep(); + const executeFunctions = mockDeep(); + const mockedMocoApiRequest = jest.mocked(mocoApiRequest); + const mockedMocoApiRequestAllItems = jest.mocked(mocoApiRequestAllItems); + + let moco: Moco; + + beforeEach(() => { + moco = new Moco(); + }); + + afterEach(() => { + mockClear(loadOptionsFunctions); + mockClear(executeFunctions); + mockClear(mockedMocoApiRequest); + mockClear(mockedMocoApiRequestAllItems); + }); + + it('should be defined', () => { + expect(moco).toBeDefined(); + }); + + it('should load companies', () => { + mockedMocoApiRequestAllItems.mockResolvedValue([ + { + id: 760253573, + name: 'Beispiel AG', + }, + { + id: 569873254, + name: 'Beispiel GmbH', + }, + ]); + + expect( + moco.methods.loadOptions.listCompanies.call(loadOptionsFunctions), + ).resolves.toEqual([ + { + name: 'Beispiel AG', + value: 760253573, + }, + { + name: 'Beispiel GmbH', + value: 569873254, + }, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/companies', + ); + }); + + it('should load customers', () => { + mockedMocoApiRequestAllItems.mockResolvedValue([ + { + id: 760253573, + name: 'Beispiel AG', + }, + { + id: 569873254, + name: 'Beispiel GmbH', + }, + ]); + + expect( + moco.methods.loadOptions.listCustomers.call(loadOptionsFunctions), + ).resolves.toEqual([ + { + name: 'Beispiel AG', + value: 760253573, + }, + { + name: 'Beispiel GmbH', + value: 569873254, + }, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/companies', + { qs: { type: 'customer' } }, + ); + }); + + it('should load leads', () => { + mockedMocoApiRequestAllItems.mockResolvedValue([ + { + id: 123, + name: 'Website V2', + }, + { + id: 456, + name: 'Website V1', + }, + ]); + + expect( + moco.methods.loadOptions.listLeads.call(loadOptionsFunctions), + ).resolves.toEqual([ + { + name: 'Website V1', + value: 456, + }, + { + name: 'Website V2', + value: 123, + }, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/deals', + ); + }); + + it('should load projects', () => { + mockedMocoApiRequestAllItems.mockResolvedValue([ + { + id: 1234567, + name: 'Website Support', + customer: { + id: 1233434, + name: 'Beispiel AG', + }, + }, + { + id: 8912345, + name: 'Entwicklung', + customer: { + id: 5639875, + name: 'Beispiel GbR', + }, + }, + ]); + + expect( + moco.methods.loadOptions.listProjects.call(loadOptionsFunctions), + ).resolves.toEqual([ + { + name: 'Beispiel AG > Website Support', + value: 1234567, + }, + { + name: 'Beispiel GbR > Entwicklung', + value: 8912345, + }, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/projects', + ); + }); + + it('should load project tasks of selected project', () => { + loadOptionsFunctions.getCurrentNodeParameter + .calledWith('projectId') + .mockReturnValue(1234567); + + mockedMocoApiRequestAllItems.mockResolvedValue([ + { + id: 760253573, + name: 'Projektleitung', + }, + ]); + + expect( + moco.methods.loadOptions.listProjectTasks.call(loadOptionsFunctions), + ).resolves.toEqual([ + { + name: 'Projektleitung', + value: 760253573, + }, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/projects/1234567/tasks', + ); + }); + + it('should load all project tasks', () => { + loadOptionsFunctions.getCurrentNodeParameter + .calledWith('projectId') + .mockReturnValue(undefined); + + mockedMocoApiRequestAllItems.mockResolvedValue([ + { + id: 1234567, + name: 'Website Support', + customer: { + id: 1233434, + name: 'Beispiel AG', + }, + tasks: [ + { + id: 125112, + name: 'Project Management', + }, + { + id: 125111, + name: 'Development', + }, + ], + }, + ]); + + expect( + moco.methods.loadOptions.listProjectTasks.call(loadOptionsFunctions), + ).resolves.toEqual([ + { + name: 'Beispiel AG > Website Support > Development', + value: 125111, + }, + { + name: 'Beispiel AG > Website Support > Project Management', + value: 125112, + }, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/projects', + ); + }); + + it('should load teams', () => { + mockedMocoApiRequestAllItems.mockResolvedValue([ + { + id: 909147861, + name: 'C Office', + }, + { + id: 569987632, + name: 'B Office', + }, + { + id: 5689732545, + name: 'A Office', + }, + ]); + + expect( + moco.methods.loadOptions.listTeams.call(loadOptionsFunctions), + ).resolves.toEqual([ + { + name: 'A Office', + value: 5689732545, + }, + { + name: 'B Office', + value: 569987632, + }, + { + name: 'C Office', + value: 909147861, + }, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/units', + ); + }); + + it('should load users', () => { + mockedMocoApiRequestAllItems.mockResolvedValue([ + { + id: 123, + firstname: 'Max', + lastname: 'Muster', + }, + { + id: 456, + firstname: 'Sabine', + lastname: 'Schäuble', + }, + ]); + + expect( + moco.methods.loadOptions.listUsers.call(loadOptionsFunctions), + ).resolves.toEqual([ + { + name: 'Max Muster', + value: 123, + }, + { + name: 'Sabine Schäuble', + value: 456, + }, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/users', + ); + }); + + it('should create an activity as authorized user', () => { + const body: ActivityParameters = { + date: '2017-06-11', + project_id: 123456, + task_id: 234567, + seconds: 0, + description: '', + }; + const activity: Partial = { + id: 982237015, + date: body.date, + project: { + id: body.project_id, + name: 'Website Relaunch', + billable: true, + }, + task: { + id: body.task_id, + name: 'Project Management', + billable: true, + }, + seconds: body.seconds, + description: body.description, + }; + const jsonArray = [{ json: activity }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('activity'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('date', 0) + .mockReturnValue(body.date); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue(body.project_id); + executeFunctions.getNodeParameter + .calledWith('taskId', 0) + .mockReturnValue(body.task_id); + executeFunctions.getNodeParameter + .calledWith('seconds', 0) + .mockReturnValue(body.seconds); + executeFunctions.getNodeParameter + .calledWith('description', 0) + .mockReturnValue(body.description); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequest.mockResolvedValue({ body: activity, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'POST', + '/activities', + { + impersonateUserId: undefined, + body, + }, + ); + }); + + it('should delete an activity', () => { + const activityId = 982237015; + const executionData = [ + { + json: {}, + pairedItem: { item: 0 }, + }, + ]; + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('activity'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('delete'); + executeFunctions.getNodeParameter + .calledWith('activityId', 0) + .mockReturnValue(activityId); + + executeFunctions.helpers.returnJsonArray.mockReturnValue([]); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'DELETE', + `/activities/${activityId}`, + ); + }); + + it('should get an activity', () => { + const activityId = 982237015; + const activity: Partial = { + id: activityId, + date: '2017-06-11', + project: { + id: 123456, + name: 'Website Relaunch', + billable: true, + }, + task: { + id: 234567, + name: 'Project Management', + billable: true, + }, + seconds: 0, + description: '', + }; + const jsonArray = [{ json: activity }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('activity'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('activityId', 0) + .mockReturnValue(activityId); + + mockedMocoApiRequest.mockResolvedValue({ body: activity, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'GET', + `/activities/${activityId}`, + ); + }); + + it('should list all activities', () => { + const activities: Partial[] = [ + { + id: 982237015, + date: '2017-06-11', + project: { + id: 123456, + name: 'Website Relaunch', + billable: true, + }, + task: { + id: 234567, + name: 'Project Management', + billable: true, + }, + seconds: 0, + description: '', + }, + { + id: 982237016, + date: '2017-06-12', + project: { + id: 123456, + name: 'Website Relaunch', + billable: true, + }, + task: { + id: 568732, + name: 'Development', + billable: true, + }, + seconds: 60, + description: 'Test', + }, + ]; + const jsonArray = activities.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('activity'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('list'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(true); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequestAllItems.mockResolvedValue(activities); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + 0, + 'GET', + '/activities', + { + qs: {}, + }, + ); + }); + + it('should list some activities', () => { + const activities: Partial[] = [ + { + id: 982237015, + date: '2017-06-11', + project: { + id: 123456, + name: 'Website Relaunch', + billable: true, + }, + task: { + id: 234567, + name: 'Project Management', + billable: true, + }, + seconds: 0, + description: '', + }, + { + id: 982237016, + date: '2017-06-12', + project: { + id: 123456, + name: 'Website Relaunch', + billable: true, + }, + task: { + id: 568732, + name: 'Development', + billable: true, + }, + seconds: 60, + description: 'Test', + }, + ]; + const jsonArray = activities.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('activity'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('list'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(false); + executeFunctions.getNodeParameter.calledWith('limit', 0).mockReturnValue(0); + executeFunctions.getNodeParameter.calledWith('ids', 0).mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('updatedAfter', 0) + .mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequestAllItems.mockResolvedValue(activities); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + 0, + 'GET', + '/activities', + { + qs: { limit: undefined, ids: undefined, updated_after: undefined }, + }, + ); + }); + + it('should update an activity', () => { + const activityId = 982237015; + const body: ActivityParameters = { + date: '2017-06-11', + project_id: 123456, + task_id: 234567, + seconds: 0, + description: '', + }; + const activity: Partial = { + id: activityId, + date: body.date, + project: { + id: body.project_id, + name: 'Website Relaunch', + billable: true, + }, + task: { + id: body.task_id, + name: 'Project Management', + billable: true, + }, + seconds: body.seconds, + description: body.description, + }; + const jsonArray = [{ json: activity }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('activity'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('update'); + executeFunctions.getNodeParameter + .calledWith('activityId', 0) + .mockReturnValue(activityId); + executeFunctions.getNodeParameter + .calledWith('date', 0) + .mockReturnValue(body.date); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue(body.project_id); + executeFunctions.getNodeParameter + .calledWith('taskId', 0) + .mockReturnValue(body.task_id); + executeFunctions.getNodeParameter + .calledWith('seconds', 0) + .mockReturnValue(body.seconds); + executeFunctions.getNodeParameter + .calledWith('description', 0) + .mockReturnValue(body.description); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequest.mockResolvedValue({ body: activity, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'PUT', + `/activities/${activityId}`, + { body }, + ); + }); + + it('should create an activity as impersonated user', () => { + const impersonateUserId = '1234567'; + const body: ActivityParameters = { + date: '2017-06-11', + project_id: 123456, + task_id: 234567, + seconds: 0, + description: '', + }; + const activity: Partial = { + id: 982237015, + date: body.date, + project: { + id: body.project_id, + name: 'Website Relaunch', + billable: true, + }, + task: { + id: body.task_id, + name: 'Project Management', + billable: true, + }, + seconds: body.seconds, + description: body.description, + }; + const jsonArray = [{ json: activity }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('activity'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('impersonateUserId', 0) + .mockReturnValue(impersonateUserId); + executeFunctions.getNodeParameter + .calledWith('date', 0) + .mockReturnValue(body.date); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue(body.project_id); + executeFunctions.getNodeParameter + .calledWith('taskId', 0) + .mockReturnValue(body.task_id); + executeFunctions.getNodeParameter + .calledWith('seconds', 0) + .mockReturnValue(body.seconds); + executeFunctions.getNodeParameter + .calledWith('description', 0) + .mockReturnValue(body.description); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequest.mockResolvedValue({ body: activity, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'POST', + '/activities', + { + impersonateUserId, + body, + }, + ); + }); + + it('should create a project', () => { + const body: ProjectParameters = { + name: 'Relaunch Website', + currency: 'EUR', + start_date: '2018-01-01', + finish_date: '2018-12-31', + fixed_price: false, + retainer: false, + leader_id: 123456, + customer_id: 234567, + identifier: 'P-123', + }; + const project: Partial = { + id: 1234567, + name: body.name, + currency: body.currency, + start_date: body.start_date, + finish_date: body.finish_date, + fixed_price: body.fixed_price, + retainer: body.retainer, + leader: { + id: body.leader_id, + firstname: 'Michael', + lastname: 'Mustermann', + }, + customer: { + id: body.customer_id, + name: 'Beispiel AG', + }, + identifier: body.identifier, + }; + const jsonArray = [{ json: project }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('project'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('name', 0) + .mockReturnValue(body.name); + executeFunctions.getNodeParameter + .calledWith('currency', 0) + .mockReturnValue(body.currency); + executeFunctions.getNodeParameter + .calledWith('startDate', 0) + .mockReturnValue(body.start_date); + executeFunctions.getNodeParameter + .calledWith('finishDate', 0) + .mockReturnValue(body.finish_date); + executeFunctions.getNodeParameter + .calledWith('fixedPrice', 0) + .mockReturnValue(body.fixed_price); + executeFunctions.getNodeParameter + .calledWith('retainer', 0) + .mockReturnValue(body.retainer); + executeFunctions.getNodeParameter + .calledWith('leaderId', 0) + .mockReturnValue(body.leader_id); + executeFunctions.getNodeParameter + .calledWith('customerId', 0) + .mockReturnValue(body.customer_id); + executeFunctions.getNodeParameter + .calledWith('budgetMonthly', 0) + .mockReturnValue(body.budget_monthly); + executeFunctions.getNodeParameter + .calledWith('identifier', 0) + .mockReturnValue(body.identifier); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequest.mockResolvedValue({ body: project, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith(0, 'POST', '/projects', { + body, + }); + }); + + it('should delete a project', () => { + const projectId = 1234567; + const executionData = [ + { + json: {}, + pairedItem: { item: 0 }, + }, + ]; + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('project'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('delete'); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue(projectId); + + executeFunctions.helpers.returnJsonArray.mockReturnValue([]); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'DELETE', + `/projects/${projectId}`, + ); + }); + + it('should get a project', () => { + const projectId = 1234567; + const project: Partial = { + id: projectId, + name: 'Relaunch Website', + currency: 'EUR', + start_date: '2018-01-01', + finish_date: '2018-12-31', + fixed_price: false, + retainer: false, + leader: { + id: 123456, + firstname: 'Michael', + lastname: 'Mustermann', + }, + customer: { + id: 234567, + name: 'Beispiel AG', + }, + identifier: 'P-123', + }; + const jsonArray = [{ json: project }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('project'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue(projectId); + + mockedMocoApiRequest.mockResolvedValue({ body: project, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'GET', + `/projects/${projectId}`, + ); + }); + + it('should list all projects', () => { + const projects: Partial[] = [ + { + id: 1234567, + name: 'Relaunch Website', + currency: 'EUR', + start_date: '2018-01-01', + finish_date: '2018-12-31', + fixed_price: false, + retainer: false, + leader: { + id: 123456, + firstname: 'Michael', + lastname: 'Mustermann', + }, + customer: { + id: 234567, + name: 'Beispiel AG', + }, + identifier: 'P-123', + }, + { + id: 7654321, + name: 'New Website', + currency: 'EUR', + start_date: '2019-01-01', + finish_date: '2019-12-31', + fixed_price: false, + retainer: false, + leader: { + id: 123456, + firstname: 'Michael', + lastname: 'Mustermann', + }, + customer: { + id: 234567, + name: 'Beispiel AG', + }, + identifier: 'P-456', + }, + ]; + const jsonArray = projects.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('project'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('list'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(true); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequestAllItems.mockResolvedValue(projects); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + 0, + 'GET', + '/projects', + { + qs: {}, + }, + ); + }); + + it('should list some projects', () => { + const projects: Partial[] = [ + { + id: 1234567, + name: 'Relaunch Website', + currency: 'EUR', + start_date: '2018-01-01', + finish_date: '2018-12-31', + fixed_price: false, + retainer: false, + leader: { + id: 123456, + firstname: 'Michael', + lastname: 'Mustermann', + }, + customer: { + id: 234567, + name: 'Beispiel AG', + }, + identifier: 'P-123', + }, + { + id: 7654321, + name: 'New Website', + currency: 'EUR', + start_date: '2019-01-01', + finish_date: '2019-12-31', + fixed_price: false, + retainer: false, + leader: { + id: 123456, + firstname: 'Michael', + lastname: 'Mustermann', + }, + customer: { + id: 234567, + name: 'Beispiel AG', + }, + identifier: 'P-456', + }, + ]; + const jsonArray = projects.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('project'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('list'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(false); + executeFunctions.getNodeParameter.calledWith('limit', 0).mockReturnValue(0); + executeFunctions.getNodeParameter.calledWith('ids', 0).mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('updatedAfter', 0) + .mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequestAllItems.mockResolvedValue(projects); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + 0, + 'GET', + '/projects', + { + qs: { limit: undefined, ids: undefined, updated_after: undefined }, + }, + ); + }); + + it('should update a project', () => { + const projectId = 933590158; + const body: ProjectParameters = { + name: 'Relaunch Website', + currency: 'EUR', + start_date: '2018-01-01', + finish_date: '2018-12-31', + fixed_price: false, + retainer: false, + leader_id: 123456, + customer_id: 234567, + identifier: 'P-123', + }; + const project: Partial = { + id: projectId, + name: body.name, + currency: body.currency, + start_date: body.start_date, + finish_date: body.finish_date, + fixed_price: body.fixed_price, + retainer: body.retainer, + leader: { + id: body.leader_id, + firstname: 'Michael', + lastname: 'Mustermann', + }, + customer: { + id: body.customer_id, + name: 'Beispiel AG', + }, + identifier: body.identifier, + }; + const jsonArray = [{ json: project }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('project'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('update'); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue(projectId); + executeFunctions.getNodeParameter + .calledWith('name', 0) + .mockReturnValue(body.name); + executeFunctions.getNodeParameter + .calledWith('currency', 0) + .mockReturnValue(body.currency); + executeFunctions.getNodeParameter + .calledWith('startDate', 0) + .mockReturnValue(body.start_date); + executeFunctions.getNodeParameter + .calledWith('finishDate', 0) + .mockReturnValue(body.finish_date); + executeFunctions.getNodeParameter + .calledWith('fixedPrice', 0) + .mockReturnValue(body.fixed_price); + executeFunctions.getNodeParameter + .calledWith('retainer', 0) + .mockReturnValue(body.retainer); + executeFunctions.getNodeParameter + .calledWith('leaderId', 0) + .mockReturnValue(body.leader_id); + executeFunctions.getNodeParameter + .calledWith('customerId', 0) + .mockReturnValue(body.customer_id); + executeFunctions.getNodeParameter + .calledWith('budgetMonthly', 0) + .mockReturnValue(body.budget_monthly); + executeFunctions.getNodeParameter + .calledWith('identifier', 0) + .mockReturnValue(body.identifier); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequest.mockResolvedValue({ body: project, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'PUT', + `/projects/${projectId}`, + { + body, + }, + ); + }); + + it('should create a user', () => { + const body: UserParameters = { + firstname: 'Tobias', + lastname: 'Miesel', + email: 'tobias@domain.com', + password: '123456', + unit_id: 909147861, + }; + const user: Partial = { + id: 933590158, + firstname: body.firstname, + lastname: body.lastname, + email: body.email, + unit: { + id: body.unit_id, + name: 'C Office', + }, + }; + const jsonArray = [{ json: user }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('user'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('firstname', 0) + .mockReturnValue(body.firstname); + executeFunctions.getNodeParameter + .calledWith('lastname', 0) + .mockReturnValue(body.lastname); + executeFunctions.getNodeParameter + .calledWith('email', 0) + .mockReturnValue(body.email); + executeFunctions.getNodeParameter + .calledWith('password', 0) + .mockReturnValue(body.password); + executeFunctions.getNodeParameter + .calledWith('unitId', 0) + .mockReturnValue(body.unit_id); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequest.mockResolvedValue({ body: user, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith(0, 'POST', '/users', { + body, + }); + }); + + it('should delete a user', () => { + const userId = 933590158; + const executionData = [ + { + json: {}, + pairedItem: { item: 0 }, + }, + ]; + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('user'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('delete'); + executeFunctions.getNodeParameter + .calledWith('userId', 0) + .mockReturnValue(userId); + + executeFunctions.helpers.returnJsonArray.mockReturnValue([]); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'DELETE', + `/users/${userId}`, + ); + }); + + it('should get a user', () => { + const userId = 933590158; + const user: Partial = { + id: userId, + firstname: 'Tobias', + lastname: 'Miesel', + email: 'tobias@domain.com', + unit: { + id: 909147861, + name: 'C Office', + }, + }; + const jsonArray = [{ json: user }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('user'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('userId', 0) + .mockReturnValue(userId); + + mockedMocoApiRequest.mockResolvedValue({ body: user, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'GET', + `/users/${userId}`, + ); + }); + + it('should list all users', () => { + const users: Partial[] = [ + { + id: 933590158, + firstname: 'Tobias', + lastname: 'Miesel', + email: 'tobias@domain.com', + unit: { + id: 909147861, + name: 'C Office', + }, + }, + { + id: 933589599, + firstname: 'Sabine', + lastname: 'Schäuble', + email: 'sabine@domain.com', + unit: { + id: 909147861, + name: 'C Office', + }, + }, + ]; + const jsonArray = users.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('user'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('list'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(true); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequestAllItems.mockResolvedValue(users); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + 0, + 'GET', + '/users', + { + qs: {}, + }, + ); + }); + + it('should list some users', () => { + const users: Partial[] = [ + { + id: 933590158, + firstname: 'Tobias', + lastname: 'Miesel', + email: 'tobias@domain.com', + unit: { + id: 909147861, + name: 'C Office', + }, + }, + { + id: 933589599, + firstname: 'Sabine', + lastname: 'Schäuble', + email: 'sabine@domain.com', + unit: { + id: 909147861, + name: 'C Office', + }, + }, + ]; + const jsonArray = users.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('user'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('list'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(false); + executeFunctions.getNodeParameter.calledWith('limit', 0).mockReturnValue(0); + executeFunctions.getNodeParameter.calledWith('ids', 0).mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('updatedAfter', 0) + .mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequestAllItems.mockResolvedValue(users); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequestAllItems).toHaveBeenCalledWith( + 0, + 'GET', + '/users', + { + qs: { limit: undefined, ids: undefined, updated_after: undefined }, + }, + ); + }); + + it('should update a user', () => { + const userId = 933590158; + const body: UserParameters = { + firstname: 'Tobias', + lastname: 'Miesel', + email: 'tobias@domain.com', + password: '123456', + unit_id: 909147861, + }; + const user: Partial = { + id: userId, + firstname: body.firstname, + lastname: body.lastname, + email: body.email, + unit: { + id: body.unit_id, + name: 'C Office', + }, + }; + const jsonArray = [{ json: user }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('user'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('update'); + executeFunctions.getNodeParameter + .calledWith('userId', 0) + .mockReturnValue(userId); + executeFunctions.getNodeParameter + .calledWith('firstname', 0) + .mockReturnValue(body.firstname); + executeFunctions.getNodeParameter + .calledWith('lastname', 0) + .mockReturnValue(body.lastname); + executeFunctions.getNodeParameter + .calledWith('email', 0) + .mockReturnValue(body.email); + executeFunctions.getNodeParameter + .calledWith('password', 0) + .mockReturnValue(body.password); + executeFunctions.getNodeParameter + .calledWith('unitId', 0) + .mockReturnValue(body.unit_id); + executeFunctions.getNodeParameter + .calledWith('additionalFields', 0) + .mockReturnValue({}); + + mockedMocoApiRequest.mockResolvedValue({ body: user, statusCode: 200 }); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + executionData, + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'PUT', + `/users/${userId}`, + { + body, + }, + ); + }); + + it('should return an error', async () => { + const userId = 933590158; + const error = new Error('__error_message__'); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('user'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('userId', 0) + .mockReturnValue(userId); + + mockedMocoApiRequest.mockRejectedValue(error); + + executeFunctions.continueOnFail.mockReturnValue(true); + + expect(moco.execute.call(executeFunctions)).resolves.toEqual([ + [{ error: '__error_message__', json: {} }], + ]); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'GET', + `/users/${userId}`, + ); + }); + + it('should throw an error', async () => { + const userId = 933590158; + const error = new Error('__error_message__'); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('user'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('userId', 0) + .mockReturnValue(userId); + + mockedMocoApiRequest.mockRejectedValue(error); + + executeFunctions.continueOnFail.mockReturnValue(false); + + expect(moco.execute.call(executeFunctions)).rejects.toEqual(error); + + expect(mockedMocoApiRequest).toHaveBeenCalledWith( + 0, + 'GET', + `/users/${userId}`, + ); + }); +}); diff --git a/nodes/moco/src/nodes/Moco/Moco.node.ts b/nodes/moco/src/nodes/Moco/Moco.node.ts new file mode 100644 index 00000000..f5e179a7 --- /dev/null +++ b/nodes/moco/src/nodes/Moco/Moco.node.ts @@ -0,0 +1,697 @@ +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; +import type { + Activity, + ActivityParameters, + ActivityFilters, + Company, + Deal, + Project, + ProjectParameters, + ProjectTask, + Unit, + User, + UserParameters, + UserFilters, +} from '../../api'; +import { + createUTCStringFromNodeParameter, + createParametersFromNodeParameter, + mocoApiRequest, + mocoApiRequestAllItems, +} from './GenericFunctions'; +import { userFields, userOperations } from './UserDescription'; +import { activityFields, activityOperations } from './ActivityDescription'; +import { projectFields, projectOperations } from './ProjectDescription'; + +export class Moco implements INodeType { + description: INodeTypeDescription = { + displayName: 'MOCO', + name: 'moco', + icon: 'file:moco.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume MOCO API', + defaults: { + name: 'MOCO', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'mocoApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Activity', + value: 'activity', + }, + { + name: 'Project', + value: 'project', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'user', + }, + ...activityOperations, + ...activityFields, + ...projectOperations, + ...projectFields, + ...userOperations, + ...userFields, + ], + }; + + methods = { + loadOptions: { + async listCompanies( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + + const companies: Company[] = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + '/companies', + ); + + for (const company of companies) { + returnData.push({ + name: company.name, + value: company.id, + }); + } + + return returnData.sort((a, b) => a.name.localeCompare(b.name)); + }, + async listCustomers( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + + const companies: Company[] = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + '/companies', + { qs: { type: 'customer' } }, + ); + + for (const company of companies) { + returnData.push({ + name: company.name, + value: company.id, + }); + } + + return returnData.sort((a, b) => a.name.localeCompare(b.name)); + }, + async listLeads( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + + const deals: Deal[] = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + '/deals', + ); + + for (const deal of deals) { + returnData.push({ + name: deal.name, + value: deal.id, + }); + } + + return returnData.sort((a, b) => a.name.localeCompare(b.name)); + }, + async listProjects( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + + const projects: Project[] = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + '/projects', + ); + + for (const project of projects) { + returnData.push({ + name: `${project.customer.name} > ${project.name}`, + value: project.id, + }); + } + + return returnData.sort((a, b) => a.name.localeCompare(b.name)); + }, + async listProjectTasks( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + + const projectId = + (this.getCurrentNodeParameter('projectId') as number) || undefined; + + if (projectId === undefined) { + const projects: Project[] = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + '/projects', + ); + + for (const project of projects) { + for (const projectTask of project.tasks) { + returnData.push({ + name: `${project.customer.name} > ${project.name} > ${projectTask.name}`, + value: projectTask.id, + }); + } + } + } else { + const projectTasks: ProjectTask[] = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + `/projects/${projectId}/tasks`, + ); + + for (const projectTask of projectTasks) { + returnData.push({ + name: projectTask.name, + value: projectTask.id, + }); + } + } + + return returnData.sort((a, b) => a.name.localeCompare(b.name)); + }, + async listTeams( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + + const units: Unit[] = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + '/units', + ); + + for (const unit of units) { + returnData.push({ + name: unit.name, + value: unit.id, + }); + } + + return returnData.sort((a, b) => a.name.localeCompare(b.name)); + }, + async listUsers( + this: ILoadOptionsFunctions, + ): Promise { + const returnData: INodePropertyOptions[] = []; + + const users: User[] = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + '/users', + ); + + for (const user of users) { + returnData.push({ + name: `${user.firstname} ${user.lastname}`, + value: user.id, + }); + } + + return returnData.sort((a, b) => a.name.localeCompare(b.name)); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const returnData: INodeExecutionData[] = []; + + let responseData: IDataObject | IDataObject[] = {}; + + const resource = this.getNodeParameter('resource', 0); + + const operation = this.getNodeParameter('operation', 0); + + for (let item = 0; item < items.length; item++) { + try { + if (resource === 'activity') { + if (operation === 'create') { + const impersonateUserId = + (this.getNodeParameter('impersonateUserId', item) as string) || + undefined; + + const body: ActivityParameters = { + date: this.getNodeParameter('date', item) as string, + project_id: this.getNodeParameter('projectId', item) as number, + task_id: this.getNodeParameter('taskId', item) as number, + seconds: this.getNodeParameter('seconds', item) as number, + description: this.getNodeParameter('description', item) as string, + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + ['billable', 'tag', 'remoteService', 'remoteId', 'remoteUrl'], + ), + }; + + responseData = (await mocoApiRequest.call( + this, + item, + 'POST', + '/activities', + { impersonateUserId, body }, + )) as Activity; + } + + if (operation === 'delete') { + const activityId = this.getNodeParameter('activityId', item); + + responseData = (await mocoApiRequest.call( + this, + item, + 'DELETE', + `/activities/${activityId}`, + )) as never; + } + + if (operation === 'get') { + const activityId = this.getNodeParameter('activityId', item); + + responseData = (await mocoApiRequest.call( + this, + item, + 'GET', + `/activities/${activityId}`, + )) as Activity; + } + + if (operation === 'list') { + const returnAll = this.getNodeParameter('returnAll', item); + + const qs: ActivityFilters = { + ...(returnAll + ? {} + : { + limit: + (this.getNodeParameter('limit', item) as number) || + undefined, + ids: + (this.getNodeParameter('ids', item) as string) || + undefined, + updated_after: createUTCStringFromNodeParameter.call( + this, + 'updatedAfter', + item, + ), + }), + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + [ + 'from', + 'to', + 'userId', + 'projectId', + 'taskId', + 'companyId', + 'term', + 'sortBy', + ], + ), + }; + + responseData = (await mocoApiRequestAllItems.call( + this, + item, + 'GET', + '/activities', + { qs }, + )) as Activity[]; + } + + if (operation === 'update') { + const activityId = this.getNodeParameter('activityId', item); + + const body: ActivityParameters = { + date: this.getNodeParameter('date', item) as string, + project_id: this.getNodeParameter('projectId', item) as number, + task_id: this.getNodeParameter('taskId', item) as number, + seconds: this.getNodeParameter('seconds', item) as number, + description: this.getNodeParameter('description', item) as string, + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + ['billable', 'tag', 'remoteService', 'remoteId', 'remoteUrl'], + ), + }; + + responseData = (await mocoApiRequest.call( + this, + item, + 'PUT', + `/activities/${activityId}`, + { body }, + )) as User; + } + } + + if (resource === 'project') { + if (operation === 'create') { + const body: ProjectParameters = { + name: this.getNodeParameter('name', item) as string, + currency: this.getNodeParameter('currency', item) as string, + start_date: this.getNodeParameter('startDate', item) as string, + finish_date: this.getNodeParameter('finishDate', item) as string, + fixed_price: this.getNodeParameter('fixedPrice', item) as boolean, + retainer: this.getNodeParameter('retainer', item) as boolean, + leader_id: this.getNodeParameter('leaderId', item) as number, + customer_id: this.getNodeParameter('customerId', item) as number, + budget_monthly: this.getNodeParameter( + 'budgetMonthly', + item, + ) as number, + identifier: this.getNodeParameter('identifier', item) as string, + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + [ + 'coLeaderId', + 'dealId', + 'billingAddress', + 'billingEmailTo', + 'billingEmailCc', + 'billingNotes', + 'settingIncludeTimeReport', + 'billingVariant', + 'hourlyRate', + 'budget', + 'budgetExpenses', + 'tags', + 'customProperties', + 'info', + ], + ), + }; + + responseData = (await mocoApiRequest.call( + this, + item, + 'POST', + '/projects', + { + body, + }, + )) as Project; + } + + if (operation === 'delete') { + const projectId = this.getNodeParameter('projectId', item); + + responseData = (await mocoApiRequest.call( + this, + item, + 'DELETE', + `/projects/${projectId}`, + )) as never; + } + + if (operation === 'get') { + const projectId = this.getNodeParameter('projectId', item); + + responseData = (await mocoApiRequest.call( + this, + item, + 'GET', + `/projects/${projectId}`, + )) as Project; + } + + if (operation === 'list') { + const returnAll = this.getNodeParameter('returnAll', item); + + const qs: UserFilters = { + ...(returnAll + ? {} + : { + limit: + (this.getNodeParameter('limit', item) as number) || + undefined, + ids: + (this.getNodeParameter('ids', item) as string) || + undefined, + updated_after: createUTCStringFromNodeParameter.call( + this, + 'updatedAfter', + item, + ), + }), + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + [ + 'includeArchived', + 'includeCompany', + 'leaderId', + 'companyId', + 'createdFrom', + 'createdTo', + 'updatedFrom', + 'updatedTo', + 'tags', + 'identifier', + 'retainer', + 'projectGroupId', + 'sortBy', + ], + ), + }; + + responseData = (await mocoApiRequestAllItems.call( + this, + item, + 'GET', + '/projects', + { qs }, + )) as Project[]; + } + + if (operation === 'update') { + const projectId = this.getNodeParameter('projectId', item); + + const body: ProjectParameters = { + name: this.getNodeParameter('name', item) as string, + currency: this.getNodeParameter('currency', item) as string, + start_date: this.getNodeParameter('startDate', item) as string, + finish_date: this.getNodeParameter('finishDate', item) as string, + fixed_price: this.getNodeParameter('fixedPrice', item) as boolean, + retainer: this.getNodeParameter('retainer', item) as boolean, + leader_id: this.getNodeParameter('leaderId', item) as number, + customer_id: this.getNodeParameter('customerId', item) as number, + budget_monthly: this.getNodeParameter( + 'budgetMonthly', + item, + ) as number, + identifier: this.getNodeParameter('identifier', item) as string, + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + [ + 'coLeaderId', + 'dealId', + 'billingAddress', + 'billingEmailTo', + 'billingEmailCc', + 'billingNotes', + 'settingIncludeTimeReport', + 'billingVariant', + 'hourlyRate', + 'budget', + 'budgetExpenses', + 'tags', + 'customProperties', + 'info', + ], + ), + }; + + responseData = (await mocoApiRequest.call( + this, + item, + 'PUT', + `/projects/${projectId}`, + { body }, + )) as Project; + } + } + + if (resource === 'user') { + if (operation === 'create') { + const body: UserParameters = { + firstname: this.getNodeParameter('firstname', item) as string, + lastname: this.getNodeParameter('lastname', item) as string, + email: this.getNodeParameter('email', item) as string, + password: this.getNodeParameter('password', item) as string, + unit_id: this.getNodeParameter('unitId', item) as number, + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + ['active', 'external'], + ), + }; + + responseData = (await mocoApiRequest.call( + this, + item, + 'POST', + '/users', + { + body, + }, + )) as User; + } + + if (operation === 'delete') { + const userId = this.getNodeParameter('userId', item); + + responseData = (await mocoApiRequest.call( + this, + item, + 'DELETE', + `/users/${userId}`, + )) as never; + } + + if (operation === 'get') { + const userId = this.getNodeParameter('userId', item); + + responseData = (await mocoApiRequest.call( + this, + item, + 'GET', + `/users/${userId}`, + )) as User; + } + + if (operation === 'list') { + const returnAll = this.getNodeParameter('returnAll', item); + + const qs: UserFilters = { + ...(returnAll + ? {} + : { + limit: + (this.getNodeParameter('limit', item) as number) || + undefined, + ids: + (this.getNodeParameter('ids', item) as string) || + undefined, + updated_after: createUTCStringFromNodeParameter.call( + this, + 'updatedAfter', + item, + ), + }), + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + ['includeArchived', 'sortBy'], + ), + }; + + responseData = (await mocoApiRequestAllItems.call( + this, + item, + 'GET', + '/users', + { qs }, + )) as User[]; + } + + if (operation === 'update') { + const userId = this.getNodeParameter('userId', item); + + const body: UserParameters = { + firstname: this.getNodeParameter('firstname', item) as string, + lastname: this.getNodeParameter('lastname', item) as string, + email: this.getNodeParameter('email', item) as string, + password: this.getNodeParameter('password', item) as string, + unit_id: this.getNodeParameter('unitId', item) as number, + ...createParametersFromNodeParameter.call( + this, + 'additionalFields', + item, + ['active', 'external'], + ), + }; + + responseData = (await mocoApiRequest.call( + this, + item, + 'PUT', + `/users/${userId}`, + { body }, + )) as User; + } + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: { item } }, + ); + + returnData.push(...executionData); + } catch (error: any) { + if (this.continueOnFail()) { + returnData.push({ error: error.message, json: {} }); + continue; + } + + throw error; + } + } + + return [returnData]; + } +} diff --git a/nodes/moco/src/nodes/Moco/MocoTrigger.node.json b/nodes/moco/src/nodes/Moco/MocoTrigger.node.json new file mode 100644 index 00000000..47d99fed --- /dev/null +++ b/nodes/moco/src/nodes/Moco/MocoTrigger.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.mocoTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Productivity"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://github.com/skriptfabrik/n8n-nodes/blob/main/nodes/moco/README.md" + } + ], + "primaryDocumentation": [ + { + "url": "https://github.com/skriptfabrik/n8n-nodes/blob/main/nodes/moco/README.md" + } + ] + } +} diff --git a/nodes/moco/src/nodes/Moco/MocoTrigger.node.spec.ts b/nodes/moco/src/nodes/Moco/MocoTrigger.node.spec.ts new file mode 100644 index 00000000..dd100f00 --- /dev/null +++ b/nodes/moco/src/nodes/Moco/MocoTrigger.node.spec.ts @@ -0,0 +1,519 @@ +import { Hmac, createHmac } from 'crypto'; +import type { Request } from 'express'; +import { mock, mockClear, mockDeep } from 'jest-mock-extended'; +import type { + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-workflow'; +import { mocoApiRequest, mocoApiRequestAllItems } from './GenericFunctions'; +import { MocoTrigger } from './MocoTrigger.node'; + +jest.mock('crypto'); +jest.mock('./GenericFunctions'); + +describe('MocoTrigger', () => { + const hookFunctions = mockDeep(); + const loadOptionsFunctions = mockDeep(); + const webhookFunctions = mockDeep(); + const mockedApiRequest = jest.mocked(mocoApiRequest); + const mockedApiRequestAllItems = jest.mocked(mocoApiRequestAllItems); + const mockedCreateHmac = jest.mocked(createHmac); + + let trigger: MocoTrigger; + + beforeEach(() => { + trigger = new MocoTrigger(); + }); + + afterEach(() => { + mockClear(hookFunctions); + mockClear(loadOptionsFunctions); + mockClear(webhookFunctions); + mockClear(mockedApiRequest); + mockClear(mockedApiRequestAllItems); + mockClear(mockedCreateHmac); + }); + + it('should be defined', () => { + expect(trigger).toBeDefined(); + }); + + it('should check that a webhook exists', async () => { + const staticData: { hookId?: string } = { + hookId: undefined, + }; + + mockedApiRequestAllItems.mockResolvedValue([ + { + id: 123, + target: 'Activity', + event: 'create', + hook: 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + disabled: false, + disabled_at: null, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }, + ]); + hookFunctions.getNodeWebhookUrl + .calledWith('default') + .mockReturnValue( + 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + ); + hookFunctions.getWorkflowStaticData + .calledWith('node') + .mockReturnValue(staticData); + + expect( + trigger.webhookMethods.default.checkExists.call(hookFunctions), + ).resolves.toBe(true); + + expect(mockedApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/account/web_hooks', + ); + + await new Promise(process.nextTick); + + expect(staticData.hookId).toBe(123); + }); + + it('should check that a webhook does not exist', async () => { + const staticData: { hookId?: string } = { + hookId: undefined, + }; + + mockedApiRequestAllItems.mockResolvedValue([ + { + id: 123, + target: 'Activity', + event: 'create', + hook: 'http://localhost:5678/webhook/67f02646-ee9c-4d5e-97bd-5fa6218dbdd0', + disabled: false, + disabled_at: null, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }, + ]); + hookFunctions.getNodeWebhookUrl + .calledWith('default') + .mockReturnValue( + 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + ); + hookFunctions.getWorkflowStaticData + .calledWith('node') + .mockReturnValue(staticData); + + expect( + trigger.webhookMethods.default.checkExists.call(hookFunctions), + ).resolves.toBe(false); + + expect(mockedApiRequestAllItems).toHaveBeenCalledWith( + undefined, + 'GET', + '/account/web_hooks', + ); + + await new Promise(process.nextTick); + + expect(staticData.hookId).toBeUndefined(); + }); + + it('should create webhook', async () => { + const staticData: { hookId?: string } = { + hookId: undefined, + }; + + hookFunctions.getNodeParameter + .calledWith('triggerOn') + .mockReturnValue('Activity/create'); + hookFunctions.getNodeWebhookUrl + .calledWith('default') + .mockReturnValue( + 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + ); + mockedApiRequest.mockResolvedValue({ + id: 123, + target: 'Activity', + event: 'create', + hook: 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + disabled: false, + disabled_at: null, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }); + hookFunctions.getWorkflowStaticData + .calledWith('node') + .mockReturnValue(staticData); + + expect( + trigger.webhookMethods.default.create.call(hookFunctions), + ).resolves.toBe(true); + + expect(mockedApiRequest).toHaveBeenCalledWith( + undefined, + 'POST', + '/account/web_hooks', + { + target: 'Activity', + event: 'create', + hook: 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + }, + ); + + await new Promise(process.nextTick); + + expect(staticData.hookId).toBe(123); + }); + + it('should not create webhook because of undefined id', async () => { + const staticData: { hookId?: string } = { + hookId: undefined, + }; + + hookFunctions.getNodeParameter + .calledWith('triggerOn') + .mockReturnValue('Activity/create'); + hookFunctions.getNodeWebhookUrl + .calledWith('default') + .mockReturnValue( + 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + ); + mockedApiRequest.mockResolvedValue({ + id: undefined, + target: 'Activity', + event: 'create', + hook: 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + disabled: false, + disabled_at: null, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }); + + expect( + trigger.webhookMethods.default.create.call(hookFunctions), + ).resolves.toBe(false); + + expect(mockedApiRequest).toHaveBeenCalledWith( + undefined, + 'POST', + '/account/web_hooks', + { + target: 'Activity', + event: 'create', + hook: 'http://localhost:5678/webhook/985dc3e9-b382-4dd0-922e-0ceee97023e9', + }, + ); + + await new Promise(process.nextTick); + + expect(staticData.hookId).toBeUndefined(); + }); + + it('should delete webhook', async () => { + const staticData: { hookId?: string } = { + hookId: '76a687e29ae1f428e7ebe101', + }; + + hookFunctions.getWorkflowStaticData + .calledWith('node') + .mockReturnValue(staticData); + + expect( + trigger.webhookMethods.default.delete.call(hookFunctions), + ).resolves.toBe(true); + + expect(mockedApiRequest).toHaveBeenCalledWith( + undefined, + 'DELETE', + '/account/web_hooks/76a687e29ae1f428e7ebe101', + ); + + await new Promise(process.nextTick); + + expect(staticData.hookId).toBeUndefined(); + }); + + it('should not delete webhook because of undefined id', async () => { + const staticData: { hookId?: string } = { + hookId: undefined, + }; + + hookFunctions.getWorkflowStaticData + .calledWith('node') + .mockReturnValue(staticData); + + expect( + trigger.webhookMethods.default.delete.call(hookFunctions), + ).resolves.toBe(true); + + expect(mockedApiRequest).not.toHaveBeenCalled(); + + await new Promise(process.nextTick); + + expect(staticData.hookId).toBeUndefined(); + }); + + it('should not delete webhook because of api exception', async () => { + const staticData: { hookId?: string } = { + hookId: '76a687e29ae1f428e7ebe101', + }; + + hookFunctions.getWorkflowStaticData + .calledWith('node') + .mockReturnValue(staticData); + mockedApiRequest.mockRejectedValue(new Error()); + + expect( + trigger.webhookMethods.default.delete.call(hookFunctions), + ).resolves.toBe(false); + + expect(mockedApiRequest).toHaveBeenCalledWith( + undefined, + 'DELETE', + '/account/web_hooks/76a687e29ae1f428e7ebe101', + ); + + await new Promise(process.nextTick); + + expect(staticData.hookId).toBe('76a687e29ae1f428e7ebe101'); + }); + + it('should provide workflow data', () => { + const body = { + id: 982237015, + date: '2018-07-03', + hours: 1.25, + seconds: 4500, + description: 'Analysis context and dependencies', + billed: false, + invoice_id: null, + billable: false, + tag: '', + remote_service: 'trello', + remote_id: '9qzOS8AA', + remote_url: 'https://trello.com/c/9qzOS8AA/123-analyse', + project: { + id: 944587499, + name: 'Website Relaunch', + billable: false, + }, + task: { + id: 658636, + name: 'Concept', + billable: false, + }, + customer: { + id: 760253684, + name: 'Example Inc.', + }, + user: { + id: 933590696, + firstname: 'John', + lastname: 'Doe', + }, + hourly_rate: 150, + timer_started_at: null, + created_at: '2018-10-17T09:33:46Z', + updated_at: '2018-10-17T09:33:46Z', + }; + + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Activity', + 'x-moco-event': 'create', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__signature__', + 'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + webhookFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + webhookSecret: '__secret__', + }); + + webhookFunctions.getRequestObject.mockReturnValue(mock({ body })); + + const hmac = mock(); + + hmac.update.mockReturnValue(hmac); + hmac.digest.mockReturnValue('__signature__'); + + mockedCreateHmac.mockReturnValue(hmac); + + webhookFunctions.getNodeParameter + .calledWith('triggerOn') + .mockReturnValue('Activity/create'); + + webhookFunctions.helpers.returnJsonArray.mockReturnValue([{ json: body }]); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({ + workflowData: [[{ json: body }]], + }); + }); + + it('should not provide workflow data because of missing x-moco-target header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + //'x-moco-target': 'Activity', + 'x-moco-event': 'create', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__signature__', + 'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); + + it('should not provide workflow data because of missing x-moco-event header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Activity', + //'x-moco-event': 'create', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__signature__', + 'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); + + it('should not provide workflow data because of missing x-moco-timestamp header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Activity', + 'x-moco-event': 'create', + //'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__signature__', + 'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); + + it('should not provide workflow data because of missing x-moco-signature header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Activity', + 'x-moco-event': 'create', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + //'x-moco-signature': '__signature__', + 'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); + + it('should not provide workflow data because of missing x-moco-user-id header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Activity', + 'x-moco-event': 'create', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__signature__', + //'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); + + it('should not provide workflow data because of missing x-moco-account-url header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Activity', + 'x-moco-event': 'create', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__signature__', + 'x-moco-user-id': '123', + //'x-moco-account-url': 'http://localhost:5678', + }); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); + + it('should not provide workflow data because of invalid signature header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Activity', + 'x-moco-event': 'create', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__invalid_signature__', + 'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + webhookFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + webhookSecret: '__secret__', + }); + + webhookFunctions.getRequestObject.mockReturnValue(mock()); + + const hmac = mock(); + + hmac.update.mockReturnValue(hmac); + hmac.digest.mockReturnValue('__signature__'); + + mockedCreateHmac.mockReturnValue(hmac); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); + + it('should not provide workflow data because of invalid target header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Invalid', + 'x-moco-event': 'create', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__signature__', + 'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + webhookFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + webhookSecret: '__secret__', + }); + + webhookFunctions.getRequestObject.mockReturnValue(mock()); + + const hmac = mock(); + + hmac.update.mockReturnValue(hmac); + hmac.digest.mockReturnValue('__signature__'); + + mockedCreateHmac.mockReturnValue(hmac); + + webhookFunctions.getNodeParameter + .calledWith('triggerOn') + .mockReturnValue('Activity/create'); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); + + it('should not provide workflow data because of invalid event header', () => { + webhookFunctions.getHeaderData.mockReturnValue({ + 'x-moco-target': 'Activity', + 'x-moco-event': 'invalid', + 'x-moco-timestamp': '2018-10-17T09:33:46Z', + 'x-moco-signature': '__signature__', + 'x-moco-user-id': '123', + 'x-moco-account-url': 'http://localhost:5678', + }); + + webhookFunctions.getCredentials.calledWith('mocoApi').mockResolvedValue({ + webhookSecret: '__secret__', + }); + + webhookFunctions.getRequestObject.mockReturnValue(mock()); + + const hmac = mock(); + + hmac.update.mockReturnValue(hmac); + hmac.digest.mockReturnValue('__signature__'); + + mockedCreateHmac.mockReturnValue(hmac); + + webhookFunctions.getNodeParameter + .calledWith('triggerOn') + .mockReturnValue('Activity/create'); + + expect(trigger.webhook.call(webhookFunctions)).resolves.toEqual({}); + }); +}); diff --git a/nodes/moco/src/nodes/Moco/MocoTrigger.node.ts b/nodes/moco/src/nodes/Moco/MocoTrigger.node.ts new file mode 100644 index 00000000..ea53b32d --- /dev/null +++ b/nodes/moco/src/nodes/Moco/MocoTrigger.node.ts @@ -0,0 +1,273 @@ +import { createHmac } from 'crypto'; +import type { + IDataObject, + IHookFunctions, + INodeType, + INodeTypeDescription, + IWebhookFunctions, + IWebhookResponseData, +} from 'n8n-workflow'; +import { CredentialData } from '../../credentials/MocoApi.credentials'; +import { mocoApiRequest, mocoApiRequestAllItems } from './GenericFunctions'; + +export interface StaticData extends IDataObject { + hookId?: string; +} + +export class MocoTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'MOCO Trigger', + name: 'mocoTrigger', + icon: 'file:moco.svg', + group: ['trigger'], + version: 1, + subtitle: + "={{$parameter[\"triggerOn\"].toLowerCase().split('/').reverse().join(': ')}}", + description: 'Listen to MOCO events', + defaults: { + name: 'MOCO Trigger', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'mocoApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Trigger On', + name: 'triggerOn', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Activity Created', + value: 'Activity/create', + }, + { + name: 'Activity Deleted', + value: 'Activity/delete', + }, + { + name: 'Activity Updated', + value: 'Activity/update', + }, + { + name: 'Company Created', + value: 'Company/create', + }, + { + name: 'Company Deleted', + value: 'Company/delete', + }, + { + name: 'Company Updated', + value: 'Company/update', + }, + { + name: 'Contact Created', + value: 'Contact/create', + }, + { + name: 'Contact Deleted', + value: 'Contact/delete', + }, + { + name: 'Contact Updated', + value: 'Contact/update', + }, + { + name: 'Deal Created', + value: 'Deal/create', + }, + { + name: 'Deal Deleted', + value: 'Deal/delete', + }, + { + name: 'Deal Updated', + value: 'Deal/update', + }, + { + name: 'Expense Created', + value: 'Expense/create', + }, + { + name: 'Expense Deleted', + value: 'Expense/delete', + }, + { + name: 'Expense Updated', + value: 'Expense/update', + }, + { + name: 'Invoice Created', + value: 'Invoice/create', + }, + { + name: 'Invoice Deleted', + value: 'Invoice/delete', + }, + { + name: 'Invoice Updated', + value: 'Invoice/update', + }, + { + name: 'Offer Created', + value: 'Offer/create', + }, + { + name: 'Offer Deleted', + value: 'Offer/delete', + }, + { + name: 'Offer Updated', + value: 'Offer/update', + }, + { + name: 'Project Created', + value: 'Project/create', + }, + { + name: 'Project Deleted', + value: 'Project/delete', + }, + { + name: 'Project Updated', + value: 'Project/update', + }, + ], + default: 'Activity/create', + }, + ], + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhooks = await mocoApiRequestAllItems.call( + this, + undefined, + 'GET', + '/account/web_hooks', + ); + + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData: StaticData = this.getWorkflowStaticData('node'); + + for (const webhook of webhooks) { + if (webhook.hook === webhookUrl) { + webhookData.hookId = webhook.id; + return true; + } + } + + return false; + }, + async create(this: IHookFunctions): Promise { + const triggerOn = this.getNodeParameter('triggerOn') as string; + const [target, event] = triggerOn.split('/', 2); + const hook = this.getNodeWebhookUrl('default'); + + const body = { + target, + event, + hook, + }; + + const responseData = await mocoApiRequest.call( + this, + undefined, + 'POST', + '/account/web_hooks', + body, + ); + + if (responseData.id === undefined) { + return false; + } + + const webhookData: StaticData = this.getWorkflowStaticData('node'); + + webhookData.hookId = responseData.id; + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData: StaticData = this.getWorkflowStaticData('node'); + + if (webhookData.hookId === undefined) { + return true; + } + + try { + await mocoApiRequest.call( + this, + undefined, + 'DELETE', + `/account/web_hooks/${webhookData.hookId}`, + ); + } catch (error) { + return false; + } + + delete webhookData.hookId; + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const headerData = this.getHeaderData(); + + if ( + headerData['x-moco-target'] === undefined || + headerData['x-moco-event'] === undefined || + headerData['x-moco-timestamp'] === undefined || + headerData['x-moco-signature'] === undefined || + headerData['x-moco-user-id'] === undefined || + headerData['x-moco-account-url'] === undefined + ) { + return {}; + } + + const credentials = (await this.getCredentials( + 'mocoApi', + )) as CredentialData; + const req = this.getRequestObject(); + + const computedSignature = createHmac('sha256', credentials.webhookSecret) + .update(req.rawBody) + .digest('hex'); + + if (headerData['x-moco-signature'] !== computedSignature) { + return {}; + } + + const triggerOn = this.getNodeParameter('triggerOn') as string; + const [target, event] = triggerOn.split('/', 2); + + if (target !== headerData['x-moco-target']) { + return {}; + } + + if (event !== headerData['x-moco-event']) { + return {}; + } + + return { + workflowData: [this.helpers.returnJsonArray(req.body)], + }; + } +} diff --git a/nodes/moco/src/nodes/Moco/ProjectDescription.ts b/nodes/moco/src/nodes/Moco/ProjectDescription.ts new file mode 100644 index 00000000..6fe23eb8 --- /dev/null +++ b/nodes/moco/src/nodes/Moco/ProjectDescription.ts @@ -0,0 +1,962 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const projectOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['project'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a project', + action: 'Create a project', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a project', + action: 'Delete a project', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a single project', + action: 'Retrieve a single project', + }, + { + name: 'List', + value: 'list', + description: 'Retrieve all projects', + action: 'Retrieve all projects', + }, + { + name: 'Update', + value: 'update', + description: 'Update a project', + action: 'Update a project', + }, + ], + default: 'create', + }, +]; + +export const projectFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* project:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + required: true, + description: 'Name of project being created', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Currency', + name: 'currency', + type: 'string', + default: '', + required: true, + description: + 'Currency of the project being created (use the 3-letter currency code)', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Start Date', + name: 'startDate', + type: 'string', + default: '', + required: true, + description: 'Start date of the project being created (format: YYYY-MM-DD)', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + retainer: [true], + }, + }, + }, + { + displayName: 'Start Date', + name: 'startDate', + type: 'string', + default: '', + description: 'Start date of the project being created (format: YYYY-MM-DD)', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + retainer: [false], + }, + }, + }, + { + displayName: 'Finish Date', + name: 'finishDate', + type: 'string', + default: '', + required: true, + description: + 'Finish date of the project being created (format: YYYY-MM-DD)', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + retainer: [true], + }, + }, + }, + { + displayName: 'Finish Date', + name: 'finishDate', + type: 'string', + default: '', + description: + 'Finish date of the project being created (format: YYYY-MM-DD)', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + retainer: [false], + }, + }, + }, + { + displayName: 'Fixed Price', + name: 'fixedPrice', + type: 'boolean', + default: false, + required: true, + description: 'Whether the project is fixed price or not', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Retainer', + name: 'retainer', + type: 'boolean', + default: false, + required: true, + description: 'Whether the project is retainer or not', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Leader Name or ID', + name: 'leaderId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listUsers', + }, + required: true, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Customer Name or ID', + name: 'customerId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listCustomers', + }, + required: true, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Budget Monthly', + name: 'budgetMonthly', + type: 'number', + default: 0, + required: true, + description: 'Monthly budget for the project being created', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + retainer: [true], + }, + }, + }, + { + displayName: 'Budget Monthly', + name: 'budgetMonthly', + type: 'number', + default: 0, + description: 'Monthly budget for the project being created', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + retainer: [false], + }, + }, + }, + { + displayName: 'Identifier', + name: 'identifier', + type: 'string', + default: '', + description: + 'Identifier of the project being created (only mandatory if number ranges are manual)', + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + placeholder: 'Add Field', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Co Leader Name or ID', + name: 'coLeaderId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listUsers', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Deal Name or ID', + name: 'dealId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listLeads', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Billing Address', + name: 'billingAddress', + type: 'string', + default: '', + typeOptions: { + rows: 4, + }, + description: 'Billing address for the project being created', + }, + { + displayName: 'Billing Email To', + name: 'billingEmailTo', + type: 'string', + default: '', + typeOptions: { + email: true, + }, + description: 'Billing email address for the project being created', + }, + { + displayName: 'Billing Email CC', + name: 'billingEmailCc', + type: 'string', + default: '', + typeOptions: { + email: true, + }, + description: 'Billing email address copy for the project being created', + }, + { + displayName: 'Billing Notes', + name: 'billingNotes', + type: 'string', + default: '', + description: 'Billing notes for the project being created', + }, + { + displayName: 'Setting Include Time Report', + name: 'settingIncludeTimeReport', + type: 'boolean', + default: false, + description: 'Whether to include time report or not', + }, + { + displayName: 'Billing Variant', + name: 'billingVariant', + type: 'options', + default: 'project', + options: [ + { + name: 'Project', + value: 'project', + }, + { + name: 'Task', + value: 'task', + }, + { + name: 'User', + value: 'user', + }, + ], + description: 'Billing variant for the project being created', + }, + { + displayName: 'Hourly Rate', + name: 'hourlyRate', + type: 'number', + default: 0, + description: 'Hourly rate for the project being created', + }, + { + displayName: 'Budget', + name: 'budget', + type: 'number', + default: 0, + description: 'Budget for the project being created', + }, + { + displayName: 'Budget Expenses', + name: 'budgetExpenses', + type: 'number', + default: 0, + description: 'Expenses budget for the project being created', + }, + { + displayName: 'Tags', + name: 'tags', + placeholder: 'Add Tag', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + description: 'Tag for the project being created', + }, + ], + description: 'Tags for the project being created', + }, + { + displayName: 'Custom Properties', + name: 'customProperties', + placeholder: 'Add Custom Property', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: + 'Name of the custom property for the project being created', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: + 'Value of the custom property for the project being created', + }, + ], + description: 'Custom properties for the project being created', + }, + { + displayName: 'Info', + name: 'info', + type: 'string', + default: '', + description: 'Info for the project being created', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* project:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Project ID', + name: 'projectId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['project'], + operation: ['delete'], + }, + }, + }, + /* -------------------------------------------------------------------------- */ + /* project:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Project ID', + name: 'projectId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['project'], + operation: ['get'], + }, + }, + }, + /* -------------------------------------------------------------------------- */ + /* project:list */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: ['project'], + operation: ['list'], + }, + }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: ['project'], + operation: ['list'], + returnAll: [false], + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'IDs', + name: 'ids', + type: 'string', + default: '', + displayOptions: { + show: { + resource: ['project'], + operation: ['list'], + returnAll: [false], + }, + }, + description: + 'Allows you to filter by IDs and fetch multiple entities comma-separated', + }, + { + displayName: 'Updated After', + name: 'updatedAfter', + type: 'dateTime', + default: '', + displayOptions: { + show: { + resource: ['project'], + operation: ['list'], + returnAll: [false], + }, + }, + description: + 'Enables you to give a timestamp for all entities that are created or updated after this timestamp', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + placeholder: 'Add Field', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['project'], + operation: ['list'], + }, + }, + options: [ + { + displayName: 'Include Archived', + name: 'includeArchived', + type: 'boolean', + default: false, + description: 'Whether to include archived projects or not', + }, + { + displayName: 'Include Company', + name: 'includeCompany', + type: 'boolean', + default: false, + description: + 'Whether to include a complete company instead of just ID and name', + }, + { + displayName: 'Leader Name or ID', + name: 'leaderId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'listUsers', + }, + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Company Name or ID', + name: 'companyId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'listCompanies', + }, + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Created From', + name: 'createdFrom', + type: 'string', + default: '', + description: 'Created from date for the projects being listed', + }, + { + displayName: 'Created To', + name: 'createdTo', + type: 'string', + default: '', + description: 'Created to date for the projects being listed', + }, + { + displayName: 'Updated From', + name: 'updatedFrom', + type: 'string', + default: '', + description: 'Updated from date for the projects being listed', + }, + { + displayName: 'Updated To', + name: 'updatedTo', + type: 'string', + default: '', + description: 'Updated to date for the projects being listed', + }, + { + displayName: 'Tags', + name: 'tags', + type: 'string', + default: '', + description: 'Comma-separated list of tags to filter by', + }, + { + displayName: 'Identifier', + name: 'identifier', + type: 'string', + default: '', + description: 'Identifier of the project being listed', + }, + { + displayName: 'Retainer', + name: 'retainer', + type: 'boolean', + default: false, + description: 'Whether the projects being listed are retainer or not', + }, + { + displayName: 'Project Group ID', + name: 'projectGroupId', + type: 'number', + default: '', + description: 'ID of the project group for the projects being listed', + }, + { + displayName: 'Sort By', + name: 'sortBy', + type: 'string', + default: '', + description: 'The field to sort the results by', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* project:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Project ID', + name: 'projectId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of project being created', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Start Date', + name: 'startDate', + type: 'string', + default: '', + description: 'Start date of the project being created (format: YYYY-MM-DD)', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Finish Date', + name: 'finishDate', + type: 'string', + default: '', + description: + 'Finish date of the project being created (format: YYYY-MM-DD)', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Fixed Price', + name: 'fixedPrice', + type: 'boolean', + default: false, + required: true, + description: 'Whether the project is fixed price or not', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Retainer', + name: 'retainer', + type: 'boolean', + default: false, + description: 'Whether the project is retainer or not', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Leader Name or ID', + name: 'leaderId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listUsers', + }, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Customer Name or ID', + name: 'customerId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listCustomers', + }, + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Budget Monthly', + name: 'budgetMonthly', + type: 'number', + default: 0, + description: 'Monthly budget for the project being created', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Identifier', + name: 'identifier', + type: 'string', + default: '', + description: + 'Identifier of the project being created (only mandatory if number ranges are manual)', + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + placeholder: 'Add Field', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Co Leader Name or ID', + name: 'coLeaderId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listUsers', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Deal Name or ID', + name: 'dealId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'listLeads', + }, + description: + 'Choose from the list, or specify an ID using an expression', + }, + { + displayName: 'Billing Address', + name: 'billingAddress', + type: 'string', + default: '', + typeOptions: { + rows: 4, + }, + description: 'Billing address for the project being created', + }, + { + displayName: 'Billing Email To', + name: 'billingEmailTo', + type: 'string', + default: '', + typeOptions: { + email: true, + }, + description: 'Billing email address for the project being created', + }, + { + displayName: 'Billing Email CC', + name: 'billingEmailCc', + type: 'string', + default: '', + typeOptions: { + email: true, + }, + description: 'Billing email address copy for the project being created', + }, + { + displayName: 'Billing Notes', + name: 'billingNotes', + type: 'string', + default: '', + description: 'Billing notes for the project being created', + }, + { + displayName: 'Setting Include Time Report', + name: 'settingIncludeTimeReport', + type: 'boolean', + default: false, + description: 'Whether to include time report or not', + }, + { + displayName: 'Billing Variant', + name: 'billingVariant', + type: 'options', + default: 'project', + options: [ + { + name: 'Project', + value: 'project', + }, + { + name: 'Task', + value: 'task', + }, + { + name: 'User', + value: 'user', + }, + ], + description: 'Billing variant for the project being created', + }, + { + displayName: 'Hourly Rate', + name: 'hourlyRate', + type: 'number', + default: 0, + description: 'Hourly rate for the project being created', + }, + { + displayName: 'Budget', + name: 'budget', + type: 'number', + default: 0, + description: 'Budget for the project being created', + }, + { + displayName: 'Budget Expenses', + name: 'budgetExpenses', + type: 'number', + default: 0, + description: 'Expenses budget for the project being created', + }, + { + displayName: 'Tags', + name: 'tags', + placeholder: 'Add Tag', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Tag', + name: 'tag', + type: 'string', + default: '', + description: 'Tag for the project being created', + }, + ], + description: 'Tags for the project being created', + }, + { + displayName: 'Custom Properties', + name: 'customProperties', + placeholder: 'Add Custom Property', + type: 'fixedCollection', + default: {}, + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: + 'Name of the custom property for the project being created', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: + 'Value of the custom property for the project being created', + }, + ], + description: 'Custom properties for the project being created', + }, + { + displayName: 'Info', + name: 'info', + type: 'string', + default: '', + description: 'Info for the project being created', + }, + ], + }, +]; diff --git a/nodes/moco/src/nodes/Moco/UserDescription.ts b/nodes/moco/src/nodes/Moco/UserDescription.ts new file mode 100644 index 00000000..ec56d261 --- /dev/null +++ b/nodes/moco/src/nodes/Moco/UserDescription.ts @@ -0,0 +1,410 @@ +import type { INodeProperties } from 'n8n-workflow'; + +export const userOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['user'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a staff member', + action: 'Create a staff member', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a staff member', + action: 'Delete a staff member', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a single staff member', + action: 'Retrieve a single staff member', + }, + { + name: 'List', + value: 'list', + description: 'Retrieve all staff members', + action: 'Retrieve all staff members', + }, + { + name: 'Update', + value: 'update', + description: 'Update a staff member', + action: 'Update a staff member', + }, + ], + default: 'create', + }, +]; + +export const userFields: INodeProperties[] = [ + /* -------------------------------------------------------------------------- */ + /* user:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'First Name', + name: 'firstname', + type: 'string', + required: true, + default: '', + description: 'Firstname of user being created', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Last Name', + name: 'lastname', + type: 'string', + required: true, + default: '', + description: 'Lastname of user being created', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + typeOptions: { + email: true, + }, + placeholder: 'name@email.com', + required: true, + default: '', + description: 'Email of user being created', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + typeOptions: { + password: true, + }, + required: true, + default: '', + description: 'Password of user being created', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Team Name or ID', + name: 'unitId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'listTeams', + }, + required: true, + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: ['user'], + operation: ['create'], + }, + }, + default: {}, + options: [ + { + displayName: 'Activve', + name: 'active', + type: 'boolean', + default: true, + description: 'Whether the user is activated or deactivated', + }, + { + displayName: 'External', + name: 'external', + type: 'boolean', + default: false, + description: 'Whether the user is an external employee or contractor', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* user:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['user'], + operation: ['delete'], + }, + }, + }, + /* -------------------------------------------------------------------------- */ + /* user:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['user'], + operation: ['get'], + }, + }, + }, + /* -------------------------------------------------------------------------- */ + /* user:list */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['user'], + operation: ['list'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['user'], + operation: ['list'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 50, + description: 'Max number of results to return', + }, + { + displayName: 'IDs', + name: 'ids', + type: 'string', + displayOptions: { + show: { + resource: ['user'], + operation: ['list'], + returnAll: [false], + }, + }, + default: '', + description: + 'Allows you to filter by IDs and fetch multiple entities comma-separated', + }, + { + displayName: 'Updated After', + name: 'updatedAfter', + type: 'dateTime', + typeOptions: {}, + displayOptions: { + show: { + resource: ['user'], + operation: ['list'], + returnAll: [false], + }, + }, + default: '', + description: + 'Enables you to give a timestamp for all entities that are created or updated after this timestamp', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: ['user'], + operation: ['list'], + }, + }, + default: {}, + options: [ + { + displayName: 'Include Archived', + name: 'includeArchived', + type: 'boolean', + default: false, + description: 'Whether to include deactivated users in the results', + }, + { + displayName: 'Sort By', + name: 'sortBy', + type: 'string', + default: '', + description: 'The field to sort the results by', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* user:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['user'], + operation: ['update'], + }, + }, + }, + { + displayName: 'First Name', + name: 'firstname', + type: 'string', + default: '', + description: 'Firstname of user being updated', + displayOptions: { + show: { + resource: ['user'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Last Name', + name: 'lastname', + type: 'string', + default: '', + description: 'Lastname of user being updated', + displayOptions: { + show: { + resource: ['user'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + typeOptions: { + email: true, + }, + placeholder: 'name@email.com', + default: '', + description: 'Email of user being updated', + displayOptions: { + show: { + resource: ['user'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + description: 'Password of user being updated', + displayOptions: { + show: { + resource: ['user'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Team Name or ID', + name: 'unitId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'listTeams', + }, + default: '', + description: + 'Choose from the list, or specify an ID using an expression', + displayOptions: { + show: { + resource: ['user'], + operation: ['update'], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + placeholder: 'Add Field', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['user'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Activve', + name: 'active', + type: 'boolean', + default: true, + description: 'Whether the user is activated or deactivated', + }, + { + displayName: 'External', + name: 'external', + type: 'boolean', + default: false, + description: 'Whether the user is an external employee or contractor', + }, + ], + }, +]; diff --git a/nodes/moco/src/nodes/Moco/moco.svg b/nodes/moco/src/nodes/Moco/moco.svg new file mode 100644 index 00000000..6d3ea7cd --- /dev/null +++ b/nodes/moco/src/nodes/Moco/moco.svg @@ -0,0 +1 @@ + diff --git a/nodes/moco/tsconfig.json b/nodes/moco/tsconfig.json new file mode 100644 index 00000000..18713437 --- /dev/null +++ b/nodes/moco/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strictBindCallApply": false, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "noImplicitAny": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "esModuleInterop": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/nodes/moco/tsconfig.lib.json b/nodes/moco/tsconfig.lib.json new file mode 100644 index 00000000..ef03d40e --- /dev/null +++ b/nodes/moco/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/nodes/moco/tsconfig.spec.json b/nodes/moco/tsconfig.spec.json new file mode 100644 index 00000000..d41aea47 --- /dev/null +++ b/nodes/moco/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 498f64ef..36e7743d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,7 +20,8 @@ ], "@skriptfabrik/n8n-nodes-google-enhanced": [ "nodes/google-enhanced/src/index.ts" - ] + ], + "@skriptfabrik/n8n-nodes-moco": ["nodes/moco/src/index.ts"] } }, "exclude": ["node_modules", "tmp"]