diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 023e3e827..bb6e64f56 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -142,6 +142,9 @@ export class Start extends Command { LoggerProxy.init(logger); logger.info('Initializing n8n process'); + // todo remove a few versions after release + logger.info('\nn8n now checks for new versions and security updates. You can turn this off using the environment variable N8N_VERSION_NOTIFICATIONS_ENABLED to "false"\nFor more information, please refer to https://docs.n8n.io/getting-started/installation/advanced/configuration.html\n'); + // Start directly with the init of the database to improve startup time const startDbInitPromise = Db.init().catch((error: Error) => { logger.error(`There was an error initializing DB: "${error.message}"`); diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 0da11e786..476cbbcfa 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -618,6 +618,27 @@ const config = convict({ }, }, + versionNotifications: { + enabled: { + doc: 'Whether feature is enabled to request notifications about new versions and security updates.', + format: Boolean, + default: true, + env: 'N8N_VERSION_NOTIFICATIONS_ENABLED', + }, + endpoint: { + doc: 'Endpoint to retrieve version information from.', + format: String, + default: 'https://api.n8n.io/versions/', + env: 'N8N_VERSION_NOTIFICATIONS_ENDPOINT', + }, + infoUrl: { + doc: `Url in New Versions Panel with more information on updating one's instance.`, + format: String, + default: 'https://docs.n8n.io/getting-started/installation/updating.html', + env: 'N8N_VERSION_NOTIFICATIONS_INFO_URL', + }, + }, + }); // Overwrite default configuration with settings which got defined in diff --git a/packages/cli/package.json b/packages/cli/package.json index 180a7a534..833123d8a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.130.0", + "version": "0.132.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -108,10 +108,10 @@ "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", "mysql2": "~2.2.0", - "n8n-core": "~0.77.0", - "n8n-editor-ui": "~0.99.0", - "n8n-nodes-base": "~0.127.0", - "n8n-workflow": "~0.63.0", + "n8n-core": "~0.78.0", + "n8n-editor-ui": "~0.100.0", + "n8n-nodes-base": "~0.129.0", + "n8n-workflow": "~0.64.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "pg": "^8.3.0", diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 66d4ec02e..be77bd101 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -39,6 +39,8 @@ import { LoggerProxy as Logger, } from 'n8n-workflow'; +const WEBHOOK_PROD_UNREGISTERED_HINT = `The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)`; + export class ActiveWorkflowRunner { private activeWorkflows: ActiveWorkflows | null = null; @@ -148,7 +150,7 @@ export class ActiveWorkflowRunner { const dynamicWebhooks = await Db.collections.Webhook?.find({ webhookId, method: httpMethod, pathLength: pathElements.length }); if (dynamicWebhooks === undefined || dynamicWebhooks.length === 0) { // The requested webhook is not registered - throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404); + throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404, WEBHOOK_PROD_UNREGISTERED_HINT); } let maxMatches = 0; @@ -169,7 +171,7 @@ export class ActiveWorkflowRunner { } }); if (webhook === undefined) { - throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404); + throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404, WEBHOOK_PROD_UNREGISTERED_HINT); } path = webhook!.webhookPath; diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index c91c87269..c812524b1 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -89,6 +89,7 @@ export async function init(): Promise { migrations: mysqlMigrations, migrationsRun: true, migrationsTableName: `${entityPrefix}migrations`, + timezone: 'Z', // set UTC as default }; break; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index e1c09cf55..d2aff0eb8 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -312,6 +312,11 @@ export interface IN8nConfigNodes { exclude: string[]; } +export interface IVersionNotificationSettings { + enabled: boolean; + endpoint: string; + infoUrl: string; +} export interface IN8nUISettings { endpointWebhook: string; @@ -331,6 +336,8 @@ export interface IN8nUISettings { n8nMetadata?: { [key: string]: string | number | undefined; }; + versionNotifications: IVersionNotificationSettings; + instanceId: string; } export interface IPackageVersions { diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 465fdb5dd..24a9d37b5 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -21,17 +21,21 @@ export class ResponseError extends Error { // The HTTP status code of response httpStatusCode?: number; - // The error code in the resonse + // The error code in the response errorCode?: number; + // The error hint the response + hint?: string; + /** * Creates an instance of ResponseError. * @param {string} message The error message * @param {number} [errorCode] The error code which can be used by frontend to identify the actual error * @param {number} [httpStatusCode] The HTTP status code the response should have + * @param {string} [hint] The error hint to provide a context (webhook related) * @memberof ResponseError */ - constructor(message: string, errorCode?: number, httpStatusCode?: number) { + constructor(message: string, errorCode?: number, httpStatusCode?: number, hint?:string) { super(message); this.name = 'ResponseError'; @@ -41,6 +45,9 @@ export class ResponseError extends Error { if (httpStatusCode) { this.httpStatusCode = httpStatusCode; } + if (hint) { + this.hint = hint; + } } } @@ -91,6 +98,7 @@ export function sendErrorResponse(res: Response, error: ResponseError) { const response = { code: 0, message: 'Unknown error', + hint: '', }; if (error.name === 'NodeApiError') { @@ -103,6 +111,9 @@ export function sendErrorResponse(res: Response, error: ResponseError) { if (error.message) { response.message = error.message; } + if (error.hint) { + response.hint = error.hint; + } if (error.stack && process.env.NODE_ENV !== 'production') { // @ts-ignore response.stack = error.stack; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index b36a0fe0f..0abd3f60f 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -21,7 +21,7 @@ import * as clientOAuth1 from 'oauth-1.0a'; import { RequestOptions } from 'oauth-1.0a'; import * as csrf from 'csrf'; import * as requestPromise from 'request-promise-native'; -import { createHmac } from 'crypto'; +import { createHash, createHmac } from 'crypto'; // IMPORTANT! Do not switch to anther bcrypt library unless really necessary and // tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ... import { compare } from 'bcryptjs'; @@ -196,6 +196,12 @@ class App { 'oauth1': urlBaseWebhook + `${this.restEndpoint}/oauth1-credential/callback`, 'oauth2': urlBaseWebhook + `${this.restEndpoint}/oauth2-credential/callback`, }, + versionNotifications: { + enabled: config.get('versionNotifications.enabled'), + endpoint: config.get('versionNotifications.endpoint'), + infoUrl: config.get('versionNotifications.infoUrl'), + }, + instanceId: '', }; } @@ -225,6 +231,7 @@ class App { this.versions = await GenericHelpers.getVersions(); this.frontendSettings.versionCli = this.versions.cli; + this.frontendSettings.instanceId = await generateInstanceId() as string; await this.externalHooks.run('frontend.settings', [this.frontendSettings]); @@ -2210,3 +2217,10 @@ async function getExecutionsCount(countFilter: IDataObject): Promise<{ count: nu const count = await Db.collections.Execution!.count(countFilter); return { count, estimate: false }; } + +async function generateInstanceId() { + const encryptionKey = await UserSettings.getEncryptionKey(); + const hash = encryptionKey ? createHash('sha256').update(encryptionKey.slice(Math.round(encryptionKey.length / 2))).digest('hex') : undefined; + + return hash; +} diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts index b9dcf09fc..a8aa17720 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/TestWebhooks.ts @@ -21,7 +21,7 @@ import { WorkflowExecuteMode, } from 'n8n-workflow'; - +const WEBHOOK_TEST_UNREGISTERED_HINT = `Click the 'Execute workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)`; export class TestWebhooks { @@ -72,7 +72,7 @@ export class TestWebhooks { webhookData = this.activeWebhooks!.get(httpMethod, pathElements.join('/'), webhookId); if (webhookData === undefined) { // The requested webhook is not registered - throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404); + throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404, WEBHOOK_TEST_UNREGISTERED_HINT); } path = webhookData.path; @@ -90,7 +90,7 @@ export class TestWebhooks { // TODO: Clean that duplication up one day and improve code generally if (this.testWebhookData[webhookKey] === undefined) { // The requested webhook is not registered - throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404); + throw new ResponseHelper.ResponseError(`The requested webhook "${httpMethod} ${path}" is not registered.`, 404, 404, WEBHOOK_TEST_UNREGISTERED_HINT); } const workflow = this.testWebhookData[webhookKey].workflow; @@ -145,7 +145,7 @@ export class TestWebhooks { if (webhookMethods === undefined) { // The requested webhook is not registered - throw new ResponseHelper.ResponseError(`The requested webhook "${path}" is not registered.`, 404, 404); + throw new ResponseHelper.ResponseError(`The requested webhook "${path}" is not registered.`, 404, 404, WEBHOOK_TEST_UNREGISTERED_HINT); } return webhookMethods; diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 679ba945e..5c5ddfe8a 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -46,6 +46,7 @@ import { import * as config from '../config'; import { LessThanOrEqual } from 'typeorm'; +import { DateUtils } from 'typeorm/util/DateUtils'; const ERROR_TRIGGER_TYPE = config.get('nodes.errorTriggerType') as string; @@ -102,7 +103,7 @@ function executeErrorWorkflow(workflowData: IWorkflowBase, fullRunData: IRun, mo * */ let throttling = false; -function pruneExecutionData(): void { +function pruneExecutionData(this: WorkflowHooks): void { if (!throttling) { Logger.verbose('Pruning execution data from database'); @@ -112,13 +113,20 @@ function pruneExecutionData(): void { const date = new Date(); // today date.setHours(date.getHours() - maxAge); + // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 + const utcDate = DateUtils.mixedDateToUtcDatetimeString(date); + // throttle just on success to allow for self healing on failure - Db.collections.Execution!.delete({ stoppedAt: LessThanOrEqual(date.toISOString()) }) + Db.collections.Execution!.delete({ stoppedAt: LessThanOrEqual(utcDate) }) .then(data => setTimeout(() => { throttling = false; }, timeout * 1000) - ).catch(err => throttling = false); + ).catch(error => { + throttling = false; + + Logger.error(`Failed pruning execution data from database for execution ID ${this.executionId} (hookFunctionsSave)`, { ...error, executionId: this.executionId, sessionId: this.sessionId, workflowId: this.workflowData.id }); + }); } } @@ -322,7 +330,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { // Prune old execution data if (config.get('executions.pruneData')) { - pruneExecutionData(); + pruneExecutionData.call(this); } const isManualMode = [this.mode, parentProcessMode].includes('manual'); diff --git a/packages/cli/src/databases/utils.ts b/packages/cli/src/databases/utils.ts index e0a833522..1816deea0 100644 --- a/packages/cli/src/databases/utils.ts +++ b/packages/cli/src/databases/utils.ts @@ -18,7 +18,7 @@ export function resolveDataType(dataType: string) { json: 'simple-json', }, postgresdb: { - datetime: 'timestamp', + datetime: 'timestamptz', }, mysqldb: {}, mariadb: {}, diff --git a/packages/core/package.json b/packages/core/package.json index c011222a8..8cbacdc85 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.77.0", + "version": "0.78.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -47,7 +47,7 @@ "file-type": "^14.6.2", "lodash.get": "^4.4.2", "mime-types": "^2.1.27", - "n8n-workflow": "~0.63.0", + "n8n-workflow": "~0.64.0", "oauth-1.0a": "^2.2.6", "p-cancelable": "^2.0.0", "request": "^2.88.2", diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 4486c5c02..5bf589dbe 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.99.0", + "version": "0.100.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -25,7 +25,9 @@ "test:unit": "vue-cli-service test:unit" }, "dependencies": { - "v-click-outside": "^3.1.2" + "timeago.js": "^4.0.2", + "v-click-outside": "^3.1.2", + "vue-fragment": "^1.5.2" }, "devDependencies": { "@beyonk/google-fonts-webpack-plugin": "^1.5.0", @@ -68,7 +70,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.63.0", + "n8n-workflow": "~0.64.0", "node-sass": "^4.12.0", "normalize-wheel": "^1.0.1", "prismjs": "^1.17.1", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index cc3f89b8c..6151d7e37 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -445,6 +445,12 @@ export interface IPushDataConsoleMessage { message: string; } +export interface IVersionNotificationSettings { + enabled: boolean; + endpoint: string; + infoUrl: string; +} + export interface IN8nUISettings { endpointWebhook: string; endpointWebhookTest: string; @@ -463,6 +469,8 @@ export interface IN8nUISettings { n8nMetadata?: { [key: string]: string | number | undefined; }; + versionNotifications: IVersionNotificationSettings; + instanceId: string; } export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { @@ -547,6 +555,29 @@ export interface ITagRow { delete?: boolean; } +export interface IVersion { + name: string; + nodes: IVersionNode[]; + createdAt: string; + description: string; + documentationUrl: string; + hasBreakingChange: boolean; + hasSecurityFix: boolean; + hasSecurityIssue: boolean; + securityIssueFixVersion: string; +} + +export interface IVersionNode { + name: string; + displayName: string; + icon: string; + defaults: INodeParameters; + iconData: { + type: string; + icon?: string; + fileBuffer?: string; + }; +} export interface IRootState { activeExecutions: IExecutionsCurrentSummaryExtended[]; activeWorkflows: string[]; @@ -583,6 +614,7 @@ export interface IRootState { urlBaseWebhook: string; workflow: IWorkflowDb; sidebarMenuItems: IMenuItem[]; + instanceId: string; } export interface ITagsState { @@ -605,6 +637,12 @@ export interface IUiState { isPageLoading: boolean; } +export interface IVersionsState { + versionNotificationSettings: IVersionNotificationSettings; + nextVersions: IVersion[]; + currentVersion: IVersion | undefined; +} + export interface IWorkflowsState { } diff --git a/packages/editor-ui/src/api/helpers.ts b/packages/editor-ui/src/api/helpers.ts index 739242c12..1ae753db1 100644 --- a/packages/editor-ui/src/api/helpers.ts +++ b/packages/editor-ui/src/api/helpers.ts @@ -6,7 +6,6 @@ import { IRestApiContext, } from '../Interface'; - class ResponseError extends Error { // The HTTP status code of response httpStatusCode?: number; @@ -91,6 +90,6 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho return response.data; } -export async function get(baseURL: string, endpoint: string, params?: IDataObject) { - return await request({method: 'GET', baseURL, endpoint, data: params}); +export async function get(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) { + return await request({method: 'GET', baseURL, endpoint, headers, data: params}); } diff --git a/packages/editor-ui/src/api/versions.ts b/packages/editor-ui/src/api/versions.ts new file mode 100644 index 000000000..009f6bc4b --- /dev/null +++ b/packages/editor-ui/src/api/versions.ts @@ -0,0 +1,9 @@ +import { IVersion } from '@/Interface'; +import { INSTANCE_ID_HEADER } from '@/constants'; +import { IDataObject } from 'n8n-workflow'; +import { get } from './helpers'; + +export async function getNextVersions(endpoint: string, version: string, instanceId: string): Promise { + const headers = {[INSTANCE_ID_HEADER as string] : instanceId}; + return await get(endpoint, version, {}, headers); +} diff --git a/packages/editor-ui/src/assets/logo.png b/packages/editor-ui/src/assets/logo.png deleted file mode 100644 index f3d2503fc..000000000 Binary files a/packages/editor-ui/src/assets/logo.png and /dev/null differ diff --git a/packages/editor-ui/src/components/About.vue b/packages/editor-ui/src/components/About.vue index 62563a1c2..8b2e901f8 100644 --- a/packages/editor-ui/src/components/About.vue +++ b/packages/editor-ui/src/components/About.vue @@ -38,8 +38,6 @@ + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/DataDisplay.vue b/packages/editor-ui/src/components/DataDisplay.vue index c32dbcecf..c10a0c684 100644 --- a/packages/editor-ui/src/components/DataDisplay.vue +++ b/packages/editor-ui/src/components/DataDisplay.vue @@ -37,11 +37,7 @@ diff --git a/packages/editor-ui/src/components/VariableSelector.vue b/packages/editor-ui/src/components/VariableSelector.vue index 2ddb7458b..3b34f2c4a 100644 --- a/packages/editor-ui/src/components/VariableSelector.vue +++ b/packages/editor-ui/src/components/VariableSelector.vue @@ -12,13 +12,10 @@ + + diff --git a/packages/editor-ui/src/components/WarningTooltip.vue b/packages/editor-ui/src/components/WarningTooltip.vue new file mode 100644 index 000000000..da6a7ad01 --- /dev/null +++ b/packages/editor-ui/src/components/WarningTooltip.vue @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/WorkflowActivator.vue b/packages/editor-ui/src/components/WorkflowActivator.vue index a3df33c04..b1517d59d 100644 --- a/packages/editor-ui/src/components/WorkflowActivator.vue +++ b/packages/editor-ui/src/components/WorkflowActivator.vue @@ -32,6 +32,7 @@ import { } from '../Interface'; import mixins from 'vue-typed-mixins'; +import { mapGetters } from "vuex"; export default mixins( externalHooks, @@ -54,6 +55,9 @@ export default mixins( }; }, computed: { + ...mapGetters({ + dirtyState: "getStateIsDirty", + }), nodesIssuesExist (): boolean { return this.$store.getters.nodesIssuesExist; }, @@ -100,9 +104,11 @@ export default mixins( // workflow. If that would not happen then it could be quite confusing // for people because it would activate a different version of the workflow // than the one they can currently see. - const importConfirm = await this.confirmMessage(`When you activate the workflow all currently unsaved changes of the workflow will be saved.`, 'Activate and save?', 'warning', 'Yes, activate and save!'); - if (importConfirm === false) { - return; + if (this.dirtyState) { + const importConfirm = await this.confirmMessage(`When you activate the workflow all currently unsaved changes of the workflow will be saved.`, 'Activate and save?', 'warning', 'Yes, activate and save!'); + if (importConfirm === false) { + return; + } } // Get the current workflow data that it gets saved together with the activation diff --git a/packages/editor-ui/src/components/WorkflowSettings.vue b/packages/editor-ui/src/components/WorkflowSettings.vue index 53ffc57fd..e92322d2a 100644 --- a/packages/editor-ui/src/components/WorkflowSettings.vue +++ b/packages/editor-ui/src/components/WorkflowSettings.vue @@ -355,7 +355,6 @@ export default mixins( Vue.set(this, 'workflows', workflows); }, async openDialog () { - const workflowId = this.$route.params.name; if (this.$route.params.name === undefined) { this.$showMessage({ title: 'No workflow active', diff --git a/packages/editor-ui/src/components/mixins/newVersions.ts b/packages/editor-ui/src/components/mixins/newVersions.ts new file mode 100644 index 000000000..0a7f26839 --- /dev/null +++ b/packages/editor-ui/src/components/mixins/newVersions.ts @@ -0,0 +1,40 @@ +import mixins from 'vue-typed-mixins'; +import { showMessage } from './showMessage'; +import { + IVersion, +} from '../../Interface'; + +export const newVersions = mixins( + showMessage, +).extend({ + methods: { + async checkForNewVersions() { + const enabled = this.$store.getters['versions/areNotificationsEnabled']; + if (!enabled) { + return; + } + + await this.$store.dispatch('versions/fetchVersions'); + + const currentVersion: IVersion | undefined = this.$store.getters['versions/currentVersion']; + const nextVersions: IVersion[] = this.$store.getters['versions/nextVersions']; + if (currentVersion && currentVersion.hasSecurityIssue && nextVersions.length) { + const fixVersion = currentVersion.securityIssueFixVersion; + let message = `Please update to latest version.`; + if (fixVersion) { + message = `Please update to version ${fixVersion} or higher.`; + } + + message = `${message} More info`; + this.$showWarning('Critical update available', message, { + onClick: () => { + this.$store.dispatch('ui/openUpdatesPanel'); + }, + closeOnClick: true, + customClass: 'clickable', + duration: 0, + }); + } + }, + }, +}); \ No newline at end of file diff --git a/packages/editor-ui/src/components/mixins/showMessage.ts b/packages/editor-ui/src/components/mixins/showMessage.ts index 67bb9301a..14a8021db 100644 --- a/packages/editor-ui/src/components/mixins/showMessage.ts +++ b/packages/editor-ui/src/components/mixins/showMessage.ts @@ -1,5 +1,5 @@ import { Notification } from 'element-ui'; -import { ElNotificationOptions } from 'element-ui/types/notification'; +import { ElNotificationComponent, ElNotificationOptions } from 'element-ui/types/notification'; import mixins from 'vue-typed-mixins'; import { externalHooks } from '@/components/mixins/externalHooks'; @@ -16,6 +16,30 @@ export const showMessage = mixins(externalHooks).extend({ return Notification(messageData); }, + $showWarning(title: string, message: string, config?: {onClick?: () => void, duration?: number, customClass?: string, closeOnClick?: boolean}) { + let notification: ElNotificationComponent; + if (config && config.closeOnClick) { + const cb = config.onClick; + config.onClick = () => { + if (notification) { + notification.close(); + } + if (cb) { + cb(); + } + }; + } + + notification = this.$showMessage({ + title, + message, + type: 'warning', + ...(config || {}), + }); + + return notification; + }, + $getExecutionError(error?: ExecutionError) { // There was a problem with executing the workflow let errorMessage = 'There was a problem executing the workflow!'; diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 2a3ce3cd4..c38929b94 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -14,6 +14,7 @@ export const MAX_TAG_NAME_LENGTH = 24; export const DUPLICATE_MODAL_KEY = 'duplicate'; export const TAGS_MANAGER_MODAL_KEY = 'tagsManager'; export const WORKLOW_OPEN_MODAL_KEY = 'workflowOpen'; +export const VERSIONS_MODAL_KEY = 'versions'; // breakpoints export const BREAKPOINT_SM = 768; @@ -48,3 +49,6 @@ export const HIDDEN_NODES = ['n8n-nodes-base.start']; export const WEBHOOK_NODE_NAME = 'n8n-nodes-base.webhook'; export const HTTP_REQUEST_NODE_NAME = 'n8n-nodes-base.httpRequest'; export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3'; + +// General +export const INSTANCE_ID_HEADER = 'n8n-instance-id'; \ No newline at end of file diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index 91ad8ab94..1ec532a92 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -21,6 +21,7 @@ import { runExternalHook } from './components/mixins/externalHooks'; // @ts-ignore import vClickOutside from 'v-click-outside'; +import Fragment from 'vue-fragment'; import { library } from '@fortawesome/fontawesome-svg-core'; import { @@ -62,6 +63,7 @@ import { faFileImport, faFilePdf, faFolderOpen, + faGift, faHdd, faHome, faHourglass, @@ -151,6 +153,7 @@ library.add(faFileExport); library.add(faFileImport); library.add(faFilePdf); library.add(faFolderOpen); +library.add(faGift); library.add(faHdd); library.add(faHome); library.add(faHourglass); @@ -194,6 +197,7 @@ library.add(faUsers); library.add(faClock); Vue.component('font-awesome-icon', FontAwesomeIcon); +Vue.use(Fragment.Plugin); Vue.config.productionTip = false; router.afterEach((to, from) => { diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts index a5681f4a8..651131311 100644 --- a/packages/editor-ui/src/modules/ui.ts +++ b/packages/editor-ui/src/modules/ui.ts @@ -1,4 +1,4 @@ -import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants'; +import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants'; import Vue from 'vue'; import { ActionContext, Module } from 'vuex'; import { @@ -19,12 +19,18 @@ const module: Module = { [WORKLOW_OPEN_MODAL_KEY]: { open: false, }, + [VERSIONS_MODAL_KEY]: { + open: false, + }, }, modalStack: [], sidebarMenuCollapsed: true, isPageLoading: true, }, getters: { + isVersionsOpen: (state: IUiState) => { + return state.modals[VERSIONS_MODAL_KEY].open; + }, isModalOpen: (state: IUiState) => { return (name: string) => state.modals[name].open; }, @@ -58,6 +64,9 @@ const module: Module = { openDuplicateModal: async (context: ActionContext) => { context.commit('openModal', DUPLICATE_MODAL_KEY); }, + openUpdatesPanel: async (context: ActionContext) => { + context.commit('openModal', VERSIONS_MODAL_KEY); + }, }, }; diff --git a/packages/editor-ui/src/modules/versions.ts b/packages/editor-ui/src/modules/versions.ts new file mode 100644 index 000000000..56d28c641 --- /dev/null +++ b/packages/editor-ui/src/modules/versions.ts @@ -0,0 +1,62 @@ +import { getNextVersions } from '@/api/versions'; +import { ActionContext, Module } from 'vuex'; +import { + IRootState, + IVersion, + IVersionsState, +} from '../Interface'; + +const module: Module = { + namespaced: true, + state: { + versionNotificationSettings: { + enabled: false, + endpoint: '', + infoUrl: '', + }, + nextVersions: [], + currentVersion: undefined, + }, + getters: { + hasVersionUpdates(state: IVersionsState) { + return state.nextVersions.length > 0; + }, + nextVersions(state: IVersionsState) { + return state.nextVersions; + }, + currentVersion(state: IVersionsState) { + return state.currentVersion; + }, + areNotificationsEnabled(state: IVersionsState) { + return state.versionNotificationSettings.enabled; + }, + infoUrl(state: IVersionsState) { + return state.versionNotificationSettings.infoUrl; + }, + }, + mutations: { + setVersions(state: IVersionsState, {versions, currentVersion}: {versions: IVersion[], currentVersion: string}) { + state.nextVersions = versions.filter((version) => version.name !== currentVersion); + state.currentVersion = versions.find((version) => version.name === currentVersion); + }, + setVersionNotificationSettings(state: IVersionsState, settings: {enabled: true, endpoint: string, infoUrl: string}) { + state.versionNotificationSettings = settings; + }, + }, + actions: { + async fetchVersions(context: ActionContext) { + try { + const { enabled, endpoint } = context.state.versionNotificationSettings; + if (enabled && endpoint) { + const currentVersion = context.rootState.versionCli; + const instanceId = context.rootState.instanceId; + const versions = await getNextVersions(endpoint, currentVersion, instanceId); + context.commit('setVersions', {versions, currentVersion}); + } + } catch (e) { + } + }, + }, +}; + +export default module; \ No newline at end of file diff --git a/packages/editor-ui/src/n8n-theme-variables.scss b/packages/editor-ui/src/n8n-theme-variables.scss index 13df4c832..9e0c61695 100644 --- a/packages/editor-ui/src/n8n-theme-variables.scss +++ b/packages/editor-ui/src/n8n-theme-variables.scss @@ -28,6 +28,16 @@ $--custom-success-text : #40c351; $--custom-warning-background : #ffffe5; $--custom-warning-text : #eb9422; +// Badge +$--badge-danger-color: #f45959; +$--badge-danger-background-color: #fef0f0; +$--badge-danger-border-color: #fde2e2; +$--badge-warning-background-color: rgba(255, 229, 100, 0.3); +$--badge-warning-color: #6b5900; + +// Warning tooltip +$--warning-tooltip-color: #ff8080; + $--custom-node-view-background : #faf9fe; // Table @@ -44,8 +54,18 @@ $--custom-input-border-shadow: 1px solid $--custom-input-border-color; $--header-height: 65px; +// sidebar $--sidebar-width: 65px; $--sidebar-expanded-width: 200px; +$--sidebar-inactive-color: #909399; +$--sidebar-active-color: $--color-primary; + +// gifts notification +$--gift-notification-active-color: $--color-primary; +$--gift-notification-inner-color: $--color-primary; +$--gift-notification-outer-color: #fff; + +// tags manager $--tags-manager-min-height: 300px; // based on element.io breakpoints @@ -83,3 +103,23 @@ $--node-creator-description-color: #7d7d87; // trigger icon $--trigger-icon-border-color: #dcdfe6; $--trigger-icon-background-color: #fff; + +// drawer +$--drawer-background-color: #fff; + +// updates-panel +$--updates-panel-info-icon-color: #909399; +$--updates-panel-info-url-color: $--color-primary; +$--updates-panel-border: 1px #dbdfe7 solid; +$--updates-panel-dark-background-color: #f8f9fb; +$--updates-panel-description-text-color: #7d7d87; +$--updates-panel-text-color: #555; + +// versions card +$--version-card-name-text-color: #666; +$--version-card-background-color: #fff; +$--version-card-border: 1px #dbdfe7 solid; +$--version-card-description-text-color: #7d7d87; +$--version-card-release-date-text-color: #909399; +$--version-card-box-shadow-color: rgba(109, 48, 40, 0.07); + diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index bf2ce0373..28d201533 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -36,6 +36,7 @@ import { import tags from './modules/tags'; import ui from './modules/ui'; import workflows from './modules/workflows'; +import versions from './modules/versions'; Vue.use(Vuex); @@ -86,12 +87,14 @@ const state: IRootState = { tags: [], }, sidebarMenuItems: [], + instanceId: '', }; const modules = { tags, ui, workflows, + versions, }; export const store = new Vuex.Store({ @@ -543,9 +546,12 @@ export const store = new Vuex.Store({ setMaxExecutionTimeout (state, maxExecutionTimeout: number) { Vue.set(state, 'maxExecutionTimeout', maxExecutionTimeout); }, - setVersionCli (state, version: string) { + setVersionCli(state, version: string) { Vue.set(state, 'versionCli', version); }, + setInstanceId(state, instanceId: string) { + Vue.set(state, 'instanceId', instanceId); + }, setOauthCallbackUrls(state, urls: IDataObject) { Vue.set(state, 'oauthCallbackUrls', urls); }, diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 5cd1bfea0..10a799742 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -125,6 +125,7 @@ import { moveNodeWorkflow } from '@/components/mixins/moveNodeWorkflow'; import { restApi } from '@/components/mixins/restApi'; import { showMessage } from '@/components/mixins/showMessage'; import { titleChange } from '@/components/mixins/titleChange'; +import { newVersions } from '@/components/mixins/newVersions'; import { workflowHelpers } from '@/components/mixins/workflowHelpers'; import { workflowRun } from '@/components/mixins/workflowRun'; @@ -148,7 +149,6 @@ import { INodeConnections, INodeIssues, INodeTypeDescription, - NodeInputConnections, NodeHelpers, Workflow, IRun, @@ -156,7 +156,6 @@ import { import { IConnectionsUi, IExecutionResponse, - IExecutionsStopData, IN8nUISettings, IWorkflowDb, IWorkflowData, @@ -198,6 +197,7 @@ export default mixins( titleChange, workflowHelpers, workflowRun, + newVersions, ) .extend({ name: 'NodeView', @@ -908,7 +908,7 @@ export default mixins( try { this.stopExecutionInProgress = true; - const stopData: IExecutionsStopData = await this.restApi().stopCurrentExecution(executionId); + await this.restApi().stopCurrentExecution(executionId); this.$showMessage({ title: 'Execution stopped', message: `The execution with the id "${executionId}" got stopped!`, @@ -948,9 +948,8 @@ export default mixins( }, async stopWaitingForWebhook () { - let result; try { - result = await this.restApi().removeTestWebhook(this.$store.getters.workflowId); + await this.restApi().removeTestWebhook(this.$store.getters.workflowId); } catch (error) { this.$showError(error, 'Problem deleting the test-webhook', 'There was a problem deleting webhook:'); return; @@ -2035,7 +2034,6 @@ export default mixins( const nodeSourceConnections = []; if (currentConnections[sourceNode][type][sourceIndex]) { for (connectionIndex = 0; connectionIndex < currentConnections[sourceNode][type][sourceIndex].length; connectionIndex++) { - const nodeConnection: NodeInputConnections = []; connectionData = currentConnections[sourceNode][type][sourceIndex][connectionIndex]; if (!createNodeNames.includes(connectionData.node)) { // Node does not get created so skip input connection @@ -2200,8 +2198,10 @@ export default mixins( this.$store.commit('setExecutionTimeout', settings.executionTimeout); this.$store.commit('setMaxExecutionTimeout', settings.maxExecutionTimeout); this.$store.commit('setVersionCli', settings.versionCli); + this.$store.commit('setInstanceId', settings.instanceId); this.$store.commit('setOauthCallbackUrls', settings.oauthCallbackUrls); this.$store.commit('setN8nMetadata', settings.n8nMetadata || {}); + this.$store.commit('versions/setVersionNotificationSettings', settings.versionNotifications); }, async loadNodeTypes (): Promise { const nodeTypes = await this.restApi().getNodeTypes(); @@ -2228,9 +2228,10 @@ export default mixins( }, }, + async mounted () { this.$root.$on('importWorkflowData', async (data: IDataObject) => { - const resData = await this.importWorkflowData(data.data as IWorkflowDataUpdate); + await this.importWorkflowData(data.data as IWorkflowDataUpdate); }); this.$root.$on('newWorkflow', this.newWorkflow); @@ -2238,7 +2239,7 @@ export default mixins( this.$root.$on('importWorkflowUrl', async (data: IDataObject) => { const workflowData = await this.getWorkflowDataFromUrl(data.url as string); if (workflowData !== undefined) { - const resData = await this.importWorkflowData(workflowData); + await this.importWorkflowData(workflowData); } }); @@ -2267,6 +2268,10 @@ export default mixins( this.$showError(error, 'Init Problem', 'There was a problem initializing the workflow:'); } this.stopLoading(); + + setTimeout(() => { + this.checkForNewVersions(); + }, 0); }); this.$externalHooks().run('nodeView.mount'); diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 9fa927fbe..06c80cb0c 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.17.0", + "version": "0.18.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -59,8 +59,8 @@ "change-case": "^4.1.1", "copyfiles": "^2.1.1", "inquirer": "^7.0.1", - "n8n-core": "~0.77.0", - "n8n-workflow": "~0.63.0", + "n8n-core": "~0.78.0", + "n8n-workflow": "~0.64.0", "oauth-1.0a": "^2.2.6", "replace-in-file": "^6.0.0", "request": "^2.88.2", diff --git a/packages/nodes-base/credentials/CiscoWebexOAuth2Api.credentials.ts b/packages/nodes-base/credentials/CiscoWebexOAuth2Api.credentials.ts new file mode 100644 index 000000000..8d9726229 --- /dev/null +++ b/packages/nodes-base/credentials/CiscoWebexOAuth2Api.credentials.ts @@ -0,0 +1,46 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class CiscoWebexOAuth2Api implements ICredentialType { + name = 'ciscoWebexOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Cisco Webex OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://webexapis.com/v1/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://webexapis.com/v1/access_token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: 'spark:memberships_read meeting:recordings_read spark:kms meeting:schedules_read spark:rooms_read spark:messages_write spark:memberships_write meeting:recordings_write meeting:preferences_read spark:messages_read meeting:schedules_write', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/credentials/FreshworksCrmApi.credentials.ts b/packages/nodes-base/credentials/FreshworksCrmApi.credentials.ts new file mode 100644 index 000000000..115fb998f --- /dev/null +++ b/packages/nodes-base/credentials/FreshworksCrmApi.credentials.ts @@ -0,0 +1,27 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class FreshworksCrmApi implements ICredentialType { + name = 'freshworksCrmApi'; + displayName = 'Freshworks CRM API'; + documentationUrl = 'freshdesk'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + default: '', + placeholder: 'BDsTn15vHezBlt_XGp3Tig', + }, + { + displayName: 'Domain', + name: 'domain', + type: 'string', + default: '', + placeholder: 'n8n-org', + description: 'Domain in the Freshworks CRM org URL. For example, in https://n8n-org.myfreshworks.com, the domain is n8n-org.', + }, + ]; +} diff --git a/packages/nodes-base/credentials/GooglePerspectiveOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GooglePerspectiveOAuth2Api.credentials.ts new file mode 100644 index 000000000..b3cd37876 --- /dev/null +++ b/packages/nodes-base/credentials/GooglePerspectiveOAuth2Api.credentials.ts @@ -0,0 +1,25 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'https://www.googleapis.com/auth/userinfo.email', +]; + +export class GooglePerspectiveOAuth2Api implements ICredentialType { + name = 'googlePerspectiveOAuth2Api'; + extends = [ + 'googleOAuth2Api', + ]; + displayName = 'Google Perspective OAuth2 API'; + documentationUrl = 'google'; + properties = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + ]; +} diff --git a/packages/nodes-base/credentials/MarketstackApi.credentials.ts b/packages/nodes-base/credentials/MarketstackApi.credentials.ts new file mode 100644 index 000000000..5c5a33c83 --- /dev/null +++ b/packages/nodes-base/credentials/MarketstackApi.credentials.ts @@ -0,0 +1,25 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class MarketstackApi implements ICredentialType { + name = 'marketstackApi'; + displayName = 'Marketstack API'; + documentationUrl = 'marketstack'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Use HTTPS', + name: 'useHttps', + type: 'boolean' as NodePropertyTypes, + default: false, + description: 'Use HTTPS (paid plans only).', + }, + ]; +} diff --git a/packages/nodes-base/credentials/NocoDb.credentials.ts b/packages/nodes-base/credentials/NocoDb.credentials.ts new file mode 100644 index 000000000..f001db77b --- /dev/null +++ b/packages/nodes-base/credentials/NocoDb.credentials.ts @@ -0,0 +1,26 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + + +export class NocoDb implements ICredentialType { + name = 'nocoDb'; + displayName = 'NocoDB'; + documentationUrl = 'nocoDb'; + properties: INodeProperties[] = [ + { + displayName: 'API Token', + name: 'apiToken', + type: 'string', + default: '', + }, + { + displayName: 'Host', + name: 'host', + type: 'string', + default: '', + placeholder: 'http(s)://localhost:8080', + }, + ]; +} diff --git a/packages/nodes-base/nodes/ActionNetwork/ActionNetwork.node.json b/packages/nodes-base/nodes/ActionNetwork/ActionNetwork.node.json new file mode 100644 index 000000000..f48280b50 --- /dev/null +++ b/packages/nodes-base/nodes/ActionNetwork/ActionNetwork.node.json @@ -0,0 +1,21 @@ +{ + "node": "n8n-nodes-base.actionNetwork", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Sales", + "Marketing & Content" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/actionNetwork" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.actionNetwork/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Aws/DynamoDB/AwsDynamoDB.node.json b/packages/nodes-base/nodes/Aws/DynamoDB/AwsDynamoDB.node.json new file mode 100644 index 000000000..8c86a9e88 --- /dev/null +++ b/packages/nodes-base/nodes/Aws/DynamoDB/AwsDynamoDB.node.json @@ -0,0 +1,21 @@ +{ + "node": "n8n-nodes-base.awsDynamoDb", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage", + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/aws" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.awsDynamoDb/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Cisco/Webex/CiscoWebex.node.json b/packages/nodes-base/nodes/Cisco/Webex/CiscoWebex.node.json new file mode 100644 index 000000000..f357d8476 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/CiscoWebex.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.ciscoWebex", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Communication" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/ciscoWebex" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.ciscoWebex/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Cisco/Webex/CiscoWebex.node.ts b/packages/nodes-base/nodes/Cisco/Webex/CiscoWebex.node.ts new file mode 100644 index 000000000..c75a26135 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/CiscoWebex.node.ts @@ -0,0 +1,499 @@ +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryData, + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + NodeOperationError, +} from 'n8n-workflow'; + +import { + getAttachemnts, + webexApiRequest, + webexApiRequestAllItems, +} from './GenericFunctions'; + +import { + meetingFields, + meetingOperations, + // meetingTranscriptFields, + // meetingTranscriptOperations, + messageFields, + messageOperations, +} from './descriptions'; + +import * as moment from 'moment-timezone'; + +export class CiscoWebex implements INodeType { + description: INodeTypeDescription = { + displayName: 'Cisco Webex', + name: 'ciscoWebex', + icon: 'file:ciscoWebex.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Cisco Webex API', + defaults: { + name: 'Cisco Webex', + color: '#29b6f6', + }, + credentials: [ + { + name: 'ciscoWebexOAuth2Api', + required: true, + }, + ], + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Meeting', + value: 'meeting', + }, + // { + // name: 'Meeeting Transcript', + // value: 'meetingTranscript', + // }, + { + name: 'Message', + value: 'message', + }, + ], + default: 'message', + description: 'Resource to consume', + }, + ...meetingOperations, + ...meetingFields, + // ...meetingTranscriptOperations, + // ...meetingTranscriptFields, + ...messageOperations, + ...messageFields, + ], + }; + + methods = { + loadOptions: { + async getRooms(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const rooms = await webexApiRequestAllItems.call(this, 'items', 'GET', '/rooms'); + for (const room of rooms) { + returnData.push({ + name: room.title, + value: room.id, + }); + } + return returnData; + }, + async getSites(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const sites = await webexApiRequestAllItems.call(this, 'sites', 'GET', '/meetingPreferences/sites'); + for (const site of sites) { + returnData.push({ + name: site.siteUrl, + value: site.siteUrl, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const timezone = this.getTimezone(); + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + let responseData; + + + for (let i = 0; i < items.length; i++) { + + try { + if (resource === 'message') { + + // ********************************************************************** + // message + // ********************************************************************** + + if (operation === 'create') { + + // ---------------------------------------- + // message: create + // ---------------------------------------- + + // https://developer.webex.com/docs/api/v1/messages/create-a-message + const destination = this.getNodeParameter('destination', i); + const file = this.getNodeParameter('additionalFields.fileUi.fileValue', i, {}) as IDataObject; + const markdown = this.getNodeParameter('additionalFields.markdown', i, '') as boolean; + const body = {} as IDataObject; + if (destination === 'room') { + body['roomId'] = this.getNodeParameter('roomId', i); + } + + if (destination === 'person') { + const specifyPersonBy = this.getNodeParameter('specifyPersonBy', 0) as string; + if (specifyPersonBy === 'id') { + body['toPersonId'] = this.getNodeParameter('toPersonId', i); + } else { + body['toPersonEmail'] = this.getNodeParameter('toPersonEmail', i); + } + } + + if (markdown) { + body['markdown'] = markdown; + } + + body['text'] = this.getNodeParameter('text', i); + + body.attachments = getAttachemnts(this.getNodeParameter('additionalFields.attachmentsUi.attachmentValues', i, []) as IDataObject[]); + + if (Object.keys(file).length) { + + const isBinaryData = file.fileLocation === 'binaryData' ? true : false; + + if (isBinaryData) { + + if (!items[i].binary) { + throw new NodeOperationError(this.getNode(), 'No binary data exists on item!'); + } + + const binaryPropertyName = file.binaryPropertyName as string; + + const binaryData = items[i].binary![binaryPropertyName] as IBinaryData; + + const formData = { + files: { + value: Buffer.from(binaryData.data, BINARY_ENCODING), + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + }; + Object.assign(body, formData); + } else { + const url = file.url as string; + Object.assign(body, { files: url }); + } + } + + if (file.fileLocation === 'binaryData') { + responseData = await webexApiRequest.call(this, 'POST', '/messages', {}, {}, undefined, { formData: body }); + } else { + responseData = await webexApiRequest.call(this, 'POST', '/messages', body); + } + + + } else if (operation === 'delete') { + + // ---------------------------------------- + // message: delete + // ---------------------------------------- + + // https://developer.webex.com/docs/api/v1/messages/delete-a-message + const messageId = this.getNodeParameter('messageId', i); + + const endpoint = `/messages/${messageId}`; + responseData = await webexApiRequest.call(this, 'DELETE', endpoint); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------------- + // message: get + // ---------------------------------------- + + // https://developer.webex.com/docs/api/v1/messages/get-message-details + const messageId = this.getNodeParameter('messageId', i); + + const endpoint = `/messages/${messageId}`; + responseData = await webexApiRequest.call(this, 'GET', endpoint); + + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // message: getAll + // ---------------------------------------- + + // https://developer.webex.com/docs/api/v1/messages/list-messages + const qs: IDataObject = { + roomId: this.getNodeParameter('roomId', i), + }; + const filters = this.getNodeParameter('filters', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + + if (Object.keys(filters).length) { + Object.assign(qs, filters); + } + + if (returnAll === true) { + responseData = await webexApiRequestAllItems.call(this, 'items', 'GET', '/messages', {}, qs); + } else { + qs.max = this.getNodeParameter('limit', i) as number; + responseData = await webexApiRequest.call(this, 'GET', '/messages', {}, qs); + responseData = responseData.items; + } + + + } else if (operation === 'update') { + + // ---------------------------------------- + // message: update + // ---------------------------------------- + + // https://developer.webex.com/docs/api/v1/messages/edit-a-message + const messageId = this.getNodeParameter('messageId', i) as string; + const markdown = this.getNodeParameter('markdown', i) as boolean; + + const endpoint = `/messages/${messageId}`; + + responseData = await webexApiRequest.call(this, 'GET', endpoint); + + const body = { + roomId: responseData.roomId, + } as IDataObject; + + if (markdown === true) { + body['markdown'] = this.getNodeParameter('markdownText', i); + } else { + body['text'] = this.getNodeParameter('text', i); + } + + responseData = await webexApiRequest.call(this, 'PUT', endpoint, body); + } + } + + if (resource === 'meeting') { + if (operation === 'create') { + const title = this.getNodeParameter('title', i) as string; + const start = this.getNodeParameter('start', i) as string; + const end = this.getNodeParameter('end', i) as string; + const invitees = this.getNodeParameter('additionalFields.inviteesUi.inviteeValues', i, []) as IDataObject[]; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + title, + start: moment.tz(start, timezone).format(), + end: moment.tz(end, timezone).format(), + ...additionalFields, + }; + + if (body.requireRegistrationInfo) { + body['registration'] = (body.requireRegistrationInfo as string[]) + .reduce((obj, value) => Object.assign(obj, { [`${value}`]: true }), {}); + delete body.requireRegistrationInfo; + } + + if (invitees) { + body['invitees'] = invitees; + delete body.inviteesUi; + } + + responseData = await webexApiRequest.call(this, 'POST', '/meetings', body); + + } + + if (operation === 'delete') { + const meetingId = this.getNodeParameter('meetingId', i) as string; + const options = this.getNodeParameter('options', i) as IDataObject; + + const qs: IDataObject = { + ...options, + }; + + responseData = await webexApiRequest.call(this, 'DELETE', `/meetings/${meetingId}`, {}, qs); + responseData = { success: true }; + } + + if (operation === 'get') { + const meetingId = this.getNodeParameter('meetingId', i) as string; + const options = this.getNodeParameter('options', i) as IDataObject; + let headers = {}; + + const qs: IDataObject = { + ...options, + }; + + if (options.passsword) { + headers = { + passsword: options.passsword, + }; + } + + responseData = await webexApiRequest.call(this, 'GET', `/meetings/${meetingId}`, {}, qs, undefined, { headers }); + } + + if (operation === 'getAll') { + const filters = this.getNodeParameter('filters', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const qs: IDataObject = { + ...filters, + }; + + if (qs.from) { + qs.from = moment(qs.from as string).utc(true).format(); + } + + if (qs.to) { + qs.to = moment(qs.to as string).utc(true).format(); + } + + if (returnAll === true) { + responseData = await webexApiRequestAllItems.call(this, 'items', 'GET', '/meetings', {}, qs); + returnData.push(...responseData); + } else { + qs.max = this.getNodeParameter('limit', i) as number; + responseData = await webexApiRequest.call(this, 'GET', '/meetings', {}, qs); + responseData = responseData.items; + } + } + + if (operation === 'update') { + const meetingId = this.getNodeParameter('meetingId', i) as string; + const invitees = this.getNodeParameter('updateFields.inviteesUi.inviteeValues', i, []) as IDataObject[]; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + const { + title, + password, + start, + end, + } = await webexApiRequest.call(this, 'GET', `/meetings/${meetingId}`); + + const body: IDataObject = { + ...updateFields, + }; + + if (body.requireRegistrationInfo) { + body['registration'] = (body.requireRegistrationInfo as string[]) + .reduce((obj, value) => Object.assign(obj, { [`${value}`]: true }), {}); + delete body.requireRegistrationInfo; + } + + if (invitees.length) { + body['invitees'] = invitees; + } + + if (body.start) { + body.start = moment.tz(updateFields.start, timezone).format(); + } else { + body.start = start; + } + + if (body.end) { + body.end = moment.tz(updateFields.end, timezone).format(); + } else { + body.end = end; + } + + if (!body.title) { + body.title = title; + } + + if (!body.password) { + body.password = password; + } + + responseData = await webexApiRequest.call(this, 'PUT', `/meetings/${meetingId}`, body); + + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } + + } catch (error) { + + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + continue; + } + + throw error; + } + + } + + // if (resource === 'meetingTranscript') { + + // if (operation === 'download') { + // for (let i = 0; i < items.length; i++) { + // const transcriptId = this.getNodeParameter('transcriptId', i) as string; + // const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; + // const meetingId = this.getNodeParameter('meetingId', i) as string; + // const options = this.getNodeParameter('options', i) as IDataObject; + + // const qs: IDataObject = { + // meetingId, + // ...options, + // }; + // const transcription = await webexApiRequest.call(this, 'GET', `/meetingTranscripts/${transcriptId}/download`, {}, qs); + + // responseData = { + // json: {}, + // binary: { + // [binaryPropertyName]: { + // data: Buffer.from(transcription, BINARY_ENCODING), + // //contentType: + // //FILE + // } + // } + // } + + // } + // } + + // if (operation === 'getAll') { + // for (let i = 0; i < items.length; i++) { + // try { + // const meetingId = this.getNodeParameter('meetingId', i) as string; + // const filters = this.getNodeParameter('filters', i) as IDataObject; + // const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + // const qs: IDataObject = { + // meetingId, + // ...filters, + // }; + + // if (returnAll === true) { + // responseData = await webexApiRequestAllItems.call(this, 'items', 'GET', '/meetingTranscripts', {}, qs); + // returnData.push(...responseData); + // } else { + // qs.max = this.getNodeParameter('limit', i) as number; + // responseData = await webexApiRequest.call(this, 'GET', '/meetingTranscripts', {}, qs); + // returnData.push(...responseData.items); + // } + // } catch (error) { + // if (this.continueOnFail()) { + // returnData.push({ + // error: error.message, + // }); + // } + // } + // } + // } + // } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Cisco/Webex/CiscoWebexTrigger.node.json b/packages/nodes-base/nodes/Cisco/Webex/CiscoWebexTrigger.node.json new file mode 100644 index 000000000..da0c127f6 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/CiscoWebexTrigger.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.ciscoWebexTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Communication" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/ciscoWebex" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.ciscoWebexTrigger/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Cisco/Webex/CiscoWebexTrigger.node.ts b/packages/nodes-base/nodes/Cisco/Webex/CiscoWebexTrigger.node.ts new file mode 100644 index 000000000..bff18e37e --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/CiscoWebexTrigger.node.ts @@ -0,0 +1,683 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + getAutomaticSecret, + getEvents, + mapResource, + webexApiRequest, + webexApiRequestAllItems, +} from './GenericFunctions'; + +import { + createHmac, +} from 'crypto'; + +export class CiscoWebexTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Cisco Webex Trigger', + name: 'ciscoWebexTrigger', + icon: 'file:ciscoWebex.svg', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["resource"] + ":" + $parameter["event"]}}', + description: 'Starts the workflow when Cisco Webex events occur.', + defaults: { + name: 'Cisco Webex Trigger', + color: '#29b6f6', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'ciscoWebexOAuth2Api', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Attachment Action', + value: 'attachmentAction', + }, + { + name: 'Meeting', + value: 'meeting', + }, + { + name: 'Membership', + value: 'membership', + }, + { + name: 'Message', + value: 'message', + }, + // { + // name: 'Telephony Call', + // value: 'telephonyCall', + // }, + { + name: 'Recording', + value: 'recording', + }, + { + name: 'Room', + value: 'room', + }, + { + name: '*', + value: 'all', + }, + ], + default: 'meeting', + required: true, + }, + ...getEvents(), + { + displayName: 'Resolve Data', + name: 'resolveData', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'attachmentAction', + ], + }, + }, + default: true, + description: 'By default the response only contain a reference to the data the user inputed
If this option gets activated it will resolve the data automatically.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + options: [ + { + displayName: 'Has Files', + name: 'hasFiles', + type: 'boolean', + displayOptions: { + show: { + '/resource': [ + 'message', + ], + '/event': [ + 'created', + 'deleted', + ], + }, + }, + default: false, + description: 'Limit to messages which contain file content attachments', + }, + { + displayName: 'Is Locked', + name: 'isLocked', + type: 'boolean', + displayOptions: { + show: { + '/resource': [ + 'room', + ], + '/event': [ + 'created', + 'updated', + ], + }, + }, + default: false, + description: 'Limit to rooms that are locked', + }, + { + displayName: 'Is Moderator', + name: 'isModerator', + type: 'boolean', + displayOptions: { + show: { + '/resource': [ + 'membership', + ], + '/event': [ + 'created', + 'updated', + 'deleted', + ], + }, + }, + default: false, + description: 'Limit to moderators of a room', + }, + { + displayName: 'Mentioned People', + name: 'mentionedPeople', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'message', + ], + '/event': [ + 'created', + 'deleted', + ], + }, + }, + default: '', + description: `Limit to messages which contain these mentioned people, by person ID; accepts me as a shorthand for your own person ID; separate multiple values with commas`, + }, + { + displayName: 'Message ID', + name: 'messageId', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'attachmentAction', + ], + '/event': [ + 'created', + ], + }, + }, + default: '', + description: 'Limit to a particular message, by ID', + }, + { + displayName: 'Owned By', + name: 'ownedBy', + displayOptions: { + show: { + '/resource': [ + 'meeting', + ], + }, + }, + type: 'string', + default: '', + }, + { + displayName: 'Person Email', + name: 'personEmail', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'membership', + ], + '/event': [ + 'created', + 'updated', + 'deleted', + ], + }, + }, + default: '', + description: 'Limit to a particular person, by email', + }, + { + displayName: 'Person Email', + name: 'personEmail', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'message', + ], + '/event': [ + 'created', + 'deleted', + ], + }, + }, + default: '', + description: 'Limit to a particular person, by email', + }, + { + displayName: 'Person ID', + name: 'personId', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'attachmentAction', + ], + '/event': [ + 'created', + ], + }, + }, + default: '', + description: 'Limit to a particular person, by ID', + }, + { + displayName: 'Person ID', + name: 'personId', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'membership', + ], + '/event': [ + 'created', + 'updated', + 'deleted', + ], + }, + }, + default: '', + description: 'Limit to a particular person, by ID', + }, + { + displayName: 'Person ID', + name: 'personId', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'message', + ], + '/event': [ + 'created', + 'deleted', + ], + }, + }, + default: '', + description: 'Limit to a particular person, by ID', + }, + + { + displayName: 'Room ID', + name: 'roomId', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'attachmentAction', + ], + '/event': [ + 'created', + ], + }, + }, + default: '', + description: 'Limit to a particular room, by ID', + }, + { + displayName: 'Room ID', + name: 'roomId', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'membership', + ], + '/event': [ + 'created', + 'updated', + 'deleted', + ], + }, + }, + default: '', + description: 'Limit to a particular room, by ID', + }, + { + displayName: 'Room ID', + name: 'roomId', + type: 'string', + displayOptions: { + show: { + '/resource': [ + 'message', + ], + '/event': [ + 'created', + 'updated', + ], + }, + }, + default: '', + description: 'Limit to a particular room, by ID', + }, + { + displayName: 'Room Type', + name: 'roomType', + type: 'options', + options: [ + { + name: 'Direct', + value: 'direct', + }, + { + name: 'Group', + value: 'group', + }, + ], + displayOptions: { + show: { + '/resource': [ + 'message', + ], + '/event': [ + 'created', + 'deleted', + ], + }, + }, + default: '', + description: `Limit to a particular room type`, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Direct', + value: 'direct', + }, + { + name: 'Group', + value: 'group', + }, + ], + displayOptions: { + show: { + '/resource': [ + 'room', + ], + '/event': [ + 'created', + 'updated', + ], + }, + }, + default: '', + description: `Limit to a particular room type`, + }, + // { + // displayName: 'Call Type', + // name: 'callType', + // type: 'options', + // options: [ + // { + // name: 'Emergency', + // value: 'emergency', + // }, + // { + // name: 'External', + // value: 'external', + // }, + // { + // name: 'Location', + // value: 'location', + // }, + // { + // name: 'Disconnected', + // value: 'disconnected', + // }, + // { + // name: 'Organization', + // value: 'organization', + // }, + // { + // name: 'Other', + // value: 'other', + // }, + // { + // name: 'Repair', + // value: 'repair', + // }, + // ], + // displayOptions: { + // show: { + // '/resource': [ + // 'telephonyCall', + // ], + // '/event': [ + // 'created', + // 'deleted', + // 'updated', + // ], + // }, + // }, + // default: '', + // description: `Limit to a particular call type`, + // }, + // { + // displayName: 'Person ID', + // name: 'personId', + // type: 'string', + // displayOptions: { + // show: { + // '/resource': [ + // 'telephonyCall', + // ], + // '/event': [ + // 'created', + // 'deleted', + // 'updated', + // ], + // }, + // }, + // default: '', + // description: 'Limit to a particular person, by ID', + // }, + // { + // displayName: 'Personality', + // name: 'personality', + // type: 'options', + // options: [ + // { + // name: 'Click To Dial', + // value: 'clickToDial', + // }, + // { + // name: 'Originator', + // value: 'originator', + // }, + // { + // name: 'Terminator', + // value: 'terminator', + // }, + // ], + // displayOptions: { + // show: { + // '/resource': [ + // 'telephonyCall', + // ], + // '/event': [ + // 'created', + // 'deleted', + // 'updated', + // ], + // }, + // }, + // default: '', + // description: `Limit to a particular call personality`, + // }, + // { + // displayName: 'State', + // name: 'state', + // type: 'options', + // options: [ + // { + // name: 'Alerting', + // value: 'alerting', + // }, + // { + // name: 'Connected', + // value: 'connected', + // }, + // { + // name: 'Connecting', + // value: 'connecting', + // }, + // { + // name: 'Disconnected', + // value: 'disconnected', + // }, + // { + // name: 'Held', + // value: 'held', + // }, + // { + // name: 'Remote Held', + // value: 'remoteHeld', + // }, + // ], + // displayOptions: { + // show: { + // '/resource': [ + // 'telephonyCall', + // ], + // '/event': [ + // 'created', + // 'deleted', + // 'updated', + // ], + // }, + // }, + // default: '', + // description: `Limit to a particular call state`, + // }, + ], + }, + ], + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + const resource = this.getNodeParameter('resource') as string; + const event = this.getNodeParameter('event') as string; + + // Check all the webhooks which exist already if it is identical to the + // one that is supposed to get created. + const data = await webexApiRequestAllItems.call(this, 'items', 'GET', '/webhooks'); + for (const webhook of data) { + if (webhook.url === webhookUrl + && webhook.resource === mapResource(resource) + && webhook.event === event + && webhook.status === 'active') { + webhookData.webhookId = webhook.id as string; + return true; + } + } + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const event = this.getNodeParameter('event') as string; + const resource = this.getNodeParameter('resource') as string; + const filters = this.getNodeParameter('filters', {}) as IDataObject; + const secret = getAutomaticSecret(this.getCredentials('ciscoWebexOAuth2Api')!); + const filter = []; + for (const key of Object.keys(filters)) { + if (key !== 'ownedBy') { + filter.push(`${key}=${filters[key]}`); + } + } + const endpoint = '/webhooks'; + + const body: IDataObject = { + name: `n8n-webhook:${webhookUrl}`, + targetUrl: webhookUrl, + event, + resource: mapResource(resource), + }; + + if (filters.ownedBy) { + body['ownedBy'] = filters.ownedBy as string; + } + + body['secret'] = secret; + + if (filter.length) { + body['filter'] = filter.join('&'); + } + + const responseData = await webexApiRequest.call(this, 'POST', endpoint, body); + if (responseData.id === undefined) { + // Required data is missing so was not successful + return false; + } + + webhookData.webhookId = responseData.id as string; + webhookData.secret = secret; + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + + const endpoint = `/webhooks/${webhookData.webhookId}`; + try { + await webexApiRequest.call(this, 'DELETE', endpoint); + } catch (error) { + return false; + } + + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + let bodyData = this.getBodyData(); + const webhookData = this.getWorkflowStaticData('node'); + const headers = this.getHeaderData() as IDataObject; + const req = this.getRequestObject(); + const resolveData = this.getNodeParameter('resolveData', false) as boolean; + + //@ts-ignore + const computedSignature = createHmac('sha1', webhookData.secret).update(req.rawBody).digest('hex'); + if (headers['x-spark-signature'] !== computedSignature) { + return {}; + } + + if (resolveData) { + const { data: { id } } = bodyData as { data: { id: string } }; + bodyData = await webexApiRequest.call(this, 'GET', `/attachment/actions/${id}`); + } + + return { + workflowData: [ + this.helpers.returnJsonArray(bodyData), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Cisco/Webex/GenericFunctions.ts b/packages/nodes-base/nodes/Cisco/Webex/GenericFunctions.ts new file mode 100644 index 000000000..8bb552c25 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/GenericFunctions.ts @@ -0,0 +1,657 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + ICredentialDataDecryptedObject, + IDataObject, + INodeProperties, + IWebhookFunctions, + NodeApiError, +} from 'n8n-workflow'; + +import { + upperFirst, +} from 'lodash'; + +import { + createHash, +} from 'crypto'; + +export async function webexApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + let options: OptionsWithUri = { + method, + body, + qs, + uri: uri || `https://webexapis.com/v1${resource}`, + json: true, + }; + try { + if (Object.keys(option).length !== 0) { + options = Object.assign({}, options, option); + } + if (Object.keys(body).length === 0) { + delete options.body; + } + if (Object.keys(qs).length === 0) { + delete options.qs; + } + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'ciscoWebexOAuth2Api', options, { tokenType: 'Bearer' }); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function webexApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}, options: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + let uri: string | undefined; + query.max = 100; + do { + responseData = await webexApiRequest.call(this, method, endpoint, body, query, uri, { resolveWithFullResponse: true, ...options }); + if (responseData.headers.link) { + uri = responseData.headers['link'].split(';')[0].replace('<', '').replace('>', ''); + } + returnData.push.apply(returnData, responseData.body[propertyName]); + } while ( + responseData.headers['link'] !== undefined && + responseData.headers['link'].includes('rel="next"') + ); + return returnData; +} + +export function getEvents() { + const resourceEvents: { [key: string]: string[] } = { + 'attachmentAction': ['created', 'deleted', 'updated', '*'], + 'membership': ['created', 'deleted', 'updated', '*'], + 'message': ['created', 'deleted', 'updated', '*'], + 'room': ['created', 'deleted', 'updated', '*'], + 'meeting': ['created', 'deleted', 'updated', 'started', 'ended', '*'], + 'recording': ['created', 'deleted', 'updated', '*'], + 'telephonyCall': ['created', 'deleted', 'updated'], + '*': ['created', 'updated', 'deleted', '*'], + }; + + const elements: INodeProperties[] = []; + + for (const resource of Object.keys(resourceEvents)) { + elements.push({ + displayName: 'Event', + name: 'event', + type: 'options', + displayOptions: { + show: { + resource: [ + (resource === '*') ? 'all' : resource, + ], + }, + }, + options: resourceEvents[resource].map((event) => ({ value: (event === '*' ? 'all' : event), name: upperFirst(event) })), + default: '', + required: true, + }); + } + return elements; +} + +export function mapResource(event: string) { + return ({ + 'attachmentAction': 'attachmentActions', + 'membership': 'memberships', + 'message': 'messages', + 'room': 'rooms', + 'meeting': 'meetings', + 'recording': 'recordings', + 'telephonyCall': 'telephony_calls', + 'all': 'all', + } as { [key: string]: string })[event]; +} + +export function getAttachemnts(attachements: IDataObject[]) { + const _attachments: IDataObject[] = []; + for (const attachment of attachements) { + const body: IDataObject[] = []; + const actions: IDataObject[] = []; + for (const element of (attachment?.elementsUi as IDataObject).elementValues as IDataObject[] || []) { + // tslint:disable-next-line: no-any + const { type, ...rest } = element as { type: string, [key: string]: any }; + if (type.startsWith('input')) { + body.push({ type: `Input.${upperFirst(type.replace('input', ''))}`, ...removeEmptyProperties(rest) }); + } else { + body.push({ type: upperFirst(type), ...removeEmptyProperties(rest) }); + } + } + for (const action of (attachment?.actionsUi as IDataObject).actionValues as IDataObject[] || []) { + // tslint:disable-next-line: no-any + const { type, ...rest } = action as { type: string, [key: string]: any }; + actions.push({ type: `Action.${upperFirst(type)}`, ...removeEmptyProperties(rest) }); + } + _attachments.push({ + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + type: 'AdaptiveCard', + version: '1.2', + body, + actions, + }, + }); + } + return _attachments; +} + +export function getActionInheritedProperties() { + return [ + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + required: true, + description: 'Label for button or link that represents this action.', + }, + { + displayName: 'Icon URL', + name: 'iconUrl', + type: 'string', + default: '', + description: 'Optional icon to be shown on the action in conjunction with the title. Supports data URI in version 1.2+', + }, + { + displayName: 'Style', + name: 'style', + type: 'options', + options: [ + { + name: 'Default', + value: 'default', + }, + { + name: 'Positive', + value: 'positive', + }, + { + name: 'Destructive', + value: 'destructive', + }, + ], + default: 'default', + description: 'Controls the style of an Action, which influences how the action is displayed, spoken, etc.', + }, + ]; +} + +export function getTextBlockProperties() { + return [ + { + displayName: 'Text', + name: 'text', + type: 'string', + default: '', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + required: true, + description: 'Text to display. A subset of markdown is supported (https://aka.ms/ACTextFeatures)', + }, + { + displayName: 'Color', + name: 'color', + type: 'options', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + options: [ + { + name: 'Default', + value: 'default', + }, + { + name: 'Dark', + value: 'dark', + }, + { + name: 'Light', + value: 'light', + }, + { + name: 'Accent', + value: 'accent', + }, + { + name: 'Good', + value: 'good', + }, + { + name: 'Warning', + value: 'warning', + }, + { + name: 'Attention', + value: 'attention', + }, + ], + default: 'default', + description: 'Color of the TextBlock element', + }, + { + displayName: 'Font Type', + name: 'fontType', + type: 'options', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + options: [ + { + name: 'Default', + value: 'default', + }, + { + name: 'Monospace', + value: 'monospace', + }, + ], + default: 'default', + description: 'Type of font to use for rendering', + }, + { + displayName: 'Horizontal Alignment', + name: 'horizontalAlignment', + type: 'options', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + options: [ + { + name: 'Left', + value: 'left', + }, + { + name: 'Center', + value: 'center', + }, + { + name: 'Right', + value: 'right', + }, + ], + default: 'left', + description: 'Controls the horizontal text alignment', + }, + { + displayName: 'Is Subtle', + name: 'isSubtle', + type: 'boolean', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + default: false, + description: 'Displays text slightly toned down to appear less prominent', + }, + { + displayName: 'Max Lines', + name: 'maxLines', + type: 'number', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + default: 1, + description: 'Specifies the maximum number of lines to display', + }, + { + displayName: 'Size', + name: 'size', + type: 'options', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + options: [ + { + name: 'Default', + value: 'default', + }, + { + name: 'Small', + value: 'small', + }, + { + name: 'Medium', + value: 'medium', + }, + { + name: 'Large', + value: 'large', + }, + { + name: 'Extra Large', + value: 'extraLarge', + }, + ], + default: 'default', + description: 'Controls size of text', + }, + { + displayName: 'Weight', + name: 'weight', + type: 'options', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + options: [ + { + name: 'Default', + value: 'default', + }, + { + name: 'Lighter', + value: 'lighter', + }, + { + name: 'Bolder', + value: 'bolder', + }, + ], + default: 'default', + description: 'Controls the weight of TextBlock elements', + }, + { + displayName: 'Wrap', + name: 'wrap', + type: 'boolean', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + default: true, + description: 'If true, allow text to wrap. Otherwise, text is clipped', + }, + { + displayName: 'Height', + name: 'height', + type: 'options', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + options: [ + { + name: 'Auto', + value: 'auto', + }, + { + name: 'Stretch', + value: 'stretch', + }, + ], + default: 'auto', + description: 'Specifies the height of the element', + }, + { + displayName: 'Separator', + name: 'separator', + type: 'boolean', + default: false, + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + description: 'When true, draw a separating line at the top of the element.', + }, + { + displayName: 'Spacing', + name: 'spacing', + type: 'options', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + options: [ + { + name: 'Default', + value: 'default', + }, + { + name: 'None', + value: 'none', + }, + { + name: 'Small', + value: 'small', + }, + { + name: 'Medium', + value: 'medium', + }, + { + name: 'Large', + value: 'large', + }, + { + name: 'Extra Large', + value: 'extraLarge', + }, + { + name: 'Padding', + value: 'padding', + }, + ], + default: 'default', + description: 'Controls the amount of spacing between this element and the preceding element', + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + default: '', + description: 'A unique identifier associated with the item', + }, + { + displayName: 'Is Visible', + name: 'isVisible', + type: 'boolean', + displayOptions: { + show: { + type: [ + 'textBlock', + ], + }, + }, + default: true, + description: 'If false, this item will be removed from the visual trees', + }, + ]; +} + +export function getInputTextProperties() { + return [ + { + displayName: 'ID', + name: 'id', + type: 'string', + required: true, + displayOptions: { + show: { + type: [ + 'inputText', + ], + }, + }, + default: '', + description: 'Unique identifier for the value. Used to identify collected input when the Submit action is performed', + }, + { + displayName: 'Is Multiline', + name: 'isMultiline', + type: 'boolean', + displayOptions: { + show: { + type: [ + 'inputText', + ], + }, + }, + default: false, + description: 'If true, allow multiple lines of input', + }, + { + displayName: 'Max Length', + name: 'maxLength', + type: 'number', + displayOptions: { + show: { + type: [ + 'inputText', + ], + }, + }, + default: 1, + description: 'Hint of maximum length characters to collect (may be ignored by some clients)', + }, + { + displayName: 'Placeholder', + name: 'placeholder', + type: 'string', + displayOptions: { + show: { + type: [ + 'inputText', + ], + }, + }, + default: '', + description: 'Description of the input desired. Displayed when no text has been input', + }, + { + displayName: 'Regex', + name: 'regex', + type: 'string', + displayOptions: { + show: { + type: [ + 'inputText', + ], + }, + }, + default: '', + description: 'Regular expression indicating the required format of this text input', + }, + { + displayName: 'Style', + name: 'style', + type: 'options', + displayOptions: { + show: { + type: [ + 'inputText', + ], + }, + }, + options: [ + { + name: 'Text', + value: 'text', + }, + { + name: 'Tel', + value: 'tel', + }, + { + name: 'URL', + value: 'url', + }, + { + name: 'Email', + value: 'email', + }, + ], + default: 'text', + description: 'Style hint for text input', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + displayOptions: { + show: { + type: [ + 'inputText', + ], + }, + }, + default: '', + description: 'The initial value for this field', + }, + ]; +} + +// tslint:disable-next-line: no-any +function removeEmptyProperties(rest: { [key: string]: any }) { + return Object.keys(rest) + .filter((k) => rest[k] !== '') + .reduce((a, k) => ({ ...a, [k]: rest[k] }), {}); +} + +export function getAutomaticSecret(credentials: ICredentialDataDecryptedObject) { + const data = `${credentials.clientId},${credentials.clientSecret}`; + return createHash('md5').update(data).digest('hex'); +} diff --git a/packages/nodes-base/nodes/Cisco/Webex/ciscoWebex.svg b/packages/nodes-base/nodes/Cisco/Webex/ciscoWebex.svg new file mode 100644 index 000000000..f8253bbf2 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/ciscoWebex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Cisco/Webex/descriptions/MeetingDescription.ts b/packages/nodes-base/nodes/Cisco/Webex/descriptions/MeetingDescription.ts new file mode 100644 index 000000000..8937ff031 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/descriptions/MeetingDescription.ts @@ -0,0 +1,969 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const meetingOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + }, + { + name: 'Delete', + value: 'delete', + }, + { + name: 'Get', + value: 'get', + }, + { + name: 'Get All', + value: 'getAll', + }, + { + name: 'Update', + value: 'update', + }, + ], + default: 'create', + description: 'Operation to perform', + }, +] as INodeProperties[]; + +export const meetingFields = [ + // ---------------------------------------- + // meeting: create + // ---------------------------------------- + { + displayName: 'Title', + name: 'title', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Meeting title. The title can be a maximum of 128 characters long', + }, + { + displayName: 'Start', + name: 'start', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Date and time for the start of meeting. Acceptable format', + }, + { + displayName: 'End', + name: 'end', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Date and time for the end of meeting. Acceptable format', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'create', + ], + }, + }, + default: {}, + placeholder: 'Add Field', + options: [ + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: 'Meeting agenda. The agenda can be a maximum of 1300 characters long', + }, + { + displayName: 'Allow Any User To Be Co-Host', + name: 'allowAnyUserToBeCoHost', + type: 'boolean', + default: false, + description: `Whether or not to allow any attendee with a host account on the target site to become a co-host when joining the meeting`, + }, + { + displayName: 'Allow Authenticated Devices', + name: 'allowAuthenticatedDevices', + type: 'boolean', + default: false, + description: `Whether or not to allow authenticated video devices in the meeting's organization to start or join the meeting without a prompt`, + }, + { + displayName: 'Allow First User To Be Co-Host', + name: 'allowFirstUserToBeCoHost', + type: 'boolean', + default: false, + description: `Whether or not to allow the first attendee of the meeting with a host account on the target site to become a co-host`, + }, + { + displayName: 'Auto Accept Request', + name: 'autoAcceptRequest', + type: 'boolean', + default: false, + description: 'Whether or not meeting registration request is accepted automatically', + }, + { + displayName: 'Enable Connect Audio Before Host', + name: 'enableConnectAudioBeforeHost', + type: 'boolean', + default: false, + description: `Whether or not to allow any attendee to connect audio in the meeting before the host joins the meeting`, + }, + { + displayName: 'Enabled Auto Record Meeting', + name: 'enabledAutoRecordMeeting', + type: 'boolean', + default: false, + description: `Whether or not meeting is recorded automatically`, + }, + { + displayName: 'Enabled Join Before Host', + name: 'enabledJoinBeforeHost', + type: 'boolean', + default: false, + description: `Whether or not to allow any attendee to join the meeting before the host joins the meeting`, + }, + { + displayName: 'Exclude Password', + name: 'excludePassword', + type: 'boolean', + default: false, + description: `Whether or not to exclude password from the meeting email invitation`, + }, + { + displayName: 'Host Email', + name: 'hostEmail', + type: 'string', + default: '', + description: `Email address for the meeting host. Can only be set if you're an admin`, + }, + { + displayName: 'Integration Tags', + name: 'integrationTags', + type: 'string', + default: '', + description: `External keys created by an integration application in its own domain. They could be Zendesk ticket IDs, Jira IDs, Salesforce Opportunity IDs, etc`, + }, + { + displayName: 'Invitees', + name: 'inviteesUi', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: '', + placeholder: 'Add Invitee', + options: [ + { + displayName: 'Invitee', + name: 'inviteeValues', + values: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + default: '', + description: 'Email address of meeting invitee', + }, + { + displayName: 'Display Name', + name: 'displayName', + type: 'string', + default: '', + description: 'Display name of meeting invitee', + }, + { + displayName: 'Co-Host', + name: 'coHost', + type: 'boolean', + default: false, + description: 'Whether or not invitee is allowed to be a co-host for the meeting', + }, + ], + }, + ], + }, + { + displayName: 'Join Before Host Minutes', + name: 'joinBeforeHostMinutes', + type: 'options', + options: [ + { + name: '0', + value: 0, + }, + { + name: '5', + value: 5, + }, + { + name: '10', + value: 10, + }, + { + name: '15', + value: 15, + }, + ], + default: 0, + description: `The number of minutes an attendee can join the meeting before the meeting start time and the host joins`, + }, + { + displayName: 'Public Meeting', + name: 'publicMeeting', + type: 'boolean', + default: false, + description: `Whether or not to allow the meeting to be listed on the public calendar`, + }, + { + displayName: 'Recurrence', + name: 'recurrence', + type: 'string', + default: '', + description: `Rule for how the meeting should recur. Acceptable format`, + }, + { + displayName: 'Required Registration Info', + name: 'requireRegistrationInfo', + type: 'multiOptions', + options: [ + { + name: 'Require First Name', + value: 'requireFirstName', + }, + { + name: 'Require Last Name', + value: 'requireLastName', + }, + { + name: 'Require Email', + value: 'requireEmail', + }, + { + name: 'Require Job Title', + value: 'requireJobTitle', + }, + { + name: 'Require Company Name', + value: 'requireCompanyName', + }, + { + name: 'Require Address 1', + value: 'requireAddress1', + }, + { + name: 'Require Address 2', + value: 'requireAddress2', + }, + { + name: 'Require City', + value: 'requireCity', + }, + { + name: 'Require State', + value: 'requireState', + }, + { + name: 'Require Zip Code', + value: 'requireZipCode', + }, + { + name: 'Require Country Region', + value: 'requireCountryRegion', + }, + { + name: 'Require Work Phone', + value: 'requireWorkPhone', + }, + { + name: 'Require Fax', + value: 'requireFax', + }, + ], + default: [], + description: 'Data required for meeting registration', + }, + { + displayName: 'Reminder Time', + name: 'reminderTime', + type: 'number', + default: 1, + description: `The number of minutes before the meeting begins, for sending an email reminder to the host`, + }, + { + displayName: 'Send Email', + name: 'sendEmail', + type: 'boolean', + default: true, + description: `Whether or not to send emails to host and invitees`, + }, + { + displayName: 'Site URL', + name: 'siteUrl', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + description: `URL of the Webex site which the meeting is created on. If not specified, the meeting is created on user's preferred site`, + }, + ], + }, + + // ---------------------------------------- + // meeting: delete + // ---------------------------------------- + { + displayName: 'Meeting ID', + name: 'meetingId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'delete', + ], + }, + }, + description: 'ID of the meeting', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'delete', + ], + }, + }, + options: [ + { + displayName: 'Host Email', + name: 'hostEmail', + type: 'string', + default: '', + description: 'Email address for the meeting host. This parameter is only used if the user or application calling the API has the admin-level scopes', + }, + { + displayName: 'Send Email', + name: 'sendEmail', + type: 'boolean', + default: true, + description: 'Whether or not to send emails to host and invitees.', + }, + ], + }, + + // ---------------------------------------- + // meeting: get + // ---------------------------------------- + { + displayName: 'Meeting ID', + name: 'meetingId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'get', + ], + }, + }, + description: 'ID of the meeting', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'get', + ], + }, + }, + options: [ + { + displayName: 'Host Email', + name: 'hostEmail', + type: 'string', + default: '', + description: 'Email address for the meeting host. This parameter is only used if the user or application calling the API has the admin-level scopes', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: `Meeting password. It's required when the meeting is protected by a password and the current user is not privileged to view it if they are not a host, co-host or invitee of the meeting`, + }, + { + displayName: 'Send Email', + name: 'sendEmail', + type: 'boolean', + default: true, + description: 'Whether or not to send emails to host and invitees. It is an optional field and default value is true', + }, + ], + }, + + // ---------------------------------------- + // meeting: getAll + // ---------------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Return all results.', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'The number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'From', + name: 'from', + type: 'dateTime', + default: '', + description: 'Start date and time (inclusive) for the meeting. Acceptable format', + }, + { + displayName: 'Host Email', + name: 'hostEmail', + type: 'string', + default: '', + description: 'Email address for the meeting host', + }, + { + displayName: 'Integration Tag', + name: 'integrationTag', + type: 'string', + default: '', + description: 'External tag created by another application, e.g. Zendesk ticket ID or Jira ID', + }, + { + displayName: 'Limit to Current Meetings', + name: 'current', + type: 'boolean', + default: true, + description: 'For meeting series, whether to return just the current meeting or all meetings', + }, + { + displayName: 'Meeting Number', + name: 'meetingNumber', + type: 'string', + default: '', + description: 'Meeting number for the meeting objects being requested', + }, + { + displayName: 'Meeting Type', + name: 'meetingType', + type: 'options', + options: [ + { + name: 'Meeting Series', + value: 'meetingSeries', + description: 'Master of a scheduled series of meetings which consists of one or more scheduled meeting based on a recurrence rule', + }, + { + name: 'Scheduled Meeting', + value: 'scheduledMeeting', + description: 'Instance from a master meeting series', + }, + { + name: 'Meeting', + value: 'meeting', + description: 'Meeting instance that is actually happening or has happened', + }, + ], + default: 'meetingSeries', + }, + { + displayName: 'Participant Email', + name: 'participantEmail', + type: 'string', + default: '', + description: 'Email of a person that must be a meeting participant', + }, + { + displayName: 'Site URL', + name: 'siteUrl', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + description: 'URL of the Webex site which the API lists meetings from', + }, + { + displayName: 'State', + name: 'state', + type: 'options', + options: [ + { + name: 'Active', + value: 'active', + }, + { + name: 'Scheduled', + value: 'scheduled', + }, + { + name: 'Ready', + value: 'ready', + }, + { + name: 'Lobby', + value: 'lobby', + }, + { + name: 'In Progress', + value: 'inProgress', + }, + { + name: 'Ended', + value: 'ended', + }, + { + name: 'Missed', + value: 'missed', + }, + { + name: 'Expired', + value: 'expired', + }, + ], + default: '', + description: 'Meeting state for the meeting objects being requested', + }, + { + displayName: 'To', + name: 'to', + type: 'dateTime', + default: '', + description: 'End date and time (inclusive) for the meeting. Acceptable format', + }, + { + displayName: 'Weblink', + name: 'webLink', + type: 'string', + default: '', + description: 'URL encoded link to information page for the meeting objects being requested', + }, + ], + }, + + // ---------------------------------------- + // meeting: update + // ---------------------------------------- + { + displayName: 'Meeting ID', + name: 'meetingId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'update', + ], + }, + }, + description: 'ID of the meeting', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + displayOptions: { + show: { + resource: [ + 'meeting', + ], + operation: [ + 'update', + ], + }, + }, + default: {}, + placeholder: 'Add Field', + options: [ + { + displayName: 'Agenda', + name: 'agenda', + type: 'string', + default: '', + description: `The meeting's agenda. Cannot be longer that 1300 characters`, + }, + { + displayName: 'Allow Any User To Be Co-Host', + name: 'allowAnyUserToBeCoHost', + type: 'boolean', + default: false, + description: `Whether or not to allow any attendee with a host account on the target site to become a co-host when joining the meeting`, + }, + { + displayName: 'Allow Authenticated Devices', + name: 'allowAuthenticatedDevices', + type: 'boolean', + default: false, + description: `Whether or not to allow authenticated video devices in the meeting's organization to start or join the meeting without a prompt`, + }, + { + displayName: 'Allow First User To Be Co-Host', + name: 'allowFirstUserToBeCoHost', + type: 'boolean', + default: false, + description: `Whether or not to allow the first attendee of the meeting with a host account on the target site to become a co-host`, + }, + { + displayName: 'Enable Connect Audio Before Host', + name: 'enableConnectAudioBeforeHost', + type: 'boolean', + default: false, + description: `Whether or not to allow any attendee to connect audio in the meeting before the host joins the meeting`, + }, + { + displayName: 'Enabled Auto Record Meeting', + name: 'enabledAutoRecordMeeting', + type: 'boolean', + default: false, + description: `Whether or not meeting is recorded automatically`, + }, + { + displayName: 'Enabled Join Before Host', + name: 'enabledJoinBeforeHost', + type: 'boolean', + default: false, + description: `Whether or not to allow any attendee to join the meeting before the host joins the meeting`, + }, + { + displayName: 'End', + name: 'end', + type: 'dateTime', + default: '', + description: 'Date and time for the end of meeting. Acceptable format', + }, + { + displayName: 'Exclude Password', + name: 'excludePassword', + type: 'boolean', + default: false, + description: `Whether or not to exclude password from the meeting email invitation`, + }, + { + displayName: 'Host Email', + name: 'hostEmail', + type: 'string', + default: '', + description: `Email address for the meeting host. This attribute should only be set if the user or application calling the API has the admin-level scopes`, + }, + { + displayName: 'Invitees', + name: 'inviteesUi', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: '', + placeholder: 'Add Invitee', + options: [ + { + displayName: 'Invitee', + name: 'inviteeValues', + values: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + default: '', + description: 'Email address of meeting invitee', + }, + { + displayName: 'Display Name', + name: 'displayName', + type: 'string', + default: '', + description: 'Display name of meeting invitee', + }, + { + displayName: 'Co-Host', + name: 'coHost', + type: 'boolean', + default: false, + description: 'Whether or not invitee is allowed to be a co-host for the meeting', + }, + ], + }, + ], + }, + { + displayName: 'Join Before Host Minutes', + name: 'joinBeforeHostMinutes', + type: 'options', + options: [ + { + name: '0', + value: 0, + }, + { + name: '5', + value: 5, + }, + { + name: '10', + value: 10, + }, + { + name: '15', + value: 15, + }, + ], + default: 0, + description: `The number of minutes an attendee can join the meeting before the meeting start time and the host joins`, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + description: `Meeting password. Must conform to the site's password complexity settings.
+ If not specified, a random password conforming to the site's password rules will be generated automatically`, + }, + { + displayName: 'Public Meeting', + name: 'publicMeeting', + type: 'boolean', + default: false, + description: `Whether or not to allow the meeting to be listed on the public calendar`, + }, + { + displayName: 'Recurrence', + name: 'recurrence', + type: 'string', + default: '', + description: `Meeting series recurrence rule (conforming with RFC 2445), applying only to meeting series`, + }, + { + displayName: 'Required Registration Info', + name: 'requireRegistrationInfo', + type: 'multiOptions', + options: [ + { + name: 'Require First Name', + value: 'requireFirstName', + }, + { + name: 'Require Last Name', + value: 'requireLastName', + }, + { + name: 'Require Email', + value: 'requireEmail', + }, + { + name: 'Require Job Title', + value: 'requireJobTitle', + }, + { + name: 'Require Company Name', + value: 'requireCompanyName', + }, + { + name: 'Require Address 1', + value: 'requireAddress1', + }, + { + name: 'Require Address 2', + value: 'requireAddress2', + }, + { + name: 'Require City', + value: 'requireCity', + }, + { + name: 'Require State', + value: 'requireState', + }, + { + name: 'Require Zip Code', + value: 'requireZipCode', + }, + { + name: 'Require Country Region', + value: 'requireCountryRegion', + }, + { + name: 'Require Work Phone', + value: 'requireWorkPhone', + }, + { + name: 'Require Fax', + value: 'requireFax', + }, + ], + default: [], + description: 'Data required for meeting registration', + }, + { + displayName: 'Reminder Time', + name: 'reminderTime', + type: 'number', + default: 1, + description: `The number of minutes before the meeting begins, for sending an email reminder to the host`, + }, + { + displayName: 'Send Email', + name: 'sendEmail', + type: 'boolean', + default: false, + description: `Whether or not to send emails to host and invitees. It is an optional field and default value is true`, + }, + { + displayName: 'Site URL', + name: 'siteUrl', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSites', + }, + default: '', + description: `URL of the Webex site which the meeting is created on. If not specified, the meeting is created on user's preferred site`, + }, + { + displayName: 'Start', + name: 'start', + type: 'dateTime', + default: '', + description: 'Date and time for the start of meeting. Acceptable format', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Meeting title', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Cisco/Webex/descriptions/MeetingTranscript.ts b/packages/nodes-base/nodes/Cisco/Webex/descriptions/MeetingTranscript.ts new file mode 100644 index 000000000..ed87bbc34 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/descriptions/MeetingTranscript.ts @@ -0,0 +1,196 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const meetingTranscriptOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'meetingTranscript', + ], + }, + }, + options: [ + { + name: 'Download', + value: 'download', + }, + { + name: 'Get All', + value: 'getAll', + }, + ], + default: 'download', + description: 'Operation to perform', + }, +] as INodeProperties[]; + +export const meetingTranscriptFields = [ + // ---------------------------------------- + // meetingTranscript: download + // ---------------------------------------- + { + displayName: 'Transcript ID', + name: 'transcriptId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meetingTranscript', + ], + operation: [ + 'download', + ], + }, + }, + description: 'Unique identifier for the meeting transcript', + }, + { + displayName: 'Meeting ID', + name: 'meetingId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meetingTranscript', + ], + operation: [ + 'download', + ], + }, + }, + description: 'Unique identifier for the meeting instance which the transcripts belong to', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + resource: [ + 'meetingTranscript', + ], + operation: [ + 'download', + ], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'Format', + name: 'format', + type: 'options', + options: [ + { + name: 'txt', + value: 'txt', + }, + { + name: 'vtt', + value: 'vtt', + }, + ], + default: 'vtt', + description: 'Format for the downloaded meeting transcript', + }, + ], + }, + + // ---------------------------------------- + // meetingTranscript: getAll + // ---------------------------------------- + { + displayName: 'Meeting ID', + name: 'meetingId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'meetingTranscript', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Unique identifier for the meeting instance which the transcripts belong to', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Return all results.', + displayOptions: { + show: { + resource: [ + 'meetingTranscript', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'The number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'meetingTranscript', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'meetingTranscript', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Host Email', + name: 'hostEmail', + type: 'string', + default: '', + description: 'Email address for the meetingTranscript host', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Cisco/Webex/descriptions/MessageDescription.ts b/packages/nodes-base/nodes/Cisco/Webex/descriptions/MessageDescription.ts new file mode 100644 index 000000000..87a398465 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/descriptions/MessageDescription.ts @@ -0,0 +1,657 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +import { + getActionInheritedProperties, getInputTextProperties, getTextBlockProperties, +} from '../GenericFunctions'; + +export const messageOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'message', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + }, + { + name: 'Delete', + value: 'delete', + }, + { + name: 'Get', + value: 'get', + }, + { + name: 'Get All', + value: 'getAll', + }, + { + name: 'Update', + value: 'update', + }, + ], + default: 'create', + description: 'Operation to perform', + }, +] as INodeProperties[]; + +export const messageFields = [ + // ---------------------------------------- + // message: create + // ---------------------------------------- + { + displayName: 'Destination', + name: 'destination', + type: 'options', + options: [ + { + name: 'Room', + value: 'room', + }, + { + name: 'Person', + value: 'person', + }, + ], + required: true, + default: 'room', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Room ID', + name: 'roomId', + description: ' The room ID', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getRooms', + }, + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'create', + ], + destination: [ + 'room', + ], + }, + }, + }, + { + displayName: 'Specify Person By', + name: 'specifyPersonBy', + type: 'options', + options: [ + { + name: 'Email', + value: 'email', + }, + { + name: 'ID', + value: 'id', + }, + ], + required: true, + default: 'email', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'create', + ], + destination: [ + 'person', + ], + }, + }, + }, + { + displayName: 'Person ID', + name: 'toPersonId', + description: 'The person ID', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'create', + ], + specifyPersonBy: [ + 'id', + ], + }, + }, + }, + { + displayName: 'Person Email', + name: 'toPersonEmail', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'create', + ], + specifyPersonBy: [ + 'email', + ], + }, + }, + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'create', + ], + }, + }, + description: 'The message, in plain text', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'create', + ], + }, + }, + default: {}, + placeholder: 'Add Field', + options: [ + { + displayName: 'Attachments', + name: 'attachmentsUi', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Attachment', + options: [ + { + displayName: 'Attachment', + name: 'attachmentValues', + values: [ + { + displayName: 'Elements', + name: 'elementsUi', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + placeholder: 'Add Element', + options: [ + { + displayName: 'Element', + name: 'elementValues', + values: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Text Block', + value: 'textBlock', + }, + { + name: 'Input Text', + value: 'inputText', + }, + ], + default: 'textBlock', + description: '', + }, + ...getTextBlockProperties(), + ...getInputTextProperties(), + ], + }, + ], + }, + { + displayName: 'Actions', + name: 'actionsUi', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + placeholder: 'Add Action', + options: [ + { + displayName: 'Action', + name: 'actionValues', + values: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Execute', + value: 'execute', + }, + { + name: 'Open URL', + value: 'openUrl', + }, + { + name: 'Submit', + value: 'submit', + }, + ], + default: 'openUrl', + description: '', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + displayOptions: { + show: { + type: [ + 'openUrl', + ], + }, + }, + description: 'The URL to open', + }, + { + displayName: 'Data', + name: 'data', + type: 'string', + displayOptions: { + show: { + type: [ + 'submit', + 'execute', + ], + }, + }, + default: '', + description: 'Any extra data to pass along. These are essentially ‘hidden’ properties', + }, + { + displayName: 'Verb', + name: 'verb', + type: 'string', + displayOptions: { + show: { + type: [ + 'execute', + ], + }, + }, + default: '', + description: 'The card author-defined verb associated with this action', + }, + ...getActionInheritedProperties(), + ], + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'File', + name: 'fileUi', + placeholder: 'Add File', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'fileValue', + displayName: 'File', + values: [ + { + displayName: 'File Location', + name: 'fileLocation', + type: 'options', + options: [ + { + name: 'URL', + value: 'url', + }, + { + name: 'Binary Data', + value: 'binaryData', + }, + ], + default: 'url', + description: '', + }, + { + displayName: 'Input Field With File', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + fileLocation: [ + 'binaryData', + ], + }, + }, + description: 'The field in the node input containing the binary file data', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + displayOptions: { + show: { + fileLocation: [ + 'url', + ], + }, + }, + description: 'The public URL of the file', + }, + ], + }, + ], + }, + { + displayName: 'Markdown', + name: 'markdown', + type: 'string', + default: '', + description: 'The message in markdown format. When used the text parameter is used to provide alternate text for UI clients that do not support rich text', + }, + ], + }, + + // ---------------------------------------- + // message: delete + // ---------------------------------------- + { + displayName: 'Message ID', + name: 'messageId', + description: 'ID of the message to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // message: get + // ---------------------------------------- + { + displayName: 'Message ID', + name: 'messageId', + description: 'ID of the message to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // message: getAll + // ---------------------------------------- + { + displayName: 'Room ID', + name: 'roomId', + description: 'List messages in a room, by ID', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getRooms', + }, + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Return all results', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'The number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Before', + name: 'before', + description: 'List messages sent before a date and time', + type: 'dateTime', + default: '', + }, + { + displayName: 'Before Message', + name: 'beforeMessage', + description: 'List messages sent before a message, by ID', + type: 'string', + default: '', + }, + { + displayName: 'Parent Message ID', + name: 'parentId', + description: 'List messages with a parent, by ID', + type: 'string', + default: '', + }, + { + displayName: 'Mentioned Person', + name: 'mentionedPeople', + type: 'string', + default: '', + description: `List only messages with certain person mentioned. Enter their ID. You can use 'me' as a shorthand for yourself`, + }, + ], + }, + + // ---------------------------------------- + // message: update + // ---------------------------------------- + { + displayName: 'Message ID', + name: 'messageId', + description: 'ID of the message to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Is Markdown', + name: 'markdown', + description: 'Whether the message uses markdown', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'update', + ], + markdown: [ + false, + ], + }, + }, + description: 'The message, in plain text', + }, + { + displayName: 'Markdown', + name: 'markdownText', + description: 'The message, in Markdown format. The maximum message length is 7439 bytes', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'update', + ], + markdown: [ + true, + ], + }, + }, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Cisco/Webex/descriptions/index.ts b/packages/nodes-base/nodes/Cisco/Webex/descriptions/index.ts new file mode 100644 index 000000000..9d94b4758 --- /dev/null +++ b/packages/nodes-base/nodes/Cisco/Webex/descriptions/index.ts @@ -0,0 +1,3 @@ +export * from './MessageDescription'; +export * from './MeetingDescription'; +export * from './MeetingTranscript'; diff --git a/packages/nodes-base/nodes/Elasticsearch/Elasticsearch.node.json b/packages/nodes-base/nodes/Elasticsearch/Elasticsearch.node.json new file mode 100644 index 000000000..8fbac02f0 --- /dev/null +++ b/packages/nodes-base/nodes/Elasticsearch/Elasticsearch.node.json @@ -0,0 +1,21 @@ +{ + "node": "n8n-nodes-base.elasticsearch", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development", + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/elasticsearch" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.elasticsearch/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts index 738134a81..7e0977ba5 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts @@ -5,6 +5,8 @@ import { import { IDataObject, + ILoadOptionsFunctions, + INodePropertyOptions, INodeType, INodeTypeDescription, IWebhookResponseData, @@ -18,7 +20,7 @@ import { } from 'change-case'; import { - facebookApiRequest, + facebookApiRequest, getAllFields, getFields, } from './GenericFunctions'; import { @@ -61,6 +63,14 @@ export class FacebookTrigger implements INodeType { }, ], properties: [ + { + displayName: 'APP ID', + name: 'appId', + type: 'string', + required: true, + default: '', + description: 'Facebook APP ID', + }, { displayName: 'Object', name: 'object', @@ -126,13 +136,20 @@ export class FacebookTrigger implements INodeType { default: 'user', description: 'The object to subscribe to', }, + //https://developers.facebook.com/docs/graph-api/webhooks/reference/page { - displayName: 'App ID', - name: 'appId', - type: 'string', - required: true, - default: '', - description: 'Facebook APP ID', + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getObjectFields', + loadOptionsDependsOn: [ + 'object', + ], + }, + required: false, + default: [], + description: 'The set of fields in this object that are subscribed to', }, { displayName: 'Options', @@ -153,6 +170,18 @@ export class FacebookTrigger implements INodeType { ], }; + + methods = { + loadOptions: { + // Get all the available organizations to display them to user so that he can + // select them easily + async getObjectFields(this: ILoadOptionsFunctions): Promise { + const object = this.getCurrentNodeParameter('object') as string; + return getFields(object) as INodePropertyOptions[]; + }, + }, + }; + // @ts-ignore (because of request) webhookMethods = { default: { @@ -175,12 +204,14 @@ export class FacebookTrigger implements INodeType { const webhookUrl = this.getNodeWebhookUrl('default') as string; const object = this.getNodeParameter('object') as string; const appId = this.getNodeParameter('appId') as string; + const fields = this.getNodeParameter('fields') as string[]; const options = this.getNodeParameter('options') as IDataObject; const body = { object: snakeCase(object), callback_url: webhookUrl, verify_token: uuid(), + fields: (fields.includes('*')) ? getAllFields(object) : fields, } as IDataObject; if (options.includeValues !== undefined) { diff --git a/packages/nodes-base/nodes/Facebook/GenericFunctions.ts b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts index 609cc1404..c80a12c57 100644 --- a/packages/nodes-base/nodes/Facebook/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Facebook/GenericFunctions.ts @@ -14,6 +14,10 @@ import { IDataObject, NodeApiError, } from 'n8n-workflow'; +import { + capitalCase, +} from 'change-case'; + export async function facebookApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any let credentials; @@ -34,7 +38,7 @@ export async function facebookApiRequest(this: IHookFunctions | IExecuteFunction qs, body, gzip: true, - uri: uri ||`https://graph.facebook.com/v8.0${resource}`, + uri: uri || `https://graph.facebook.com/v8.0${resource}`, json: true, }; @@ -44,3 +48,506 @@ export async function facebookApiRequest(this: IHookFunctions | IExecuteFunction throw new NodeApiError(this.getNode(), error); } } + +export function getFields(object: string) { + const data = { + 'adAccount': [ + { + value: 'in_process_ad_objects', + }, + { + value: 'with_issues_ad_objects', + }, + ], + 'page': [ + { + value: 'affiliation', + description: `Describes changes to a page's Affliation profile field`, + }, + { + value: 'attire', + description: `Describes changes to a page's Attire profile field`, + }, + { + value: 'awards', + description: `Describes changes to a page's Awards profile field`, + }, + { + value: 'bio', + description: `Describes changes to a page's Biography profile field`, + }, + { + value: 'birthday', + description: `Describes changes to a page's Birthday profile field`, + }, + { + value: 'category', + description: `Describes changes to a page's Birthday profile field`, + }, + { + value: 'company_overview', + description: `Describes changes to a page's Company Overview profile field`, + }, + { + value: 'culinary_team', + description: `Describes changes to a page's Culinary Team profile field`, + }, + { + value: 'current_location', + description: `Describes changes to a page's Current Location profile field`, + }, + { + value: 'description', + description: `Describes changes to a page's Story Description profile field`, + }, + { + value: 'email', + description: `Describes changes to a page's Email profile field`, + }, + { + value: 'feed', + description: `Describes nearly all changes to a Page's feed, such as Posts, shares, likes, etc`, + }, + { + value: 'founded', + description: `Describes changes to a page's Founded profile field. This is different from the Start Date field`, + }, + { + value: 'general_info', + description: `Describes changes to a page's General Information profile field`, + }, + { + value: 'general_manager', + description: `Describes changes to a page's General Information profile field`, + }, + { + value: 'hometown', + description: `Describes changes to a page's Homewtown profile field`, + }, + { + value: 'hours', + description: `Describes changes to a page's Hours profile field`, + }, + { + value: 'leadgen', + description: `Describes changes to a page's leadgen settings`, + }, + { + value: 'live_videos', + description: `Describes changes to a page's live video status`, + }, + { + value: 'location', + description: `Describes changes to a page's Location profile field`, + }, + { + value: 'members', + description: `Describes changes to a page's Members profile field`, + }, + { + value: 'mention', + description: `Describes new mentions of a page, including mentions in comments, posts, etc`, + }, + { + value: 'merchant_review', + description: `Describes changes to a page's merchant review settings`, + }, + { + value: 'mission', + description: `Describes changes to a page's Mission profile field`, + }, + { + value: 'name', + description: `Describes changes to a page's Name profile field.`, + }, + { + value: 'page_about_story', + }, + { + value: 'page_change_proposal', + description: `Data for page change proposal.`, + }, + { + value: 'page_upcoming_change', + description: `Webhooks data for page upcoming changes`, + }, + { + value: 'parking', + description: `Describes changes to a page's Parking profile field`, + }, + { + value: 'payment_options', + description: `Describes change to a page's Payment profile field`, + }, + { + value: 'personal_info', + description: `Describes changes to a page's Personal Information profile field.`, + }, + { + value: 'personal_interests', + description: `Describes changes to a page's Personal Interests profile field.`, + }, + { + value: 'phone', + description: `Describes changes to a page's Phone profile field`, + }, + { + value: 'picture', + description: `Describes changes to a page's profile picture`, + }, + { + value: 'price_range', + description: `Describes changes to a page's Price Range profile field`, + }, + { + value: 'product_review', + description: `Describes changes to a page's product review settings`, + }, + { + value: 'products', + description: `Describes changes to a page's Products profile field`, + }, + { + value: 'public_transit', + description: `Describes changes to a page's Public Transit profile field`, + }, + { + value: 'ratings', + description: `Describes changes to a page's ratings, including new ratings or a user's comments or reactions on a rating`, + }, + { + value: 'videos', + description: `Describes changes to the encoding status of a video on a page`, + }, + { + value: 'website', + description: `Describes changes to a page's Website profile field`, + }, + ], + 'application': [ + { + value: 'ad_account', + }, + { + value: 'ads_rules_engine', + }, + { + value: 'async_requests', + }, + { + value: 'async_sessions', + }, + { + value: 'group_install', + }, + { + value: 'oe_reseller_onboarding_request_created', + }, + { + value: 'plugin_comment', + }, + { + value: 'plugin_comment_reply', + }, + { + value: 'plugin_comment_reply', + }, + ], + 'certificateTransparency': [ + { + value: 'certificate', + }, + { + value: 'phishing', + }, + ], + 'instagram': [ + { + value: 'comments', + description: 'Notifies you when an Instagram User comments on a media object that you own', + }, + { + value: 'messaging_handover', + }, + { + value: 'mentions', + description: 'Notifies you when an Instagram User @mentions you in a comment or caption on a media object that you do not own', + }, + { + value: 'messages', + }, + { + value: 'messaging_seen', + }, + { + value: 'standby', + }, + { + value: 'story_insights', + }, + ], + 'permissions': [ + { + value: 'bookmarked', + description: 'Whether the user has added or removed the app bookmark', + }, + { + value: 'connected', + description: 'Whether the user is connected or disconnected from the app', + }, + { + value: 'user_birthday', + }, + { + value: 'user_hometown', + }, + { + value: 'user_location', + }, + { + value: 'user_likes', + }, + { + value: 'user_managed_groups', + }, + { + value: 'user_events', + }, + { + value: 'user_photos', + }, + { + value: 'user_videos', + }, + { + value: 'user_friends', + }, + { + value: 'user_posts', + }, + { + value: 'user_gender', + }, + { + value: 'user_link', + }, + { + value: 'user_age_range', + }, + { + value: 'email', + }, + { + value: 'read_insights', + }, + { + value: 'read_page_mailboxes', + }, + { + value: 'pages_show_list', + }, + { + value: 'pages_manage_cta', + }, + { + value: 'business_management', + }, + { + value: 'pages_messaging', + }, + { + value: 'pages_messaging_phone_number', + }, + { + value: 'pages_messaging_subscriptions', + }, + { + value: 'read_audience_network_insights', + }, + { + value: 'pages_manage_instant_articles', + }, + { + value: 'publish_video', + }, + { + value: 'openid', + }, + { + value: 'catalog_management', + }, + { + value: 'gaming_user_locale', + }, + { + value: 'groups_show_list', + }, + { + value: 'instagram_basic', + }, + { + value: 'instagram_manage_comments', + }, + { + value: 'instagram_manage_insights', + }, + { + value: 'instagram_content_publish', + }, + { + value: 'publish_to_groups', + }, + { + value: 'groups_access_member_info', + }, + { + value: 'leads_retrieval', + }, + { + value: 'whatsapp_business_management', + }, + { + value: 'instagram_manage_messages', + }, + { + value: 'attribution_read', + }, + { + value: 'page_events', + }, + { + value: 'ads_management', + }, + { + value: 'ads_read', + }, + { + value: 'pages_read_engagement', + }, + { + value: 'pages_manage_metadata', + }, + { + value: 'pages_read_user_content', + }, + { + value: 'pages_manage_ads', + }, + { + value: 'pages_manage_posts', + }, + { + value: 'pages_manage_engagement', + }, + { + value: 'public_search', + }, + { + value: 'social_ads', + }, + ], + 'users': [ + { + value: 'about', + }, + { + value: 'birthday', + }, + { + value: 'books', + }, + { + value: 'email', + }, + { + value: 'feed', + }, + { + value: 'first_name', + }, + { + value: 'friends', + }, + { + value: 'gender', + }, + { + value: 'hometown', + }, + { + value: 'last_name', + }, + { + value: 'likes', + }, + { + value: 'live_videos', + }, + { + value: 'location', + }, + { + value: 'music', + }, + { + value: 'name', + }, + { + value: 'photos', + }, + { + value: 'pic_big_https', + }, + { + value: 'pic_https', + }, + { + value: 'pic_small_https', + }, + { + value: 'pic_square_https', + }, + { + value: 'platform', + }, + { + value: 'quotes', + }, + { + value: 'status', + }, + { + value: 'television', + }, + { + value: 'videos', + }, + ], + 'whatsappBusinessAccount': [ + { + value: 'message_template_status_update', + }, + { + value: 'phone_number_name_update', + }, + { + value: 'phone_number_quality_update', + }, + { + value: 'account_review_update', + }, + { + value: 'account_update', + }, + ], + // tslint:disable-next-line: no-any + } as { [key: string]: any }; + + return [{ name: '*', value: '*' }].concat(data[object as string] || []) + .map((fieldObject: IDataObject) => + ({ ...fieldObject, name: (fieldObject.value !== '*') ? capitalCase(fieldObject.value as string) : fieldObject.value })); +} + +export function getAllFields(object: string) { + return getFields(object).filter((field: IDataObject) => field.value !== '*').map((field: IDataObject) => field.value); +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/FreshworksCrm/FreshworksCrm.node.ts b/packages/nodes-base/nodes/FreshworksCrm/FreshworksCrm.node.ts new file mode 100644 index 000000000..b4b6255c1 --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/FreshworksCrm.node.ts @@ -0,0 +1,996 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + adjustAccounts, + adjustAttendees, + freshworksCrmApiRequest, + getAllItemsViewId, + handleListing, + loadResource, + throwOnEmptyFilter, + throwOnEmptyUpdate, +} from './GenericFunctions'; + +import { + accountFields, + accountOperations, + appointmentFields, + appointmentOperations, + contactFields, + contactOperations, + dealFields, + dealOperations, + noteFields, + noteOperations, + salesActivityFields, + salesActivityOperations, + taskFields, + taskOperations, +} from './descriptions'; + +import { + FreshworksConfigResponse, + LoadedCurrency, + LoadedUser, + LoadOption, +} from './types'; + +import { + tz, +} from 'moment-timezone'; + +export class FreshworksCrm implements INodeType { + description: INodeTypeDescription = { + displayName: 'Freshworks CRM', + name: 'freshworksCrm', + icon: 'file:freshworksCrm.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Freshworks CRM API', + defaults: { + name: 'Freshworks CRM', + color: '#ffa800', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'freshworksCrmApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Account', + value: 'account', + }, + { + name: 'Appointment', + value: 'appointment', + }, + { + name: 'Contact', + value: 'contact', + }, + { + name: 'Deal', + value: 'deal', + }, + { + name: 'Note', + value: 'note', + }, + { + name: 'Sales Activity', + value: 'salesActivity', + }, + { + name: 'Task', + value: 'task', + }, + ], + default: 'account', + }, + ...accountOperations, + ...accountFields, + ...appointmentOperations, + ...appointmentFields, + ...contactOperations, + ...contactFields, + ...dealOperations, + ...dealFields, + ...noteOperations, + ...noteFields, + ...salesActivityOperations, + ...salesActivityFields, + ...taskOperations, + ...taskFields, + ], + }; + + methods = { + loadOptions: { + async getAccounts(this: ILoadOptionsFunctions) { + const viewId = await getAllItemsViewId.call(this, { fromLoadOptions: true }); + const responseData = await handleListing.call(this, 'GET', `/sales_accounts/view/${viewId}`); + + return responseData.map(({ name, id }) => ({ name, value: id })) as LoadOption[]; + }, + + async getAccountViews(this: ILoadOptionsFunctions) { + const responseData = await handleListing.call(this, 'GET', '/sales_accounts/filters'); + return responseData.map(({ name, id }) => ({ name, value: id })) as LoadOption[]; + }, + + async getBusinessTypes(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'business_types'); + }, + + async getCampaigns(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'campaigns'); + }, + + async getContactStatuses(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'contact_statuses'); + }, + + async getContactViews(this: ILoadOptionsFunctions) { + const responseData = await handleListing.call(this, 'GET', '/contacts/filters'); + + return responseData.map(({ name, id }) => ({ name, value: id })) as LoadOption[]; + }, + + async getCurrencies(this: ILoadOptionsFunctions) { + const response = await freshworksCrmApiRequest.call( + this, 'GET', '/selector/currencies', + ) as FreshworksConfigResponse; + + const key = Object.keys(response)[0]; + + return response[key].map(({ currency_code, id }) => ({ name: currency_code, value: id })); + }, + + async getDealPaymentStatuses(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'deal_payment_statuses'); + }, + + async getDealPipelines(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'deal_pipelines'); + }, + + async getDealProducts(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'deal_products'); + }, + + async getDealReasons(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'deal_reasons'); + }, + + async getDealStages(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'deal_stages'); + }, + + async getDealTypes(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'deal_types'); + }, + + async getDealViews(this: ILoadOptionsFunctions) { + const responseData = await handleListing.call(this, 'GET', '/deals/filters'); + + return responseData.map(({ name, id }) => ({ name, value: id })) as LoadOption[]; + }, + + async getIndustryTypes(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'industry_types'); + }, + + async getLifecycleStages(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'lifecycle_stages'); + }, + + async getOutcomes(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'sales_activity_outcomes'); + }, + + async getSalesActivityTypes(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'sales_activity_types'); + }, + + async getTerritories(this: ILoadOptionsFunctions) { + return await loadResource.call(this, 'territories'); + }, + + async getUsers(this: ILoadOptionsFunctions) { // for attendees, owners, and creators + const response = await freshworksCrmApiRequest.call( + this, 'GET', `/selector/owners`, + ) as FreshworksConfigResponse; + + const key = Object.keys(response)[0]; + + return response[key].map( + ({ display_name, id }) => ({ name: display_name, value: id }), + ); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const defaultTimezone = this.getTimezone(); + + let responseData; + + for (let i = 0; i < items.length; i++) { + + try { + + if (resource === 'account') { + + // ********************************************************************** + // account + // ********************************************************************** + + // https://developers.freshworks.com/crm/api/#accounts + + if (operation === 'create') { + + // ---------------------------------------- + // account: create + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#create_account + + const body = { + name: this.getNodeParameter('name', i), + } as IDataObject; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (Object.keys(additionalFields).length) { + Object.assign(body, additionalFields); + } + + responseData = await freshworksCrmApiRequest.call(this, 'POST', '/sales_accounts', body); + responseData = responseData.sales_account; + + } else if (operation === 'delete') { + + // ---------------------------------------- + // account: delete + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#delete_account + + const accountId = this.getNodeParameter('accountId', i); + + const endpoint = `/sales_accounts/${accountId}`; + await freshworksCrmApiRequest.call(this, 'DELETE', endpoint); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------------- + // account: get + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#view_account + + const accountId = this.getNodeParameter('accountId', i); + + const endpoint = `/sales_accounts/${accountId}`; + responseData = await freshworksCrmApiRequest.call(this, 'GET', endpoint); + responseData = responseData.sales_account; + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // account: getAll + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#list_all_accounts + + const view = this.getNodeParameter('view', i) as string; + + responseData = await handleListing.call(this, 'GET', `/sales_accounts/view/${view}`); + + } else if (operation === 'update') { + + // ---------------------------------------- + // account: update + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#update_a_account + + const body = {} as IDataObject; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + if (Object.keys(updateFields).length) { + Object.assign(body, updateFields); + } else { + throwOnEmptyUpdate.call(this, resource); + } + + const accountId = this.getNodeParameter('accountId', i); + + const endpoint = `/sales_accounts/${accountId}`; + responseData = await freshworksCrmApiRequest.call(this, 'PUT', endpoint, body); + responseData = responseData.sales_account; + + } + + } else if (resource === 'appointment') { + + // ********************************************************************** + // appointment + // ********************************************************************** + + // https://developers.freshworks.com/crm/api/#appointments + + if (operation === 'create') { + + // ---------------------------------------- + // appointment: create + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#create_appointment + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject & { + time_zone: string; + is_allday: boolean; + }; + + const startDate = this.getNodeParameter('fromDate', i) as string; + const endDate = this.getNodeParameter('endDate', i) as string; + const attendees = this.getNodeParameter('attendees.attendee', i, []) as [{ type: string, contactId: string, userId: string }]; + + const timezone = additionalFields.time_zone ?? defaultTimezone; + + let allDay = false; + + if (additionalFields.is_allday) { + allDay = additionalFields.is_allday as boolean; + } + + const start = tz(startDate, timezone); + const end = tz(endDate, timezone); + + const body = { + title: this.getNodeParameter('title', i), + from_date: start.format(), + end_date: (allDay) ? start.format() : end.format(), + } as IDataObject; + + Object.assign(body, additionalFields); + + if (attendees.length) { + body['appointment_attendees_attributes'] = adjustAttendees(attendees); + } + responseData = await freshworksCrmApiRequest.call(this, 'POST', '/appointments', body); + responseData = responseData.appointment; + + } else if (operation === 'delete') { + + // ---------------------------------------- + // appointment: delete + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#delete_a_appointment + + const appointmentId = this.getNodeParameter('appointmentId', i); + + const endpoint = `/appointments/${appointmentId}`; + await freshworksCrmApiRequest.call(this, 'DELETE', endpoint); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------------- + // appointment: get + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#view_a_appointment + + const appointmentId = this.getNodeParameter('appointmentId', i); + + const endpoint = `/appointments/${appointmentId}`; + responseData = await freshworksCrmApiRequest.call(this, 'GET', endpoint); + responseData = responseData.appointment; + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // appointment: getAll + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#list_all_appointments + + const { filter, include } = this.getNodeParameter('filters', i) as { + filter: string; + include: string[]; + }; + + const qs: IDataObject = {}; + + if (filter) { + qs.filter = filter; + } + + if (include) { + qs.include = include; + } + responseData = await handleListing.call(this, 'GET', '/appointments', {}, qs); + + } else if (operation === 'update') { + + // ---------------------------------------- + // appointment: update + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#update_a_appointment + + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject & { + from_date: string; + end_date: string; + time_zone: string; + }; + + const attendees = this.getNodeParameter('updateFields.attendees.attendee', i, []) as [{ type: string, contactId: string, userId: string }]; + + if (!Object.keys(updateFields).length) { + throwOnEmptyUpdate.call(this, resource); + } + + const body = {} as IDataObject; + const { from_date, end_date, ...rest } = updateFields; + + const timezone = rest.time_zone ?? defaultTimezone; + + if (from_date) { + body.from_date = tz(from_date, timezone).format(); + } + + if (end_date) { + body.end_date = tz(end_date, timezone).format(); + } + + Object.assign(body, rest); + + if (attendees.length) { + body['appointment_attendees_attributes'] = adjustAttendees(attendees); + delete body.attendees; + } + + const appointmentId = this.getNodeParameter('appointmentId', i); + + const endpoint = `/appointments/${appointmentId}`; + responseData = await freshworksCrmApiRequest.call(this, 'PUT', endpoint, body); + responseData = responseData.appointment; + + } + + } else if (resource === 'contact') { + + // ********************************************************************** + // contact + // ********************************************************************** + + // https://developers.freshworks.com/crm/api/#contacts + + if (operation === 'create') { + + // ---------------------------------------- + // contact: create + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#create_contact + + const body = { + first_name: this.getNodeParameter('firstName', i), + last_name: this.getNodeParameter('lastName', i), + emails: this.getNodeParameter('emails', i), + } as IDataObject; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (Object.keys(additionalFields).length) { + Object.assign(body, adjustAccounts(additionalFields)); + } + + responseData = await freshworksCrmApiRequest.call(this, 'POST', '/contacts', body); + responseData = responseData.contact; + + } else if (operation === 'delete') { + + // ---------------------------------------- + // contact: delete + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#delete_a_contact + + const contactId = this.getNodeParameter('contactId', i); + + const endpoint = `/contacts/${contactId}`; + await freshworksCrmApiRequest.call(this, 'DELETE', endpoint); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------------- + // contact: get + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#view_a_contact + + const contactId = this.getNodeParameter('contactId', i); + + const endpoint = `/contacts/${contactId}`; + responseData = await freshworksCrmApiRequest.call(this, 'GET', endpoint); + responseData = responseData.contact; + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // contact: getAll + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#list_all_contacts + + const view = this.getNodeParameter('view', i) as string; + + responseData = await handleListing.call(this, 'GET', `/contacts/view/${view}`); + + } else if (operation === 'update') { + + // ---------------------------------------- + // contact: update + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#update_a_contact + + const body = {} as IDataObject; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + if (Object.keys(updateFields).length) { + Object.assign(body, adjustAccounts(updateFields)); + } else { + throwOnEmptyUpdate.call(this, resource); + } + + const contactId = this.getNodeParameter('contactId', i); + + const endpoint = `/contacts/${contactId}`; + responseData = await freshworksCrmApiRequest.call(this, 'PUT', endpoint, body); + responseData = responseData.contact; + + } + + } else if (resource === 'deal') { + + // ********************************************************************** + // deal + // ********************************************************************** + + // https://developers.freshworks.com/crm/api/#deals + + if (operation === 'create') { + + // ---------------------------------------- + // deal: create + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#create_deal + + const body = { + name: this.getNodeParameter('name', i), + amount: this.getNodeParameter('amount', i), + } as IDataObject; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (Object.keys(additionalFields).length) { + Object.assign(body, adjustAccounts(additionalFields)); + } + + responseData = await freshworksCrmApiRequest.call(this, 'POST', '/deals', body); + responseData = responseData.deal; + + } else if (operation === 'delete') { + + // ---------------------------------------- + // deal: delete + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#delete_a_deal + + const dealId = this.getNodeParameter('dealId', i); + + await freshworksCrmApiRequest.call(this, 'DELETE', `/deals/${dealId}`); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------------- + // deal: get + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#view_a_deal + + const dealId = this.getNodeParameter('dealId', i); + + responseData = await freshworksCrmApiRequest.call(this, 'GET', `/deals/${dealId}`); + responseData = responseData.deal; + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // deal: getAll + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#list_all_deals + + const view = this.getNodeParameter('view', i) as string; + + responseData = await handleListing.call(this, 'GET', `/deals/view/${view}`); + + } else if (operation === 'update') { + + // ---------------------------------------- + // deal: update + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#update_a_deal + + const body = {} as IDataObject; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + if (Object.keys(updateFields).length) { + Object.assign(body, adjustAccounts(updateFields)); + } else { + throwOnEmptyUpdate.call(this, resource); + } + + const dealId = this.getNodeParameter('dealId', i); + + responseData = await freshworksCrmApiRequest.call(this, 'PUT', `/deals/${dealId}`, body); + responseData = responseData.deal; + + } + + } else if (resource === 'note') { + + // ********************************************************************** + // note + // ********************************************************************** + + // https://developers.freshworks.com/crm/api/#notes + + if (operation === 'create') { + + // ---------------------------------------- + // note: create + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#create_note + + const body = { + description: this.getNodeParameter('description', i), + targetable_id: this.getNodeParameter('targetable_id', i), + targetable_type: this.getNodeParameter('targetableType', i), + } as IDataObject; + + responseData = await freshworksCrmApiRequest.call(this, 'POST', '/notes', body); + responseData = responseData.note; + + } else if (operation === 'delete') { + + // ---------------------------------------- + // note: delete + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#delete_a_note + + const noteId = this.getNodeParameter('noteId', i); + + await freshworksCrmApiRequest.call(this, 'DELETE', `/notes/${noteId}`); + responseData = { success: true }; + + } else if (operation === 'update') { + + // ---------------------------------------- + // note: update + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#update_a_note + + const body = {} as IDataObject; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + if (Object.keys(updateFields).length) { + Object.assign(body, updateFields); + } else { + throwOnEmptyUpdate.call(this, resource); + } + + const noteId = this.getNodeParameter('noteId', i); + + responseData = await freshworksCrmApiRequest.call(this, 'PUT', `/notes/${noteId}`, body); + responseData = responseData.note; + + } + + } else if (resource === 'salesActivity') { + + // ********************************************************************** + // salesActivity + // ********************************************************************** + + // https://developers.freshworks.com/crm/api/#sales-activities + + if (operation === 'create') { + + // ---------------------------------------- + // salesActivity: create + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#create_sales_activity + + const startDate = this.getNodeParameter('from_date', i) as string; + const endDate = this.getNodeParameter('end_date', i) as string; + + const body = { + sales_activity_type_id: this.getNodeParameter('sales_activity_type_id', i), + title: this.getNodeParameter('title', i), + owner_id: this.getNodeParameter('ownerId', i), + start_date: tz(startDate, defaultTimezone).format(), + end_date: tz(endDate, defaultTimezone).format(), + targetable_type: this.getNodeParameter('targetableType', i), + targetable_id: this.getNodeParameter('targetable_id', i), + } as IDataObject; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (Object.keys(additionalFields).length) { + Object.assign(body, additionalFields); + } + + responseData = await freshworksCrmApiRequest.call(this, 'POST', '/sales_activities', { sales_activity: body }); + responseData = responseData.sales_activity; + + } else if (operation === 'delete') { + + // ---------------------------------------- + // salesActivity: delete + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#delete_a_sales_activity + + const salesActivityId = this.getNodeParameter('salesActivityId', i); + + const endpoint = `/sales_activities/${salesActivityId}`; + await freshworksCrmApiRequest.call(this, 'DELETE', endpoint); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------------- + // salesActivity: get + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#view_a_sales_activity + + const salesActivityId = this.getNodeParameter('salesActivityId', i); + + const endpoint = `/sales_activities/${salesActivityId}`; + responseData = await freshworksCrmApiRequest.call(this, 'GET', endpoint); + responseData = responseData.sales_activity; + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // salesActivity: getAll + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#list_all_sales_activities + + responseData = await handleListing.call(this, 'GET', '/sales_activities'); + + } else if (operation === 'update') { + + // ---------------------------------------- + // salesActivity: update + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#update_a_sales_activity + + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject & { + from_date: string; + end_date: string; + time_zone: string; + }; + + if (!Object.keys(updateFields).length) { + throwOnEmptyUpdate.call(this, resource); + } + + const body = {} as IDataObject; + const { from_date, end_date, ...rest } = updateFields; + + if (from_date) { + body.from_date = tz(from_date, defaultTimezone).format(); + } + + if (end_date) { + body.end_date = tz(end_date, defaultTimezone).format(); + } + + if (Object.keys(rest).length) { + Object.assign(body, rest); + } + + const salesActivityId = this.getNodeParameter('salesActivityId', i); + + const endpoint = `/sales_activities/${salesActivityId}`; + responseData = await freshworksCrmApiRequest.call(this, 'PUT', endpoint, body); + responseData = responseData.sales_activity; + + } + + } else if (resource === 'task') { + + // ********************************************************************** + // task + // ********************************************************************** + + // https://developers.freshworks.com/crm/api/#tasks + + if (operation === 'create') { + + // ---------------------------------------- + // task: create + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#create_task + + const dueDate = this.getNodeParameter('dueDate', i); + + const body = { + title: this.getNodeParameter('title', i), + owner_id: this.getNodeParameter('ownerId', i), + due_date: tz(dueDate, defaultTimezone).format(), + targetable_type: this.getNodeParameter('targetableType', i), + targetable_id: this.getNodeParameter('targetable_id', i), + } as IDataObject; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (Object.keys(additionalFields).length) { + Object.assign(body, additionalFields); + } + + responseData = await freshworksCrmApiRequest.call(this, 'POST', '/tasks', body); + responseData = responseData.task; + + } else if (operation === 'delete') { + + // ---------------------------------------- + // task: delete + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#delete_a_task + + const taskId = this.getNodeParameter('taskId', i); + + await freshworksCrmApiRequest.call(this, 'DELETE', `/tasks/${taskId}`); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------------- + // task: get + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#view_a_task + + const taskId = this.getNodeParameter('taskId', i); + + responseData = await freshworksCrmApiRequest.call(this, 'GET', `/tasks/${taskId}`); + responseData = responseData.task; + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // task: getAll + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#list_all_tasks + + const { filter, include } = this.getNodeParameter('filters', i) as { + filter: string; + include: string; + }; + + const qs: IDataObject = { + filter: 'open', + }; + + if (filter) { + qs.filter = filter; + } + + if (include) { + qs.include = include; + } + + responseData = await handleListing.call(this, 'GET', '/tasks', {}, qs); + + } else if (operation === 'update') { + + // ---------------------------------------- + // task: update + // ---------------------------------------- + + // https://developers.freshworks.com/crm/api/#update_a_task + + const body = {} as IDataObject; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + if (!Object.keys(updateFields).length) { + throwOnEmptyUpdate.call(this, resource); + } + + const { dueDate, ...rest } = updateFields; + + if (dueDate) { + body.due_date = tz(dueDate, defaultTimezone).format(); + } + + if (Object.keys(rest).length) { + Object.assign(body, rest); + } + + const taskId = this.getNodeParameter('taskId', i); + + responseData = await freshworksCrmApiRequest.call(this, 'PUT', `/tasks/${taskId}`, body); + responseData = responseData.task; + + } + + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ json: { error: error.message } }); + continue; + } + throw error; + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/FreshworksCrm/GenericFunctions.ts b/packages/nodes-base/nodes/FreshworksCrm/GenericFunctions.ts new file mode 100644 index 000000000..b7d27d093 --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/GenericFunctions.ts @@ -0,0 +1,215 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +import { + OptionsWithUri, +} from 'request'; + +import { + FreshworksConfigResponse, + FreshworksCrmApiCredentials, + SalesAccounts, + ViewsResponse, +} from './types'; + +import { + omit, +} from 'lodash'; + +export async function freshworksCrmApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const { apiKey, domain } = this.getCredentials('freshworksCrmApi') as FreshworksCrmApiCredentials; + + const options: OptionsWithUri = { + headers: { + Authorization: `Token token=${apiKey}`, + }, + method, + body, + qs, + uri: `https://${domain}.myfreshworks.com/crm/sales/api${endpoint}`, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + if (!Object.keys(qs).length) { + delete options.qs; + } + try { + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function getAllItemsViewId( + this: IExecuteFunctions | ILoadOptionsFunctions, + { fromLoadOptions } = { fromLoadOptions: false }, +) { + let resource = this.getNodeParameter('resource', 0) as string; + let keyword = 'All'; + + if (resource === 'account' || fromLoadOptions) { + resource = 'sales_account'; // adjust resource to endpoint + } + + if (resource === 'deal') { + keyword = 'My Deals'; // no 'All Deals' available + } + + const response = await freshworksCrmApiRequest.call(this, 'GET', `/${resource}s/filters`) as ViewsResponse; + + const view = response.filters.find(v => v.name.includes(keyword)); + + if (!view) { + throw new NodeOperationError(this.getNode(), 'Failed to get all items view'); + } + + return view.id.toString(); +} + +export async function freshworksCrmApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const returnData: IDataObject[] = []; + let response: any; // tslint:disable-line: no-any + + qs.page = 1; + + do { + response = await freshworksCrmApiRequest.call(this, method, endpoint, body, qs); + const key = Object.keys(response)[0]; + returnData.push(...response[key]); + qs.page++; + } while ( + response.meta.total_pages && qs.page <= response.meta.total_pages + ); + + return returnData; +} + +export async function handleListing( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + + if (returnAll) { + return await freshworksCrmApiRequestAllItems.call(this, method, endpoint, body, qs); + } + + const responseData = await freshworksCrmApiRequestAllItems.call(this, method, endpoint, body, qs); + const limit = this.getNodeParameter('limit', 0) as number; + + if (limit) return responseData.slice(0, limit); + + return responseData; +} + +/** + * Load resources for options, except users. + * + * See: https://developers.freshworks.com/crm/api/#admin_configuration + */ +export async function loadResource( + this: ILoadOptionsFunctions, + resource: string, +) { + const response = await freshworksCrmApiRequest.call( + this, 'GET', `/selector/${resource}`, + ) as FreshworksConfigResponse; + + const key = Object.keys(response)[0]; + return response[key].map(({ name, id }) => ({ name, value: id })); +} + +export function adjustAttendees(attendees: [{ type: string, contactId: string, userId: string }]) { + return attendees.map((attendee) => { + if (attendee.type === 'contact') { + return { + attendee_type: 'Contact', + attendee_id: attendee.contactId.toString(), + }; + } else if (attendee.type === 'user') { + return { + attendee_type: 'FdMultitenant::User', + attendee_id: attendee.userId.toString(), + }; + } + }); +} + + +// /** +// * Adjust attendee data from n8n UI to the format expected by Freshworks CRM API. +// */ +// export function adjustAttendees(additionalFields: IDataObject & Attendees) { +// if (!additionalFields?.appointment_attendees_attributes) return additionalFields; + +// return { +// ...omit(additionalFields, ['appointment_attendees_attributes']), +// appointment_attendees_attributes: additionalFields.appointment_attendees_attributes.map(attendeeId => { +// return { type: 'user', id: attendeeId }; +// }), +// }; +// } + +/** + * Adjust account data from n8n UI to the format expected by Freshworks CRM API. + */ +export function adjustAccounts(additionalFields: IDataObject & SalesAccounts) { + if (!additionalFields?.sales_accounts) return additionalFields; + + const adjusted = additionalFields.sales_accounts.map(accountId => { + return { id: accountId, is_primary: false }; + }); + + adjusted[0].is_primary = true; + + return { + ...omit(additionalFields, ['sales_accounts']), + sales_accounts: adjusted, + }; +} + +export function throwOnEmptyUpdate( + this: IExecuteFunctions, + resource: string, +) { + throw new NodeOperationError( + this.getNode(), + `Please enter at least one field to update for the ${resource}.`, + ); +} + +export function throwOnEmptyFilter( + this: IExecuteFunctions, +) { + throw new NodeOperationError( + this.getNode(), + `Please select at least one filter.`, + ); +} diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/AccountDescription.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/AccountDescription.ts new file mode 100644 index 000000000..645aae75e --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/AccountDescription.ts @@ -0,0 +1,507 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const accountOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'account', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an account', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an account', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve an account', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all accounts', + }, + { + name: 'Update', + value: 'update', + description: 'Update an account', + }, + ], + default: 'create', + }, +] as INodeProperties[]; + +export const accountFields = [ + // ---------------------------------------- + // account: create + // ---------------------------------------- + { + displayName: 'Name', + name: 'name', + description: 'Name of the account', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Address', + name: 'address', + type: 'string', + default: '', + description: 'Address of the account', + }, + { + displayName: 'Annual Revenue', + name: 'annual_revenue', + type: 'number', + default: 0, + description: 'Annual revenue of the account', + }, + { + displayName: 'Business Type ID', + name: 'business_type_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getBusinessTypes', + }, + description: 'ID of the business that the account belongs to', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + description: 'City that the account belongs to', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + description: 'Country that the account belongs to', + }, + { + displayName: 'Facebook', + name: 'facebook', + type: 'string', + default: '', + description: 'Facebook username of the account', + }, + { + displayName: 'Industry Type ID', + name: 'industry_type_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getIndustryTypes', + }, + description: 'ID of the industry that the account belongs to', + }, + { + displayName: 'LinkedIn', + name: 'linkedin', + type: 'string', + default: '', + description: 'LinkedIn account of the account', + }, + { + displayName: 'Number of Employees', + name: 'number_of_employees', + type: 'number', + default: 0, + description: 'Number of employees in the account', + }, + { + displayName: 'Owner ID', + name: 'owner_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user to whom the account is assigned', + }, + { + displayName: 'Parent Sales Account ID', + name: 'parent_sales_account_id', + type: 'string', + default: '', + description: 'Parent account ID of the account', + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + default: '', + description: 'Phone number of the account', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + description: 'State that the account belongs to', + }, + { + displayName: 'Territory ID', + name: 'territory_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getTerritories', + }, + description: 'ID of the territory that the account belongs to', + }, + { + displayName: 'Twitter', + name: 'twitter', + type: 'string', + default: '', + description: 'Twitter username of the account', + }, + { + displayName: 'Website', + name: 'website', + type: 'string', + default: '', + description: 'Website of the account', + }, + { + displayName: 'Zipcode', + name: 'zipcode', + type: 'string', + default: '', + description: 'Zipcode of the region that the account belongs to', + }, + ], + }, + + // ---------------------------------------- + // account: delete + // ---------------------------------------- + { + displayName: 'Account ID', + name: 'accountId', + description: 'ID of the account to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // account: get + // ---------------------------------------- + { + displayName: 'Account ID', + name: 'accountId', + description: 'ID of the account to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // account: getAll + // ---------------------------------------- + { + displayName: 'View', + name: 'view', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getAccountViews', + }, + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'getAll', + ], + }, + }, + default: '', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'How many results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + + // ---------------------------------------- + // account: update + // ---------------------------------------- + { + displayName: 'Account ID', + name: 'accountId', + description: 'ID of the account to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'account', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Address', + name: 'address', + type: 'string', + default: '', + description: 'Address of the account', + }, + { + displayName: 'Annual Revenue', + name: 'annual_revenue', + type: 'number', + default: 0, + description: 'Annual revenue of the account', + }, + { + displayName: 'Business Type ID', + name: 'business_type_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getBusinessTypes', + }, + description: 'ID of the business that the account belongs to', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + description: 'City that the account belongs to', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + description: 'Country that the account belongs to', + }, + { + displayName: 'Facebook', + name: 'facebook', + type: 'string', + default: '', + description: 'Facebook username of the account', + }, + { + displayName: 'Industry Type ID', + name: 'industry_type_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getIndustryTypes', + }, + description: 'ID of the industry that the account belongs to', + }, + { + displayName: 'LinkedIn', + name: 'linkedin', + type: 'string', + default: '', + description: 'LinkedIn account of the account', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the account', + }, + { + displayName: 'Number of Employees', + name: 'number_of_employees', + type: 'number', + default: 0, + description: 'Number of employees in the account', + }, + { + displayName: 'Owner ID', + name: 'owner_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user to whom the account is assigned', + }, + { + displayName: 'Parent Sales Account ID', + name: 'parent_sales_account_id', + type: 'string', + default: '', + description: 'Parent account ID of the account', + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + default: '', + description: 'Phone number of the account', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + description: 'State that the account belongs to', + }, + { + displayName: 'Territory ID', + name: 'territory_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getTerritories', + }, + description: 'ID of the territory that the account belongs to', + }, + { + displayName: 'Twitter', + name: 'twitter', + type: 'string', + default: '', + description: 'Twitter username of the account', + }, + { + displayName: 'Website', + name: 'website', + type: 'string', + default: '', + description: 'Website of the account', + }, + { + displayName: 'Zipcode', + name: 'zipcode', + type: 'string', + default: '', + description: 'Zipcode of the region that the account belongs to', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/AppointmentDescription.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/AppointmentDescription.ts new file mode 100644 index 000000000..172d4c993 --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/AppointmentDescription.ts @@ -0,0 +1,636 @@ +import { + tz, +} from 'moment-timezone'; + +import { + INodeProperties, +} from 'n8n-workflow'; + +export const appointmentOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an appointment', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an appointment', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve an appointment', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all appointments', + }, + { + name: 'Update', + value: 'update', + description: 'Update an appointment', + }, + ], + default: 'create', + }, +] as INodeProperties[]; + +export const appointmentFields = [ + // ---------------------------------------- + // appointment: create + // ---------------------------------------- + { + displayName: 'Title', + name: 'title', + description: 'Title of the appointment', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Start Date', + name: 'fromDate', + description: 'Timestamp that denotes the start of appointment. Start date if this is an all-day appointment.', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'End Date', + name: 'endDate', + description: 'Timestamp that denotes the end of appointment. End date if this is an all-day appointment.', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Attendees', + name: 'attendees', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'create', + ], + }, + }, + placeholder: 'Add Attendee', + default: {}, + options: [ + { + name: 'attendee', + displayName: 'Attendee', + values: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'contact', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'options', + displayOptions: { + show: { + type: [ + 'user', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + default: '', + }, + { + displayName: 'Contact ID', + name: 'contactId', + displayOptions: { + show: { + type: [ + 'contact', + ], + }, + }, + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Creator ID', + name: 'creater_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user who created the appointment', + }, + { + displayName: 'Is All-Day', + name: 'is_allday', + type: 'boolean', + default: false, + description: 'Whether it is an all-day appointment or not', + }, + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + default: '', + description: 'Latitude of the location when you check in for an appointment', + }, + { + displayName: 'Location', + name: 'location', + type: 'string', + default: '', + description: 'Location of the appointment', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + default: '', + description: 'Longitude of the location when you check in for an appointment', + }, + { + displayName: 'Outcome ID', + name: 'outcome_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getOutcomes', + }, + description: 'ID of outcome of Appointment sales activity type', + }, + { + displayName: 'Target ID', + name: 'targetable_id', + type: 'string', + default: '', + description: 'ID of contact/account against whom appointment is created', + }, + { + displayName: 'Target Type', + name: 'targetable_type', + type: 'options', + default: 'Contact', + options: [ + { + name: 'Contact', + value: 'Contact', + }, + { + name: 'Deal', + value: 'Deal', + }, + { + name: 'SalesAccount', + value: 'SalesAccount', + }, + ], + }, + { + displayName: 'Time Zone', + name: 'time_zone', + type: 'options', + default: '', + description: 'Timezone that the appointment is scheduled in', + options: tz.names().map(tz => ({ name: tz, value: tz })), + }, + ], + }, + + // ---------------------------------------- + // appointment: delete + // ---------------------------------------- + { + displayName: 'Appointment ID', + name: 'appointmentId', + description: 'ID of the appointment to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // appointment: get + // ---------------------------------------- + { + displayName: 'Appointment ID', + name: 'appointmentId', + description: 'ID of the appointment to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // appointment: getAll + // ---------------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'How many results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + default: '', + placeholder: 'Add Filter', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Include', + name: 'include', + type: 'options', + default: 'creater', + options: [ + { + name: 'Appointment Attendees', + value: 'appointment_attendees', + }, + { + name: 'Creator', + value: 'creater', + }, + { + name: 'Target', + value: 'targetable', + }, + ], + }, + { + displayName: 'Time', + name: 'filter', + type: 'options', + default: 'upcoming', + options: [ + { + name: 'Past', + value: 'past', + }, + { + name: 'Upcoming', + value: 'upcoming', + }, + ], + }, + ], + }, + + // ---------------------------------------- + // appointment: update + // ---------------------------------------- + { + displayName: 'Appointment ID', + name: 'appointmentId', + description: 'ID of the appointment to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'appointment', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Attendees', + name: 'attendees', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Attendee', + default: {}, + options: [ + { + name: 'attendee', + displayName: 'Attendee', + values: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Contact', + value: 'contact', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'contact', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'options', + displayOptions: { + show: { + type: [ + 'user', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + default: '', + }, + { + displayName: 'Contact ID', + name: 'contactId', + displayOptions: { + show: { + type: [ + 'contact', + ], + }, + }, + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Creator ID', + name: 'creater_id', + type: 'options', + default: [], + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user who created the appointment', + }, + { + displayName: 'End Date', + name: 'endDate', + description: 'Timestamp that denotes the end of appointment. End date if this is an all-day appointment.', + type: 'dateTime', + default: '', + }, + { + displayName: 'Is All-Day', + name: 'is_allday', + type: 'boolean', + default: false, + description: 'Whether it is an all-day appointment or not', + }, + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + default: '', + description: 'Latitude of the location when you check in for an appointment', + }, + { + displayName: 'Location', + name: 'location', + type: 'string', + default: '', + description: 'Location of the appointment', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + default: '', + description: 'Longitude of the location when you check in for an appointment', + }, + { + displayName: 'Outcome ID', + name: 'outcome_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getOutcomes', + }, + description: 'ID of outcome of Appointment sales activity type', + }, + { + displayName: 'Start Date', + name: 'fromDate', + description: 'Timestamp that denotes the start of appointment. Start date if this is an all-day appointment.', + type: 'dateTime', + default: '', + }, + { + displayName: 'Target ID', + name: 'targetable_id', + type: 'string', + default: '', + description: 'ID of contact/account against whom appointment is created', + }, + { + displayName: 'Target Type', + name: 'targetable_type', + type: 'options', + default: 'Contact', + options: [ + { + name: 'Contact', + value: 'Contact', + }, + { + name: 'Deal', + value: 'Deal', + }, + { + name: 'SalesAccount', + value: 'SalesAccount', + }, + ], + }, + { + displayName: 'Time Zone', + name: 'time_zone', + type: 'options', + default: '', + description: 'Timezone that the appointment is scheduled in', + options: tz.names().map(tz => ({ name: tz, value: tz })), + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Title of the appointment', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/ContactDescription.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/ContactDescription.ts new file mode 100644 index 000000000..9d5315536 --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/ContactDescription.ts @@ -0,0 +1,668 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const contactOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'contact', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a contact', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a contact', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a contact', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all contacts', + }, + { + name: 'Update', + value: 'update', + description: 'Update a contact', + }, + ], + default: 'create', + }, +] as INodeProperties[]; + +export const contactFields = [ + // ---------------------------------------- + // contact: create + // ---------------------------------------- + { + displayName: 'First Name', + name: 'firstName', + description: 'First name of the contact', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Last Name', + name: 'lastName', + description: 'Last name of the contact', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Email Address', + name: 'emails', + type: 'string', + default: '', + description: 'Email addresses of the contact', + required: true, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Address', + name: 'address', + type: 'string', + default: '', + description: 'Address of the contact', + }, + { + displayName: 'Campaign ID', + name: 'campaign_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getCampaigns', + }, + description: 'ID of the campaign that led your contact to your webapp', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + description: 'City that the contact belongs to', + }, + { + displayName: 'Contact Status ID', + name: 'contact_status_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getContactStatuses', + }, + description: 'ID of the contact status that the contact belongs to', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + description: 'Country that the contact belongs to', + }, + { + displayName: 'External ID', + name: 'external_id', + type: 'string', + default: '', + description: 'External ID of the contact', + }, + { + displayName: 'Facebook', + name: 'facebook', + type: 'string', + default: '', + description: 'Facebook username of the contact', + }, + { + displayName: 'Job Title', + name: 'job_title', + type: 'string', + default: '', + description: 'Designation of the contact in the account they belong to', + }, + { + displayName: 'Keywords', + name: 'keyword', + type: 'string', + default: '', + description: 'Keywords that the contact used to reach your website/web app', + }, + { + displayName: 'Lead Source ID', + name: 'lead_source_id', + type: 'string', // not obtainable from API + default: '', + description: 'ID of the source where contact came from', + }, + { + displayName: 'Lifecycle Stage ID', + name: 'lifecycle_stage_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getLifecycleStages', + }, + description: 'ID of the lifecycle stage that the contact belongs to', + }, + { + displayName: 'LinkedIn', + name: 'linkedin', + type: 'string', + default: '', + description: 'LinkedIn account of the contact', + }, + { + displayName: 'Medium', + name: 'medium', + type: 'string', + default: '', + description: 'Medium that led your contact to your website/webapp', + }, + { + displayName: 'Mobile Number', + name: 'mobile_number', + type: 'string', + default: '', + description: 'Mobile phone number of the contact', + }, + { + displayName: 'Owner ID', + name: 'owner_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user to whom the contact is assigned', + }, + { + displayName: 'Sales Accounts', + name: 'sales_accounts', + type: 'multiOptions', + default: [], + typeOptions: { + loadOptionsMethod: 'getAccounts', + }, + description: 'Accounts which contact belongs to', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + description: 'State that the contact belongs to', + }, + { + displayName: 'Subscription Status', + name: 'subscription_status', + type: 'string', // not obtainable from API + default: '', + description: 'Status of subscription that the contact is in', + }, + { + displayName: 'Subscription Types', + name: 'subscription_types', + type: 'string', // not obtainable from API + default: '', + description: 'Type of subscription that the contact is in', + }, + { + displayName: 'Territory ID', + name: 'territory_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getTerritories', + }, + description: 'ID of the territory that the contact belongs to', + }, + { + displayName: 'Time Zone', + name: 'time_zone', + type: 'string', + default: '', + description: 'Timezone that the contact belongs to', + }, + { + displayName: 'Twitter', + name: 'twitter', + type: 'string', + default: '', + description: 'Twitter username of the contact', + }, + { + displayName: 'Work Number', + name: 'work_number', + type: 'string', + default: '', + description: 'Work phone number of the contact', + }, + { + displayName: 'Zipcode', + name: 'zipcode', + type: 'string', + default: '', + description: 'Zipcode of the region that the contact belongs to', + }, + ], + }, + + // ---------------------------------------- + // contact: delete + // ---------------------------------------- + { + displayName: 'Contact ID', + name: 'contactId', + description: 'ID of the contact to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // contact: get + // ---------------------------------------- + { + displayName: 'Contact ID', + name: 'contactId', + description: 'ID of the contact to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // contact: getAll + // ---------------------------------------- + { + displayName: 'View', + name: 'view', + type: 'options', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getContactViews', + }, + default: '', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'How many results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + + // ---------------------------------------- + // contact: update + // ---------------------------------------- + { + displayName: 'Contact ID', + name: 'contactId', + description: 'ID of the contact to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'contact', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Address', + name: 'address', + type: 'string', + default: '', + description: 'Address of the contact', + }, + { + displayName: 'Campaign ID', + name: 'campaign_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getCampaigns', + }, + description: 'ID of the campaign that led your contact to your webapp', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + description: 'City that the contact belongs to', + }, + { + displayName: 'Contact Status ID', + name: 'contact_status_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getContactStatuses', + }, + description: 'ID of the contact status that the contact belongs to', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + description: 'Country that the contact belongs to', + }, + { + displayName: 'External ID', + name: 'external_id', + type: 'string', + default: '', + description: 'External ID of the contact', + }, + { + displayName: 'Facebook', + name: 'facebook', + type: 'string', + default: '', + description: 'Facebook username of the contact', + }, + { + displayName: 'First Name', + name: 'first_name', + type: 'string', + default: '', + description: 'First name of the contact', + }, + { + displayName: 'Job Title', + name: 'job_title', + type: 'string', + default: '', + description: 'Designation of the contact in the account they belong to', + }, + { + displayName: 'Keywords', + name: 'keyword', + type: 'string', + default: '', + description: 'Keywords that the contact used to reach your website/web app', + }, + { + displayName: 'Last Name', + name: 'last_name', + type: 'string', + default: '', + description: 'Last name of the contact', + }, + { + displayName: 'Lead Source ID', + name: 'lead_source_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getLeadSources', + }, + description: 'ID of the source where contact came from', + }, + { + displayName: 'Lifecycle Stage ID', + name: 'lifecycle_stage_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getLifecycleStages', + }, + description: 'ID of the lifecycle stage that the contact belongs to', + }, + { + displayName: 'LinkedIn', + name: 'linkedin', + type: 'string', + default: '', + description: 'LinkedIn account of the contact', + }, + { + displayName: 'Medium', + name: 'medium', + type: 'string', + default: '', + description: 'Medium that led your contact to your website/webapp', + }, + { + displayName: 'Mobile Number', + name: 'mobile_number', + type: 'string', + default: '', + description: 'Mobile phone number of the contact', + }, + { + displayName: 'Owner ID', + name: 'owner_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user to whom the contact is assigned', + }, + { + displayName: 'Sales Accounts', + name: 'sales_accounts', + type: 'multiOptions', + default: [], + typeOptions: { + loadOptionsMethod: 'getAccounts', + }, + description: 'Accounts which contact belongs to', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + description: 'State that the contact belongs to', + }, + { + displayName: 'Subscription Status', + name: 'subscription_status', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getSubscriptionStatuses', + }, + description: 'Status of subscription that the contact is in', + }, + { + displayName: 'Subscription Types', + name: 'subscription_types', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getSubscriptionTypes', + }, + description: 'Type of subscription that the contact is in', + }, + { + displayName: 'Territory ID', + name: 'territory_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getTerritories', + }, + description: 'ID of the territory that the contact belongs to', + }, + { + displayName: 'Time Zone', + name: 'time_zone', + type: 'string', + default: '', + description: 'Timezone that the contact belongs to', + }, + { + displayName: 'Twitter', + name: 'twitter', + type: 'string', + default: '', + description: 'Twitter username of the contact', + }, + { + displayName: 'Work Number', + name: 'work_number', + type: 'string', + default: '', + description: 'Work phone number of the contact', + }, + { + displayName: 'Zipcode', + name: 'zipcode', + type: 'string', + default: '', + description: 'Zipcode of the region that the contact belongs to', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/DealDescription.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/DealDescription.ts new file mode 100644 index 000000000..ad5ca251d --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/DealDescription.ts @@ -0,0 +1,545 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const dealOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'deal', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a deal', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a deal', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a deal', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all deals', + }, + { + name: 'Update', + value: 'update', + description: 'Update a deal', + }, + ], + default: 'create', + }, +] as INodeProperties[]; + +export const dealFields = [ + // ---------------------------------------- + // deal: create + // ---------------------------------------- + { + displayName: 'Amount', + name: 'amount', + description: 'Value of the deal', + type: 'number', + required: true, + default: 0, + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Name', + name: 'name', + description: 'Name of the deal', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Base Currency Amount', + name: 'base_currency_amount', + type: 'number', + default: 0, + description: 'Value of the deal in base currency', + }, + { + displayName: 'Campaign ID', + name: 'campaign_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getCampaigns', + }, + description: 'ID of the campaign that landed this deal', + }, + { + displayName: 'Currency ID', + name: 'currency_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getCurrencies', + }, + description: 'ID of the currency that the deal belongs to', + }, + { + displayName: 'Deal Payment Status ID', + name: 'deal_payment_status_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealPaymentStatuses', + }, + description: 'ID of the mode of payment for the deal', + }, + { + displayName: 'Deal Pipeline ID', + name: 'deal_pipeline_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealPipelines', + }, + description: 'ID of the deal pipeline that it belongs to', + }, + { + displayName: 'Deal Product ID', + name: 'deal_product_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealProducts', + }, + description: 'ID of the product that the deal belongs to (in a multi-product company)', + }, + { + displayName: 'Deal Reason ID', + name: 'deal_reason_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealReasons', + }, + description: 'ID of the reason for losing the deal. Can only be set if the deal is in \'Lost\' stage.', + }, + { + displayName: 'Deal Stage ID', + name: 'deal_stage_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealStages', + }, + description: 'ID of the deal stage that the deal belongs to', + }, + { + displayName: 'Deal Type ID', + name: 'deal_type_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealTypes', + }, + description: 'ID of the deal type that the deal belongs to', + }, + { + displayName: 'Lead Source ID', + name: 'lead_source_id', + type: 'string', // not obtainable from API + default: '', + description: 'ID of the source where deal came from', + }, + { + displayName: 'Owner ID', + name: 'owner_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user to whom the deal is assigned', + }, + { + displayName: 'Probability', + name: 'probability', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + maxValue: 100, + }, + description: 'Probability of winning the deal as a number between 0 and 100', + }, + { + displayName: 'Sales Account ID', + name: 'sales_account_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getAccounts', + }, + description: 'ID of the account that the deal belongs to', + }, + { + displayName: 'Territory ID', + name: 'territory_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getTerritories', + }, + description: 'ID of the territory that the deal belongs to', + }, + ], + }, + + // ---------------------------------------- + // deal: delete + // ---------------------------------------- + { + displayName: 'Deal ID', + name: 'dealId', + description: 'ID of the deal to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // deal: get + // ---------------------------------------- + { + displayName: 'Deal ID', + name: 'dealId', + description: 'ID of the deal to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // deal: getAll + // ---------------------------------------- + { + displayName: 'View', + name: 'view', + type: 'options', + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'getAll', + ], + }, + }, + typeOptions: { + loadOptionsMethod: 'getDealViews', + }, + default: '', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'How many results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + + // ---------------------------------------- + // deal: update + // ---------------------------------------- + { + displayName: 'Deal ID', + name: 'dealId', + description: 'ID of the deal to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'deal', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Amount', + name: 'amount', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + }, + description: 'Value of the deal', + }, + { + displayName: 'Base Currency Amount', + name: 'base_currency_amount', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + }, + description: 'Value of the deal in base currency', + }, + { + displayName: 'Campaign ID', + name: 'campaign_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getCampaigns', + }, + description: 'ID of the campaign that landed this deal', + }, + { + displayName: 'Currency ID', + name: 'currency_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getCurrencies', + }, + description: 'ID of the currency that the deal belongs to', + }, + { + displayName: 'Deal Payment Status ID', + name: 'deal_payment_status_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealPaymentStatuses', + }, + description: 'ID of the mode of payment for the deal', + }, + { + displayName: 'Deal Pipeline ID', + name: 'deal_pipeline_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealPipelines', + }, + description: 'ID of the deal pipeline that it belongs to', + }, + { + displayName: 'Deal Product ID', + name: 'deal_product_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealProducts', + }, + description: 'ID of the product that the deal belongs to (in a multi-product company)', + }, + { + displayName: 'Deal Reason ID', + name: 'deal_reason_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealReasons', + }, + description: 'ID of the reason for losing the deal. Can only be set if the deal is in \'Lost\' stage.', + }, + { + displayName: 'Deal Stage ID', + name: 'deal_stage_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealStages', + }, + description: 'ID of the deal stage that the deal belongs to', + }, + { + displayName: 'Deal Type ID', + name: 'deal_type_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDealTypes', + }, + description: 'ID of the deal type that the deal belongs to', + }, + { + displayName: 'Lead Source ID', + name: 'lead_source_id', + type: 'string', // not obtainable from API + default: '', + description: 'ID of the source where deal came from', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the deal', + }, + { + displayName: 'Owner ID', + name: 'owner_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user to whom the deal is assigned', + }, + { + displayName: 'Probability', + name: 'probability', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + maxValue: 100, + }, + description: 'Probability of winning the deal as a number between 0 and 100', + }, + { + displayName: 'Sales Account ID', + name: 'sales_account_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getAccounts', + }, + description: 'ID of the account that the deal belongs to', + }, + { + displayName: 'Territory ID', + name: 'territory_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getTerritories', + }, + description: 'ID of the territory that the deal belongs to', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/NoteDescription.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/NoteDescription.ts new file mode 100644 index 000000000..d5f48085f --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/NoteDescription.ts @@ -0,0 +1,214 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const noteOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'note', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a note', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a note', + }, + { + name: 'Update', + value: 'update', + description: 'Update a note', + }, + ], + default: 'create', + }, +] as INodeProperties[]; + +export const noteFields = [ + // ---------------------------------------- + // note: create + // ---------------------------------------- + { + displayName: 'Content', + name: 'description', + description: 'Content of the note', + type: 'string', + required: true, + typeOptions: { + rows: 5, + }, + default: '', + displayOptions: { + show: { + resource: [ + 'note', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Target Type', + name: 'targetableType', + description: 'Type of the entity for which the note is created', + type: 'options', + required: true, + default: 'Contact', + options: [ + { + name: 'Contact', + value: 'Contact', + }, + { + name: 'Deal', + value: 'Deal', + }, + { + name: 'Sales Account', + value: 'SalesAccount', + }, + ], + displayOptions: { + show: { + resource: [ + 'note', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Target ID', + name: 'targetable_id', + description: 'ID of the entity for which note is created. The type of entity is selected in "Target Type".', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'note', + ], + operation: [ + 'create', + ], + }, + }, + }, + + // ---------------------------------------- + // note: delete + // ---------------------------------------- + { + displayName: 'Note ID', + name: 'noteId', + description: 'ID of the note to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'note', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // note: update + // ---------------------------------------- + { + displayName: 'Note ID', + name: 'noteId', + description: 'ID of the note to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'note', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'note', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Content', + name: 'description', + type: 'string', + typeOptions: { + rows: 5, + }, + default: '', + description: 'Content of the note', + }, + { + displayName: 'Target ID', + name: 'targetable_id', + type: 'string', + default: '', + description: 'ID of the entity for which the note is updated', + }, + { + displayName: 'Target Type', + name: 'targetable_type', + type: 'options', + default: 'Contact', + description: 'Type of the entity for which the note is updated', + options: [ + { + name: 'Contact', + value: 'Contact', + }, + { + name: 'Deal', + value: 'Deal', + }, + { + name: 'Sales Account', + value: 'SalesAccount', + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/SalesActivityDescription.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/SalesActivityDescription.ts new file mode 100644 index 000000000..d410df0bb --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/SalesActivityDescription.ts @@ -0,0 +1,508 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const salesActivityOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + }, + }, + options: [ + // { + // name: 'Create', + // value: 'create', + // description: 'Create a sales activity', + // }, + // { + // name: 'Delete', + // value: 'delete', + // description: 'Delete a sales activity', + // }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a sales activity', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all sales activities', + }, + // { + // name: 'Update', + // value: 'update', + // description: 'Update a sales activity', + // }, + ], + default: 'get', + }, +] as INodeProperties[]; + +export const salesActivityFields = [ + // ---------------------------------------- + // salesActivity: create + // ---------------------------------------- + { + displayName: 'Sales Activity Type ID', + name: 'sales_activity_type_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getSalesActivityTypes', + }, + description: 'ID of a sales activity type for which the sales activity is created', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Title', + name: 'title', + description: 'Title of the sales activity to create', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Owner ID', + name: 'ownerId', + description: 'ID of the user who owns the sales activity', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + required: true, + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Start Date', + name: 'from_date', + description: 'Timestamp that denotes the end of sales activity', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'End Date', + name: 'end_date', + description: 'Timestamp that denotes the end of sales activity', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Target Type', + name: 'targetableType', + description: 'Type of the entity for which the sales activity is created', + type: 'options', + required: true, + default: 'Contact', + options: [ + { + name: 'Contact', + value: 'Contact', + }, + { + name: 'Deal', + value: 'Deal', + }, + { + name: 'Sales Account', + value: 'SalesAccount', + }, + ], + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Target ID', + name: 'targetable_id', + description: 'ID of the entity for which the sales activity is created. The type of entity is selected in "Target Type".', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Creator ID', + name: 'creater_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user who created the sales activity', + }, + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + default: '', + description: 'Latitude of the location when you check in on a sales activity', + }, + { + displayName: 'Location', + name: 'location', + type: 'string', + default: '', + description: 'Location of the sales activity', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + default: '', + description: 'Longitude of the location when you check in for a sales activity', + }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + default: '', + description: 'Description about the sales activity', + }, + { + displayName: 'Sales Activity Outcome ID', + name: 'sales_activity_outcome_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getOutcomes', + }, + description: 'ID of a sales activity\'s outcome', + }, + ], + }, + + // ---------------------------------------- + // salesActivity: delete + // ---------------------------------------- + { + displayName: 'Sales Activity ID', + name: 'salesActivityId', + description: 'ID of the salesActivity to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // salesActivity: get + // ---------------------------------------- + { + displayName: 'Sales Activity ID', + name: 'salesActivityId', + description: 'ID of the salesActivity to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // salesActivity: getAll + // ---------------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'How many results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + + // ---------------------------------------- + // salesActivity: update + // ---------------------------------------- + { + displayName: 'Sales Activity ID', + name: 'salesActivityId', + description: 'ID of the salesActivity to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'salesActivity', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Creator ID', + name: 'creater_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user who created the sales activity', + }, + { + displayName: 'Start Date', + name: 'end_date', + description: 'Timestamp that denotes the start of the sales activity', + type: 'dateTime', + }, + { + displayName: 'Latitude', + name: 'latitude', + type: 'string', + default: '', + description: 'Latitude of the location when you check in on a sales activity', + }, + { + displayName: 'Location', + name: 'location', + type: 'string', + default: '', + description: 'Location of the sales activity', + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'string', + default: '', + description: 'Longitude of the location when you check in for a sales activity', + }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + default: '', + description: 'Description about the sales activity', + }, + { + displayName: 'Owner ID', + name: 'owner_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user who owns the sales activity', + }, + { + displayName: 'Sales Activity Outcome ID', + name: 'sales_activity_outcome_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getOutcomes', + }, + description: 'ID of a sales activity\'s outcome', + }, + { + displayName: 'Sales Activity Type ID', + name: 'sales_activity_type_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getSalesActivityTypes', + }, + description: 'ID of a sales activity type for which the sales activity is updated', + }, + { + displayName: 'Start Date', + name: 'from_date', + description: 'Timestamp that denotes the start of the sales activity', + type: 'dateTime', + }, + { + displayName: 'Target ID', + name: 'targetable_id', + type: 'string', + default: '', + description: 'ID of the entity for which the sales activity is updated. The type of entity is selected in "Target Type".', + }, + { + displayName: 'Target Type', + name: 'targetable_type', + type: 'options', + default: 'Contact', + description: 'Type of the entity for which the sales activity is updated', + options: [ + { + name: 'Contact', + value: 'Contact', + }, + { + name: 'Deal', + value: 'Deal', + }, + { + name: 'SalesAccount', + value: 'SalesAccount', + }, + ], + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Title of the sales activity to update', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/TaskDescription.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/TaskDescription.ts new file mode 100644 index 000000000..0f8fc6de3 --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/TaskDescription.ts @@ -0,0 +1,480 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const taskOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'task', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a task', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a task', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a task', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all tasks', + }, + { + name: 'Update', + value: 'update', + description: 'Update a task', + }, + ], + default: 'create', + }, +] as INodeProperties[]; + +export const taskFields = [ + // ---------------------------------------- + // task: create + // ---------------------------------------- + { + displayName: 'Title', + name: 'title', + description: 'Title of the task', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Due Date', + name: 'dueDate', + description: 'Timestamp that denotes when the task is due to be completed', + type: 'dateTime', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Owner ID', + name: 'ownerId', + description: 'ID of the user to whom the task is assigned', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + required: true, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Target Type', + name: 'targetableType', + description: 'Type of the entity for which the task is updated', + type: 'options', + required: true, + default: 'Contact', + options: [ + { + name: 'Contact', + value: 'Contact', + }, + { + name: 'Deal', + value: 'Deal', + }, + { + name: 'SalesAccount', + value: 'SalesAccount', + }, + ], + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Target ID', + name: 'targetable_id', + description: 'ID of the entity for which the task is created. The type of entity is selected in "Target Type".', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Creator ID', + name: 'creater_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user who created the task', + }, + { + displayName: 'Outcome ID', + name: 'outcome_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getOutcomes', + }, + description: 'ID of the outcome of the task', + }, + { + displayName: 'Task Type ID', + name: 'task_type_id', + type: 'string', // not obtainable from API + default: '', + description: 'ID of the type of task', + }, + ], + }, + + // ---------------------------------------- + // task: delete + // ---------------------------------------- + { + displayName: 'Task ID', + name: 'taskId', + description: 'ID of the task to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // task: get + // ---------------------------------------- + { + displayName: 'Task ID', + name: 'taskId', + description: 'ID of the task to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // task: getAll + // ---------------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'How many results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + default: false, + placeholder: 'Add Filter', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Include', + name: 'include', + type: 'options', + default: 'owner', + options: [ + { + name: 'Owner', + value: 'owner', + }, + { + name: 'Target', + value: 'targetable', + }, + { + name: 'Users', + value: 'users', + }, + ], + }, + { + displayName: 'Status', + name: 'filter', + type: 'options', + default: 'open', + options: [ + { + name: 'Completed', + value: 'completed', + }, + { + name: 'Due Today', + value: 'due_today', + }, + { + name: 'Due Tomorrow', + value: 'due_tomorrow', + }, + { + name: 'Open', + value: 'open', + }, + { + name: 'Overdue', + value: 'overdue', + }, + ], + }, + ], + }, + + // ---------------------------------------- + // task: update + // ---------------------------------------- + { + displayName: 'Task ID', + name: 'taskId', + description: 'ID of the task to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Creator ID', + name: 'creater_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user who created the sales activity', + }, + { + displayName: 'Due Date', + name: 'dueDate', + description: 'Timestamp that denotes when the task is due to be completed', + type: 'dateTime', + default: '', + }, + { + displayName: 'Outcome ID', + name: 'outcome_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getOutcomes', + }, + description: 'ID of the outcome of the task', + }, + { + displayName: 'Owner ID', + name: 'owner_id', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + description: 'ID of the user to whom the task is assigned', + }, + { + displayName: 'Target ID', + name: 'targetable_id', + type: 'string', + default: '', + description: 'ID of the entity for which the task is updated. The type of entity is selected in "Target Type".', + }, + { + displayName: 'Target Type', + name: 'targetable_type', + description: 'Type of the entity for which the task is updated', + type: 'options', + default: 'Contact', + options: [ + { + name: 'Contact', + value: 'Contact', + }, + { + name: 'Deal', + value: 'Deal', + }, + { + name: 'SalesAccount', + value: 'SalesAccount', + }, + ], + }, + { + displayName: 'Task Type ID', + name: 'task_type_id', + type: 'string', // not obtainable from API + default: '', + description: 'ID of the type of task', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Title of the task', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/FreshworksCrm/descriptions/index.ts b/packages/nodes-base/nodes/FreshworksCrm/descriptions/index.ts new file mode 100644 index 000000000..70957a2c3 --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/descriptions/index.ts @@ -0,0 +1,7 @@ +export * from './AccountDescription'; +export * from './AppointmentDescription'; +export * from './ContactDescription'; +export * from './DealDescription'; +export * from './NoteDescription'; +export * from './SalesActivityDescription'; +export * from './TaskDescription'; diff --git a/packages/nodes-base/nodes/FreshworksCrm/freshworksCrm.svg b/packages/nodes-base/nodes/FreshworksCrm/freshworksCrm.svg new file mode 100644 index 000000000..06e0cf9ac --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/freshworksCrm.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/FreshworksCrm/types.d.ts b/packages/nodes-base/nodes/FreshworksCrm/types.d.ts new file mode 100644 index 000000000..4d2534236 --- /dev/null +++ b/packages/nodes-base/nodes/FreshworksCrm/types.d.ts @@ -0,0 +1,43 @@ +export type FreshworksCrmApiCredentials = { + apiKey: string; + domain: string; +} + +export type FreshworksConfigResponse = { + [key: string]: T[]; +}; + +export type LoadOption = { + name: string; + value: string; +}; + +export type LoadedCurrency = { + currency_code: string; + id: string; +}; + +export type LoadedUser = { + id: string; + display_name: string; +}; + +export type SalesAccounts = { + sales_accounts?: number[]; +}; + +export type ViewsResponse = { + filters: View[]; + meta: object; +} + +export type View = { + id: number; + name: string; + model_class_name: string; + user_id: number; + is_default: boolean; + updated_at: string; + user_name: string; + current_user_permissions: string[]; +}; diff --git a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts index 778f6ea2f..d76dd48d2 100644 --- a/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Gmail/GenericFunctions.ts @@ -142,6 +142,7 @@ export async function encodeEmail(email: IEmail) { let mailBody: Buffer; const mailOptions = { + from: email.from, to: email.to, cc: email.cc, bcc: email.bcc, diff --git a/packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts b/packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts index 1107630f0..5b72a2c80 100644 --- a/packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts @@ -45,6 +45,7 @@ import { } from 'lodash'; export interface IEmail { + from?: string; to?: string; cc?: string; bcc?: string; @@ -355,6 +356,7 @@ export class Gmail implements INodeType { } const email: IEmail = { + from: additionalFields.senderName as string || '', to: toStr, cc: ccStr, bcc: bccStr, @@ -455,6 +457,7 @@ export class Gmail implements INodeType { } const email: IEmail = { + from: additionalFields.senderName as string || '', to: toStr, cc: ccStr, bcc: bccStr, diff --git a/packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts b/packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts index a86058095..a25798550 100644 --- a/packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts +++ b/packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts @@ -277,6 +277,16 @@ export const messageFields = [ placeholder: 'info@example.com', default: [], }, + { + displayName: 'Sender Name', + name: 'senderName', + type: 'string', + placeholder: 'Name ', + default: '', + description: `The name displayed in your contacts inboxes.
+ It has to be in the format: "Display-Name <name@gmail.com>".
+ The email address has to match the email address of the logged in user for the API`, + }, ], }, { diff --git a/packages/nodes-base/nodes/Google/Perspective/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Perspective/GenericFunctions.ts new file mode 100644 index 000000000..ad93b1a0e --- /dev/null +++ b/packages/nodes-base/nodes/Google/Perspective/GenericFunctions.ts @@ -0,0 +1,40 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + NodeApiError, +} from 'n8n-workflow'; + +export async function googleApiRequest( + this: IExecuteFunctions, + method: 'POST', + endpoint: string, + body: IDataObject = {}, +) { + const options: OptionsWithUri = { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method, + body, + uri: `https://commentanalyzer.googleapis.com${endpoint}`, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + try { + return await this.helpers.requestOAuth2.call(this, 'googlePerspectiveOAuth2Api', options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} diff --git a/packages/nodes-base/nodes/Google/Perspective/GooglePerspective.node.json b/packages/nodes-base/nodes/Google/Perspective/GooglePerspective.node.json new file mode 100644 index 000000000..b785a9d94 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Perspective/GooglePerspective.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.perspective", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/perspective" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.perspective/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Google/Perspective/GooglePerspective.node.ts b/packages/nodes-base/nodes/Google/Perspective/GooglePerspective.node.ts new file mode 100644 index 000000000..662573de0 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Perspective/GooglePerspective.node.ts @@ -0,0 +1,292 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + NodeOperationError, +} from 'n8n-workflow'; + +import { + AttributesValuesUi, + CommentAnalyzeBody, + Language, + RequestedAttributes, +} from './types'; + +import { + googleApiRequest, +} from './GenericFunctions'; + +const ISO6391 = require('iso-639-1'); + +export class GooglePerspective implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google Perspective', + name: 'googlePerspective', + icon: 'file:perspective.svg', + group: [ + 'transform', + ], + version: 1, + description: 'Consume Google Perspective API', + subtitle: '={{$parameter["operation"]}}', + defaults: { + name: 'Google Perspective', + color: '#200647', + }, + inputs: [ + 'main', + ], + outputs: [ + 'main', + ], + credentials: [ + { + name: 'googlePerspectiveOAuth2Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Analyze Comment', + value: 'analyzeComment', + }, + ], + default: 'analyzeComment', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'analyzeComment', + ], + }, + }, + }, + { + displayName: 'Attributes to Analyze', + name: 'requestedAttributesUi', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Atrribute', + required: true, + displayOptions: { + show: { + operation: [ + 'analyzeComment', + ], + }, + }, + options: [ + { + displayName: 'Properties', + name: 'requestedAttributesValues', + values: [ + { + displayName: 'Attribute Name', + name: 'attributeName', + type: 'options', + options: [ + { + name: 'Flirtation', + value: 'flirtation', + }, + { + name: 'Identity Attack', + value: 'identity_attack', + }, + { + name: 'Insult', + value: 'insult', + }, + { + name: 'Profanity', + value: 'profanity', + }, + { + name: 'Severe Toxicity', + value: 'severe_toxicity', + }, + { + name: 'Sexually Explicit', + value: 'sexually_explicit', + }, + { + name: 'Threat', + value: 'threat', + }, + { + name: 'Toxicity', + value: 'toxicity', + }, + ], + description: 'Attribute to analyze in the text. Details here', + default: 'flirtation', + }, + { + displayName: 'Score Threshold', + name: 'scoreThreshold', + type: 'number', + typeOptions: { + numberStepSize: 0.1, + numberPrecision: 2, + minValue: 0, + maxValue: 1, + }, + description: 'Score above which to return results. At zero, all scores are returned.', + default: 0, + }, + ], + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'analyzeComment', + ], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'Languages', + name: 'languages', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLanguages', + }, + default: '', + description: 'Languages of the text input. If unspecified, the API will auto-detect the comment language', + }, + ], + }, + ], + }; + + methods = { + loadOptions: { + // Get all the available languages to display them to user so that he can + // select them easily + async getLanguages(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const supportedLanguages = [ + 'English', + 'Spanish', + 'French', + 'German', + 'Portuguese', + 'Italian', + 'Russian', + ]; + + const languages = ISO6391.getAllNames().filter((language: string) => supportedLanguages.includes(language)); + for (const language of languages) { + const languageName = language; + const languageId = ISO6391.getCode(language); + returnData.push({ + name: languageName, + value: languageId, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const operation = this.getNodeParameter('operation', 0); + + const returnData: IDataObject[] = []; + let responseData; + + for (let i = 0; i < items.length; i++) { + + try { + + + if (operation === 'analyzeComment') { + + // https://developers.perspectiveapi.com/s/about-the-api-methods + + const attributes = this.getNodeParameter( + 'requestedAttributesUi.requestedAttributesValues', i, [], + ) as AttributesValuesUi[]; + + if (!attributes.length) { + throw new NodeOperationError( + this.getNode(), + 'Please enter at least one attribute to analyze.', + ); + } + + const requestedAttributes = attributes.reduce((acc, cur) => { + return Object.assign(acc, { + [cur.attributeName.toUpperCase()]: { + scoreType: 'probability', + scoreThreshold: cur.scoreThreshold, + }, + }); + }, {}); + + const body: CommentAnalyzeBody = { + comment: { + type: 'PLAIN_TEXT', + text: this.getNodeParameter('text', i) as string, + }, + requestedAttributes, + }; + + const { languages } = this.getNodeParameter('options', i) as { languages: Language }; + + if (languages?.length) { + body.languages = languages; + } + + responseData = await googleApiRequest.call(this, 'POST', '/v1alpha1/comments:analyze', body); + } + + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + + } + + return [this.helpers.returnJsonArray(responseData)]; + } +} diff --git a/packages/nodes-base/nodes/Google/Perspective/perspective.svg b/packages/nodes-base/nodes/Google/Perspective/perspective.svg new file mode 100644 index 000000000..2cfbaf8a3 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Perspective/perspective.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/Google/Perspective/types.d.ts b/packages/nodes-base/nodes/Google/Perspective/types.d.ts new file mode 100644 index 000000000..bb4ade830 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Perspective/types.d.ts @@ -0,0 +1,26 @@ +export type CommentAnalyzeBody = { + comment: Comment; + requestedAttributes: RequestedAttributes; + languages?: Language; +}; + +export type Language = 'de' | 'en' | 'fr' | 'ar' | 'es' | 'it' | 'pt' | 'ru'; + +export type Comment = { + text?: string; + type?: string; +}; + +export type RequestedAttributes = { + [key: string]: { + scoreType?: string; + scoreThreshold?: { + value: number + }; + }; +}; + +export type AttributesValuesUi = { + attributeName: string; + scoreThreshold: number; +}; diff --git a/packages/nodes-base/nodes/HomeAssistant/EventDescription.ts b/packages/nodes-base/nodes/HomeAssistant/EventDescription.ts index 8a5dfb76f..6aa1ae124 100644 --- a/packages/nodes-base/nodes/HomeAssistant/EventDescription.ts +++ b/packages/nodes-base/nodes/HomeAssistant/EventDescription.ts @@ -15,16 +15,16 @@ export const eventOperations = [ }, }, options: [ + { + name: 'Create', + value: 'create', + description: 'Create an event', + }, { name: 'Get All', value: 'getAll', description: 'Get all events', }, - { - name: 'Post', - value: 'post', - description: 'Post an event', - }, ], default: 'getAll', description: 'The operation to perform.', @@ -79,7 +79,7 @@ export const eventFields = [ }, /* -------------------------------------------------------------------------- */ - /* event:post */ + /* event:create */ /* -------------------------------------------------------------------------- */ { displayName: 'Event Type', @@ -88,7 +88,7 @@ export const eventFields = [ displayOptions: { show: { operation: [ - 'post', + 'create', ], resource: [ 'event', @@ -114,7 +114,7 @@ export const eventFields = [ 'event', ], operation: [ - 'post', + 'create', ], }, }, diff --git a/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts b/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts index e8508685e..d84525f15 100644 --- a/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts +++ b/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts @@ -87,10 +87,10 @@ export class HomeAssistant implements INodeType { name: 'Config', value: 'config', }, - // { - // name: 'Event', - // value: 'event', - // }, + { + name: 'Event', + value: 'event', + }, // { // name: 'History', // value: 'history', @@ -226,7 +226,7 @@ export class HomeAssistant implements INodeType { const limit = this.getNodeParameter('limit', i) as number; responseData = responseData.slice(0, limit); } - } else if (operation === 'post') { + } else if (operation === 'create') { const eventType = this.getNodeParameter('eventType', i) as string; const eventAttributes = this.getNodeParameter('eventAttributes', i) as { attributes: IDataObject[], diff --git a/packages/nodes-base/nodes/Interval.node.ts b/packages/nodes-base/nodes/Interval.node.ts index cd7bcbd21..55592236e 100644 --- a/packages/nodes-base/nodes/Interval.node.ts +++ b/packages/nodes-base/nodes/Interval.node.ts @@ -78,7 +78,13 @@ export class Interval implements INodeType { this.emit([this.helpers.returnJsonArray([{}])]); }; - const intervalObj = setInterval(executeTrigger, intervalValue * 1000); + intervalValue *= 1000; + + if (intervalValue > Number.MAX_SAFE_INTEGER) { + throw new Error('The interval value is too large.'); + } + + const intervalObj = setInterval(executeTrigger, ); async function closeFunction() { clearInterval(intervalObj); diff --git a/packages/nodes-base/nodes/Marketstack/GenericFunctions.ts b/packages/nodes-base/nodes/Marketstack/GenericFunctions.ts new file mode 100644 index 000000000..3a20c1ca2 --- /dev/null +++ b/packages/nodes-base/nodes/Marketstack/GenericFunctions.ts @@ -0,0 +1,96 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +export async function marketstackApiRequest( + this: IExecuteFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const credentials = this.getCredentials('marketstackApi') as IDataObject; + const protocol = credentials.useHttps ? 'https' : 'http'; // Free API does not support HTTPS + + const options: OptionsWithUri = { + method, + uri: `${protocol}://api.marketstack.com/v1${endpoint}`, + qs: { + access_key: credentials.apiKey, + ...qs, + }, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + try { + return await this.helpers.request(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function marketstackApiRequestAllItems( + this: IExecuteFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const returnAll = this.getNodeParameter('returnAll', 0, false) as boolean; + const limit = this.getNodeParameter('limit', 0, 0) as number; + + let responseData; + const returnData: IDataObject[] = []; + + qs.offset = 0; + + do { + responseData = await marketstackApiRequest.call(this, method, endpoint, body, qs); + returnData.push(...responseData.data); + + if (!returnAll && returnData.length > limit) { + return returnData.slice(0, limit); + } + + qs.offset += responseData.count; + } while ( + responseData.total > returnData.length + ); + + return returnData; +} + +export const format = (datetime?: string) => datetime?.split('T')[0]; + +export function validateTimeOptions( + this: IExecuteFunctions, + timeOptions: boolean[], +) { + if (timeOptions.every(o => !o)) { + throw new NodeOperationError( + this.getNode(), + 'Please filter by latest, specific date or timeframe (start and end dates).', + ); + } + + if (timeOptions.filter(Boolean).length > 1) { + throw new NodeOperationError( + this.getNode(), + 'Please filter by one of latest, specific date, or timeframe (start and end dates).', + ); + } +} diff --git a/packages/nodes-base/nodes/Marketstack/Marketstack.node.ts b/packages/nodes-base/nodes/Marketstack/Marketstack.node.ts new file mode 100644 index 000000000..325855713 --- /dev/null +++ b/packages/nodes-base/nodes/Marketstack/Marketstack.node.ts @@ -0,0 +1,203 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeOperationError, +} from 'n8n-workflow'; + +import { + endOfDayDataFields, + endOfDayDataOperations, + exchangeFields, + exchangeOperations, + tickerFields, + tickerOperations, +} from './descriptions'; + +import { + format, + marketstackApiRequest, + marketstackApiRequestAllItems, + validateTimeOptions, +} from './GenericFunctions'; + +import { + EndOfDayDataFilters, + Operation, + Resource, +} from './types'; + +export class Marketstack implements INodeType { + description: INodeTypeDescription = { + displayName: 'Marketstack', + name: 'marketstack', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + icon: 'file:marketstack.svg', + group: ['transform'], + version: 1, + description: 'Consume Marketstack API', + defaults: { + name: 'Marketstack', + color: '#02283e', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'marketstackApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'End-of-Day Data', + value: 'endOfDayData', + description: 'Stock market closing data', + }, + { + name: 'Exchange', + value: 'exchange', + description: 'Stock market exchange', + }, + { + name: 'Ticker', + value: 'ticker', + description: 'Stock market symbol', + }, + ], + default: 'endOfDayData', + required: true, + }, + ...endOfDayDataOperations, + ...endOfDayDataFields, + ...exchangeOperations, + ...exchangeFields, + ...tickerOperations, + ...tickerFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const resource = this.getNodeParameter('resource', 0) as Resource; + const operation = this.getNodeParameter('operation', 0) as Operation; + + let responseData: any; // tslint:disable-line: no-any + const returnData: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + + try { + + if (resource === 'endOfDayData') { + + if (operation === 'getAll') { + + // ---------------------------------- + // endOfDayData: getAll + // ---------------------------------- + + const qs: IDataObject = { + symbols: this.getNodeParameter('symbols', i), + }; + + const { + latest, + specificDate, + dateFrom, + dateTo, + ...rest + } = this.getNodeParameter('filters', i) as EndOfDayDataFilters; + + validateTimeOptions.call(this, [ + latest !== undefined && latest !== false, + specificDate !== undefined, + dateFrom !== undefined && dateTo !== undefined, + ]); + + if (Object.keys(rest).length) { + Object.assign(qs, rest); + } + + let endpoint: string; + + if (latest) { + endpoint = '/eod/latest'; + } else if (specificDate) { + endpoint = `/eod/${format(specificDate)}`; + } else { + if (!dateFrom || !dateTo) { + throw new NodeOperationError( + this.getNode(), + 'Please enter a start and end date to filter by timeframe.', + ); + } + endpoint = '/eod'; + qs.date_from = format(dateFrom); + qs.date_to = format(dateTo); + } + + responseData = await marketstackApiRequestAllItems.call(this, 'GET', endpoint, {}, qs); + + } + + } else if (resource === 'exchange') { + + if (operation === 'get') { + + // ---------------------------------- + // exchange: get + // ---------------------------------- + + const exchange = this.getNodeParameter('exchange', i); + const endpoint = `/exchanges/${exchange}`; + + responseData = await marketstackApiRequest.call(this, 'GET', endpoint); + + } + + } else if (resource === 'ticker') { + + if (operation === 'get') { + + // ---------------------------------- + // ticker: get + // ---------------------------------- + + const symbol = this.getNodeParameter('symbol', i); + const endpoint = `/tickers/${symbol}`; + + responseData = await marketstackApiRequest.call(this, 'GET', endpoint); + + } + + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Marketstack/descriptions/EndOfDayDataDescription.ts b/packages/nodes-base/nodes/Marketstack/descriptions/EndOfDayDataDescription.ts new file mode 100644 index 000000000..7b3620144 --- /dev/null +++ b/packages/nodes-base/nodes/Marketstack/descriptions/EndOfDayDataDescription.ts @@ -0,0 +1,157 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const endOfDayDataOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Get All', + value: 'getAll', + }, + ], + default: 'getAll', + displayOptions: { + show: { + resource: [ + 'endOfDayData', + ], + }, + }, + }, +]; + +export const endOfDayDataFields: INodeProperties[] = [ + { + displayName: 'Ticker', + name: 'symbols', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'endOfDayData', + ], + operation: [ + 'getAll', + ], + }, + }, + default: '', + description: 'One or multiple comma-separated stock symbols (tickers) to retrieve, e.g. AAPL or AAPL,MSFT', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'endOfDayData', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'How many results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'endOfDayData', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'endOfDayData', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Exchange', + name: 'exchange', + type: 'string', + default: '', + description: 'Stock exchange to filter results by, specified by Market Identifier Code, e.g. XNAS', + }, + { + displayName: 'Latest', + name: 'latest', + type: 'boolean', + default: false, + description: 'Whether to fetch the most recent stock market data', + }, + { + displayName: 'Sort Order', + name: 'sort', + description: 'Order to sort results in', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'ASC', + }, + { + name: 'Descending', + value: 'DESC', + }, + ], + default: 'DESC', + }, + { + displayName: 'Specific Date', + name: 'specificDate', + type: 'dateTime', + default: '', + description: 'Date in YYYY-MM-DD format, e.g. 2020-01-01, or in ISO-8601 date format, e.g. 2020-05-21T00:00:00+0000', + }, + { + displayName: 'Timeframe Start Date', + name: 'dateFrom', + type: 'dateTime', + default: '', + description: 'Timeframe start date in YYYY-MM-DD format, e.g. 2020-01-01, or in ISO-8601 date format, e.g. 2020-05-21T00:00:00+0000', + }, + { + displayName: 'Timeframe End Date', + name: 'dateTo', + type: 'dateTime', + default: '', + description: 'Timeframe end date in YYYY-MM-DD format, e.g. 2020-01-01, or in ISO-8601 date format, e.g. 2020-05-21T00:00:00+0000', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Marketstack/descriptions/ExchangeDescription.ts b/packages/nodes-base/nodes/Marketstack/descriptions/ExchangeDescription.ts new file mode 100644 index 000000000..5973020ab --- /dev/null +++ b/packages/nodes-base/nodes/Marketstack/descriptions/ExchangeDescription.ts @@ -0,0 +1,46 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const exchangeOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Get', + value: 'get', + }, + ], + default: 'get', + displayOptions: { + show: { + resource: [ + 'exchange', + ], + }, + }, + }, +]; + +export const exchangeFields: INodeProperties[] = [ + { + displayName: 'Exchange', + name: 'exchange', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'exchange', + ], + operation: [ + 'get', + ], + }, + }, + default: '', + description: 'Stock exchange to retrieve, specified by Market Identifier Code, e.g. XNAS', + }, +]; diff --git a/packages/nodes-base/nodes/Marketstack/descriptions/TickerDescription.ts b/packages/nodes-base/nodes/Marketstack/descriptions/TickerDescription.ts new file mode 100644 index 000000000..d0e8839f0 --- /dev/null +++ b/packages/nodes-base/nodes/Marketstack/descriptions/TickerDescription.ts @@ -0,0 +1,46 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const tickerOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Get', + value: 'get', + }, + ], + default: 'get', + displayOptions: { + show: { + resource: [ + 'ticker', + ], + }, + }, + }, +]; + +export const tickerFields: INodeProperties[] = [ + { + displayName: 'Ticker', + name: 'symbol', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'ticker', + ], + operation: [ + 'get', + ], + }, + }, + default: '', + description: 'Stock symbol (ticker) to retrieve, e.g. AAPL', + }, +]; diff --git a/packages/nodes-base/nodes/Marketstack/descriptions/index.ts b/packages/nodes-base/nodes/Marketstack/descriptions/index.ts new file mode 100644 index 000000000..8015eaae6 --- /dev/null +++ b/packages/nodes-base/nodes/Marketstack/descriptions/index.ts @@ -0,0 +1,3 @@ +export * from './EndOfDayDataDescription'; +export * from './TickerDescription'; +export * from './ExchangeDescription'; diff --git a/packages/nodes-base/nodes/Marketstack/marketstack.svg b/packages/nodes-base/nodes/Marketstack/marketstack.svg new file mode 100644 index 000000000..25ad681cc --- /dev/null +++ b/packages/nodes-base/nodes/Marketstack/marketstack.svg @@ -0,0 +1,57 @@ + + + + + +Marketstack + + +Marketstack diff --git a/packages/nodes-base/nodes/Marketstack/types.d.ts b/packages/nodes-base/nodes/Marketstack/types.d.ts new file mode 100644 index 000000000..17760df15 --- /dev/null +++ b/packages/nodes-base/nodes/Marketstack/types.d.ts @@ -0,0 +1,12 @@ +export type Resource = 'endOfDayData' | 'exchange' | 'ticker'; + +export type Operation = 'get' | 'getAll'; + +export type EndOfDayDataFilters = { + latest?: boolean; + sort?: 'ASC' | 'DESC'; + specificDate?: string; + dateFrom?: string; + dateTo?: string; + exchange?: string; +}; diff --git a/packages/nodes-base/nodes/NocoDB/GenericFunctions.ts b/packages/nodes-base/nodes/NocoDB/GenericFunctions.ts new file mode 100644 index 000000000..abdb64c77 --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/GenericFunctions.ts @@ -0,0 +1,135 @@ +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + OptionsWithUri, +} from 'request'; + +import { + IBinaryKeyData, + IDataObject, + INodeExecutionData, + IPollFunctions, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + + +interface IAttachment { + url: string; + title: string; + mimetype: string; + size: number; +} + +/** + * Make an API request to NocoDB + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, method: string, endpoint: string, body: object, query?: IDataObject, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('nocoDb'); + + if (credentials === undefined) { + throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); + } + + query = query || {}; + + const options: OptionsWithUri = { + headers: { + 'xc-auth': credentials.apiToken, + }, + method, + body, + qs: query, + uri: uri || `${credentials.host}${endpoint}`, + json: true, + + }; + + if (Object.keys(option).length !== 0) { + Object.assign(options, option); + } + + if (Object.keys(body).length === 0) { + delete options.body; + } + + try { + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + + +/** + * Make an API request to paginated NocoDB endpoint + * and return all results + * + * @export + * @param {(IHookFunctions | IExecuteFunctions)} this + * @param {string} method + * @param {string} endpoint + * @param {IDataObject} body + * @param {IDataObject} [query] + * @returns {Promise} + */ +export async function apiRequestAllItems(this: IHookFunctions | IExecuteFunctions | IPollFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject): Promise { // tslint:disable-line:no-any + + if (query === undefined) { + query = {}; + } + query.limit = 100; + query.offset = query?.offset ? query.offset as number : 0; + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await apiRequest.call(this, method, endpoint, body, query); + + returnData.push(...responseData); + + query.offset += query.limit; + + } while ( + responseData.length === 0 + ); + + return returnData; +} + +export async function downloadRecordAttachments(this: IExecuteFunctions | IPollFunctions, records: IDataObject[], fieldNames: string[]): Promise { + const elements: INodeExecutionData[] = []; + + for (const record of records) { + const element: INodeExecutionData = { json: {}, binary: {} }; + element.json = record as unknown as IDataObject; + for (const fieldName of fieldNames) { + if (record[fieldName]) { + for (const [index, attachment] of (JSON.parse(record[fieldName] as string) as IAttachment[]).entries()) { + const file = await apiRequest.call(this, 'GET', '', {}, {}, attachment.url, { json: false, encoding: null }); + element.binary![`${fieldName}_${index}`] = { + data: Buffer.from(file).toString('base64'), + fileName: attachment.title, + mimeType: attachment.mimetype, + }; + } + } + } + if (Object.keys(element.binary as IBinaryKeyData).length === 0) { + delete element.binary; + } + elements.push(element); + } + return elements; +} diff --git a/packages/nodes-base/nodes/NocoDB/NocoDB.node.json b/packages/nodes-base/nodes/NocoDB/NocoDB.node.json new file mode 100644 index 000000000..0e9c652a9 --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/NocoDB.node.json @@ -0,0 +1,22 @@ +{ + "node": "n8n-nodes-base.nocoDb", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/nocoDb" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.nocoDb/" + } + ], + "generic": [ + ] + } +} diff --git a/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts b/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts new file mode 100644 index 000000000..a32eb12db --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/NocoDB.node.ts @@ -0,0 +1,380 @@ +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryData, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +import { + apiRequest, + apiRequestAllItems, + downloadRecordAttachments, +} from './GenericFunctions'; + +import { + operationFields +} from './OperationDescription'; + +export class NocoDB implements INodeType { + description: INodeTypeDescription = { + displayName: 'NocoDB', + name: 'nocoDb', + icon: 'file:nocodb.svg', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Read, update, write and delete data from NocoDB', + defaults: { + name: 'NocoDB', + color: '#0989ff', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'nocoDb', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Row', + value: 'row', + }, + ], + default: 'row', + description: 'The Resource to operate on', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'row', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a row', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a row', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all rows', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a row', + }, + { + name: 'Update', + value: 'update', + description: 'Update a row', + }, + ], + default: 'get', + description: 'The operation to perform', + }, + ...operationFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + let responseData; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const projectId = this.getNodeParameter('projectId', 0) as string; + const table = this.getNodeParameter('table', 0) as string; + + let returnAll = false; + let endpoint = ''; + let requestMethod = ''; + + let qs: IDataObject = {}; + + if (resource === 'row') { + + if (operation === 'create') { + + requestMethod = 'POST'; + endpoint = `/nc/${projectId}/api/v1/${table}/bulk`; + + const body: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + const newItem: IDataObject = {}; + const dataToSend = this.getNodeParameter('dataToSend', i) as 'defineBelow' | 'autoMapInputData'; + + if (dataToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputDataToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + newItem[key] = items[i].json[key]; + } + } else { + const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as Array<{ + fieldName: string; + binaryData: boolean; + fieldValue?: string; + binaryProperty?: string; + }>; + + for (const field of fields) { + if (!field.binaryData) { + newItem[field.fieldName] = field.fieldValue; + } else if (field.binaryProperty) { + if (!items[i].binary) { + throw new NodeOperationError(this.getNode(), 'No binary data exists on item!'); + } + const binaryPropertyName = field.binaryProperty; + if (binaryPropertyName && !items[i].binary![binaryPropertyName]) { + throw new NodeOperationError(this.getNode(), `Binary property ${binaryPropertyName} does not exist on item!`); + } + const binaryData = items[i].binary![binaryPropertyName] as IBinaryData; + + const formData = { + file: { + value: Buffer.from(binaryData.data, BINARY_ENCODING), + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + json: JSON.stringify({ + api: 'xcAttachmentUpload', + project_id: projectId, + dbAlias: 'db', + args: {}, + }), + }; + const qs = { project_id: projectId }; + + responseData = await apiRequest.call(this, 'POST', '/dashboard', {}, qs, undefined, { formData }); + newItem[field.fieldName] = JSON.stringify([responseData]); + } + } + } + body.push(newItem); + } + try { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + + // Calculate ID manually and add to return data + let id = responseData[0]; + for (let i = body.length - 1; i >= 0; i--) { + body[i].id = id--; + } + + returnData.push(...body); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + } + throw new NodeApiError(this.getNode(), error); + } + } else if (operation === 'delete') { + + requestMethod = 'DELETE'; + endpoint = `/nc/${projectId}/api/v1/${table}/bulk`; + const body: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + const id = this.getNodeParameter('id', i) as string; + body.push({ id }); + } + try { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + returnData.push(...items.map(item => item.json)); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + } + throw new NodeApiError(this.getNode(), error); + } + } else if (operation === 'getAll') { + const data = []; + const downloadAttachments = this.getNodeParameter('downloadAttachments', 0) as boolean; + try { + for (let i = 0; i < items.length; i++) { + requestMethod = 'GET'; + endpoint = `/nc/${projectId}/api/v1/${table}`; + + returnAll = this.getNodeParameter('returnAll', 0) as boolean; + qs = this.getNodeParameter('options', i, {}) as IDataObject; + + if (qs.sort) { + const properties = (qs.sort as IDataObject).property as Array<{ field: string, direction: string }>; + qs.sort = properties.map(prop => `${prop.direction === 'asc' ? '' : '-'}${prop.field}`).join(','); + } + + if (qs.fields) { + qs.fields = (qs.fields as IDataObject[]).join(','); + } + + if (returnAll === true) { + responseData = await apiRequestAllItems.call(this, requestMethod, endpoint, {}, qs); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await apiRequest.call(this, requestMethod, endpoint, {}, qs); + } + + returnData.push.apply(returnData, responseData); + + if (downloadAttachments === true) { + const downloadFieldNames = (this.getNodeParameter('downloadFieldNames', 0) as string).split(','); + const response = await downloadRecordAttachments.call(this, responseData, downloadFieldNames); + data.push(...response); + } + } + + if (downloadAttachments) { + return [data]; + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + } + throw error; + } + } else if (operation === 'get') { + + requestMethod = 'GET'; + const newItems: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + const id = this.getNodeParameter('id', i) as string; + endpoint = `/nc/${projectId}/api/v1/${table}/${id}`; + responseData = await apiRequest.call(this, requestMethod, endpoint, {}, qs); + const newItem: INodeExecutionData = { json: responseData }; + + const downloadAttachments = this.getNodeParameter('downloadAttachments', i) as boolean; + + if (downloadAttachments === true) { + const downloadFieldNames = (this.getNodeParameter('downloadFieldNames', i) as string).split(','); + const data = await downloadRecordAttachments.call(this, [responseData], downloadFieldNames); + newItem.binary = data[0].binary; + } + + newItems.push(newItem); + } catch (error) { + if (this.continueOnFail()) { + newItems.push({ json: { error: error.toString() } }); + continue; + } + throw new NodeApiError(this.getNode(), error); + } + } + return this.prepareOutputData(newItems); + + } else if (operation === 'update') { + + requestMethod = 'PUT'; + endpoint = `/nc/${projectId}/api/v1/${table}/bulk`; + + const body: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + + const id = this.getNodeParameter('id', i) as string; + const newItem: IDataObject = { id }; + const dataToSend = this.getNodeParameter('dataToSend', i) as 'defineBelow' | 'autoMapInputData'; + + if (dataToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputDataToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + newItem[key] = items[i].json[key]; + } + } else { + const fields = this.getNodeParameter('fieldsUi.fieldValues', i, []) as Array<{ + fieldName: string; + upload: boolean; + fieldValue?: string; + binaryProperty?: string; + }>; + + for (const field of fields) { + if (!field.upload) { + newItem[field.fieldName] = field.fieldValue; + } else if (field.binaryProperty) { + if (!items[i].binary) { + throw new NodeOperationError(this.getNode(), 'No binary data exists on item!'); + } + const binaryPropertyName = field.binaryProperty; + if (binaryPropertyName && !items[i].binary![binaryPropertyName]) { + throw new NodeOperationError(this.getNode(), `Binary property ${binaryPropertyName} does not exist on item!`); + } + const binaryData = items[i].binary![binaryPropertyName] as IBinaryData; + + const formData = { + file: { + value: Buffer.from(binaryData.data, BINARY_ENCODING), + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + json: JSON.stringify({ + api: 'xcAttachmentUpload', + project_id: projectId, + dbAlias: 'db', + args: {}, + }), + }; + const qs = { project_id: projectId }; + + responseData = await apiRequest.call(this, 'POST', '/dashboard', {}, qs, undefined, { formData }); + newItem[field.fieldName] = JSON.stringify([responseData]); + } + } + } + body.push(newItem); + } + + try { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + returnData.push(...body); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.toString() }); + } + throw new NodeApiError(this.getNode(), error); + } + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/NocoDB/OperationDescription.ts b/packages/nodes-base/nodes/NocoDB/OperationDescription.ts new file mode 100644 index 000000000..c679e4c05 --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/OperationDescription.ts @@ -0,0 +1,383 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const operationFields = [ + // ---------------------------------- + // Shared + // ---------------------------------- + { + displayName: 'Project ID', + name: 'projectId', + type: 'string', + default: '', + required: true, + description: 'The ID of the project', + }, + { + displayName: 'Table', + name: 'table', + type: 'string', + default: '', + required: true, + description: 'The name of the table', + }, + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Row ID', + name: 'id', + type: 'string', + displayOptions: { + show: { + operation: [ + 'delete', + ], + }, + }, + default: '', + required: true, + description: 'ID of the row to delete', + }, + // ---------------------------------- + // getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'The max number of results to return', + }, + { + displayName: 'Download Attachments', + name: 'downloadAttachments', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: `When set to true the attachment fields define in 'Download Fields' will be downloaded.`, + }, + { + displayName: 'Download Fields', + name: 'downloadFieldNames', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + downloadAttachments: [ + true, + ], + }, + }, + default: '', + description: `Name of the fields of type 'attachment' that should be downloaded. Multiple ones can be defined separated by comma. Case sensitive.`, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'Fields', + name: 'fields', + type: 'string', + typeOptions: { + multipleValues: true, + multipleValueButtonText: 'Add Field', + }, + default: [], + placeholder: 'Name', + description: 'The select fields of the returned rows', + }, + { + displayName: 'Filter By Formula', + name: 'where', + type: 'string', + default: '', + placeholder: '(name,like,example%)~or(name,eq,test)', + description: 'A formula used to filter rows', + }, + { + displayName: 'Sort', + name: 'sort', + placeholder: 'Add Sort Rule', + description: 'The sorting rules for the returned rows', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'property', + displayName: 'Property', + values: [ + { + displayName: 'Field', + name: 'field', + type: 'string', + default: '', + description: 'Name of the field to sort on', + }, + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { + name: 'ASC', + value: 'asc', + description: 'Sort in ascending order (small -> large)', + }, + { + name: 'DESC', + value: 'desc', + description: 'Sort in descending order (large -> small)', + }, + ], + default: 'asc', + description: 'The sort direction', + }, + ], + }, + ], + }, + + ], + }, + // ---------------------------------- + // get + // ---------------------------------- + { + displayName: 'Row ID', + name: 'id', + type: 'string', + displayOptions: { + show: { + operation: [ + 'get', + ], + }, + }, + default: '', + required: true, + description: 'ID of the row to return', + }, + { + displayName: 'Download Attachments', + name: 'downloadAttachments', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'get', + ], + }, + }, + default: false, + description: `When set to true the attachment fields define in 'Download Fields' will be downloaded.`, + }, + { + displayName: 'Download Fields', + name: 'downloadFieldNames', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + downloadAttachments: [ + true, + ], + }, + }, + default: '', + description: `Name of the fields of type 'attachment' that should be downloaded. Multiple ones can be defined separated by comma. Case sensitive.`, + }, + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Row ID', + name: 'id', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update', + ], + }, + }, + default: '', + required: true, + description: 'ID of the row to update', + }, + // ---------------------------------- + // Shared + // ---------------------------------- + { + displayName: 'Data to Send', + name: 'dataToSend', + type: 'options', + options: [ + { + name: 'Auto-map Input Data to Columns', + value: 'autoMapInputData', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineBelow', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + }, + }, + default: 'defineBelow', + description: 'Whether to insert the input data this node receives in the new row', + }, + { + displayName: 'Inputs to Ignore', + name: 'inputsToIgnore', + type: 'string', + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + dataToSend: [ + 'autoMapInputData', + ], + }, + }, + default: '', + required: false, + description: 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties', + placeholder: 'Enter properties...', + }, + { + displayName: 'Fields to Send', + name: 'fieldsUi', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Field to Send', + multipleValues: true, + }, + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + dataToSend: [ + 'defineBelow', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Field', + name: 'fieldValues', + values: [ + { + displayName: 'Field Name', + name: 'fieldName', + type: 'string', + default: '', + }, + { + displayName: 'Is Binary Data', + name: 'binaryData', + type: 'boolean', + default: false, + description: 'If the field data to set is binary and should be taken from a binary property', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + displayOptions: { + show: { + binaryData: [ + false, + ], + }, + }, + }, + { + displayName: 'Take Input From Field', + name: 'binaryProperty', + type: 'string', + description: 'The field containing the binary file data to be uploaded', + default: '', + displayOptions: { + show: { + binaryData: [ + true, + ], + }, + }, + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/NocoDB/nocodb.svg b/packages/nodes-base/nodes/NocoDB/nocodb.svg new file mode 100644 index 000000000..42a90146b --- /dev/null +++ b/packages/nodes-base/nodes/NocoDB/nocodb.svg @@ -0,0 +1,425 @@ + + + + diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index fae5a70ad..9df80ee92 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -96,7 +96,7 @@ export async function pipedriveApiRequest(this: IHookFunctions | IExecuteFunctio return { additionalData: responseData.additional_data, - data: responseData.data, + data: (responseData.data === null) ? [] : responseData.data, }; } catch (error) { throw new NodeApiError(this.getNode(), error); diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 2fbb5de90..44dce1e4c 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -118,6 +118,10 @@ export class Pipedrive implements INodeType { name: 'Deal', value: 'deal', }, + { + name: 'Deal Product', + value: 'dealProduct', + }, { name: 'File', value: 'file', @@ -246,6 +250,42 @@ export class Pipedrive implements INodeType { description: 'The operation to perform.', }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'dealProduct', + ], + }, + }, + options: [ + { + name: 'Add', + value: 'add', + description: 'Add a product to a deal', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all products in a deal', + }, + { + name: 'Remove', + value: 'remove', + description: 'Remove a product from a deal', + }, + { + name: 'Update', + value: 'update', + description: 'Update a product in a deal', + }, + ], + default: 'add', + }, + { displayName: 'Operation', name: 'operation', @@ -412,6 +452,11 @@ export class Pipedrive implements INodeType { value: 'update', description: 'Update an organization', }, + { + name: 'Search', + value: 'search', + description: 'Search organizations', + }, ], default: 'create', description: 'The operation to perform.', @@ -1420,6 +1465,330 @@ export class Pipedrive implements INodeType { }, ], }, + // ---------------------------------- + // dealProduct:add + // ---------------------------------- + { + displayName: 'Deal ID', + name: 'dealId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDeals', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'dealProduct', + ], + }, + }, + description: 'The ID of the deal to add a product to', + }, + { + displayName: 'Product ID', + name: 'productId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getProducts', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'dealProduct', + ], + }, + }, + description: 'The ID of the product to add to a deal', + }, + { + displayName: 'Item Price', + name: 'item_price', + type: 'number', + typeOptions: { + numberPrecision: 2, + }, + default: 0.00, + required: true, + description: 'Price at which to add or update this product in a deal', + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'dealProduct', + ], + }, + }, + }, + { + displayName: 'Quantity', + name: 'quantity', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, + required: true, + description: 'How many items of this product to add/update in a deal', + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'dealProduct', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'dealProduct', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Comments', + name: 'comments', + type: 'string', + typeOptions: { + rows: 4, + }, + default: '', + description: 'Text to describe this product-deal attachment', + }, + { + displayName: 'Discount Percentage', + name: 'discount_percentage', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + maxValue: 100, + }, + description: 'Percentage of discount to apply', + }, + { + displayName: 'Product Variation ID', + name: 'product_variation_id', + type: 'string', + default: '', + description: 'ID of the product variation to use', + }, + ], + }, + // ---------------------------------- + // dealProduct:update + // ---------------------------------- + { + displayName: 'Deal ID', + name: 'dealId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDeals', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'dealProduct', + ], + }, + }, + description: 'The ID of the deal whose product to update', + }, + { + displayName: 'Product Attachment ID', + name: 'productAttachmentId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getProductsDeal', + loadOptionsDependsOn: [ + 'dealId', + ], + }, + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'dealProduct', + ], + }, + }, + description: 'ID of the deal-product (the ID of the product attached to the deal)', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'dealProduct', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Comments', + name: 'comments', + type: 'string', + typeOptions: { + rows: 4, + }, + default: '', + description: 'Text to describe this product-deal attachment', + }, + { + displayName: 'Discount Percentage', + name: 'discount_percentage', + type: 'number', + default: 0, + typeOptions: { + minValue: 0, + maxValue: 100, + }, + description: 'Percentage of discount to apply', + }, + { + displayName: 'Item Price', + name: 'item_price', + type: 'number', + typeOptions: { + numberPrecision: 2, + }, + default: 0.00, + required: true, + description: 'Price at which to add or update this product in a deal', + }, + { + displayName: 'Quantity', + name: 'quantity', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, + required: true, + description: 'How many items of this product to add/update in a deal', + }, + { + displayName: 'Product Variation ID', + name: 'product_variation_id', + type: 'string', + default: '', + description: 'ID of the product variation to use', + }, + ], + }, + // ---------------------------------- + // dealProduct:remove + // ---------------------------------- + { + displayName: 'Deal ID', + name: 'dealId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDeals', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'remove', + ], + resource: [ + 'dealProduct', + ], + }, + }, + description: 'The ID of the deal whose product to remove', + }, + { + displayName: 'Product Attachment ID', + name: 'productAttachmentId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getProductsDeal', + loadOptionsDependsOn: [ + 'dealId', + ], + }, + required: true, + displayOptions: { + show: { + operation: [ + 'remove', + ], + resource: [ + 'dealProduct', + ], + }, + }, + description: 'ID of the deal-product (the ID of the product attached to the deal)', + }, + // ---------------------------------- + // dealProduct:getAll + // ---------------------------------- + { + displayName: 'Deal ID', + name: 'dealId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDeals', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'dealProduct', + ], + }, + }, + description: 'The ID of the deal whose products to retrieve', + }, + // ---------------------------------- // deal:search // ---------------------------------- @@ -2444,6 +2813,85 @@ export class Pipedrive implements INodeType { description: 'ID of the organization to get.', }, + // ---------------------------------- + // organization:search + // ---------------------------------- + { + displayName: 'Term', + name: 'term', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'organization', + ], + }, + }, + default: '', + description: 'The search term to look for. Minimum 2 characters (or 1 if using exact_match).', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'organization', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Exact Match', + name: 'exactMatch', + type: 'boolean', + default: false, + description: 'When enabled, only full exact matches against the given term are returned. It is not case sensitive.', + }, + { + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + default: [], + description: 'Fields to the search in. Defaults to all of them.', + options: [ + { + name: 'Address', + value: 'address', + }, + { + name: 'Custom Fields', + value: 'custom_fields', + }, + { + name: 'Name', + value: 'name', + }, + { + name: 'Notes', + value: 'notes', + }, + ], + }, + { + displayName: 'RAW Data', + name: 'rawData', + type: 'boolean', + default: false, + description: `Returns the data exactly in the way it got received from the API.`, + }, + ], + }, // ---------------------------------- // organization:update // ---------------------------------- @@ -3316,6 +3764,32 @@ export class Pipedrive implements INodeType { return returnData; }, + // Get all Deals to display them to user so that he can + // select them easily + async getDeals(this: ILoadOptionsFunctions): Promise { + const { data } = await pipedriveApiRequest.call(this, 'GET', '/deals', {}) as { + data: Array<{ id: string; title: string; }> + }; + return data.map(({ id, title }) => ({ value: id, name: title })); + }, + // Get all Products to display them to user so that he can + // select them easily + async getProducts(this: ILoadOptionsFunctions): Promise { + const { data } = await pipedriveApiRequest.call(this, 'GET', '/products', {}) as { + data: Array<{ id: string; name: string; }> + }; + return data.map(({ id, name }) => ({ value: id, name })); + }, + // Get all Products related to a deal and display them to user so that he can + // select them easily + async getProductsDeal(this: ILoadOptionsFunctions): Promise { + + const dealId = this.getCurrentNodeParameter('dealId'); + const { data } = await pipedriveApiRequest.call(this, 'GET', `/deals/${dealId}/products`, {}) as { + data: Array<{ id: string; name: string; }> + }; + return data.map(({ id, name }) => ({ value: id, name })); + }, // Get all Stages to display them to user so that he can // select them easily async getStageIds(this: ILoadOptionsFunctions): Promise { @@ -3801,12 +4275,67 @@ export class Pipedrive implements INodeType { endpoint = `/deals/search`; } + + } else if (resource === 'dealProduct') { + + if (operation === 'add') { + // ---------------------------------- + // dealProduct: add + // ---------------------------------- + + requestMethod = 'POST'; + const dealId = this.getNodeParameter('dealId', i) as string; + + endpoint = `/deals/${dealId}/products`; + + body.product_id = this.getNodeParameter('productId', i) as string; + body.item_price = this.getNodeParameter('item_price', i) as string; + body.quantity = this.getNodeParameter('quantity', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + addAdditionalFields(body, additionalFields); + + } else if (operation === 'getAll') { + // ---------------------------------- + // dealProduct: getAll + // ---------------------------------- + + requestMethod = 'GET'; + const dealId = this.getNodeParameter('dealId', i) as string; + + endpoint = `/deals/${dealId}/products`; + + } else if (operation === 'remove') { + // ---------------------------------- + // dealProduct: remove + // ---------------------------------- + + requestMethod = 'DELETE'; + const dealId = this.getNodeParameter('dealId', i) as string; + const productAttachmentId = this.getNodeParameter('productAttachmentId', i) as string; + + endpoint = `/deals/${dealId}/products/${productAttachmentId}`; + + } else if (operation === 'update') { + // ---------------------------------- + // dealProduct: update + // ---------------------------------- + + requestMethod = 'PUT'; + const dealId = this.getNodeParameter('dealId', i) as string; + const productAttachmentId = this.getNodeParameter('productAttachmentId', i) as string; + + endpoint = `/deals/${dealId}/products/${productAttachmentId}`; + + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + addAdditionalFields(body, updateFields); + } + } else if (resource === 'file') { if (operation === 'create') { // ---------------------------------- // file:create // ---------------------------------- - requestMethod = 'POST'; endpoint = '/files'; @@ -4110,8 +4639,7 @@ export class Pipedrive implements INodeType { endpoint = `/organizations`; - } - if (operation === 'update') { + } else if (operation === 'update') { // ---------------------------------- // organization:update // ---------------------------------- @@ -4128,6 +4656,32 @@ export class Pipedrive implements INodeType { body.label = null; } + } else if (operation === 'search') { + // ---------------------------------- + // organization:search + // ---------------------------------- + + requestMethod = 'GET'; + + qs.term = this.getNodeParameter('term', i) as string; + returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === false) { + qs.limit = this.getNodeParameter('limit', i) as number; + } + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject & { + fields?: string[]; + }; + + if (additionalFields?.fields?.length) { + qs.fields = additionalFields.fields.join(','); + } + + if (additionalFields.exactMatch) { + qs.exact_match = additionalFields.exactMatch as boolean; + } + + endpoint = `/organizations/search`; } } else if (resource === 'person') { if (operation === 'create') { diff --git a/packages/nodes-base/nodes/Salesforce/AttachmentInterface.ts b/packages/nodes-base/nodes/Salesforce/AttachmentInterface.ts index e26b2a9c8..e0868cd20 100644 --- a/packages/nodes-base/nodes/Salesforce/AttachmentInterface.ts +++ b/packages/nodes-base/nodes/Salesforce/AttachmentInterface.ts @@ -1,9 +1,9 @@ export interface IAttachment { ParentId?: string; Name?: string; - Body?: string; OwnerId?: string; IsPrivate?: boolean; ContentType?: string; Description?: string; + Body?: string; } diff --git a/packages/nodes-base/nodes/Salesforce/DocumentDescription.ts b/packages/nodes-base/nodes/Salesforce/DocumentDescription.ts new file mode 100644 index 000000000..819b2eb03 --- /dev/null +++ b/packages/nodes-base/nodes/Salesforce/DocumentDescription.ts @@ -0,0 +1,107 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const documentOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'document', + ], + }, + }, + options: [ + { + name: 'Upload', + value: 'upload', + description: 'Upload a document', + }, + ], + default: 'upload', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const documentFields = [ + + /* -------------------------------------------------------------------------- */ + /* document:upload */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'upload', + ], + }, + }, + description: 'Name of the file', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'upload', + ], + }, + }, + placeholder: '', + description: 'Name of the binary property which contains
the data for the file to be uploaded.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'document', + ], + operation: [ + 'upload', + ], + }, + }, + options: [ + { + displayName: 'Link To Object ID', + name: 'linkToObjectId', + type: 'string', + default: '', + description: 'ID of the object you want to link this document to', + }, + { + displayName: 'Owner ID', + name: 'ownerId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + default: '', + description: 'ID of the owner of this document', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts index 1200b9e70..1671e54c9 100644 --- a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts @@ -24,7 +24,6 @@ import { export async function salesforceApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any const authenticationMethod = this.getNodeParameter('authentication', 0, 'oAuth2') as string; - try { if (authenticationMethod === 'jwt') { // https://help.salesforce.com/articleView?id=remoteaccess_oauth_jwt_flow.htm&type=5 @@ -35,6 +34,7 @@ export async function salesforceApiRequest(this: IExecuteFunctions | IExecuteSin const options = getOptions.call(this, method, (uri || endpoint), body, qs, instance_url as string); Logger.debug(`Authentication for "Salesforce" node is using "jwt". Invoking URI ${options.uri}`); options.headers!.Authorization = `Bearer ${access_token}`; + Object.assign(options, option); //@ts-ignore return await this.helpers.request(options); } else { @@ -43,6 +43,7 @@ export async function salesforceApiRequest(this: IExecuteFunctions | IExecuteSin const credentials = this.getCredentials(credentialsType) as { oauthTokenData: { instance_url: string } }; const options = getOptions.call(this, method, (uri || endpoint), body, qs, credentials.oauthTokenData.instance_url); Logger.debug(`Authentication for "Salesforce" node is using "OAuth2". Invoking URI ${options.uri}`); + Object.assign(options, option); //@ts-ignore return await this.helpers.requestOAuth2.call(this, credentialsType, options); } @@ -90,12 +91,16 @@ function getOptions(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOpt 'Content-Type': 'application/json', }, method, - body: method === 'GET' ? undefined : body, + body, qs, uri: `${instanceUrl}/services/data/v39.0${endpoint}`, json: true, }; + if (!Object.keys(options.body).length) { + delete options.body; + } + //@ts-ignore return options; } diff --git a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts index f9b41623b..b91ad6cb1 100644 --- a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts +++ b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts @@ -1,4 +1,5 @@ import { + BINARY_ENCODING, IExecuteFunctions, } from 'n8n-core'; @@ -112,6 +113,11 @@ import { userOperations, } from './UserDescription'; +import { + documentFields, + documentOperations, +} from './DocumentDescription'; + import { LoggerProxy as Logger, } from 'n8n-workflow'; @@ -203,6 +209,11 @@ export class Salesforce implements INodeType { value: 'customObject', description: 'Represents a custom object.', }, + { + name: 'Document', + value: 'document', + description: 'Represents a document.', + }, { name: 'Flow', value: 'flow', @@ -243,6 +254,8 @@ export class Salesforce implements INodeType { ...contactFields, ...customObjectOperations, ...customObjectFields, + ...documentOperations, + ...documentFields, ...opportunityOperations, ...opportunityFields, ...accountOperations, @@ -936,6 +949,27 @@ export class Salesforce implements INodeType { sortOptions(returnData); return returnData; }, + // // Get all folders to display them to user so that he can + // // select them easily + // async getFolders(this: ILoadOptionsFunctions): Promise { + // const returnData: INodePropertyOptions[] = []; + // const fields = await salesforceApiRequestAllItems.call(this, 'records', 'GET', '/sobjects/folder/describe'); + // console.log(JSON.stringify(fields, undefined, 2)) + // const qs = { + // //ContentFolderItem ContentWorkspace ContentFolder + // q: `SELECT Id, Title FROM ContentVersion`, + // //q: `SELECT Id FROM Folder where Type = 'Document'`, + + // }; + // const folders = await salesforceApiRequestAllItems.call(this, 'records', 'GET', '/query', {}, qs); + // for (const folder of folders) { + // returnData.push({ + // name: folder.Name, + // value: folder.Id, + // }); + // } + // return returnData; + // }, }, }; @@ -1588,6 +1622,49 @@ export class Salesforce implements INodeType { } } } + if (resource === 'document') { + //https://developer.salesforce.com/docs/atlas.en-us.206.0.api_rest.meta/api_rest/dome_sobject_insert_update_blob.htm + if (operation === 'upload') { + const title = this.getNodeParameter('title', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string; + let data; + const body: { entity_content: { [key: string]: string } } = { + entity_content: { + Title: title, + ContentLocation: 'S', + }, + }; + if (additionalFields.ownerId) { + body.entity_content['ownerId'] = additionalFields.ownerId as string; + } + if (additionalFields.linkToObjectId) { + body.entity_content['FirstPublishLocationId'] = additionalFields.linkToObjectId as string; + } + if (items[i].binary && items[i].binary![binaryPropertyName]) { + const binaryData = items[i].binary![binaryPropertyName]; + body.entity_content['PathOnClient'] = `${title}.${binaryData.fileExtension}`; + data = { + entity_content: { + value: JSON.stringify(body.entity_content), + options: { + contentType: 'application/json', + }, + }, + VersionData: { + value: Buffer.from(binaryData.data, BINARY_ENCODING), + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + }; + } else { + throw new NodeOperationError(this.getNode(), `The property ${binaryPropertyName} does not exist`); + } + responseData = await salesforceApiRequest.call(this, 'POST', '/sobjects/ContentVersion', {}, {}, undefined, { formData: data }); + } + } if (resource === 'opportunity') { //https://developer.salesforce.com/docs/api-explorer/sobject/Opportunity/post-opportunity if (operation === 'create' || operation === 'upsert') { diff --git a/packages/nodes-base/nodes/ServiceNow/ServiceNow.node.json b/packages/nodes-base/nodes/ServiceNow/ServiceNow.node.json new file mode 100644 index 000000000..84e6b75a8 --- /dev/null +++ b/packages/nodes-base/nodes/ServiceNow/ServiceNow.node.json @@ -0,0 +1,21 @@ +{ + "node": "n8n-nodes-base.serviceNow", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Productivity", + "Communication" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/serviceNow" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.serviceNow/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/SpreadsheetFile.node.ts b/packages/nodes-base/nodes/SpreadsheetFile.node.ts index b87dbde1a..fcf972de6 100644 --- a/packages/nodes-base/nodes/SpreadsheetFile.node.ts +++ b/packages/nodes-base/nodes/SpreadsheetFile.node.ts @@ -31,6 +31,10 @@ function flattenObject(data: IDataObject) { const returnData: IDataObject = {}; for (const key1 of Object.keys(data)) { if (data[key1] !== null && (typeof data[key1]) === 'object') { + if (data[key1] instanceof Date) { + returnData[key1] = data[key1]?.toString(); + continue; + } const flatObject = flattenObject(data[key1] as IDataObject); for (const key2 in flatObject) { if (flatObject[key2] === undefined) { diff --git a/packages/nodes-base/nodes/Stripe/Stripe.node.json b/packages/nodes-base/nodes/Stripe/Stripe.node.json new file mode 100644 index 000000000..a8129f5a0 --- /dev/null +++ b/packages/nodes-base/nodes/Stripe/Stripe.node.json @@ -0,0 +1,21 @@ +{ + "node": "n8n-nodes-base.stripe", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Finance & Accounting", + "Sales" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/stripe" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.stripe/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Taiga/TaigaTrigger.node.ts b/packages/nodes-base/nodes/Taiga/TaigaTrigger.node.ts index a01ff66b4..0a63f51ae 100644 --- a/packages/nodes-base/nodes/Taiga/TaigaTrigger.node.ts +++ b/packages/nodes-base/nodes/Taiga/TaigaTrigger.node.ts @@ -63,6 +63,70 @@ export class TaigaTrigger implements INodeType { description: 'Project ID', required: true, }, + { + displayName: 'Resources', + name: 'resources', + type: 'multiOptions', + required: true, + default: [ + 'all', + ], + options: [ + { + name: 'All', + value: 'all', + }, + { + name: 'Issue', + value: 'issue', + }, + { + name: 'Milestone (Sprint)', + value: 'milestone', + }, + { + name: 'Task', + value: 'task', + }, + { + name: 'User Story', + value: 'userstory', + }, + { + name: 'Wikipage', + value: 'wikipage', + }, + ], + description: 'Resources to listen to', + }, + { + displayName: 'Operations', + name: 'operations', + type: 'multiOptions', + required: true, + default: [ + 'all', + ], + description: 'Operations to listen to', + options: [ + { + name: 'All', + value: 'all', + }, + { + name: 'Create', + value: 'create', + }, + { + name: 'Delete', + value: 'delete', + }, + { + name: 'Update', + value: 'change', + }, + ], + }, ], }; @@ -125,7 +189,7 @@ export class TaigaTrigger implements INodeType { const body: IDataObject = { name: `n8n-webhook:${webhookUrl}`, url: webhookUrl, - key, //can't validate the secret, see: https://github.com/taigaio/taiga-back/issues/1031 + key, project: projectId, }; const { id } = await taigaApiRequest.call(this, 'POST', '/webhooks', body); @@ -150,25 +214,34 @@ export class TaigaTrigger implements INodeType { }; async webhook(this: IWebhookFunctions): Promise { - //const webhookData = this.getWorkflowStaticData('node'); - const req = this.getRequestObject(); - const bodyData = req.body; - //const headerData = this.getHeaderData(); + const body = this.getRequestObject().body as WebhookPayload; + const operations = this.getNodeParameter('operations', []) as Operations[]; + const resources = this.getNodeParameter('resources', []) as Resources[]; - // TODO - // Validate signature + if (!operations.includes('all') && !operations.includes(body.action)) { + return {}; + } + + if (!resources.includes('all') && !resources.includes(body.type)) { + return {}; + } + + // TODO: Signature does not match payload hash // https://github.com/taigaio/taiga-back/issues/1031 - // //@ts-ignore - // const requestSignature: string = headerData['x-taiga-webhook-signature']; + // const webhookData = this.getWorkflowStaticData('node'); + // const headerData = this.getHeaderData(); + + // // @ts-ignore + // const requestSignature = headerData['x-taiga-webhook-signature']; + // console.log(requestSignature); // if (requestSignature === undefined) { // return {}; // } - // //@ts-ignore - // const computedSignature = createHmac('sha1', webhookData.key as string).update(JSON.stringify(bodyData)).digest('hex'); + // const computedSignature = createHmac('sha1', webhookData.key as string).update(JSON.stringify(body)).digest('hex'); // if (requestSignature !== computedSignature) { // return {}; @@ -176,7 +249,7 @@ export class TaigaTrigger implements INodeType { return { workflowData: [ - this.helpers.returnJsonArray(bodyData), + this.helpers.returnJsonArray(body), ], }; } diff --git a/packages/nodes-base/nodes/Taiga/types.d.ts b/packages/nodes-base/nodes/Taiga/types.d.ts index 4eafdbbe9..4a136dc82 100644 --- a/packages/nodes-base/nodes/Taiga/types.d.ts +++ b/packages/nodes-base/nodes/Taiga/types.d.ts @@ -7,6 +7,11 @@ type LoadedResource = { name: string; }; +type LoadOption = { + value: string; + name: string; +}; + type LoadedUser = { id: string; full_name_display: string; @@ -22,3 +27,15 @@ type LoadedEpic = LoadedUserStory; type LoadedTags = { [tagName: string]: string | null; // hex color } + +type Operations = 'all' | 'create' | 'delete' | 'change'; + +type Resources = 'all' | 'issue' | 'milestone' | 'task' | 'userstory' | 'wikipage'; + +type WebhookPayload = { + action: Operations; + type: Resources; + by: Record; + date: string; + data: Record; +} diff --git a/packages/nodes-base/nodes/WooCommerce/GenericFunctions.ts b/packages/nodes-base/nodes/WooCommerce/GenericFunctions.ts index 3e42c8850..7be0c7722 100644 --- a/packages/nodes-base/nodes/WooCommerce/GenericFunctions.ts +++ b/packages/nodes-base/nodes/WooCommerce/GenericFunctions.ts @@ -32,6 +32,10 @@ import { snakeCase, } from 'change-case'; +import { + omit +} from 'lodash'; + export async function woocommerceApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('wooCommerceApi'); if (credentials === undefined) { @@ -144,3 +148,18 @@ export function toSnakeCase(data: } } } + +export function adjustMetadata(fields: IDataObject & Metadata) { + if (!fields.meta_data) return fields; + + return { + ...omit(fields, ['meta_data']), + meta_data: fields.meta_data.meta_data_fields, + }; +} + +type Metadata = { + meta_data?: { + meta_data_fields: Array<{ key: string; value: string }>; + } +}; diff --git a/packages/nodes-base/nodes/WooCommerce/WooCommerce.node.ts b/packages/nodes-base/nodes/WooCommerce/WooCommerce.node.ts index 025318bdc..9b7361402 100644 --- a/packages/nodes-base/nodes/WooCommerce/WooCommerce.node.ts +++ b/packages/nodes-base/nodes/WooCommerce/WooCommerce.node.ts @@ -10,6 +10,7 @@ import { INodeTypeDescription, } from 'n8n-workflow'; import { + adjustMetadata, setMetadata, toSnakeCase, woocommerceApiRequest, @@ -37,11 +38,16 @@ import { IShoppingLine, } from './OrderInterface'; +import { + customerFields, + customerOperations, +} from './descriptions'; + export class WooCommerce implements INodeType { description: INodeTypeDescription = { displayName: 'WooCommerce', name: 'wooCommerce', - icon: 'file:wooCommerce.png', + icon: 'file:wooCommerce.svg', group: ['output'], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', @@ -64,6 +70,10 @@ export class WooCommerce implements INodeType { name: 'resource', type: 'options', options: [ + { + name: 'Customer', + value: 'customer', + }, { name: 'Order', value: 'order', @@ -76,6 +86,8 @@ export class WooCommerce implements INodeType { default: 'product', description: 'Resource to consume.', }, + ...customerOperations, + ...customerFields, ...productOperations, ...productFields, ...orderOperations, @@ -128,7 +140,111 @@ export class WooCommerce implements INodeType { const operation = this.getNodeParameter('operation', 0) as string; for (let i = 0; i < length; i++) { - if (resource === 'product') { + + if (resource === 'customer') { + + // ********************************************************************** + // customer + // ********************************************************************** + + // https://woocommerce.github.io/woocommerce-rest-api-docs/?shell#customer-properties + + if (operation === 'create') { + + // ---------------------------------------- + // customer: create + // ---------------------------------------- + + // https://woocommerce.github.io/woocommerce-rest-api-docs/?javascript#create-a-customer + + const body = { + email: this.getNodeParameter('email', i), + } as IDataObject; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (Object.keys(additionalFields).length) { + Object.assign(body, adjustMetadata(additionalFields)); + } + + responseData = await woocommerceApiRequest.call(this, 'POST', '/customers', body); + + } else if (operation === 'delete') { + + // ---------------------------------------- + // customer: delete + // ---------------------------------------- + + // https://woocommerce.github.io/woocommerce-rest-api-docs/?javascript#delete-a-customer + + const customerId = this.getNodeParameter('customerId', i); + + const qs: IDataObject = { + force: true, // required, customers do not support trashing + }; + + const endpoint = `/customers/${customerId}`; + responseData = await woocommerceApiRequest.call(this, 'DELETE', endpoint, {}, qs); + + } else if (operation === 'get') { + + // ---------------------------------------- + // customer: get + // ---------------------------------------- + + // https://woocommerce.github.io/woocommerce-rest-api-docs/?javascript#retrieve-a-customer + + const customerId = this.getNodeParameter('customerId', i); + + const endpoint = `/customers/${customerId}`; + responseData = await woocommerceApiRequest.call(this, 'GET', endpoint); + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // customer: getAll + // ---------------------------------------- + + // https://woocommerce.github.io/woocommerce-rest-api-docs/?javascript#list-all-customers + + const qs = {} as IDataObject; + const filters = this.getNodeParameter('filters', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (Object.keys(filters).length) { + Object.assign(qs, filters); + } + + if (returnAll) { + responseData = await woocommerceApiRequestAllItems.call(this, 'GET', '/customers', {}, qs); + } else { + qs.per_page = this.getNodeParameter('limit', i) as number; + responseData = await woocommerceApiRequest.call(this, 'GET', '/customers', {}, qs); + } + + } else if (operation === 'update') { + + // ---------------------------------------- + // customer: update + // ---------------------------------------- + + // https://woocommerce.github.io/woocommerce-rest-api-docs/?javascript#update-a-customer + + const body = {} as IDataObject; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + if (Object.keys(updateFields).length) { + Object.assign(body, adjustMetadata(updateFields)); + } + + const customerId = this.getNodeParameter('customerId', i); + + const endpoint = `/customers/${customerId}`; + responseData = await woocommerceApiRequest.call(this, 'PUT', endpoint, body); + + } + + } else if (resource === 'product') { //https://woocommerce.github.io/woocommerce-rest-api-docs/#create-a-product if (operation === 'create') { const name = this.getNodeParameter('name', i) as string; diff --git a/packages/nodes-base/nodes/WooCommerce/WooCommerceTrigger.node.ts b/packages/nodes-base/nodes/WooCommerce/WooCommerceTrigger.node.ts index 1bea61ff6..6c4deb2e8 100644 --- a/packages/nodes-base/nodes/WooCommerce/WooCommerceTrigger.node.ts +++ b/packages/nodes-base/nodes/WooCommerce/WooCommerceTrigger.node.ts @@ -23,7 +23,7 @@ export class WooCommerceTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'WooCommerce Trigger', name: 'wooCommerceTrigger', - icon: 'file:wooCommerce.png', + icon: 'file:wooCommerce.svg', group: ['trigger'], version: 1, description: 'Handle WooCommerce events via webhooks', @@ -118,7 +118,7 @@ export class WooCommerceTrigger implements INodeType { const webhookData = this.getWorkflowStaticData('node'); const currentEvent = this.getNodeParameter('event') as string; const endpoint = `/webhooks`; - + const webhooks = await woocommerceApiRequest.call(this, 'GET', endpoint, {}, { status: 'active', per_page: 100 }); for (const webhook of webhooks) { @@ -185,4 +185,4 @@ export class WooCommerceTrigger implements INodeType { ], }; } -} \ No newline at end of file +} diff --git a/packages/nodes-base/nodes/WooCommerce/descriptions/CustomerDescription.ts b/packages/nodes-base/nodes/WooCommerce/descriptions/CustomerDescription.ts new file mode 100644 index 000000000..4a52553fe --- /dev/null +++ b/packages/nodes-base/nodes/WooCommerce/descriptions/CustomerDescription.ts @@ -0,0 +1,254 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +import { + customerCreateFields, + customerUpdateFields, +} from './shared'; + +export const customerOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'customer', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a customer', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a customer', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a customer', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all customers', + }, + { + name: 'Update', + value: 'update', + description: 'Update a customer', + }, + ], + default: 'create', + }, +] as INodeProperties[]; + +export const customerFields = [ + // ---------------------------------------- + // customer: create + // ---------------------------------------- + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'create', + ], + }, + }, + }, + customerCreateFields, + + // ---------------------------------------- + // customer: delete + // ---------------------------------------- + { + displayName: 'Customer ID', + name: 'customerId', + description: 'ID of the customer to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // customer: get + // ---------------------------------------- + { + displayName: 'Customer ID', + name: 'customerId', + description: 'ID of the customer to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // customer: getAll + // ---------------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'How many results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + description: 'Email address to filter customers by', + }, + { + displayName: 'Sort Order', + name: 'order', + description: 'Order to sort customers in', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'asc', + }, + { + name: 'Descending', + value: 'desc', + }, + ], + default: 'asc', + }, + { + displayName: 'Order By', + name: 'orderby', + description: 'Field to sort customers by', + type: 'options', + options: [ + { + name: 'ID', + value: 'id', + }, + { + name: 'Include', + value: 'include', + }, + { + name: 'Name', + value: 'name', + }, + { + name: 'Registered Date', + value: 'registered_date', + }, + ], + default: 'id', + }, + ], + }, + + // ---------------------------------------- + // customer: update + // ---------------------------------------- + { + displayName: 'Customer ID', + name: 'customerId', + description: 'ID of the customer to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'update', + ], + }, + }, + }, + customerUpdateFields, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/WooCommerce/descriptions/index.ts b/packages/nodes-base/nodes/WooCommerce/descriptions/index.ts new file mode 100644 index 000000000..184b55e69 --- /dev/null +++ b/packages/nodes-base/nodes/WooCommerce/descriptions/index.ts @@ -0,0 +1 @@ +export * from './CustomerDescription'; diff --git a/packages/nodes-base/nodes/WooCommerce/descriptions/shared.ts b/packages/nodes-base/nodes/WooCommerce/descriptions/shared.ts new file mode 100644 index 000000000..14e3fa1f9 --- /dev/null +++ b/packages/nodes-base/nodes/WooCommerce/descriptions/shared.ts @@ -0,0 +1,177 @@ +const customerAddressOptions = [ + { + displayName: 'First Name', + name: 'first_name', + type: 'string', + default: '', + }, + { + displayName: 'Last Name', + name: 'last_name', + type: 'string', + default: '', + }, + { + displayName: 'Company', + name: 'company', + type: 'string', + default: '', + }, + { + displayName: 'Address 1', + name: 'address_1', + type: 'string', + default: '', + }, + { + displayName: 'Address 2', + name: 'address_2', + type: 'string', + default: '', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + }, + { + displayName: 'Postcode', + name: 'postcode', + type: 'string', + default: '', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + default: '', + }, +]; + +const customerUpdateOptions = [ + { + displayName: 'Billing Address', + name: 'billing', + type: 'collection', + default: {}, + placeholder: 'Add Field', + options: customerAddressOptions, + }, + { + displayName: 'First Name', + name: 'first_name', + type: 'string', + default: '', + }, + { + displayName: 'Last Name', + name: 'last_name', + type: 'string', + default: '', + }, + { + displayName: 'Metadata', + name: 'meta_data', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + placeholder: 'Add Metadata Field', + options: [ + { + displayName: 'Metadata Fields', + name: 'meta_data_fields', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Shipping Address', + name: 'shipping', + type: 'collection', + default: {}, + placeholder: 'Add Field', + options: customerAddressOptions, + }, +]; + +const customerCreateOptions = [ + ...customerUpdateOptions, + { + displayName: 'Username', + name: 'username', + type: 'string', + default: '', + }, +]; + +export const customerCreateFields = { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'create', + ], + }, + }, + options: customerCreateOptions, +}; + +export const customerUpdateFields = { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'update', + ], + }, + }, + options: customerUpdateOptions, +}; diff --git a/packages/nodes-base/nodes/WooCommerce/wooCommerce.png b/packages/nodes-base/nodes/WooCommerce/wooCommerce.png deleted file mode 100644 index 187de2a2f..000000000 Binary files a/packages/nodes-base/nodes/WooCommerce/wooCommerce.png and /dev/null differ diff --git a/packages/nodes-base/nodes/WooCommerce/wooCommerce.svg b/packages/nodes-base/nodes/WooCommerce/wooCommerce.svg new file mode 100644 index 000000000..9cde2a9d4 --- /dev/null +++ b/packages/nodes-base/nodes/WooCommerce/wooCommerce.svg @@ -0,0 +1,14 @@ + +WooCommerce Logo + + + +image/svg+xml + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 14a4cb09d..03e740d6d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.127.0", + "version": "0.129.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -53,6 +53,7 @@ "dist/credentials/BubbleApi.credentials.js", "dist/credentials/ChargebeeApi.credentials.js", "dist/credentials/CircleCiApi.credentials.js", + "dist/credentials/CiscoWebexOAuth2Api.credentials.js", "dist/credentials/ClearbitApi.credentials.js", "dist/credentials/ClickUpApi.credentials.js", "dist/credentials/ClickUpOAuth2Api.credentials.js", @@ -84,6 +85,7 @@ "dist/credentials/FacebookGraphApi.credentials.js", "dist/credentials/FacebookGraphAppApi.credentials.js", "dist/credentials/FreshdeskApi.credentials.js", + "dist/credentials/FreshworksCrmApi.credentials.js", "dist/credentials/FileMaker.credentials.js", "dist/credentials/FlowApi.credentials.js", "dist/credentials/Ftp.credentials.js", @@ -109,6 +111,7 @@ "dist/credentials/GoogleFirebaseCloudFirestoreOAuth2Api.credentials.js", "dist/credentials/GoogleFirebaseRealtimeDatabaseOAuth2Api.credentials.js", "dist/credentials/GoogleOAuth2Api.credentials.js", + "dist/credentials/GooglePerspectiveOAuth2Api.credentials.js", "dist/credentials/GoogleSheetsOAuth2Api.credentials.js", "dist/credentials/GoogleSlidesOAuth2Api.credentials.js", "dist/credentials/GSuiteAdminOAuth2Api.credentials.js", @@ -152,6 +155,7 @@ "dist/credentials/MailjetEmailApi.credentials.js", "dist/credentials/MailjetSmsApi.credentials.js", "dist/credentials/MandrillApi.credentials.js", + "dist/credentials/MarketstackApi.credentials.js", "dist/credentials/MatrixApi.credentials.js", "dist/credentials/MattermostApi.credentials.js", "dist/credentials/MauticApi.credentials.js", @@ -178,6 +182,7 @@ "dist/credentials/NasaApi.credentials.js", "dist/credentials/NextCloudApi.credentials.js", "dist/credentials/NextCloudOAuth2Api.credentials.js", + "dist/credentials/NocoDb.credentials.js", "dist/credentials/NotionApi.credentials.js", "dist/credentials/NotionOAuth2Api.credentials.js", "dist/credentials/OAuth1Api.credentials.js", @@ -327,6 +332,8 @@ "dist/nodes/Chargebee/Chargebee.node.js", "dist/nodes/Chargebee/ChargebeeTrigger.node.js", "dist/nodes/CircleCi/CircleCi.node.js", + "dist/nodes/Cisco/Webex/CiscoWebex.node.js", + "dist/nodes/Cisco/Webex/CiscoWebexTrigger.node.js", "dist/nodes/Clearbit/Clearbit.node.js", "dist/nodes/ClickUp/ClickUp.node.js", "dist/nodes/ClickUp/ClickUpTrigger.node.js", @@ -372,6 +379,7 @@ "dist/nodes/FileMaker/FileMaker.node.js", "dist/nodes/Ftp.node.js", "dist/nodes/Freshdesk/Freshdesk.node.js", + "dist/nodes/FreshworksCrm/FreshworksCrm.node.js", "dist/nodes/Flow/Flow.node.js", "dist/nodes/Flow/FlowTrigger.node.js", "dist/nodes/Function.node.js", @@ -396,6 +404,7 @@ "dist/nodes/Google/Firebase/RealtimeDatabase/RealtimeDatabase.node.js", "dist/nodes/Google/Gmail/Gmail.node.js", "dist/nodes/Google/GSuiteAdmin/GSuiteAdmin.node.js", + "dist/nodes/Google/Perspective/GooglePerspective.node.js", "dist/nodes/Google/Sheet/GoogleSheets.node.js", "dist/nodes/Google/Slides/GoogleSlides.node.js", "dist/nodes/Google/Task/GoogleTasks.node.js", @@ -445,6 +454,7 @@ "dist/nodes/Mailjet/Mailjet.node.js", "dist/nodes/Mailjet/MailjetTrigger.node.js", "dist/nodes/Mandrill/Mandrill.node.js", + "dist/nodes/Marketstack/Marketstack.node.js", "dist/nodes/Matrix/Matrix.node.js", "dist/nodes/Mattermost/Mattermost.node.js", "dist/nodes/Mautic/Mautic.node.js", @@ -472,6 +482,7 @@ "dist/nodes/Nasa/Nasa.node.js", "dist/nodes/NextCloud/NextCloud.node.js", "dist/nodes/NoOp.node.js", + "dist/nodes/NocoDB/NocoDB.node.js", "dist/nodes/Notion/Notion.node.js", "dist/nodes/Notion/NotionTrigger.node.js", "dist/nodes/N8nTrainingCustomerDatastore.node.js", @@ -620,7 +631,7 @@ "@types/xml2js": "^0.4.3", "gulp": "^4.0.0", "jest": "^26.4.2", - "n8n-workflow": "~0.63.0", + "n8n-workflow": "~0.64.0", "ts-jest": "^26.3.0", "tslint": "^6.1.2", "typescript": "~3.9.7" @@ -659,7 +670,7 @@ "mqtt": "4.2.6", "mssql": "^6.2.0", "mysql2": "~2.2.0", - "n8n-core": "~0.77.0", + "n8n-core": "~0.78.0", "node-ssh": "^11.0.0", "nodemailer": "^6.5.0", "pdf-parse": "^1.1.1", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 0fa3d4943..2e7869a2f 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.63.0", + "version": "0.64.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index f48d605e2..b040ac1db 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -608,6 +608,7 @@ export interface IWorkflowDataProxyData { $json: any; // tslint:disable-line:no-any $node: any; // tslint:disable-line:no-any $parameter: any; // tslint:disable-line:no-any + $position: any; // tslint:disable-line:no-any $workflow: any; // tslint:disable-line:no-any } diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index fe52de7a0..516d4056c 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -395,6 +395,7 @@ export class WorkflowDataProxy { $node: this.nodeGetter(), $self: this.selfGetter(), $parameter: this.nodeParameterGetter(this.activeNodeName), + $position: this.itemIndex, $runIndex: this.runIndex, $mode: this.mode, $workflow: this.workflowGetter(),