diff --git a/.github/docker-compose.yml b/.github/docker-compose.yml index 84a1b9c9610b8..f567dfc926810 100644 --- a/.github/docker-compose.yml +++ b/.github/docker-compose.yml @@ -1,18 +1,12 @@ -version: '3.9' - services: - mysql: - image: mysql:5.7 + mariadb: + image: mariadb:10.9 environment: - - MYSQL_DATABASE=n8n - - MYSQL_ROOT_PASSWORD=password + - MARIADB_DATABASE=n8n + - MARIADB_ROOT_PASSWORD=password + - MARIADB_MYSQL_LOCALHOST_USER=true ports: - 3306:3306 - ulimits: - nproc: 65535 - nofile: - soft: 26677 - hard: 46677 postgres: image: postgres:16 diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index 23088633b52f6..bca99ebb65ed8 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -8,6 +8,7 @@ on: paths: - packages/cli/src/databases/** - .github/workflows/ci-postgres-mysql.yml + - .github/docker-compose.yml pull_request_review: types: [submitted] @@ -71,8 +72,8 @@ jobs: working-directory: packages/cli run: pnpm jest - mysql: - name: MySQL + mariadb: + name: MariaDB runs-on: ubuntu-latest needs: build timeout-minutes: 20 @@ -96,16 +97,16 @@ jobs: path: ./packages/**/dist key: ${{ github.sha }}:db-tests - - name: Start MySQL + - name: Start MariaDB uses: isbang/compose-action@v2.0.0 with: compose-file: ./.github/docker-compose.yml services: | - mysql + mariadb - - name: Test MySQL + - name: Test MariaDB working-directory: packages/cli - run: pnpm test:mysql --testTimeout 20000 + run: pnpm test:mariadb --testTimeout 20000 postgres: name: Postgres @@ -147,7 +148,7 @@ jobs: notify-on-failure: name: Notify Slack on failure runs-on: ubuntu-latest - needs: [mysql, postgres] + needs: [mariadb, postgres] steps: - name: Notify Slack on failure uses: act10ns/slack@v2.0.0 @@ -156,4 +157,4 @@ jobs: status: ${{ job.status }} channel: '#alerts-build' webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} - message: Postgres or MySQL tests failed (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + message: Postgres or MariaDB tests failed (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 05d783ec5e8f9..4819e7fcccf91 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -72,6 +72,10 @@ export function getOutputPanelTable() { return getOutputPanelDataContainer().get('table'); } +export function getRunDataInfoCallout() { + return cy.getByTestId('run-data-callout'); +} + export function getOutputPanelItemsCount() { return getOutputPanel().getByTestId('ndv-items-count'); } diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index 52a28cba621f0..3e1b2fd46a9f7 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -6,8 +6,32 @@ const credentialsModal = new CredentialsModal(); export const getHomeButton = () => cy.getByTestId('project-home-menu-item'); export const getMenuItems = () => cy.getByTestId('project-menu-item'); -export const getAddProjectButton = () => - cy.getByTestId('add-project-menu-item').should('contain', 'Add project').should('be.visible'); +export const getAddProjectButton = () => { + cy.getByTestId('universal-add').should('be.visible').click(); + cy.getByTestId('universal-add') + .find('.el-sub-menu__title') + .as('menuitem') + .should('have.attr', 'aria-describedby'); + + cy.get('@menuitem') + .invoke('attr', 'aria-describedby') + .then((el) => cy.get(`[id="${el}"]`)) + .as('submenu'); + + cy.get('@submenu').within((submenu) => + cy + .wrap(submenu) + .getByTestId('navigation-menu-item') + .should('be.visible') + .filter(':contains("Project")') + .as('button'), + ); + + return cy.get('@button'); +}; + +// export const getAddProjectButton = () => +// cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible'); export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]'); export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]'); diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index 78934c3ce5d69..96a03be961df7 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -26,6 +26,8 @@ import { clickCreateNewCredential, clickExecuteNode, clickGetBackToCanvas, + getRunDataInfoCallout, + getOutputPanelTable, toggleParameterCheckboxInputByName, } from '../composables/ndv'; import { @@ -418,4 +420,102 @@ describe('Langchain Integration', () => { assertInputOutputText('Berlin', 'not.exist'); assertInputOutputText('Kyiv', 'not.exist'); }); + + it('should show tool info notice if no existing tools were used during execution', () => { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true); + + addLanguageModelNodeToParent( + AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, + AGENT_NODE_NAME, + true, + ); + + clickCreateNewCredential(); + setCredentialValues({ + apiKey: 'sk_test_123', + }); + clickGetBackToCanvas(); + + addToolNodeToParent(AI_TOOL_CALCULATOR_NODE_NAME, AGENT_NODE_NAME); + clickGetBackToCanvas(); + openNode(AGENT_NODE_NAME); + + const inputMessage = 'Hello!'; + const outputMessage = 'Hi there! How can I assist you today?'; + + clickExecuteNode(); + + runMockWorkflowExecution({ + trigger: () => sendManualChatMessage(inputMessage), + runData: [ + createMockNodeExecutionData(AGENT_NODE_NAME, { + jsonData: { + main: { output: outputMessage }, + }, + metadata: { + subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }], + }, + }), + ], + lastNodeExecuted: AGENT_NODE_NAME, + }); + closeManualChatModal(); + openNode(AGENT_NODE_NAME); + + getRunDataInfoCallout().should('exist'); + }); + + it('should not show tool info notice if tools were used during execution', () => { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true, true); + getRunDataInfoCallout().should('not.exist'); + clickGetBackToCanvas(); + + addLanguageModelNodeToParent( + AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, + AGENT_NODE_NAME, + true, + ); + + clickCreateNewCredential(); + setCredentialValues({ + apiKey: 'sk_test_123', + }); + clickGetBackToCanvas(); + + addToolNodeToParent(AI_TOOL_CALCULATOR_NODE_NAME, AGENT_NODE_NAME); + clickGetBackToCanvas(); + openNode(AGENT_NODE_NAME); + + getRunDataInfoCallout().should('not.exist'); + + const inputMessage = 'Hello!'; + const outputMessage = 'Hi there! How can I assist you today?'; + + clickExecuteNode(); + + runMockWorkflowExecution({ + trigger: () => sendManualChatMessage(inputMessage), + runData: [ + createMockNodeExecutionData(AGENT_NODE_NAME, { + jsonData: { + main: { output: outputMessage }, + }, + metadata: { + subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }], + }, + }), + createMockNodeExecutionData(AI_TOOL_CALCULATOR_NODE_NAME, {}), + ], + lastNodeExecuted: AGENT_NODE_NAME, + }); + + closeManualChatModal(); + openNode(AGENT_NODE_NAME); + // This waits to ensure the output panel is rendered + getOutputPanelTable(); + + getRunDataInfoCallout().should('not.exist'); + }); }); diff --git a/cypress/e2e/34-template-credentials-setup.cy.ts b/cypress/e2e/34-template-credentials-setup.cy.ts index 815f4b1cebfcb..386c83eb0ad0d 100644 --- a/cypress/e2e/34-template-credentials-setup.cy.ts +++ b/cypress/e2e/34-template-credentials-setup.cy.ts @@ -56,10 +56,10 @@ describe('Template credentials setup', () => { it('can be opened from template collection page', () => { visitTemplateCollectionPage(testData.ecommerceStarterPack); templateCredentialsSetupPage.enableTemplateCredentialSetupFeatureFlag(); - clickUseWorkflowButtonByTitle('Promote new Shopify products on Twitter and Telegram'); + clickUseWorkflowButtonByTitle('Promote new Shopify products'); templateCredentialsSetupPage.getters - .title("Set up 'Promote new Shopify products on Twitter and Telegram' template") + .title("Set up 'Promote new Shopify products' template") .should('be.visible'); }); @@ -67,7 +67,7 @@ describe('Template credentials setup', () => { templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id); templateCredentialsSetupPage.getters - .title("Set up 'Promote new Shopify products on Twitter and Telegram' template") + .title("Set up 'Promote new Shopify products' template") .should('be.visible'); templateCredentialsSetupPage.getters diff --git a/cypress/e2e/42-nps-survey.cy.ts b/cypress/e2e/42-nps-survey.cy.ts index e06fe43ba8050..11e5ebb88e5e4 100644 --- a/cypress/e2e/42-nps-survey.cy.ts +++ b/cypress/e2e/42-nps-survey.cy.ts @@ -10,7 +10,7 @@ import { WorkflowPage } from '../pages/workflow'; const workflowPage = new WorkflowPage(); -const NOW = 1717771477012; +const NOW = Date.now(); const ONE_DAY = 24 * 60 * 60 * 1000; const THREE_DAYS = ONE_DAY * 3; const SEVEN_DAYS = ONE_DAY * 7; diff --git a/cypress/e2e/45-ai-assistant.cy.ts b/cypress/e2e/45-ai-assistant.cy.ts index 9b50fef44a0df..9d3381136327b 100644 --- a/cypress/e2e/45-ai-assistant.cy.ts +++ b/cypress/e2e/45-ai-assistant.cy.ts @@ -557,6 +557,8 @@ describe('General help', () => { }).as('chatRequest'); aiAssistant.getters.askAssistantFloatingButton().click(); + wf.getters.zoomToFitButton().click(); + aiAssistant.actions.sendMessage('What is wrong with this workflow?'); cy.wait('@chatRequest'); diff --git a/cypress/e2e/45-workflow-selector-parameter.cy.ts b/cypress/e2e/45-workflow-selector-parameter.cy.ts index a6dc23e6c2926..3a4de55f501d2 100644 --- a/cypress/e2e/45-workflow-selector-parameter.cy.ts +++ b/cypress/e2e/45-workflow-selector-parameter.cy.ts @@ -27,7 +27,7 @@ describe('Workflow Selector Parameter', () => { getVisiblePopper() .should('have.length', 1) .findChildByTestId('rlc-item') - .should('have.length', 2); + .should('have.length', 3); }); it('should show required parameter warning', () => { @@ -44,7 +44,8 @@ describe('Workflow Selector Parameter', () => { getVisiblePopper() .should('have.length', 1) .findChildByTestId('rlc-item') - .should('have.length', 1) + .should('have.length', 2) + .eq(1) .click(); ndv.getters @@ -57,7 +58,7 @@ describe('Workflow Selector Parameter', () => { ndv.getters.resourceLocator('workflowId').should('be.visible'); ndv.getters.resourceLocatorInput('workflowId').click(); - getVisiblePopper().findChildByTestId('rlc-item').first().click(); + getVisiblePopper().findChildByTestId('rlc-item').eq(1).click(); ndv.getters.resourceLocatorInput('workflowId').find('a').should('exist'); cy.getByTestId('radio-button-expression').eq(1).click(); @@ -68,7 +69,7 @@ describe('Workflow Selector Parameter', () => { ndv.getters.resourceLocator('workflowId').should('be.visible'); ndv.getters.resourceLocatorInput('workflowId').click(); - getVisiblePopper().findChildByTestId('rlc-item').first().click(); + getVisiblePopper().findChildByTestId('rlc-item').eq(1).click(); ndv.getters .resourceLocatorModeSelector('workflowId') .find('input') @@ -79,4 +80,24 @@ describe('Workflow Selector Parameter', () => { .find('input') .should('have.value', 'By ID'); }); + + it('should render add resource option and redirect to the correct route when clicked', () => { + cy.window().then((win) => { + cy.stub(win, 'open').as('windowOpen'); + }); + + ndv.getters.resourceLocator('workflowId').should('be.visible'); + ndv.getters.resourceLocatorInput('workflowId').click(); + + getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist'); + getVisiblePopper() + .findChildByTestId('rlc-item') + .eq(0) + .find('span') + .should('have.text', 'Create a new sub-workflow'); + + getVisiblePopper().findChildByTestId('rlc-item').eq(0).click(); + + cy.get('@windowOpen').should('be.calledWith', '/workflows/onboarding/0?sampleSubWorkflows=0'); + }); }); diff --git a/cypress/fixtures/Ecommerce_starter_pack_template_collection.json b/cypress/fixtures/Ecommerce_starter_pack_template_collection.json index 1f908c587bae5..d6235b358c90f 100644 --- a/cypress/fixtures/Ecommerce_starter_pack_template_collection.json +++ b/cypress/fixtures/Ecommerce_starter_pack_template_collection.json @@ -1 +1,1555 @@ -{"collection":{"id":1,"name":"eCommerce Starter Pack","description":"eCommerce operations are complex — but there are many things that you can automate to make your life easier. This collection provides a few ideas to get started.\n\nReduce manual work and the risk of human error by automating processes such as social media promotion of products, updating customer databases, and get notifications for important events.","totalViews":0,"createdAt":"2022-02-17T12:40:50.498Z","nodes":[{"id":20,"name":"n8n-nodes-base.if","defaults":{"name":"IF","color":"#408000"},"displayName":"IF","icon":"fa:map-signs","iconData":{"icon":"map-signs","type":"icon"},"typeVersion":1,"categories":[{"id":9,"name":"Core Nodes"}]},{"id":49,"name":"n8n-nodes-base.telegram","defaults":{"name":"Telegram"},"displayName":"Telegram","icon":"file:telegram.svg","iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNjYgNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiIGZpbGw9IiMzN2FlZTIiLz48cGF0aCBkPSJNMjEuNjYxIDM0LjMzOGwzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIgZmlsbD0iI2M4ZGFlYSIvPjxwYXRoIGQ9Ik0yNi42OTUgMzcuMDM0bC0uNzI5IDcuNzQ2cy0uMzA1IDIuMzczIDIuMDY4IDBsNC42NDQtNC4yMDMiIGZpbGw9IiNhOWM2ZDgiLz48cGF0aCBkPSJNMjEuNzMgMzQuNzEybC03LjgwOS0yLjU0NXMtLjkzMi0uMzc4LS42MzMtMS4yMzdjLjA2Mi0uMTc3LjE4Ni0uMzI4LjU1OS0uNTg4IDEuNzMxLTEuMjA2IDMyLjAyOC0xMi4wOTYgMzIuMDI4LTEyLjA5NnMuODU2LS4yODggMS4zNjEtLjA5N2MuMjMxLjA4OC4zNzguMTg3LjUwMy41NDguMDQ1LjEzMi4wNzEuNDExLjA2OC42ODktLjAwMy4yMDEtLjAyNy4zODYtLjA0NS42NzgtLjE4NCAyLjk3OC01LjcwNiAyNS4xOTgtNS43MDYgMjUuMTk4cy0uMzMgMS4zLTEuNTE0IDEuMzQ1Yy0uNDMyLjAxNi0uOTU2LS4wNzEtMS41ODItLjYxLTIuMzIzLTEuOTk4LTEwLjM1Mi03LjM5NC0xMi4xMjYtOC41OGEuMzQuMzQgMCAwMS0uMTQ2LS4yMzljLS4wMjUtLjEyNS4xMDgtLjI4LjEwOC0uMjhzMTMuOTgtMTIuNDI3IDE0LjM1Mi0xMy43MzFjLjAyOS0uMTAxLS4wNzktLjE1MS0uMjI2LS4xMDctLjkyOS4zNDItMTcuMDI1IDEwLjUwNi0xOC44MDEgMTEuNjI5LS4xMDQuMDY2LS4zOTUuMDIzLS4zOTUuMDIzIi8+PC9nPjwvc3ltYm9sPjwvc3ZnPg=="},"typeVersion":1,"categories":[{"id":6,"name":"Communication"}]},{"id":107,"name":"n8n-nodes-base.shopifyTrigger","defaults":{"name":"Shopify Trigger"},"displayName":"Shopify Trigger","icon":"file:shopify.svg","iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNTggNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTQ5LjI1NSAxMi40ODRhLjYzMy42MzMgMCAwMC0uNTY0LS41MjdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdjLS4zNC0uMzc2LTEuMDkyLS4yNjYtMS4zNzYtLjE4OC0uMDM3IDAtLjc1Mi4yMjUtMS45MjIuNjA1LTEuMTM3LTMuMy0zLjE1LTYuMzA2LTYuNjk2LTYuMzA2aC0uMzAzQzI4LjQzOC42MDUgMjcuMTk0IDAgMjYuMTQ0IDBjLTguMjU2LjAzNy0xMi4yIDEwLjMzMy0xMy40MzQgMTUuNTk0bC01Ljc3IDEuNzdjLTEuNzcuNTY0LTEuODM1LjYwNS0yLjA3MyAyLjI5M0wwIDU3LjE3NSAzNi40NjggNjRsMTkuNzYzLTQuMjZjMC0uMDM3LTYuOTQtNDYuODk3LTYuOTc2LTQ3LjI1NXpNMzQuNDMxIDguODZjLS45MTcuMzAzLTEuOTYzLjYwNS0zLjEuOTQ1di0uNjhhMTUuMDMgMTUuMDMgMCAwMC0uNzUyLTQuOTk5YzEuODQ4LjI4NCAzLjEgMi4zNTcgMy44NDMgNC43MzN6bS02LjA2OC00LjI5OGMuNjAzIDEuNzc4Ljg4MyAzLjY1LjgyNiA1LjUyN3YuMzRsLTYuMzc1IDEuOTYzYzEuMjQ4LTQuNjYgMy41NS02Ljk2MiA1LjU1LTcuODN6bS0yLjQ1LTIuMjkzYTEuOTQgMS45NCAwIDAxMS4wNTUuMzM5Yy0yLjY2IDEuMjM4LTUuNDcyIDQuMzY2LTYuNjc4IDEwLjYyN2wtNS4wNDUgMS41NDZDMTYuNjY4IDEwLjAzIDE5Ljk4OCAyLjI2IDI1LjkxIDIuMjZ6IiBmaWxsPSIjOTViZjQ3Ii8+PHBhdGggZD0iTTQ4LjY5MSAxMS45NTdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdhLjc1My43NTMgMCAwMC0uNTI3LS4yMjVMMzYuNDcyIDY0bDE5Ljc2My00LjI2LTYuOTgtNDcuMjE4YS42OC42OCAwIDAwLS41NjQtLjU2NHoiIGZpbGw9IiM1ZThlM2UiLz48cGF0aCBkPSJNMjkuNzU4IDIyLjlsLTIuNDU0IDcuMjQyYTExLjM2IDExLjM2IDAgMDAtNC43NTItMS4xMzNjLTMuODQ4IDAtNC4wMzYgMi40MTItNC4wMzYgMy4wMTggMCAzLjI5OCA4LjYzNiA0LjU2NCA4LjYzNiAxMi4zMzMgMCA2LjEtMy44ODUgMTAuMDMtOS4xIDEwLjAzLTYuMjYgMC05LjQ2Ny0zLjg4NS05LjQ2Ny0zLjg4NWwxLjY2NS01LjU1czMuMjggMi44MyA2LjA3MyAyLjgzYTIuNDcgMi40NyAwIDAwMi41NjQtMi40OWMwLTQuMzQtNy4xLTQuNTI3LTcuMS0xMS42MTggMC01Ljk2MiA0LjI5OC0xMS43NyAxMi45MzQtMTEuNzcgMy4zOTQuMDUgNS4wMTggMSA1LjAxOCAxeiIvPjwvZz48L3N5bWJvbD48L3N2Zz4="},"typeVersion":1,"categories":[{"id":2,"name":"Sales"}]},{"id":126,"name":"n8n-nodes-base.mautic","defaults":{"name":"Mautic"},"displayName":"Mautic","icon":"file:mautic.svg","iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzQ5Ljc3OXB4IiBoZWlnaHQ9IjM0OS43NzlweCIgdmlld0JveD0iMCAwIDM0OS43NzkgMzQ5Ljc3OSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMzQ5Ljc3OSAzNDkuNzc5Ig0KCSB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xNzQuODksMzQ5Ljc3OUM3OC42MTIsMzQ5Ljc3OSwwLDI3MS40NjIsMCwxNzQuODlTNzguNjEyLDAsMTc0Ljg5LDBjMjMuMjYsMCw0NS45MzEsNC40MTcsNjcuMTI5LDEzLjU0Mw0KCWM1Ljg4OSwyLjY1LDguODMzLDkuNDIyLDYuNDc4LDE1LjYwNWMtMi42NDksNS44ODgtOS40MjEsOC44MzMtMTUuNjA0LDYuNDc3Yy0xOC41NDktNy42NTUtMzcuOTgtMTEuNDgyLTU4LjAwMi0xMS40ODINCgljLTgzLjMyMywwLTE1MS4wNDEsNjcuNzE4LTE1MS4wNDEsMTUxLjA0MVM5MS41NjcsMzI2LjIyNSwxNzQuODksMzI2LjIyNWM4My4zMjMsMCwxNTEuMDQxLTY3LjcxOCwxNTEuMDQxLTE1MS4wNDENCgljMC0xNy45Ni0yLjk0NC0zNS4zMzItOS4xMjctNTEuODE5Yy0yLjM1NS02LjE4MywwLjg4My0xMi45NTUsNy4wNjYtMTUuMzFjNi4xODMtMi4zNTUsMTIuOTU0LDAuODgzLDE1LjMxLDcuMDY2DQoJYzcuMDY2LDE5LjEzOCwxMC42LDM5LjQ1MywxMC42LDYwLjA2M0MzNDkuNzc5LDI3MS4xNjcsMjcxLjQ2MiwzNDkuNzc5LDE3NC44OSwzNDkuNzc5Ii8+DQo8Zz4NCgk8cG9seWdvbiBmaWxsPSIjRkRCOTMzIiBwb2ludHM9IjI1MS40NCwxNTYuOTMgMjI0LjM1NCwxODUuMTk0IDIzOS4zNjksMjQ4LjQ5NiAyNzMuNTIyLDI0OC40OTYgCSIvPg0KPC9nPg0KPHBvbHlnb24gZmlsbD0iI0ZEQjkzMyIgcG9pbnRzPSIyNDAuMjUzLDczLjMxMiAyNDkuNjc0LDgyLjczNCAxNzQuODksMTYxLjkzNSAxMTAuOTk5LDk2LjI3NyA3NC4xOTYsMjQ4LjQ5NiAxMDguMzUsMjQ4LjQ5NiANCgkxMjguNjY1LDE2My45OTYgMTc0Ljg5LDIxNC4zNDMgMjczLjgxNywxMDYuNTgzIDI4My4yMzksMTE2LjI5OSAyOTIuNjYsNjMuMDA3ICIvPg0KPC9zdmc+DQo="},"typeVersion":1,"categories":[{"id":1,"name":"Marketing & Content"},{"id":6,"name":"Communication"}]},{"id":235,"name":"n8n-nodes-base.wooCommerceTrigger","defaults":{"name":"WooCommerce Trigger"},"displayName":"WooCommerce Trigger","icon":"file:wooCommerce.svg","iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo="},"typeVersion":1,"categories":[{"id":2,"name":"Sales"}]},{"id":325,"name":"n8n-nodes-base.twitter","defaults":{"name":"X"},"displayName":"X (Formerly Twitter)","icon":"file:x.svg","iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4LjI0NCAyLjI1aDMuMzA4bC03LjIyNyA4LjI2IDguNTAyIDExLjI0SDE2LjE3bC01LjIxNC02LjgxN0w0Ljk5IDIxLjc1SDEuNjhsNy43My04LjgzNUwxLjI1NCAyLjI1SDguMDhsNC43MTMgNi4yMzF6bS0xLjE2MSAxNy41MmgxLjgzM0w3LjA4NCA0LjEyNkg1LjExN3oiPjwvcGF0aD48L3N2Zz4K"},"typeVersion":2,"categories":[{"id":1,"name":"Marketing & Content"}]}],"categories":[{"id":2,"name":"Sales"}],"workflows":[{"id":1205,"name":"Promote new Shopify products on Twitter and Telegram","views":485,"recentViews":9850,"totalViews":485,"createdAt":"2021-08-24T10:40:50.007Z","description":"This workflow automatically promotes your new Shopify products on Twitter and Telegram. This workflow is also featured in the blog post [*6 e-commerce workflows to power up your Shopify store*](https://n8n.io/blog/no-code-ecommerce-workflow-automations/#promote-your-new-products-on-social-media).\n\n## Prerequisites\n\n- A Shopify account and [credentials](https://docs.n8n.io/integrations/credentials/shopify/)\n- A Twitter account and [credentials](https://docs.n8n.io/integrations/credentials/twitter/)\n- A Telegram account and [credentials](https://docs.n8n.io/integrations/credentials/telegram/) for the channel you want to send messages to.\n\n## Nodes\n\n- [Shopify Trigger node](https://docs.n8n.io/integrations/trigger-nodes/n8n-nodes-base.shopifytrigger/) triggers the workflow when you create a new product in Shopify.\n- [Twitter node](https://docs.n8n.io/integrations/nodes/n8n-nodes-base.twitter/) posts a tweet with the text \"Hey there, my design is now on a new product! Visit my {shop name} to get this cool {product title} (and check out more {product type})\".\n- [Telegram node](https://docs.n8n.io/integrations/nodes/n8n-nodes-base.telegram/) posts a message with the same text as above in a Telegram channel.","workflow":{"nodes":[{"name":"Twitter","type":"n8n-nodes-base.twitter","position":[720,-220],"parameters":{"text":"=Hey there, my design is now on a new product ✨\nVisit my {{$json[\"vendor\"]}} shop to get this cool{{$json[\"title\"]}} (and check out more {{$json[\"product_type\"]}}) 🛍️","additionalFields":{}},"credentials":{"twitterOAuth1Api":"twitter"},"typeVersion":1},{"name":"Telegram","type":"n8n-nodes-base.telegram","position":[720,-20],"parameters":{"text":"=Hey there, my design is now on a new product!\nVisit my {{$json[\"vendor\"]}} shop to get this cool{{$json[\"title\"]}} (and check out more {{$json[\"product_type\"]}})","chatId":"123456","additionalFields":{}},"credentials":{"telegramApi":"telegram_habot"},"typeVersion":1},{"name":"product created","type":"n8n-nodes-base.shopifyTrigger","position":[540,-110],"webhookId":"2a7e0e50-8f09-4a2b-bf54-a849a6ac4fe0","parameters":{"topic":"products/create"},"credentials":{"shopifyApi":"shopify_nodeqa"},"typeVersion":1}],"connections":{"product created":{"main":[[{"node":"Twitter","type":"main","index":0},{"node":"Telegram","type":"main","index":0}]]}}},"workflowInfo":{"nodeCount":3,"nodeTypes":{"n8n-nodes-base.twitter":{"count":1},"n8n-nodes-base.telegram":{"count":1},"n8n-nodes-base.shopifyTrigger":{"count":1}}},"user":{"username":"lorenanda"},"nodes":[{"id":49,"icon":"file:telegram.svg","name":"n8n-nodes-base.telegram","defaults":{"name":"Telegram"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNjYgNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiIGZpbGw9IiMzN2FlZTIiLz48cGF0aCBkPSJNMjEuNjYxIDM0LjMzOGwzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIgZmlsbD0iI2M4ZGFlYSIvPjxwYXRoIGQ9Ik0yNi42OTUgMzcuMDM0bC0uNzI5IDcuNzQ2cy0uMzA1IDIuMzczIDIuMDY4IDBsNC42NDQtNC4yMDMiIGZpbGw9IiNhOWM2ZDgiLz48cGF0aCBkPSJNMjEuNzMgMzQuNzEybC03LjgwOS0yLjU0NXMtLjkzMi0uMzc4LS42MzMtMS4yMzdjLjA2Mi0uMTc3LjE4Ni0uMzI4LjU1OS0uNTg4IDEuNzMxLTEuMjA2IDMyLjAyOC0xMi4wOTYgMzIuMDI4LTEyLjA5NnMuODU2LS4yODggMS4zNjEtLjA5N2MuMjMxLjA4OC4zNzguMTg3LjUwMy41NDguMDQ1LjEzMi4wNzEuNDExLjA2OC42ODktLjAwMy4yMDEtLjAyNy4zODYtLjA0NS42NzgtLjE4NCAyLjk3OC01LjcwNiAyNS4xOTgtNS43MDYgMjUuMTk4cy0uMzMgMS4zLTEuNTE0IDEuMzQ1Yy0uNDMyLjAxNi0uOTU2LS4wNzEtMS41ODItLjYxLTIuMzIzLTEuOTk4LTEwLjM1Mi03LjM5NC0xMi4xMjYtOC41OGEuMzQuMzQgMCAwMS0uMTQ2LS4yMzljLS4wMjUtLjEyNS4xMDgtLjI4LjEwOC0uMjhzMTMuOTgtMTIuNDI3IDE0LjM1Mi0xMy43MzFjLjAyOS0uMTAxLS4wNzktLjE1MS0uMjI2LS4xMDctLjkyOS4zNDItMTcuMDI1IDEwLjUwNi0xOC44MDEgMTEuNjI5LS4xMDQuMDY2LS4zOTUuMDIzLS4zOTUuMDIzIi8+PC9nPjwvc3ltYm9sPjwvc3ZnPg=="},"categories":[{"id":6,"name":"Communication"}],"displayName":"Telegram","typeVersion":1},{"id":107,"icon":"file:shopify.svg","name":"n8n-nodes-base.shopifyTrigger","defaults":{"name":"Shopify Trigger"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNTggNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTQ5LjI1NSAxMi40ODRhLjYzMy42MzMgMCAwMC0uNTY0LS41MjdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdjLS4zNC0uMzc2LTEuMDkyLS4yNjYtMS4zNzYtLjE4OC0uMDM3IDAtLjc1Mi4yMjUtMS45MjIuNjA1LTEuMTM3LTMuMy0zLjE1LTYuMzA2LTYuNjk2LTYuMzA2aC0uMzAzQzI4LjQzOC42MDUgMjcuMTk0IDAgMjYuMTQ0IDBjLTguMjU2LjAzNy0xMi4yIDEwLjMzMy0xMy40MzQgMTUuNTk0bC01Ljc3IDEuNzdjLTEuNzcuNTY0LTEuODM1LjYwNS0yLjA3MyAyLjI5M0wwIDU3LjE3NSAzNi40NjggNjRsMTkuNzYzLTQuMjZjMC0uMDM3LTYuOTQtNDYuODk3LTYuOTc2LTQ3LjI1NXpNMzQuNDMxIDguODZjLS45MTcuMzAzLTEuOTYzLjYwNS0zLjEuOTQ1di0uNjhhMTUuMDMgMTUuMDMgMCAwMC0uNzUyLTQuOTk5YzEuODQ4LjI4NCAzLjEgMi4zNTcgMy44NDMgNC43MzN6bS02LjA2OC00LjI5OGMuNjAzIDEuNzc4Ljg4MyAzLjY1LjgyNiA1LjUyN3YuMzRsLTYuMzc1IDEuOTYzYzEuMjQ4LTQuNjYgMy41NS02Ljk2MiA1LjU1LTcuODN6bS0yLjQ1LTIuMjkzYTEuOTQgMS45NCAwIDAxMS4wNTUuMzM5Yy0yLjY2IDEuMjM4LTUuNDcyIDQuMzY2LTYuNjc4IDEwLjYyN2wtNS4wNDUgMS41NDZDMTYuNjY4IDEwLjAzIDE5Ljk4OCAyLjI2IDI1LjkxIDIuMjZ6IiBmaWxsPSIjOTViZjQ3Ii8+PHBhdGggZD0iTTQ4LjY5MSAxMS45NTdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdhLjc1My43NTMgMCAwMC0uNTI3LS4yMjVMMzYuNDcyIDY0bDE5Ljc2My00LjI2LTYuOTgtNDcuMjE4YS42OC42OCAwIDAwLS41NjQtLjU2NHoiIGZpbGw9IiM1ZThlM2UiLz48cGF0aCBkPSJNMjkuNzU4IDIyLjlsLTIuNDU0IDcuMjQyYTExLjM2IDExLjM2IDAgMDAtNC43NTItMS4xMzNjLTMuODQ4IDAtNC4wMzYgMi40MTItNC4wMzYgMy4wMTggMCAzLjI5OCA4LjYzNiA0LjU2NCA4LjYzNiAxMi4zMzMgMCA2LjEtMy44ODUgMTAuMDMtOS4xIDEwLjAzLTYuMjYgMC05LjQ2Ny0zLjg4NS05LjQ2Ny0zLjg4NWwxLjY2NS01LjU1czMuMjggMi44MyA2LjA3MyAyLjgzYTIuNDcgMi40NyAwIDAwMi41NjQtMi40OWMwLTQuMzQtNy4xLTQuNTI3LTcuMS0xMS42MTggMC01Ljk2MiA0LjI5OC0xMS43NyAxMi45MzQtMTEuNzcgMy4zOTQuMDUgNS4wMTggMSA1LjAxOCAxeiIvPjwvZz48L3N5bWJvbD48L3N2Zz4="},"categories":[{"id":2,"name":"Sales"}],"displayName":"Shopify Trigger","typeVersion":1},{"id":325,"icon":"file:x.svg","name":"n8n-nodes-base.twitter","defaults":{"name":"X"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4LjI0NCAyLjI1aDMuMzA4bC03LjIyNyA4LjI2IDguNTAyIDExLjI0SDE2LjE3bC01LjIxNC02LjgxN0w0Ljk5IDIxLjc1SDEuNjhsNy43My04LjgzNUwxLjI1NCAyLjI1SDguMDhsNC43MTMgNi4yMzF6bS0xLjE2MSAxNy41MmgxLjgzM0w3LjA4NCA0LjEyNkg1LjExN3oiPjwvcGF0aD48L3N2Zz4K"},"categories":[{"id":1,"name":"Marketing & Content"}],"displayName":"X (Formerly Twitter)","typeVersion":2}],"categories":[{"id":2,"name":"Sales"},{"id":19,"name":"Marketing & Growth"}],"image":[{"id":527,"url":"https://n8niostorageaccount.blob.core.windows.net/n8nio-strapi-blobs-prod/assets/89a078b208fe4c6181902608b1cd1332.png"}]},{"id":1456,"name":"Add new customers from WooCommerce to Mautic","views":333,"recentViews":9833,"totalViews":333,"createdAt":"2022-02-17T15:00:40.748Z","description":"This workflow uses a WooCommerce trigger that will run when a new customer has been added, It will then add the customer to Mautic.\n\nTo use this workflow you will need to set the credentials to use for the WooCommerce and Mautic nodes.","workflow":{"id":83,"name":"New WooCommerce Customer to Mautic","nodes":[{"name":"Check for Existing","type":"n8n-nodes-base.mautic","position":[280,480],"parameters":{"options":{"search":"={{$json[\"email\"]}}"},"operation":"getAll","authentication":"oAuth2"},"credentials":{"mauticOAuth2Api":{"id":"54","name":"Mautic account"}},"typeVersion":1,"alwaysOutputData":true},{"name":"If New","type":"n8n-nodes-base.if","position":[460,480],"parameters":{"conditions":{"string":[{"value1":"={{$json[\"id\"]}}","operation":"isEmpty"}]}},"typeVersion":1},{"name":"Create Contact","type":"n8n-nodes-base.mautic","position":[680,320],"parameters":{"email":"={{$node[\"Customer Created\"].json[\"email\"]}}","company":"={{$node[\"Customer Created\"].json[\"billing\"][\"company\"]}}","options":{},"lastName":"={{$node[\"Customer Created\"].json[\"last_name\"]}}","firstName":"={{$node[\"Customer Created\"].json[\"first_name\"]}}","authentication":"oAuth2","additionalFields":{}},"credentials":{"mauticOAuth2Api":{"id":"54","name":"Mautic account"}},"typeVersion":1},{"name":"Update Contact","type":"n8n-nodes-base.mautic","position":[680,580],"parameters":{"options":{},"contactId":"={{$json[\"id\"]}}","operation":"update","updateFields":{"lastName":"={{$node[\"Customer Created or Updated\"].json[\"last_name\"]}}","firstName":"={{$node[\"Customer Created or Updated\"].json[\"first_name\"]}}"},"authentication":"oAuth2"},"credentials":{"mauticOAuth2Api":{"id":"54","name":"Mautic account"}},"typeVersion":1},{"name":"Customer Created or Updated","type":"n8n-nodes-base.wooCommerceTrigger","position":[100,480],"webhookId":"5d89e322-a5e0-4cce-9eab-185e8375175b","parameters":{"event":"customer.updated"},"credentials":{"wooCommerceApi":{"id":"48","name":"WooCommerce account"}},"typeVersion":1}],"active":false,"settings":{"saveManualExecutions":true,"saveExecutionProgress":true,"saveDataSuccessExecution":"all"},"connections":{"If New":{"main":[[{"node":"Create Contact","type":"main","index":0}],[{"node":"Update Contact","type":"main","index":0}]]},"Check for Existing":{"main":[[{"node":"If New","type":"main","index":0}]]},"Customer Created or Updated":{"main":[[{"node":"Check for Existing","type":"main","index":0}]]}}},"workflowInfo":{"nodeCount":6,"nodeTypes":{"n8n-nodes-base.if":{"count":1},"n8n-nodes-base.mautic":{"count":3},"n8n-nodes-base.wooCommerceTrigger":{"count":1}}},"user":{"username":"jon-n8n"},"nodes":[{"id":20,"icon":"fa:map-signs","name":"n8n-nodes-base.if","defaults":{"name":"IF","color":"#408000"},"iconData":{"icon":"map-signs","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"IF","typeVersion":1},{"id":42,"icon":"fa:play","name":"n8n-nodes-base.start","defaults":{"name":"Start","color":"#00e000"},"iconData":{"icon":"play","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"Start","typeVersion":1},{"id":126,"icon":"file:mautic.svg","name":"n8n-nodes-base.mautic","defaults":{"name":"Mautic"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzQ5Ljc3OXB4IiBoZWlnaHQ9IjM0OS43NzlweCIgdmlld0JveD0iMCAwIDM0OS43NzkgMzQ5Ljc3OSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMzQ5Ljc3OSAzNDkuNzc5Ig0KCSB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xNzQuODksMzQ5Ljc3OUM3OC42MTIsMzQ5Ljc3OSwwLDI3MS40NjIsMCwxNzQuODlTNzguNjEyLDAsMTc0Ljg5LDBjMjMuMjYsMCw0NS45MzEsNC40MTcsNjcuMTI5LDEzLjU0Mw0KCWM1Ljg4OSwyLjY1LDguODMzLDkuNDIyLDYuNDc4LDE1LjYwNWMtMi42NDksNS44ODgtOS40MjEsOC44MzMtMTUuNjA0LDYuNDc3Yy0xOC41NDktNy42NTUtMzcuOTgtMTEuNDgyLTU4LjAwMi0xMS40ODINCgljLTgzLjMyMywwLTE1MS4wNDEsNjcuNzE4LTE1MS4wNDEsMTUxLjA0MVM5MS41NjcsMzI2LjIyNSwxNzQuODksMzI2LjIyNWM4My4zMjMsMCwxNTEuMDQxLTY3LjcxOCwxNTEuMDQxLTE1MS4wNDENCgljMC0xNy45Ni0yLjk0NC0zNS4zMzItOS4xMjctNTEuODE5Yy0yLjM1NS02LjE4MywwLjg4My0xMi45NTUsNy4wNjYtMTUuMzFjNi4xODMtMi4zNTUsMTIuOTU0LDAuODgzLDE1LjMxLDcuMDY2DQoJYzcuMDY2LDE5LjEzOCwxMC42LDM5LjQ1MywxMC42LDYwLjA2M0MzNDkuNzc5LDI3MS4xNjcsMjcxLjQ2MiwzNDkuNzc5LDE3NC44OSwzNDkuNzc5Ii8+DQo8Zz4NCgk8cG9seWdvbiBmaWxsPSIjRkRCOTMzIiBwb2ludHM9IjI1MS40NCwxNTYuOTMgMjI0LjM1NCwxODUuMTk0IDIzOS4zNjksMjQ4LjQ5NiAyNzMuNTIyLDI0OC40OTYgCSIvPg0KPC9nPg0KPHBvbHlnb24gZmlsbD0iI0ZEQjkzMyIgcG9pbnRzPSIyNDAuMjUzLDczLjMxMiAyNDkuNjc0LDgyLjczNCAxNzQuODksMTYxLjkzNSAxMTAuOTk5LDk2LjI3NyA3NC4xOTYsMjQ4LjQ5NiAxMDguMzUsMjQ4LjQ5NiANCgkxMjguNjY1LDE2My45OTYgMTc0Ljg5LDIxNC4zNDMgMjczLjgxNywxMDYuNTgzIDI4My4yMzksMTE2LjI5OSAyOTIuNjYsNjMuMDA3ICIvPg0KPC9zdmc+DQo="},"categories":[{"id":1,"name":"Marketing & Content"},{"id":6,"name":"Communication"}],"displayName":"Mautic","typeVersion":1},{"id":235,"icon":"file:wooCommerce.svg","name":"n8n-nodes-base.wooCommerceTrigger","defaults":{"name":"WooCommerce Trigger"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo="},"categories":[{"id":2,"name":"Sales"}],"displayName":"WooCommerce Trigger","typeVersion":1}],"categories":[{"id":2,"name":"Sales"}],"image":[]},{"id":1459,"name":"Notify on Telegram and Twitter when new order is added in WooCommerce","views":620,"recentViews":9823,"totalViews":620,"createdAt":"2022-02-17T15:02:14.961Z","description":"This workflow uses a WooCommerce trigger that will run a new product has been added, It will then post the product to Telegram and Twitter.\n\nTo use this workflow you will need to set the credentials to use for the WooCommerce, Twitter and Telegram nodes.","workflow":{"id":85,"name":"New WooCommerce Product to Twitter and Telegram","nodes":[{"name":"Twitter","type":"n8n-nodes-base.twitter","position":[720,300],"parameters":{"text":"=✨ New Product Announcement ✨\nWe have just added {{$json[\"name\"]}}, Head to {{$json[\"permalink\"]}} to find out more.","additionalFields":{}},"credentials":{"twitterOAuth1Api":{"id":"37","name":"joffcom"}},"typeVersion":1},{"name":"Telegram","type":"n8n-nodes-base.telegram","position":[720,500],"parameters":{"text":"=✨ New Product Announcement ✨\nWe have just added {{$json[\"name\"]}}, Head to {{$json[\"permalink\"]}} to find out more.","chatId":"123456","additionalFields":{}},"credentials":{"telegramApi":{"id":"56","name":"Telegram account"}},"typeVersion":1},{"name":"WooCommerce Trigger","type":"n8n-nodes-base.wooCommerceTrigger","position":[540,400],"webhookId":"ab7b134b-9b2d-4e0d-b496-1aee30db0808","parameters":{"event":"product.created"},"credentials":{"wooCommerceApi":{"id":"48","name":"WooCommerce account"}},"typeVersion":1}],"active":false,"settings":{},"connections":{"WooCommerce Trigger":{"main":[[{"node":"Twitter","type":"main","index":0},{"node":"Telegram","type":"main","index":0}]]}}},"workflowInfo":{"nodeCount":4,"nodeTypes":{"n8n-nodes-base.twitter":{"count":1},"n8n-nodes-base.telegram":{"count":1},"n8n-nodes-base.wooCommerceTrigger":{"count":1}}},"user":{"username":"jon-n8n"},"nodes":[{"id":42,"icon":"fa:play","name":"n8n-nodes-base.start","defaults":{"name":"Start","color":"#00e000"},"iconData":{"icon":"play","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"Start","typeVersion":1},{"id":49,"icon":"file:telegram.svg","name":"n8n-nodes-base.telegram","defaults":{"name":"Telegram"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNjYgNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiIGZpbGw9IiMzN2FlZTIiLz48cGF0aCBkPSJNMjEuNjYxIDM0LjMzOGwzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIgZmlsbD0iI2M4ZGFlYSIvPjxwYXRoIGQ9Ik0yNi42OTUgMzcuMDM0bC0uNzI5IDcuNzQ2cy0uMzA1IDIuMzczIDIuMDY4IDBsNC42NDQtNC4yMDMiIGZpbGw9IiNhOWM2ZDgiLz48cGF0aCBkPSJNMjEuNzMgMzQuNzEybC03LjgwOS0yLjU0NXMtLjkzMi0uMzc4LS42MzMtMS4yMzdjLjA2Mi0uMTc3LjE4Ni0uMzI4LjU1OS0uNTg4IDEuNzMxLTEuMjA2IDMyLjAyOC0xMi4wOTYgMzIuMDI4LTEyLjA5NnMuODU2LS4yODggMS4zNjEtLjA5N2MuMjMxLjA4OC4zNzguMTg3LjUwMy41NDguMDQ1LjEzMi4wNzEuNDExLjA2OC42ODktLjAwMy4yMDEtLjAyNy4zODYtLjA0NS42NzgtLjE4NCAyLjk3OC01LjcwNiAyNS4xOTgtNS43MDYgMjUuMTk4cy0uMzMgMS4zLTEuNTE0IDEuMzQ1Yy0uNDMyLjAxNi0uOTU2LS4wNzEtMS41ODItLjYxLTIuMzIzLTEuOTk4LTEwLjM1Mi03LjM5NC0xMi4xMjYtOC41OGEuMzQuMzQgMCAwMS0uMTQ2LS4yMzljLS4wMjUtLjEyNS4xMDgtLjI4LjEwOC0uMjhzMTMuOTgtMTIuNDI3IDE0LjM1Mi0xMy43MzFjLjAyOS0uMTAxLS4wNzktLjE1MS0uMjI2LS4xMDctLjkyOS4zNDItMTcuMDI1IDEwLjUwNi0xOC44MDEgMTEuNjI5LS4xMDQuMDY2LS4zOTUuMDIzLS4zOTUuMDIzIi8+PC9nPjwvc3ltYm9sPjwvc3ZnPg=="},"categories":[{"id":6,"name":"Communication"}],"displayName":"Telegram","typeVersion":1},{"id":235,"icon":"file:wooCommerce.svg","name":"n8n-nodes-base.wooCommerceTrigger","defaults":{"name":"WooCommerce Trigger"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo="},"categories":[{"id":2,"name":"Sales"}],"displayName":"WooCommerce Trigger","typeVersion":1},{"id":325,"icon":"file:x.svg","name":"n8n-nodes-base.twitter","defaults":{"name":"X"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4LjI0NCAyLjI1aDMuMzA4bC03LjIyNyA4LjI2IDguNTAyIDExLjI0SDE2LjE3bC01LjIxNC02LjgxN0w0Ljk5IDIxLjc1SDEuNjhsNy43My04LjgzNUwxLjI1NCAyLjI1SDguMDhsNC43MTMgNi4yMzF6bS0xLjE2MSAxNy41MmgxLjgzM0w3LjA4NCA0LjEyNkg1LjExN3oiPjwvcGF0aD48L3N2Zz4K"},"categories":[{"id":1,"name":"Marketing & Content"}],"displayName":"X (Formerly Twitter)","typeVersion":2}],"categories":[{"id":2,"name":"Sales"},{"id":19,"name":"Marketing & Growth"}],"image":[]},{"id":1457,"name":"Notify on Slack when new order is registered in WooCommerce","views":178,"recentViews":9787,"totalViews":178,"createdAt":"2022-02-17T15:01:13.489Z","description":"This workflow uses a WooCommerce trigger that will run when an order has been placed.\n\nIf the value of this is over 100 it will post it to a Slack channel.\n\nTo use this workflow you will need to set the credentials to use for the WooCommerce and Slack nodes, You will also need to pick a channel to post the message to.","workflow":{"id":81,"name":"New WooCommerce order to Slack","nodes":[{"name":"Order Created","type":"n8n-nodes-base.wooCommerceTrigger","position":[340,500],"webhookId":"287b4bf4-67ec-4c97-85d9-c0d3e6f59e6b","parameters":{"event":"order.created"},"credentials":{"wooCommerceApi":{"id":"48","name":"WooCommerce account"}},"typeVersion":1},{"name":"Send to Slack","type":"n8n-nodes-base.slack","position":[780,480],"parameters":{"text":":sparkles: There is a new order :sparkles:","channel":"woo-commerce","blocksUi":{"blocksValues":[]},"attachments":[{"color":"#66FF00","fields":{"item":[{"short":true,"title":"Order ID","value":"={{$json[\"id\"]}}"},{"short":true,"title":"Status","value":"={{$json[\"status\"]}}"},{"short":true,"title":"Total","value":"={{$json[\"currency_symbol\"]}}{{$json[\"total\"]}}"},{"short":false,"title":"Link","value":"={{$node[\"Order Created\"].json[\"_links\"][\"self\"][0][\"href\"]}}"}]},"footer":"=*Ordered:* {{$json[\"date_created\"]}} | *Transaction ID:* {{$json[\"transaction_id\"]}}"}],"otherOptions":{}},"credentials":{"slackApi":{"id":"53","name":"Slack Access Token"}},"typeVersion":1},{"name":"Price over 100","type":"n8n-nodes-base.if","position":[540,500],"parameters":{"conditions":{"number":[{"value1":"={{$json[\"total\"]}}","value2":100,"operation":"largerEqual"}]}},"typeVersion":1}],"active":false,"settings":{"saveManualExecutions":true,"saveExecutionProgress":true,"saveDataSuccessExecution":"all"},"connections":{"Order Created":{"main":[[{"node":"Price over 100","type":"main","index":0}]]},"Price over 100":{"main":[[{"node":"Send to Slack","type":"main","index":0}],[]]}}},"workflowInfo":{"nodeCount":4,"nodeTypes":{"n8n-nodes-base.if":{"count":1},"n8n-nodes-base.slack":{"count":1},"n8n-nodes-base.wooCommerceTrigger":{"count":1}}},"user":{"username":"jon-n8n"},"nodes":[{"id":20,"icon":"fa:map-signs","name":"n8n-nodes-base.if","defaults":{"name":"IF","color":"#408000"},"iconData":{"icon":"map-signs","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"IF","typeVersion":1},{"id":40,"icon":"file:slack.svg","name":"n8n-nodes-base.slack","defaults":{"name":"Slack"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTUwLjg1MiAxNTAuODUyIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48dXNlIHhsaW5rOmhyZWY9IiNhIiB4PSIuOTI2IiB5PSIuOTI2Ii8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxnIHN0cm9rZS13aWR0aD0iMS44NTIiPjxwYXRoIGQ9Ik00MC43NDEgOTMuNTVjMC04LjczNSA2LjYwNy0xNS43NzIgMTQuODE1LTE1Ljc3MnMxNC44MTUgNy4wMzcgMTQuODE1IDE1Ljc3MnYzOC44MjRjMCA4LjczNy02LjYwNyAxNS43NzQtMTQuODE1IDE1Ljc3NHMtMTQuODE1LTcuMDM3LTE0LjgxNS0xNS43NzJ6IiBmaWxsPSIjZTAxZTVhIiBzdHJva2U9IiNlMDFlNWEiLz48cGF0aCBkPSJNOTMuNTUgMTA3LjQwOGMtOC43MzUgMC0xNS43NzItNi42MDctMTUuNzcyLTE0LjgxNXM3LjAzNy0xNC44MTUgMTUuNzcyLTE0LjgxNWgzOC44MjZjOC43MzUgMCAxNS43NzIgNi42MDcgMTUuNzcyIDE0LjgxNXMtNy4wMzcgMTQuODE1LTE1Ljc3MiAxNC44MTV6IiBmaWxsPSIjZWNiMjJkIiBzdHJva2U9IiNlY2IyMmQiLz48cGF0aCBkPSJNNzcuNzc4IDE1Ljc3MkM3Ny43NzggNy4wMzcgODQuMzg1IDAgOTIuNTkzIDBzMTQuODE1IDcuMDM3IDE0LjgxNSAxNS43NzJ2MzguODI2YzAgOC43MzUtNi42MDcgMTUuNzcyLTE0LjgxNSAxNS43NzJzLTE0LjgxNS03LjAzNy0xNC44MTUtMTUuNzcyeiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE1Ljc3MiA3MC4zNzFDNy4wMzcgNzAuMzcxIDAgNjMuNzYzIDAgNTUuNTU2czcuMDM3LTE0LjgxNSAxNS43NzItMTQuODE1aDM4LjgyNmM4LjczNSAwIDE1Ljc3MiA2LjYwNyAxNS43NzIgMTQuODE1cy03LjAzNyAxNC44MTUtMTUuNzcyIDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjxnIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiPjxwYXRoIGQ9Ik03Ny43NzggMTMzLjMzM2MwIDguMjA4IDYuNjA3IDE0LjgxNSAxNC44MTUgMTQuODE1czE0LjgxNS02LjYwNyAxNC44MTUtMTQuODE1LTYuNjA3LTE0LjgxNS0xNC44MTUtMTQuODE1SDc3Ljc3OHoiIGZpbGw9IiNlY2IyMmQiIHN0cm9rZT0iI2VjYjIyZCIvPjxwYXRoIGQ9Ik0xMzMuMzM0IDcwLjM3MWgtMTQuODE1VjU1LjU1NmMwLTguMjA3IDYuNjA3LTE0LjgxNSAxNC44MTUtMTQuODE1czE0LjgxNSA2LjYwNyAxNC44MTUgMTQuODE1LTYuNjA3IDE0LjgxNS0xNC44MTUgMTQuODE1eiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE0LjgxNSA3Ny43NzhIMjkuNjN2MTQuODE1YzAgOC4yMDctNi42MDcgMTQuODE1LTE0LjgxNSAxNC44MTVTMCAxMDAuOCAwIDkyLjU5M3M2LjYwNy0xNC44MTUgMTQuODE1LTE0LjgxNXoiIGZpbGw9IiNlMDFlNWEiIHN0cm9rZT0iI2UwMWU1YSIvPjxwYXRoIGQ9Ik03MC4zNzEgMTQuODE1VjI5LjYzSDU1LjU1NmMtOC4yMDcgMC0xNC44MTUtNi42MDctMTQuODE1LTE0LjgxNVM0Ny4zNDggMCA1NS41NTYgMHMxNC44MTUgNi42MDcgMTQuODE1IDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjwvZz48L2c+PC9zeW1ib2w+PC9zdmc+"},"categories":[{"id":6,"name":"Communication"}],"displayName":"Slack","typeVersion":2},{"id":42,"icon":"fa:play","name":"n8n-nodes-base.start","defaults":{"name":"Start","color":"#00e000"},"iconData":{"icon":"play","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"Start","typeVersion":1},{"id":235,"icon":"file:wooCommerce.svg","name":"n8n-nodes-base.wooCommerceTrigger","defaults":{"name":"WooCommerce Trigger"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo="},"categories":[{"id":2,"name":"Sales"}],"displayName":"WooCommerce Trigger","typeVersion":1}],"categories":[{"id":2,"name":"Sales"}],"image":[]},{"id":1765,"name":"Get Slack notifications when new product published on WooCommerce","views":79,"recentViews":9577,"totalViews":79,"createdAt":"2022-08-12T12:36:53.409Z","description":"This workflow let's a bot in Slack notify a specific channel when a new product in WooCommerce is published and live on the site. \n\n## Prerequisites\n\n[WooCommerce](https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.woocommercetrigger/) account\n[Slack](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.slack/) and a [Slack bot](https://slack.com/help/articles/115005265703-Create-a-bot-for-your-workspace)\n\n## How it works\n\n1. Listen for WooCommerce product creation\n2. If permalink starts with https://[your-url-here].com/product/\n3. Slack bot notifies channel that a new product has been added. \n\nPlease note, you must update the URL in the IF node to match your url. If your WooCommerce doesn't use the slug /product/, that will need to be updated too. \n","workflow":{"id":1016,"name":"Woocommerce to slack: notify new product created","tags":[{"id":"5","name":"FVF","createdAt":"2022-07-30T07:43:44.795Z","updatedAt":"2022-07-30T07:43:44.795Z"}],"nodes":[{"name":"If URL has /product/","type":"n8n-nodes-base.if","position":[640,300],"parameters":{"conditions":{"string":[{"value1":"={{$json[\"permalink\"]}}","value2":"https://[add-your-url-here]/product/","operation":"startsWith"}]}},"typeVersion":1},{"name":"Send message to slack","type":"n8n-nodes-base.slack","position":[920,260],"parameters":{"text":":new: A new product has been added! :new:","channel":"newproducts","blocksUi":{"blocksValues":[]},"attachments":[{"color":"#66FF00","fields":{"item":[{"short":false,"title":"Name","value":"={{$json[\"name\"]}}"},{"short":true,"title":"Price","value":"={{$json[\"regular_price\"]}}"},{"short":true,"title":"Sale Price","value":"={{$json[\"sale_price\"]}}"},{"short":false,"title":"Link","value":"={{$json[\"permalink\"]}}"}]},"footer":"=Added: {{$json[\"date_created\"]}}"}],"otherOptions":{}},"credentials":{"slackApi":{"id":"21","name":"FVF bot"}},"typeVersion":1},{"name":"On product creation","type":"n8n-nodes-base.wooCommerceTrigger","position":[460,300],"webhookId":"267c4855-6227-4d33-867e-74600097473e","parameters":{"event":"product.created"},"credentials":{"wooCommerceApi":{"id":"20","name":"WooCommerce account FVF"}},"typeVersion":1}],"active":true,"settings":{},"connections":{"On product creation":{"main":[[{"node":"If URL has /product/","type":"main","index":0}]]},"If URL has /product/":{"main":[[{"node":"Send message to slack","type":"main","index":0}]]}}},"workflowInfo":{"nodeCount":4,"nodeTypes":{"n8n-nodes-base.if":{"count":1},"n8n-nodes-base.slack":{"count":1},"n8n-nodes-base.wooCommerceTrigger":{"count":1}}},"user":{"username":"n8n-team"},"nodes":[{"id":20,"icon":"fa:map-signs","name":"n8n-nodes-base.if","defaults":{"name":"IF","color":"#408000"},"iconData":{"icon":"map-signs","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"IF","typeVersion":1},{"id":40,"icon":"file:slack.svg","name":"n8n-nodes-base.slack","defaults":{"name":"Slack"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTUwLjg1MiAxNTAuODUyIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48dXNlIHhsaW5rOmhyZWY9IiNhIiB4PSIuOTI2IiB5PSIuOTI2Ii8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxnIHN0cm9rZS13aWR0aD0iMS44NTIiPjxwYXRoIGQ9Ik00MC43NDEgOTMuNTVjMC04LjczNSA2LjYwNy0xNS43NzIgMTQuODE1LTE1Ljc3MnMxNC44MTUgNy4wMzcgMTQuODE1IDE1Ljc3MnYzOC44MjRjMCA4LjczNy02LjYwNyAxNS43NzQtMTQuODE1IDE1Ljc3NHMtMTQuODE1LTcuMDM3LTE0LjgxNS0xNS43NzJ6IiBmaWxsPSIjZTAxZTVhIiBzdHJva2U9IiNlMDFlNWEiLz48cGF0aCBkPSJNOTMuNTUgMTA3LjQwOGMtOC43MzUgMC0xNS43NzItNi42MDctMTUuNzcyLTE0LjgxNXM3LjAzNy0xNC44MTUgMTUuNzcyLTE0LjgxNWgzOC44MjZjOC43MzUgMCAxNS43NzIgNi42MDcgMTUuNzcyIDE0LjgxNXMtNy4wMzcgMTQuODE1LTE1Ljc3MiAxNC44MTV6IiBmaWxsPSIjZWNiMjJkIiBzdHJva2U9IiNlY2IyMmQiLz48cGF0aCBkPSJNNzcuNzc4IDE1Ljc3MkM3Ny43NzggNy4wMzcgODQuMzg1IDAgOTIuNTkzIDBzMTQuODE1IDcuMDM3IDE0LjgxNSAxNS43NzJ2MzguODI2YzAgOC43MzUtNi42MDcgMTUuNzcyLTE0LjgxNSAxNS43NzJzLTE0LjgxNS03LjAzNy0xNC44MTUtMTUuNzcyeiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE1Ljc3MiA3MC4zNzFDNy4wMzcgNzAuMzcxIDAgNjMuNzYzIDAgNTUuNTU2czcuMDM3LTE0LjgxNSAxNS43NzItMTQuODE1aDM4LjgyNmM4LjczNSAwIDE1Ljc3MiA2LjYwNyAxNS43NzIgMTQuODE1cy03LjAzNyAxNC44MTUtMTUuNzcyIDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjxnIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiPjxwYXRoIGQ9Ik03Ny43NzggMTMzLjMzM2MwIDguMjA4IDYuNjA3IDE0LjgxNSAxNC44MTUgMTQuODE1czE0LjgxNS02LjYwNyAxNC44MTUtMTQuODE1LTYuNjA3LTE0LjgxNS0xNC44MTUtMTQuODE1SDc3Ljc3OHoiIGZpbGw9IiNlY2IyMmQiIHN0cm9rZT0iI2VjYjIyZCIvPjxwYXRoIGQ9Ik0xMzMuMzM0IDcwLjM3MWgtMTQuODE1VjU1LjU1NmMwLTguMjA3IDYuNjA3LTE0LjgxNSAxNC44MTUtMTQuODE1czE0LjgxNSA2LjYwNyAxNC44MTUgMTQuODE1LTYuNjA3IDE0LjgxNS0xNC44MTUgMTQuODE1eiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE0LjgxNSA3Ny43NzhIMjkuNjN2MTQuODE1YzAgOC4yMDctNi42MDcgMTQuODE1LTE0LjgxNSAxNC44MTVTMCAxMDAuOCAwIDkyLjU5M3M2LjYwNy0xNC44MTUgMTQuODE1LTE0LjgxNXoiIGZpbGw9IiNlMDFlNWEiIHN0cm9rZT0iI2UwMWU1YSIvPjxwYXRoIGQ9Ik03MC4zNzEgMTQuODE1VjI5LjYzSDU1LjU1NmMtOC4yMDcgMC0xNC44MTUtNi42MDctMTQuODE1LTE0LjgxNVM0Ny4zNDggMCA1NS41NTYgMHMxNC44MTUgNi42MDcgMTQuODE1IDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjwvZz48L2c+PC9zeW1ib2w+PC9zdmc+"},"categories":[{"id":6,"name":"Communication"}],"displayName":"Slack","typeVersion":2},{"id":42,"icon":"fa:play","name":"n8n-nodes-base.start","defaults":{"name":"Start","color":"#00e000"},"iconData":{"icon":"play","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"Start","typeVersion":1},{"id":235,"icon":"file:wooCommerce.svg","name":"n8n-nodes-base.wooCommerceTrigger","defaults":{"name":"WooCommerce Trigger"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo="},"categories":[{"id":2,"name":"Sales"}],"displayName":"WooCommerce Trigger","typeVersion":1}],"categories":[{"id":2,"name":"Sales"}],"image":[]},{"id":1460,"name":"Notify on Slack when refund is registered in WooCommerce","views":85,"recentViews":9541,"totalViews":85,"createdAt":"2022-02-17T15:02:58.662Z","description":"This workflow uses a WooCommerce trigger that will run when an order has been updated and the status is refunded.\n\nIf the value of this is over 100 it will post it to a Slack channel.\n\nTo use this workflow you will need to set the credentials to use for the WooCommerce and Slack nodes, You will also need to pick a channel to post the message to.","workflow":{"id":82,"name":"New WooCommerce refund to Slack","nodes":[{"name":"Order Updated","type":"n8n-nodes-base.wooCommerceTrigger","position":[320,500],"webhookId":"f7736be3-e978-4a17-b936-7ce9f8ccdb72","parameters":{"event":"order.updated"},"credentials":{"wooCommerceApi":{"id":"48","name":"WooCommerce account"}},"typeVersion":1},{"name":"If Refund and Over 100","type":"n8n-nodes-base.if","position":[540,500],"parameters":{"conditions":{"number":[{"value1":"={{$json[\"total\"]}}","value2":100,"operation":"largerEqual"}],"string":[{"value1":"={{$json[\"status\"]}}","value2":"refunded"}]}},"typeVersion":1},{"name":"Send to Slack","type":"n8n-nodes-base.slack","position":[780,480],"parameters":{"text":":x: A refund has been issued :x:","channel":"woo-commerce","blocksUi":{"blocksValues":[]},"attachments":[{"color":"#FF0000","fields":{"item":[{"short":true,"title":"Order ID","value":"={{$json[\"id\"]}}"},{"short":true,"title":"Status","value":"={{$json[\"status\"]}}"},{"short":true,"title":"Total","value":"={{$json[\"currency_symbol\"]}}{{$json[\"total\"]}}"}]},"footer":"=*Order updated:* {{$json[\"date_modified\"]}}"}],"otherOptions":{}},"credentials":{"slackApi":{"id":"53","name":"Slack Access Token"}},"typeVersion":1}],"active":false,"settings":{"saveManualExecutions":true,"saveExecutionProgress":true,"saveDataSuccessExecution":"all"},"connections":{"Order Updated":{"main":[[{"node":"If Refund and Over 100","type":"main","index":0}]]},"If Refund and Over 100":{"main":[[{"node":"Send to Slack","type":"main","index":0}],[]]}}},"workflowInfo":{"nodeCount":4,"nodeTypes":{"n8n-nodes-base.if":{"count":1},"n8n-nodes-base.slack":{"count":1},"n8n-nodes-base.wooCommerceTrigger":{"count":1}}},"user":{"username":"jon-n8n"},"nodes":[{"id":20,"icon":"fa:map-signs","name":"n8n-nodes-base.if","defaults":{"name":"IF","color":"#408000"},"iconData":{"icon":"map-signs","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"IF","typeVersion":1},{"id":40,"icon":"file:slack.svg","name":"n8n-nodes-base.slack","defaults":{"name":"Slack"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTUwLjg1MiAxNTAuODUyIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48dXNlIHhsaW5rOmhyZWY9IiNhIiB4PSIuOTI2IiB5PSIuOTI2Ii8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxnIHN0cm9rZS13aWR0aD0iMS44NTIiPjxwYXRoIGQ9Ik00MC43NDEgOTMuNTVjMC04LjczNSA2LjYwNy0xNS43NzIgMTQuODE1LTE1Ljc3MnMxNC44MTUgNy4wMzcgMTQuODE1IDE1Ljc3MnYzOC44MjRjMCA4LjczNy02LjYwNyAxNS43NzQtMTQuODE1IDE1Ljc3NHMtMTQuODE1LTcuMDM3LTE0LjgxNS0xNS43NzJ6IiBmaWxsPSIjZTAxZTVhIiBzdHJva2U9IiNlMDFlNWEiLz48cGF0aCBkPSJNOTMuNTUgMTA3LjQwOGMtOC43MzUgMC0xNS43NzItNi42MDctMTUuNzcyLTE0LjgxNXM3LjAzNy0xNC44MTUgMTUuNzcyLTE0LjgxNWgzOC44MjZjOC43MzUgMCAxNS43NzIgNi42MDcgMTUuNzcyIDE0LjgxNXMtNy4wMzcgMTQuODE1LTE1Ljc3MiAxNC44MTV6IiBmaWxsPSIjZWNiMjJkIiBzdHJva2U9IiNlY2IyMmQiLz48cGF0aCBkPSJNNzcuNzc4IDE1Ljc3MkM3Ny43NzggNy4wMzcgODQuMzg1IDAgOTIuNTkzIDBzMTQuODE1IDcuMDM3IDE0LjgxNSAxNS43NzJ2MzguODI2YzAgOC43MzUtNi42MDcgMTUuNzcyLTE0LjgxNSAxNS43NzJzLTE0LjgxNS03LjAzNy0xNC44MTUtMTUuNzcyeiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE1Ljc3MiA3MC4zNzFDNy4wMzcgNzAuMzcxIDAgNjMuNzYzIDAgNTUuNTU2czcuMDM3LTE0LjgxNSAxNS43NzItMTQuODE1aDM4LjgyNmM4LjczNSAwIDE1Ljc3MiA2LjYwNyAxNS43NzIgMTQuODE1cy03LjAzNyAxNC44MTUtMTUuNzcyIDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjxnIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiPjxwYXRoIGQ9Ik03Ny43NzggMTMzLjMzM2MwIDguMjA4IDYuNjA3IDE0LjgxNSAxNC44MTUgMTQuODE1czE0LjgxNS02LjYwNyAxNC44MTUtMTQuODE1LTYuNjA3LTE0LjgxNS0xNC44MTUtMTQuODE1SDc3Ljc3OHoiIGZpbGw9IiNlY2IyMmQiIHN0cm9rZT0iI2VjYjIyZCIvPjxwYXRoIGQ9Ik0xMzMuMzM0IDcwLjM3MWgtMTQuODE1VjU1LjU1NmMwLTguMjA3IDYuNjA3LTE0LjgxNSAxNC44MTUtMTQuODE1czE0LjgxNSA2LjYwNyAxNC44MTUgMTQuODE1LTYuNjA3IDE0LjgxNS0xNC44MTUgMTQuODE1eiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE0LjgxNSA3Ny43NzhIMjkuNjN2MTQuODE1YzAgOC4yMDctNi42MDcgMTQuODE1LTE0LjgxNSAxNC44MTVTMCAxMDAuOCAwIDkyLjU5M3M2LjYwNy0xNC44MTUgMTQuODE1LTE0LjgxNXoiIGZpbGw9IiNlMDFlNWEiIHN0cm9rZT0iI2UwMWU1YSIvPjxwYXRoIGQ9Ik03MC4zNzEgMTQuODE1VjI5LjYzSDU1LjU1NmMtOC4yMDcgMC0xNC44MTUtNi42MDctMTQuODE1LTE0LjgxNVM0Ny4zNDggMCA1NS41NTYgMHMxNC44MTUgNi42MDcgMTQuODE1IDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjwvZz48L2c+PC9zeW1ib2w+PC9zdmc+"},"categories":[{"id":6,"name":"Communication"}],"displayName":"Slack","typeVersion":2},{"id":42,"icon":"fa:play","name":"n8n-nodes-base.start","defaults":{"name":"Start","color":"#00e000"},"iconData":{"icon":"play","type":"icon"},"categories":[{"id":9,"name":"Core Nodes"}],"displayName":"Start","typeVersion":1},{"id":235,"icon":"file:wooCommerce.svg","name":"n8n-nodes-base.wooCommerceTrigger","defaults":{"name":"WooCommerce Trigger"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo="},"categories":[{"id":2,"name":"Sales"}],"displayName":"WooCommerce Trigger","typeVersion":1}],"categories":[{"id":2,"name":"Sales"},{"id":8,"name":"Finance & Accounting"}],"image":[]}],"image":[]}} +{ + "collection": { + "id": 1, + "name": "eCommerce Starter Pack", + "description": "eCommerce operations are complex — but there are many things that you can automate to make your life easier. This collection provides a few ideas to get started.\n\nReduce manual work and the risk of human error by automating processes such as social media promotion of products, updating customer databases, and get notifications for important events.", + "totalViews": 0, + "createdAt": "2022-02-17T12:40:50.498Z", + "nodes": [ + { + "id": 20, + "name": "n8n-nodes-base.if", + "defaults": { + "name": "IF", + "color": "#408000" + }, + "displayName": "IF", + "icon": "fa:map-signs", + "iconData": { + "icon": "map-signs", + "type": "icon" + }, + "typeVersion": 1, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ] + }, + { + "id": 49, + "name": "n8n-nodes-base.telegram", + "defaults": { + "name": "Telegram" + }, + "displayName": "Telegram", + "icon": "file:telegram.svg", + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNjYgNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiIGZpbGw9IiMzN2FlZTIiLz48cGF0aCBkPSJNMjEuNjYxIDM0LjMzOGwzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIgZmlsbD0iI2M4ZGFlYSIvPjxwYXRoIGQ9Ik0yNi42OTUgMzcuMDM0bC0uNzI5IDcuNzQ2cy0uMzA1IDIuMzczIDIuMDY4IDBsNC42NDQtNC4yMDMiIGZpbGw9IiNhOWM2ZDgiLz48cGF0aCBkPSJNMjEuNzMgMzQuNzEybC03LjgwOS0yLjU0NXMtLjkzMi0uMzc4LS42MzMtMS4yMzdjLjA2Mi0uMTc3LjE4Ni0uMzI4LjU1OS0uNTg4IDEuNzMxLTEuMjA2IDMyLjAyOC0xMi4wOTYgMzIuMDI4LTEyLjA5NnMuODU2LS4yODggMS4zNjEtLjA5N2MuMjMxLjA4OC4zNzguMTg3LjUwMy41NDguMDQ1LjEzMi4wNzEuNDExLjA2OC42ODktLjAwMy4yMDEtLjAyNy4zODYtLjA0NS42NzgtLjE4NCAyLjk3OC01LjcwNiAyNS4xOTgtNS43MDYgMjUuMTk4cy0uMzMgMS4zLTEuNTE0IDEuMzQ1Yy0uNDMyLjAxNi0uOTU2LS4wNzEtMS41ODItLjYxLTIuMzIzLTEuOTk4LTEwLjM1Mi03LjM5NC0xMi4xMjYtOC41OGEuMzQuMzQgMCAwMS0uMTQ2LS4yMzljLS4wMjUtLjEyNS4xMDgtLjI4LjEwOC0uMjhzMTMuOTgtMTIuNDI3IDE0LjM1Mi0xMy43MzFjLjAyOS0uMTAxLS4wNzktLjE1MS0uMjI2LS4xMDctLjkyOS4zNDItMTcuMDI1IDEwLjUwNi0xOC44MDEgMTEuNjI5LS4xMDQuMDY2LS4zOTUuMDIzLS4zOTUuMDIzIi8+PC9nPjwvc3ltYm9sPjwvc3ZnPg==" + }, + "typeVersion": 1, + "categories": [ + { + "id": 6, + "name": "Communication" + } + ] + }, + { + "id": 107, + "name": "n8n-nodes-base.shopifyTrigger", + "defaults": { + "name": "Shopify Trigger" + }, + "displayName": "Shopify Trigger", + "icon": "file:shopify.svg", + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNTggNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTQ5LjI1NSAxMi40ODRhLjYzMy42MzMgMCAwMC0uNTY0LS41MjdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdjLS4zNC0uMzc2LTEuMDkyLS4yNjYtMS4zNzYtLjE4OC0uMDM3IDAtLjc1Mi4yMjUtMS45MjIuNjA1LTEuMTM3LTMuMy0zLjE1LTYuMzA2LTYuNjk2LTYuMzA2aC0uMzAzQzI4LjQzOC42MDUgMjcuMTk0IDAgMjYuMTQ0IDBjLTguMjU2LjAzNy0xMi4yIDEwLjMzMy0xMy40MzQgMTUuNTk0bC01Ljc3IDEuNzdjLTEuNzcuNTY0LTEuODM1LjYwNS0yLjA3MyAyLjI5M0wwIDU3LjE3NSAzNi40NjggNjRsMTkuNzYzLTQuMjZjMC0uMDM3LTYuOTQtNDYuODk3LTYuOTc2LTQ3LjI1NXpNMzQuNDMxIDguODZjLS45MTcuMzAzLTEuOTYzLjYwNS0zLjEuOTQ1di0uNjhhMTUuMDMgMTUuMDMgMCAwMC0uNzUyLTQuOTk5YzEuODQ4LjI4NCAzLjEgMi4zNTcgMy44NDMgNC43MzN6bS02LjA2OC00LjI5OGMuNjAzIDEuNzc4Ljg4MyAzLjY1LjgyNiA1LjUyN3YuMzRsLTYuMzc1IDEuOTYzYzEuMjQ4LTQuNjYgMy41NS02Ljk2MiA1LjU1LTcuODN6bS0yLjQ1LTIuMjkzYTEuOTQgMS45NCAwIDAxMS4wNTUuMzM5Yy0yLjY2IDEuMjM4LTUuNDcyIDQuMzY2LTYuNjc4IDEwLjYyN2wtNS4wNDUgMS41NDZDMTYuNjY4IDEwLjAzIDE5Ljk4OCAyLjI2IDI1LjkxIDIuMjZ6IiBmaWxsPSIjOTViZjQ3Ii8+PHBhdGggZD0iTTQ4LjY5MSAxMS45NTdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdhLjc1My43NTMgMCAwMC0uNTI3LS4yMjVMMzYuNDcyIDY0bDE5Ljc2My00LjI2LTYuOTgtNDcuMjE4YS42OC42OCAwIDAwLS41NjQtLjU2NHoiIGZpbGw9IiM1ZThlM2UiLz48cGF0aCBkPSJNMjkuNzU4IDIyLjlsLTIuNDU0IDcuMjQyYTExLjM2IDExLjM2IDAgMDAtNC43NTItMS4xMzNjLTMuODQ4IDAtNC4wMzYgMi40MTItNC4wMzYgMy4wMTggMCAzLjI5OCA4LjYzNiA0LjU2NCA4LjYzNiAxMi4zMzMgMCA2LjEtMy44ODUgMTAuMDMtOS4xIDEwLjAzLTYuMjYgMC05LjQ2Ny0zLjg4NS05LjQ2Ny0zLjg4NWwxLjY2NS01LjU1czMuMjggMi44MyA2LjA3MyAyLjgzYTIuNDcgMi40NyAwIDAwMi41NjQtMi40OWMwLTQuMzQtNy4xLTQuNTI3LTcuMS0xMS42MTggMC01Ljk2MiA0LjI5OC0xMS43NyAxMi45MzQtMTEuNzcgMy4zOTQuMDUgNS4wMTggMSA1LjAxOCAxeiIvPjwvZz48L3N5bWJvbD48L3N2Zz4=" + }, + "typeVersion": 1, + "categories": [ + { + "id": 2, + "name": "Sales" + } + ] + }, + { + "id": 126, + "name": "n8n-nodes-base.mautic", + "defaults": { + "name": "Mautic" + }, + "displayName": "Mautic", + "icon": "file:mautic.svg", + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzQ5Ljc3OXB4IiBoZWlnaHQ9IjM0OS43NzlweCIgdmlld0JveD0iMCAwIDM0OS43NzkgMzQ5Ljc3OSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMzQ5Ljc3OSAzNDkuNzc5Ig0KCSB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xNzQuODksMzQ5Ljc3OUM3OC42MTIsMzQ5Ljc3OSwwLDI3MS40NjIsMCwxNzQuODlTNzguNjEyLDAsMTc0Ljg5LDBjMjMuMjYsMCw0NS45MzEsNC40MTcsNjcuMTI5LDEzLjU0Mw0KCWM1Ljg4OSwyLjY1LDguODMzLDkuNDIyLDYuNDc4LDE1LjYwNWMtMi42NDksNS44ODgtOS40MjEsOC44MzMtMTUuNjA0LDYuNDc3Yy0xOC41NDktNy42NTUtMzcuOTgtMTEuNDgyLTU4LjAwMi0xMS40ODINCgljLTgzLjMyMywwLTE1MS4wNDEsNjcuNzE4LTE1MS4wNDEsMTUxLjA0MVM5MS41NjcsMzI2LjIyNSwxNzQuODksMzI2LjIyNWM4My4zMjMsMCwxNTEuMDQxLTY3LjcxOCwxNTEuMDQxLTE1MS4wNDENCgljMC0xNy45Ni0yLjk0NC0zNS4zMzItOS4xMjctNTEuODE5Yy0yLjM1NS02LjE4MywwLjg4My0xMi45NTUsNy4wNjYtMTUuMzFjNi4xODMtMi4zNTUsMTIuOTU0LDAuODgzLDE1LjMxLDcuMDY2DQoJYzcuMDY2LDE5LjEzOCwxMC42LDM5LjQ1MywxMC42LDYwLjA2M0MzNDkuNzc5LDI3MS4xNjcsMjcxLjQ2MiwzNDkuNzc5LDE3NC44OSwzNDkuNzc5Ii8+DQo8Zz4NCgk8cG9seWdvbiBmaWxsPSIjRkRCOTMzIiBwb2ludHM9IjI1MS40NCwxNTYuOTMgMjI0LjM1NCwxODUuMTk0IDIzOS4zNjksMjQ4LjQ5NiAyNzMuNTIyLDI0OC40OTYgCSIvPg0KPC9nPg0KPHBvbHlnb24gZmlsbD0iI0ZEQjkzMyIgcG9pbnRzPSIyNDAuMjUzLDczLjMxMiAyNDkuNjc0LDgyLjczNCAxNzQuODksMTYxLjkzNSAxMTAuOTk5LDk2LjI3NyA3NC4xOTYsMjQ4LjQ5NiAxMDguMzUsMjQ4LjQ5NiANCgkxMjguNjY1LDE2My45OTYgMTc0Ljg5LDIxNC4zNDMgMjczLjgxNywxMDYuNTgzIDI4My4yMzksMTE2LjI5OSAyOTIuNjYsNjMuMDA3ICIvPg0KPC9zdmc+DQo=" + }, + "typeVersion": 1, + "categories": [ + { + "id": 1, + "name": "Marketing & Content" + }, + { + "id": 6, + "name": "Communication" + } + ] + }, + { + "id": 235, + "name": "n8n-nodes-base.wooCommerceTrigger", + "defaults": { + "name": "WooCommerce Trigger" + }, + "displayName": "WooCommerce Trigger", + "icon": "file:wooCommerce.svg", + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo=" + }, + "typeVersion": 1, + "categories": [ + { + "id": 2, + "name": "Sales" + } + ] + }, + { + "id": 325, + "name": "n8n-nodes-base.twitter", + "defaults": { + "name": "X" + }, + "displayName": "X (Formerly Twitter)", + "icon": "file:x.svg", + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4LjI0NCAyLjI1aDMuMzA4bC03LjIyNyA4LjI2IDguNTAyIDExLjI0SDE2LjE3bC01LjIxNC02LjgxN0w0Ljk5IDIxLjc1SDEuNjhsNy43My04LjgzNUwxLjI1NCAyLjI1SDguMDhsNC43MTMgNi4yMzF6bS0xLjE2MSAxNy41MmgxLjgzM0w3LjA4NCA0LjEyNkg1LjExN3oiPjwvcGF0aD48L3N2Zz4K" + }, + "typeVersion": 2, + "categories": [ + { + "id": 1, + "name": "Marketing & Content" + } + ] + } + ], + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "workflows": [ + { + "id": 1205, + "name": "Promote new Shopify products", + "views": 485, + "recentViews": 9850, + "totalViews": 485, + "createdAt": "2021-08-24T10:40:50.007Z", + "description": "This workflow automatically promotes your new Shopify products on Twitter and Telegram. This workflow is also featured in the blog post [*6 e-commerce workflows to power up your Shopify store*](https://n8n.io/blog/no-code-ecommerce-workflow-automations/#promote-your-new-products-on-social-media).\n\n## Prerequisites\n\n- A Shopify account and [credentials](https://docs.n8n.io/integrations/credentials/shopify/)\n- A Twitter account and [credentials](https://docs.n8n.io/integrations/credentials/twitter/)\n- A Telegram account and [credentials](https://docs.n8n.io/integrations/credentials/telegram/) for the channel you want to send messages to.\n\n## Nodes\n\n- [Shopify Trigger node](https://docs.n8n.io/integrations/trigger-nodes/n8n-nodes-base.shopifytrigger/) triggers the workflow when you create a new product in Shopify.\n- [Twitter node](https://docs.n8n.io/integrations/nodes/n8n-nodes-base.twitter/) posts a tweet with the text \"Hey there, my design is now on a new product! Visit my {shop name} to get this cool {product title} (and check out more {product type})\".\n- [Telegram node](https://docs.n8n.io/integrations/nodes/n8n-nodes-base.telegram/) posts a message with the same text as above in a Telegram channel.", + "workflow": { + "nodes": [ + { + "name": "Twitter", + "type": "n8n-nodes-base.twitter", + "position": [ + 720, + -220 + ], + "parameters": { + "text": "=Hey there, my design is now on a new product ✨\nVisit my {{$json[\"vendor\"]}} shop to get this cool{{$json[\"title\"]}} (and check out more {{$json[\"product_type\"]}}) 🛍️", + "additionalFields": {} + }, + "credentials": { + "twitterOAuth1Api": "twitter" + }, + "typeVersion": 1 + }, + { + "name": "Telegram", + "type": "n8n-nodes-base.telegram", + "position": [ + 720, + -20 + ], + "parameters": { + "text": "=Hey there, my design is now on a new product!\nVisit my {{$json[\"vendor\"]}} shop to get this cool{{$json[\"title\"]}} (and check out more {{$json[\"product_type\"]}})", + "chatId": "123456", + "additionalFields": {} + }, + "credentials": { + "telegramApi": "telegram_habot" + }, + "typeVersion": 1 + }, + { + "name": "product created", + "type": "n8n-nodes-base.shopifyTrigger", + "position": [ + 540, + -110 + ], + "webhookId": "2a7e0e50-8f09-4a2b-bf54-a849a6ac4fe0", + "parameters": { + "topic": "products/create" + }, + "credentials": { + "shopifyApi": "shopify_nodeqa" + }, + "typeVersion": 1 + } + ], + "connections": { + "product created": { + "main": [ + [ + { + "node": "Twitter", + "type": "main", + "index": 0 + }, + { + "node": "Telegram", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + "workflowInfo": { + "nodeCount": 3, + "nodeTypes": { + "n8n-nodes-base.twitter": { + "count": 1 + }, + "n8n-nodes-base.telegram": { + "count": 1 + }, + "n8n-nodes-base.shopifyTrigger": { + "count": 1 + } + } + }, + "user": { + "username": "lorenanda" + }, + "nodes": [ + { + "id": 49, + "icon": "file:telegram.svg", + "name": "n8n-nodes-base.telegram", + "defaults": { + "name": "Telegram" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNjYgNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiIGZpbGw9IiMzN2FlZTIiLz48cGF0aCBkPSJNMjEuNjYxIDM0LjMzOGwzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIgZmlsbD0iI2M4ZGFlYSIvPjxwYXRoIGQ9Ik0yNi42OTUgMzcuMDM0bC0uNzI5IDcuNzQ2cy0uMzA1IDIuMzczIDIuMDY4IDBsNC42NDQtNC4yMDMiIGZpbGw9IiNhOWM2ZDgiLz48cGF0aCBkPSJNMjEuNzMgMzQuNzEybC03LjgwOS0yLjU0NXMtLjkzMi0uMzc4LS42MzMtMS4yMzdjLjA2Mi0uMTc3LjE4Ni0uMzI4LjU1OS0uNTg4IDEuNzMxLTEuMjA2IDMyLjAyOC0xMi4wOTYgMzIuMDI4LTEyLjA5NnMuODU2LS4yODggMS4zNjEtLjA5N2MuMjMxLjA4OC4zNzguMTg3LjUwMy41NDguMDQ1LjEzMi4wNzEuNDExLjA2OC42ODktLjAwMy4yMDEtLjAyNy4zODYtLjA0NS42NzgtLjE4NCAyLjk3OC01LjcwNiAyNS4xOTgtNS43MDYgMjUuMTk4cy0uMzMgMS4zLTEuNTE0IDEuMzQ1Yy0uNDMyLjAxNi0uOTU2LS4wNzEtMS41ODItLjYxLTIuMzIzLTEuOTk4LTEwLjM1Mi03LjM5NC0xMi4xMjYtOC41OGEuMzQuMzQgMCAwMS0uMTQ2LS4yMzljLS4wMjUtLjEyNS4xMDgtLjI4LjEwOC0uMjhzMTMuOTgtMTIuNDI3IDE0LjM1Mi0xMy43MzFjLjAyOS0uMTAxLS4wNzktLjE1MS0uMjI2LS4xMDctLjkyOS4zNDItMTcuMDI1IDEwLjUwNi0xOC44MDEgMTEuNjI5LS4xMDQuMDY2LS4zOTUuMDIzLS4zOTUuMDIzIi8+PC9nPjwvc3ltYm9sPjwvc3ZnPg==" + }, + "categories": [ + { + "id": 6, + "name": "Communication" + } + ], + "displayName": "Telegram", + "typeVersion": 1 + }, + { + "id": 107, + "icon": "file:shopify.svg", + "name": "n8n-nodes-base.shopifyTrigger", + "defaults": { + "name": "Shopify Trigger" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNTggNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTQ5LjI1NSAxMi40ODRhLjYzMy42MzMgMCAwMC0uNTY0LS41MjdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdjLS4zNC0uMzc2LTEuMDkyLS4yNjYtMS4zNzYtLjE4OC0uMDM3IDAtLjc1Mi4yMjUtMS45MjIuNjA1LTEuMTM3LTMuMy0zLjE1LTYuMzA2LTYuNjk2LTYuMzA2aC0uMzAzQzI4LjQzOC42MDUgMjcuMTk0IDAgMjYuMTQ0IDBjLTguMjU2LjAzNy0xMi4yIDEwLjMzMy0xMy40MzQgMTUuNTk0bC01Ljc3IDEuNzdjLTEuNzcuNTY0LTEuODM1LjYwNS0yLjA3MyAyLjI5M0wwIDU3LjE3NSAzNi40NjggNjRsMTkuNzYzLTQuMjZjMC0uMDM3LTYuOTQtNDYuODk3LTYuOTc2LTQ3LjI1NXpNMzQuNDMxIDguODZjLS45MTcuMzAzLTEuOTYzLjYwNS0zLjEuOTQ1di0uNjhhMTUuMDMgMTUuMDMgMCAwMC0uNzUyLTQuOTk5YzEuODQ4LjI4NCAzLjEgMi4zNTcgMy44NDMgNC43MzN6bS02LjA2OC00LjI5OGMuNjAzIDEuNzc4Ljg4MyAzLjY1LjgyNiA1LjUyN3YuMzRsLTYuMzc1IDEuOTYzYzEuMjQ4LTQuNjYgMy41NS02Ljk2MiA1LjU1LTcuODN6bS0yLjQ1LTIuMjkzYTEuOTQgMS45NCAwIDAxMS4wNTUuMzM5Yy0yLjY2IDEuMjM4LTUuNDcyIDQuMzY2LTYuNjc4IDEwLjYyN2wtNS4wNDUgMS41NDZDMTYuNjY4IDEwLjAzIDE5Ljk4OCAyLjI2IDI1LjkxIDIuMjZ6IiBmaWxsPSIjOTViZjQ3Ii8+PHBhdGggZD0iTTQ4LjY5MSAxMS45NTdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdhLjc1My43NTMgMCAwMC0uNTI3LS4yMjVMMzYuNDcyIDY0bDE5Ljc2My00LjI2LTYuOTgtNDcuMjE4YS42OC42OCAwIDAwLS41NjQtLjU2NHoiIGZpbGw9IiM1ZThlM2UiLz48cGF0aCBkPSJNMjkuNzU4IDIyLjlsLTIuNDU0IDcuMjQyYTExLjM2IDExLjM2IDAgMDAtNC43NTItMS4xMzNjLTMuODQ4IDAtNC4wMzYgMi40MTItNC4wMzYgMy4wMTggMCAzLjI5OCA4LjYzNiA0LjU2NCA4LjYzNiAxMi4zMzMgMCA2LjEtMy44ODUgMTAuMDMtOS4xIDEwLjAzLTYuMjYgMC05LjQ2Ny0zLjg4NS05LjQ2Ny0zLjg4NWwxLjY2NS01LjU1czMuMjggMi44MyA2LjA3MyAyLjgzYTIuNDcgMi40NyAwIDAwMi41NjQtMi40OWMwLTQuMzQtNy4xLTQuNTI3LTcuMS0xMS42MTggMC01Ljk2MiA0LjI5OC0xMS43NyAxMi45MzQtMTEuNzcgMy4zOTQuMDUgNS4wMTggMSA1LjAxOCAxeiIvPjwvZz48L3N5bWJvbD48L3N2Zz4=" + }, + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "displayName": "Shopify Trigger", + "typeVersion": 1 + }, + { + "id": 325, + "icon": "file:x.svg", + "name": "n8n-nodes-base.twitter", + "defaults": { + "name": "X" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4LjI0NCAyLjI1aDMuMzA4bC03LjIyNyA4LjI2IDguNTAyIDExLjI0SDE2LjE3bC01LjIxNC02LjgxN0w0Ljk5IDIxLjc1SDEuNjhsNy43My04LjgzNUwxLjI1NCAyLjI1SDguMDhsNC43MTMgNi4yMzF6bS0xLjE2MSAxNy41MmgxLjgzM0w3LjA4NCA0LjEyNkg1LjExN3oiPjwvcGF0aD48L3N2Zz4K" + }, + "categories": [ + { + "id": 1, + "name": "Marketing & Content" + } + ], + "displayName": "X (Formerly Twitter)", + "typeVersion": 2 + } + ], + "categories": [ + { + "id": 2, + "name": "Sales" + }, + { + "id": 19, + "name": "Marketing & Growth" + } + ], + "image": [ + { + "id": 527, + "url": "https://n8niostorageaccount.blob.core.windows.net/n8nio-strapi-blobs-prod/assets/89a078b208fe4c6181902608b1cd1332.png" + } + ] + }, + { + "id": 1456, + "name": "Add new customers from WooCommerce to Mautic", + "views": 333, + "recentViews": 9833, + "totalViews": 333, + "createdAt": "2022-02-17T15:00:40.748Z", + "description": "This workflow uses a WooCommerce trigger that will run when a new customer has been added, It will then add the customer to Mautic.\n\nTo use this workflow you will need to set the credentials to use for the WooCommerce and Mautic nodes.", + "workflow": { + "id": 83, + "name": "New WooCommerce Customer to Mautic", + "nodes": [ + { + "name": "Check for Existing", + "type": "n8n-nodes-base.mautic", + "position": [ + 280, + 480 + ], + "parameters": { + "options": { + "search": "={{$json[\"email\"]}}" + }, + "operation": "getAll", + "authentication": "oAuth2" + }, + "credentials": { + "mauticOAuth2Api": { + "id": "54", + "name": "Mautic account" + } + }, + "typeVersion": 1, + "alwaysOutputData": true + }, + { + "name": "If New", + "type": "n8n-nodes-base.if", + "position": [ + 460, + 480 + ], + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{$json[\"id\"]}}", + "operation": "isEmpty" + } + ] + } + }, + "typeVersion": 1 + }, + { + "name": "Create Contact", + "type": "n8n-nodes-base.mautic", + "position": [ + 680, + 320 + ], + "parameters": { + "email": "={{$node[\"Customer Created\"].json[\"email\"]}}", + "company": "={{$node[\"Customer Created\"].json[\"billing\"][\"company\"]}}", + "options": {}, + "lastName": "={{$node[\"Customer Created\"].json[\"last_name\"]}}", + "firstName": "={{$node[\"Customer Created\"].json[\"first_name\"]}}", + "authentication": "oAuth2", + "additionalFields": {} + }, + "credentials": { + "mauticOAuth2Api": { + "id": "54", + "name": "Mautic account" + } + }, + "typeVersion": 1 + }, + { + "name": "Update Contact", + "type": "n8n-nodes-base.mautic", + "position": [ + 680, + 580 + ], + "parameters": { + "options": {}, + "contactId": "={{$json[\"id\"]}}", + "operation": "update", + "updateFields": { + "lastName": "={{$node[\"Customer Created or Updated\"].json[\"last_name\"]}}", + "firstName": "={{$node[\"Customer Created or Updated\"].json[\"first_name\"]}}" + }, + "authentication": "oAuth2" + }, + "credentials": { + "mauticOAuth2Api": { + "id": "54", + "name": "Mautic account" + } + }, + "typeVersion": 1 + }, + { + "name": "Customer Created or Updated", + "type": "n8n-nodes-base.wooCommerceTrigger", + "position": [ + 100, + 480 + ], + "webhookId": "5d89e322-a5e0-4cce-9eab-185e8375175b", + "parameters": { + "event": "customer.updated" + }, + "credentials": { + "wooCommerceApi": { + "id": "48", + "name": "WooCommerce account" + } + }, + "typeVersion": 1 + } + ], + "active": false, + "settings": { + "saveManualExecutions": true, + "saveExecutionProgress": true, + "saveDataSuccessExecution": "all" + }, + "connections": { + "If New": { + "main": [ + [ + { + "node": "Create Contact", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Update Contact", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check for Existing": { + "main": [ + [ + { + "node": "If New", + "type": "main", + "index": 0 + } + ] + ] + }, + "Customer Created or Updated": { + "main": [ + [ + { + "node": "Check for Existing", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + "workflowInfo": { + "nodeCount": 6, + "nodeTypes": { + "n8n-nodes-base.if": { + "count": 1 + }, + "n8n-nodes-base.mautic": { + "count": 3 + }, + "n8n-nodes-base.wooCommerceTrigger": { + "count": 1 + } + } + }, + "user": { + "username": "jon-n8n" + }, + "nodes": [ + { + "id": 20, + "icon": "fa:map-signs", + "name": "n8n-nodes-base.if", + "defaults": { + "name": "IF", + "color": "#408000" + }, + "iconData": { + "icon": "map-signs", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "IF", + "typeVersion": 1 + }, + { + "id": 42, + "icon": "fa:play", + "name": "n8n-nodes-base.start", + "defaults": { + "name": "Start", + "color": "#00e000" + }, + "iconData": { + "icon": "play", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "Start", + "typeVersion": 1 + }, + { + "id": 126, + "icon": "file:mautic.svg", + "name": "n8n-nodes-base.mautic", + "defaults": { + "name": "Mautic" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzQ5Ljc3OXB4IiBoZWlnaHQ9IjM0OS43NzlweCIgdmlld0JveD0iMCAwIDM0OS43NzkgMzQ5Ljc3OSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMzQ5Ljc3OSAzNDkuNzc5Ig0KCSB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xNzQuODksMzQ5Ljc3OUM3OC42MTIsMzQ5Ljc3OSwwLDI3MS40NjIsMCwxNzQuODlTNzguNjEyLDAsMTc0Ljg5LDBjMjMuMjYsMCw0NS45MzEsNC40MTcsNjcuMTI5LDEzLjU0Mw0KCWM1Ljg4OSwyLjY1LDguODMzLDkuNDIyLDYuNDc4LDE1LjYwNWMtMi42NDksNS44ODgtOS40MjEsOC44MzMtMTUuNjA0LDYuNDc3Yy0xOC41NDktNy42NTUtMzcuOTgtMTEuNDgyLTU4LjAwMi0xMS40ODINCgljLTgzLjMyMywwLTE1MS4wNDEsNjcuNzE4LTE1MS4wNDEsMTUxLjA0MVM5MS41NjcsMzI2LjIyNSwxNzQuODksMzI2LjIyNWM4My4zMjMsMCwxNTEuMDQxLTY3LjcxOCwxNTEuMDQxLTE1MS4wNDENCgljMC0xNy45Ni0yLjk0NC0zNS4zMzItOS4xMjctNTEuODE5Yy0yLjM1NS02LjE4MywwLjg4My0xMi45NTUsNy4wNjYtMTUuMzFjNi4xODMtMi4zNTUsMTIuOTU0LDAuODgzLDE1LjMxLDcuMDY2DQoJYzcuMDY2LDE5LjEzOCwxMC42LDM5LjQ1MywxMC42LDYwLjA2M0MzNDkuNzc5LDI3MS4xNjcsMjcxLjQ2MiwzNDkuNzc5LDE3NC44OSwzNDkuNzc5Ii8+DQo8Zz4NCgk8cG9seWdvbiBmaWxsPSIjRkRCOTMzIiBwb2ludHM9IjI1MS40NCwxNTYuOTMgMjI0LjM1NCwxODUuMTk0IDIzOS4zNjksMjQ4LjQ5NiAyNzMuNTIyLDI0OC40OTYgCSIvPg0KPC9nPg0KPHBvbHlnb24gZmlsbD0iI0ZEQjkzMyIgcG9pbnRzPSIyNDAuMjUzLDczLjMxMiAyNDkuNjc0LDgyLjczNCAxNzQuODksMTYxLjkzNSAxMTAuOTk5LDk2LjI3NyA3NC4xOTYsMjQ4LjQ5NiAxMDguMzUsMjQ4LjQ5NiANCgkxMjguNjY1LDE2My45OTYgMTc0Ljg5LDIxNC4zNDMgMjczLjgxNywxMDYuNTgzIDI4My4yMzksMTE2LjI5OSAyOTIuNjYsNjMuMDA3ICIvPg0KPC9zdmc+DQo=" + }, + "categories": [ + { + "id": 1, + "name": "Marketing & Content" + }, + { + "id": 6, + "name": "Communication" + } + ], + "displayName": "Mautic", + "typeVersion": 1 + }, + { + "id": 235, + "icon": "file:wooCommerce.svg", + "name": "n8n-nodes-base.wooCommerceTrigger", + "defaults": { + "name": "WooCommerce Trigger" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo=" + }, + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "displayName": "WooCommerce Trigger", + "typeVersion": 1 + } + ], + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "image": [] + }, + { + "id": 1459, + "name": "Notify on Telegram and Twitter when new order is added in WooCommerce", + "views": 620, + "recentViews": 9823, + "totalViews": 620, + "createdAt": "2022-02-17T15:02:14.961Z", + "description": "This workflow uses a WooCommerce trigger that will run a new product has been added, It will then post the product to Telegram and Twitter.\n\nTo use this workflow you will need to set the credentials to use for the WooCommerce, Twitter and Telegram nodes.", + "workflow": { + "id": 85, + "name": "New WooCommerce Product to Twitter and Telegram", + "nodes": [ + { + "name": "Twitter", + "type": "n8n-nodes-base.twitter", + "position": [ + 720, + 300 + ], + "parameters": { + "text": "=✨ New Product Announcement ✨\nWe have just added {{$json[\"name\"]}}, Head to {{$json[\"permalink\"]}} to find out more.", + "additionalFields": {} + }, + "credentials": { + "twitterOAuth1Api": { + "id": "37", + "name": "joffcom" + } + }, + "typeVersion": 1 + }, + { + "name": "Telegram", + "type": "n8n-nodes-base.telegram", + "position": [ + 720, + 500 + ], + "parameters": { + "text": "=✨ New Product Announcement ✨\nWe have just added {{$json[\"name\"]}}, Head to {{$json[\"permalink\"]}} to find out more.", + "chatId": "123456", + "additionalFields": {} + }, + "credentials": { + "telegramApi": { + "id": "56", + "name": "Telegram account" + } + }, + "typeVersion": 1 + }, + { + "name": "WooCommerce Trigger", + "type": "n8n-nodes-base.wooCommerceTrigger", + "position": [ + 540, + 400 + ], + "webhookId": "ab7b134b-9b2d-4e0d-b496-1aee30db0808", + "parameters": { + "event": "product.created" + }, + "credentials": { + "wooCommerceApi": { + "id": "48", + "name": "WooCommerce account" + } + }, + "typeVersion": 1 + } + ], + "active": false, + "settings": {}, + "connections": { + "WooCommerce Trigger": { + "main": [ + [ + { + "node": "Twitter", + "type": "main", + "index": 0 + }, + { + "node": "Telegram", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + "workflowInfo": { + "nodeCount": 4, + "nodeTypes": { + "n8n-nodes-base.twitter": { + "count": 1 + }, + "n8n-nodes-base.telegram": { + "count": 1 + }, + "n8n-nodes-base.wooCommerceTrigger": { + "count": 1 + } + } + }, + "user": { + "username": "jon-n8n" + }, + "nodes": [ + { + "id": 42, + "icon": "fa:play", + "name": "n8n-nodes-base.start", + "defaults": { + "name": "Start", + "color": "#00e000" + }, + "iconData": { + "icon": "play", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "Start", + "typeVersion": 1 + }, + { + "id": 49, + "icon": "file:telegram.svg", + "name": "n8n-nodes-base.telegram", + "defaults": { + "name": "Telegram" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNjYgNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiIGZpbGw9IiMzN2FlZTIiLz48cGF0aCBkPSJNMjEuNjYxIDM0LjMzOGwzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIgZmlsbD0iI2M4ZGFlYSIvPjxwYXRoIGQ9Ik0yNi42OTUgMzcuMDM0bC0uNzI5IDcuNzQ2cy0uMzA1IDIuMzczIDIuMDY4IDBsNC42NDQtNC4yMDMiIGZpbGw9IiNhOWM2ZDgiLz48cGF0aCBkPSJNMjEuNzMgMzQuNzEybC03LjgwOS0yLjU0NXMtLjkzMi0uMzc4LS42MzMtMS4yMzdjLjA2Mi0uMTc3LjE4Ni0uMzI4LjU1OS0uNTg4IDEuNzMxLTEuMjA2IDMyLjAyOC0xMi4wOTYgMzIuMDI4LTEyLjA5NnMuODU2LS4yODggMS4zNjEtLjA5N2MuMjMxLjA4OC4zNzguMTg3LjUwMy41NDguMDQ1LjEzMi4wNzEuNDExLjA2OC42ODktLjAwMy4yMDEtLjAyNy4zODYtLjA0NS42NzgtLjE4NCAyLjk3OC01LjcwNiAyNS4xOTgtNS43MDYgMjUuMTk4cy0uMzMgMS4zLTEuNTE0IDEuMzQ1Yy0uNDMyLjAxNi0uOTU2LS4wNzEtMS41ODItLjYxLTIuMzIzLTEuOTk4LTEwLjM1Mi03LjM5NC0xMi4xMjYtOC41OGEuMzQuMzQgMCAwMS0uMTQ2LS4yMzljLS4wMjUtLjEyNS4xMDgtLjI4LjEwOC0uMjhzMTMuOTgtMTIuNDI3IDE0LjM1Mi0xMy43MzFjLjAyOS0uMTAxLS4wNzktLjE1MS0uMjI2LS4xMDctLjkyOS4zNDItMTcuMDI1IDEwLjUwNi0xOC44MDEgMTEuNjI5LS4xMDQuMDY2LS4zOTUuMDIzLS4zOTUuMDIzIi8+PC9nPjwvc3ltYm9sPjwvc3ZnPg==" + }, + "categories": [ + { + "id": 6, + "name": "Communication" + } + ], + "displayName": "Telegram", + "typeVersion": 1 + }, + { + "id": 235, + "icon": "file:wooCommerce.svg", + "name": "n8n-nodes-base.wooCommerceTrigger", + "defaults": { + "name": "WooCommerce Trigger" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo=" + }, + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "displayName": "WooCommerce Trigger", + "typeVersion": 1 + }, + { + "id": 325, + "icon": "file:x.svg", + "name": "n8n-nodes-base.twitter", + "defaults": { + "name": "X" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4LjI0NCAyLjI1aDMuMzA4bC03LjIyNyA4LjI2IDguNTAyIDExLjI0SDE2LjE3bC01LjIxNC02LjgxN0w0Ljk5IDIxLjc1SDEuNjhsNy43My04LjgzNUwxLjI1NCAyLjI1SDguMDhsNC43MTMgNi4yMzF6bS0xLjE2MSAxNy41MmgxLjgzM0w3LjA4NCA0LjEyNkg1LjExN3oiPjwvcGF0aD48L3N2Zz4K" + }, + "categories": [ + { + "id": 1, + "name": "Marketing & Content" + } + ], + "displayName": "X (Formerly Twitter)", + "typeVersion": 2 + } + ], + "categories": [ + { + "id": 2, + "name": "Sales" + }, + { + "id": 19, + "name": "Marketing & Growth" + } + ], + "image": [] + }, + { + "id": 1457, + "name": "Notify on Slack when new order is registered in WooCommerce", + "views": 178, + "recentViews": 9787, + "totalViews": 178, + "createdAt": "2022-02-17T15:01:13.489Z", + "description": "This workflow uses a WooCommerce trigger that will run when an order has been placed.\n\nIf the value of this is over 100 it will post it to a Slack channel.\n\nTo use this workflow you will need to set the credentials to use for the WooCommerce and Slack nodes, You will also need to pick a channel to post the message to.", + "workflow": { + "id": 81, + "name": "New WooCommerce order to Slack", + "nodes": [ + { + "name": "Order Created", + "type": "n8n-nodes-base.wooCommerceTrigger", + "position": [ + 340, + 500 + ], + "webhookId": "287b4bf4-67ec-4c97-85d9-c0d3e6f59e6b", + "parameters": { + "event": "order.created" + }, + "credentials": { + "wooCommerceApi": { + "id": "48", + "name": "WooCommerce account" + } + }, + "typeVersion": 1 + }, + { + "name": "Send to Slack", + "type": "n8n-nodes-base.slack", + "position": [ + 780, + 480 + ], + "parameters": { + "text": ":sparkles: There is a new order :sparkles:", + "channel": "woo-commerce", + "blocksUi": { + "blocksValues": [] + }, + "attachments": [ + { + "color": "#66FF00", + "fields": { + "item": [ + { + "short": true, + "title": "Order ID", + "value": "={{$json[\"id\"]}}" + }, + { + "short": true, + "title": "Status", + "value": "={{$json[\"status\"]}}" + }, + { + "short": true, + "title": "Total", + "value": "={{$json[\"currency_symbol\"]}}{{$json[\"total\"]}}" + }, + { + "short": false, + "title": "Link", + "value": "={{$node[\"Order Created\"].json[\"_links\"][\"self\"][0][\"href\"]}}" + } + ] + }, + "footer": "=*Ordered:* {{$json[\"date_created\"]}} | *Transaction ID:* {{$json[\"transaction_id\"]}}" + } + ], + "otherOptions": {} + }, + "credentials": { + "slackApi": { + "id": "53", + "name": "Slack Access Token" + } + }, + "typeVersion": 1 + }, + { + "name": "Price over 100", + "type": "n8n-nodes-base.if", + "position": [ + 540, + 500 + ], + "parameters": { + "conditions": { + "number": [ + { + "value1": "={{$json[\"total\"]}}", + "value2": 100, + "operation": "largerEqual" + } + ] + } + }, + "typeVersion": 1 + } + ], + "active": false, + "settings": { + "saveManualExecutions": true, + "saveExecutionProgress": true, + "saveDataSuccessExecution": "all" + }, + "connections": { + "Order Created": { + "main": [ + [ + { + "node": "Price over 100", + "type": "main", + "index": 0 + } + ] + ] + }, + "Price over 100": { + "main": [ + [ + { + "node": "Send to Slack", + "type": "main", + "index": 0 + } + ], + [] + ] + } + } + }, + "workflowInfo": { + "nodeCount": 4, + "nodeTypes": { + "n8n-nodes-base.if": { + "count": 1 + }, + "n8n-nodes-base.slack": { + "count": 1 + }, + "n8n-nodes-base.wooCommerceTrigger": { + "count": 1 + } + } + }, + "user": { + "username": "jon-n8n" + }, + "nodes": [ + { + "id": 20, + "icon": "fa:map-signs", + "name": "n8n-nodes-base.if", + "defaults": { + "name": "IF", + "color": "#408000" + }, + "iconData": { + "icon": "map-signs", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "IF", + "typeVersion": 1 + }, + { + "id": 40, + "icon": "file:slack.svg", + "name": "n8n-nodes-base.slack", + "defaults": { + "name": "Slack" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTUwLjg1MiAxNTAuODUyIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48dXNlIHhsaW5rOmhyZWY9IiNhIiB4PSIuOTI2IiB5PSIuOTI2Ii8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxnIHN0cm9rZS13aWR0aD0iMS44NTIiPjxwYXRoIGQ9Ik00MC43NDEgOTMuNTVjMC04LjczNSA2LjYwNy0xNS43NzIgMTQuODE1LTE1Ljc3MnMxNC44MTUgNy4wMzcgMTQuODE1IDE1Ljc3MnYzOC44MjRjMCA4LjczNy02LjYwNyAxNS43NzQtMTQuODE1IDE1Ljc3NHMtMTQuODE1LTcuMDM3LTE0LjgxNS0xNS43NzJ6IiBmaWxsPSIjZTAxZTVhIiBzdHJva2U9IiNlMDFlNWEiLz48cGF0aCBkPSJNOTMuNTUgMTA3LjQwOGMtOC43MzUgMC0xNS43NzItNi42MDctMTUuNzcyLTE0LjgxNXM3LjAzNy0xNC44MTUgMTUuNzcyLTE0LjgxNWgzOC44MjZjOC43MzUgMCAxNS43NzIgNi42MDcgMTUuNzcyIDE0LjgxNXMtNy4wMzcgMTQuODE1LTE1Ljc3MiAxNC44MTV6IiBmaWxsPSIjZWNiMjJkIiBzdHJva2U9IiNlY2IyMmQiLz48cGF0aCBkPSJNNzcuNzc4IDE1Ljc3MkM3Ny43NzggNy4wMzcgODQuMzg1IDAgOTIuNTkzIDBzMTQuODE1IDcuMDM3IDE0LjgxNSAxNS43NzJ2MzguODI2YzAgOC43MzUtNi42MDcgMTUuNzcyLTE0LjgxNSAxNS43NzJzLTE0LjgxNS03LjAzNy0xNC44MTUtMTUuNzcyeiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE1Ljc3MiA3MC4zNzFDNy4wMzcgNzAuMzcxIDAgNjMuNzYzIDAgNTUuNTU2czcuMDM3LTE0LjgxNSAxNS43NzItMTQuODE1aDM4LjgyNmM4LjczNSAwIDE1Ljc3MiA2LjYwNyAxNS43NzIgMTQuODE1cy03LjAzNyAxNC44MTUtMTUuNzcyIDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjxnIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiPjxwYXRoIGQ9Ik03Ny43NzggMTMzLjMzM2MwIDguMjA4IDYuNjA3IDE0LjgxNSAxNC44MTUgMTQuODE1czE0LjgxNS02LjYwNyAxNC44MTUtMTQuODE1LTYuNjA3LTE0LjgxNS0xNC44MTUtMTQuODE1SDc3Ljc3OHoiIGZpbGw9IiNlY2IyMmQiIHN0cm9rZT0iI2VjYjIyZCIvPjxwYXRoIGQ9Ik0xMzMuMzM0IDcwLjM3MWgtMTQuODE1VjU1LjU1NmMwLTguMjA3IDYuNjA3LTE0LjgxNSAxNC44MTUtMTQuODE1czE0LjgxNSA2LjYwNyAxNC44MTUgMTQuODE1LTYuNjA3IDE0LjgxNS0xNC44MTUgMTQuODE1eiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE0LjgxNSA3Ny43NzhIMjkuNjN2MTQuODE1YzAgOC4yMDctNi42MDcgMTQuODE1LTE0LjgxNSAxNC44MTVTMCAxMDAuOCAwIDkyLjU5M3M2LjYwNy0xNC44MTUgMTQuODE1LTE0LjgxNXoiIGZpbGw9IiNlMDFlNWEiIHN0cm9rZT0iI2UwMWU1YSIvPjxwYXRoIGQ9Ik03MC4zNzEgMTQuODE1VjI5LjYzSDU1LjU1NmMtOC4yMDcgMC0xNC44MTUtNi42MDctMTQuODE1LTE0LjgxNVM0Ny4zNDggMCA1NS41NTYgMHMxNC44MTUgNi42MDcgMTQuODE1IDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjwvZz48L2c+PC9zeW1ib2w+PC9zdmc+" + }, + "categories": [ + { + "id": 6, + "name": "Communication" + } + ], + "displayName": "Slack", + "typeVersion": 2 + }, + { + "id": 42, + "icon": "fa:play", + "name": "n8n-nodes-base.start", + "defaults": { + "name": "Start", + "color": "#00e000" + }, + "iconData": { + "icon": "play", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "Start", + "typeVersion": 1 + }, + { + "id": 235, + "icon": "file:wooCommerce.svg", + "name": "n8n-nodes-base.wooCommerceTrigger", + "defaults": { + "name": "WooCommerce Trigger" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo=" + }, + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "displayName": "WooCommerce Trigger", + "typeVersion": 1 + } + ], + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "image": [] + }, + { + "id": 1765, + "name": "Get Slack notifications when new product published on WooCommerce", + "views": 79, + "recentViews": 9577, + "totalViews": 79, + "createdAt": "2022-08-12T12:36:53.409Z", + "description": "This workflow let's a bot in Slack notify a specific channel when a new product in WooCommerce is published and live on the site. \n\n## Prerequisites\n\n[WooCommerce](https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.woocommercetrigger/) account\n[Slack](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.slack/) and a [Slack bot](https://slack.com/help/articles/115005265703-Create-a-bot-for-your-workspace)\n\n## How it works\n\n1. Listen for WooCommerce product creation\n2. If permalink starts with https://[your-url-here].com/product/\n3. Slack bot notifies channel that a new product has been added. \n\nPlease note, you must update the URL in the IF node to match your url. If your WooCommerce doesn't use the slug /product/, that will need to be updated too. \n", + "workflow": { + "id": 1016, + "name": "Woocommerce to slack: notify new product created", + "tags": [ + { + "id": "5", + "name": "FVF", + "createdAt": "2022-07-30T07:43:44.795Z", + "updatedAt": "2022-07-30T07:43:44.795Z" + } + ], + "nodes": [ + { + "name": "If URL has /product/", + "type": "n8n-nodes-base.if", + "position": [ + 640, + 300 + ], + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{$json[\"permalink\"]}}", + "value2": "https://[add-your-url-here]/product/", + "operation": "startsWith" + } + ] + } + }, + "typeVersion": 1 + }, + { + "name": "Send message to slack", + "type": "n8n-nodes-base.slack", + "position": [ + 920, + 260 + ], + "parameters": { + "text": ":new: A new product has been added! :new:", + "channel": "newproducts", + "blocksUi": { + "blocksValues": [] + }, + "attachments": [ + { + "color": "#66FF00", + "fields": { + "item": [ + { + "short": false, + "title": "Name", + "value": "={{$json[\"name\"]}}" + }, + { + "short": true, + "title": "Price", + "value": "={{$json[\"regular_price\"]}}" + }, + { + "short": true, + "title": "Sale Price", + "value": "={{$json[\"sale_price\"]}}" + }, + { + "short": false, + "title": "Link", + "value": "={{$json[\"permalink\"]}}" + } + ] + }, + "footer": "=Added: {{$json[\"date_created\"]}}" + } + ], + "otherOptions": {} + }, + "credentials": { + "slackApi": { + "id": "21", + "name": "FVF bot" + } + }, + "typeVersion": 1 + }, + { + "name": "On product creation", + "type": "n8n-nodes-base.wooCommerceTrigger", + "position": [ + 460, + 300 + ], + "webhookId": "267c4855-6227-4d33-867e-74600097473e", + "parameters": { + "event": "product.created" + }, + "credentials": { + "wooCommerceApi": { + "id": "20", + "name": "WooCommerce account FVF" + } + }, + "typeVersion": 1 + } + ], + "active": true, + "settings": {}, + "connections": { + "On product creation": { + "main": [ + [ + { + "node": "If URL has /product/", + "type": "main", + "index": 0 + } + ] + ] + }, + "If URL has /product/": { + "main": [ + [ + { + "node": "Send message to slack", + "type": "main", + "index": 0 + } + ] + ] + } + } + }, + "workflowInfo": { + "nodeCount": 4, + "nodeTypes": { + "n8n-nodes-base.if": { + "count": 1 + }, + "n8n-nodes-base.slack": { + "count": 1 + }, + "n8n-nodes-base.wooCommerceTrigger": { + "count": 1 + } + } + }, + "user": { + "username": "n8n-team" + }, + "nodes": [ + { + "id": 20, + "icon": "fa:map-signs", + "name": "n8n-nodes-base.if", + "defaults": { + "name": "IF", + "color": "#408000" + }, + "iconData": { + "icon": "map-signs", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "IF", + "typeVersion": 1 + }, + { + "id": 40, + "icon": "file:slack.svg", + "name": "n8n-nodes-base.slack", + "defaults": { + "name": "Slack" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTUwLjg1MiAxNTAuODUyIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48dXNlIHhsaW5rOmhyZWY9IiNhIiB4PSIuOTI2IiB5PSIuOTI2Ii8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxnIHN0cm9rZS13aWR0aD0iMS44NTIiPjxwYXRoIGQ9Ik00MC43NDEgOTMuNTVjMC04LjczNSA2LjYwNy0xNS43NzIgMTQuODE1LTE1Ljc3MnMxNC44MTUgNy4wMzcgMTQuODE1IDE1Ljc3MnYzOC44MjRjMCA4LjczNy02LjYwNyAxNS43NzQtMTQuODE1IDE1Ljc3NHMtMTQuODE1LTcuMDM3LTE0LjgxNS0xNS43NzJ6IiBmaWxsPSIjZTAxZTVhIiBzdHJva2U9IiNlMDFlNWEiLz48cGF0aCBkPSJNOTMuNTUgMTA3LjQwOGMtOC43MzUgMC0xNS43NzItNi42MDctMTUuNzcyLTE0LjgxNXM3LjAzNy0xNC44MTUgMTUuNzcyLTE0LjgxNWgzOC44MjZjOC43MzUgMCAxNS43NzIgNi42MDcgMTUuNzcyIDE0LjgxNXMtNy4wMzcgMTQuODE1LTE1Ljc3MiAxNC44MTV6IiBmaWxsPSIjZWNiMjJkIiBzdHJva2U9IiNlY2IyMmQiLz48cGF0aCBkPSJNNzcuNzc4IDE1Ljc3MkM3Ny43NzggNy4wMzcgODQuMzg1IDAgOTIuNTkzIDBzMTQuODE1IDcuMDM3IDE0LjgxNSAxNS43NzJ2MzguODI2YzAgOC43MzUtNi42MDcgMTUuNzcyLTE0LjgxNSAxNS43NzJzLTE0LjgxNS03LjAzNy0xNC44MTUtMTUuNzcyeiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE1Ljc3MiA3MC4zNzFDNy4wMzcgNzAuMzcxIDAgNjMuNzYzIDAgNTUuNTU2czcuMDM3LTE0LjgxNSAxNS43NzItMTQuODE1aDM4LjgyNmM4LjczNSAwIDE1Ljc3MiA2LjYwNyAxNS43NzIgMTQuODE1cy03LjAzNyAxNC44MTUtMTUuNzcyIDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjxnIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiPjxwYXRoIGQ9Ik03Ny43NzggMTMzLjMzM2MwIDguMjA4IDYuNjA3IDE0LjgxNSAxNC44MTUgMTQuODE1czE0LjgxNS02LjYwNyAxNC44MTUtMTQuODE1LTYuNjA3LTE0LjgxNS0xNC44MTUtMTQuODE1SDc3Ljc3OHoiIGZpbGw9IiNlY2IyMmQiIHN0cm9rZT0iI2VjYjIyZCIvPjxwYXRoIGQ9Ik0xMzMuMzM0IDcwLjM3MWgtMTQuODE1VjU1LjU1NmMwLTguMjA3IDYuNjA3LTE0LjgxNSAxNC44MTUtMTQuODE1czE0LjgxNSA2LjYwNyAxNC44MTUgMTQuODE1LTYuNjA3IDE0LjgxNS0xNC44MTUgMTQuODE1eiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE0LjgxNSA3Ny43NzhIMjkuNjN2MTQuODE1YzAgOC4yMDctNi42MDcgMTQuODE1LTE0LjgxNSAxNC44MTVTMCAxMDAuOCAwIDkyLjU5M3M2LjYwNy0xNC44MTUgMTQuODE1LTE0LjgxNXoiIGZpbGw9IiNlMDFlNWEiIHN0cm9rZT0iI2UwMWU1YSIvPjxwYXRoIGQ9Ik03MC4zNzEgMTQuODE1VjI5LjYzSDU1LjU1NmMtOC4yMDcgMC0xNC44MTUtNi42MDctMTQuODE1LTE0LjgxNVM0Ny4zNDggMCA1NS41NTYgMHMxNC44MTUgNi42MDcgMTQuODE1IDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjwvZz48L2c+PC9zeW1ib2w+PC9zdmc+" + }, + "categories": [ + { + "id": 6, + "name": "Communication" + } + ], + "displayName": "Slack", + "typeVersion": 2 + }, + { + "id": 42, + "icon": "fa:play", + "name": "n8n-nodes-base.start", + "defaults": { + "name": "Start", + "color": "#00e000" + }, + "iconData": { + "icon": "play", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "Start", + "typeVersion": 1 + }, + { + "id": 235, + "icon": "file:wooCommerce.svg", + "name": "n8n-nodes-base.wooCommerceTrigger", + "defaults": { + "name": "WooCommerce Trigger" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo=" + }, + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "displayName": "WooCommerce Trigger", + "typeVersion": 1 + } + ], + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "image": [] + }, + { + "id": 1460, + "name": "Notify on Slack when refund is registered in WooCommerce", + "views": 85, + "recentViews": 9541, + "totalViews": 85, + "createdAt": "2022-02-17T15:02:58.662Z", + "description": "This workflow uses a WooCommerce trigger that will run when an order has been updated and the status is refunded.\n\nIf the value of this is over 100 it will post it to a Slack channel.\n\nTo use this workflow you will need to set the credentials to use for the WooCommerce and Slack nodes, You will also need to pick a channel to post the message to.", + "workflow": { + "id": 82, + "name": "New WooCommerce refund to Slack", + "nodes": [ + { + "name": "Order Updated", + "type": "n8n-nodes-base.wooCommerceTrigger", + "position": [ + 320, + 500 + ], + "webhookId": "f7736be3-e978-4a17-b936-7ce9f8ccdb72", + "parameters": { + "event": "order.updated" + }, + "credentials": { + "wooCommerceApi": { + "id": "48", + "name": "WooCommerce account" + } + }, + "typeVersion": 1 + }, + { + "name": "If Refund and Over 100", + "type": "n8n-nodes-base.if", + "position": [ + 540, + 500 + ], + "parameters": { + "conditions": { + "number": [ + { + "value1": "={{$json[\"total\"]}}", + "value2": 100, + "operation": "largerEqual" + } + ], + "string": [ + { + "value1": "={{$json[\"status\"]}}", + "value2": "refunded" + } + ] + } + }, + "typeVersion": 1 + }, + { + "name": "Send to Slack", + "type": "n8n-nodes-base.slack", + "position": [ + 780, + 480 + ], + "parameters": { + "text": ":x: A refund has been issued :x:", + "channel": "woo-commerce", + "blocksUi": { + "blocksValues": [] + }, + "attachments": [ + { + "color": "#FF0000", + "fields": { + "item": [ + { + "short": true, + "title": "Order ID", + "value": "={{$json[\"id\"]}}" + }, + { + "short": true, + "title": "Status", + "value": "={{$json[\"status\"]}}" + }, + { + "short": true, + "title": "Total", + "value": "={{$json[\"currency_symbol\"]}}{{$json[\"total\"]}}" + } + ] + }, + "footer": "=*Order updated:* {{$json[\"date_modified\"]}}" + } + ], + "otherOptions": {} + }, + "credentials": { + "slackApi": { + "id": "53", + "name": "Slack Access Token" + } + }, + "typeVersion": 1 + } + ], + "active": false, + "settings": { + "saveManualExecutions": true, + "saveExecutionProgress": true, + "saveDataSuccessExecution": "all" + }, + "connections": { + "Order Updated": { + "main": [ + [ + { + "node": "If Refund and Over 100", + "type": "main", + "index": 0 + } + ] + ] + }, + "If Refund and Over 100": { + "main": [ + [ + { + "node": "Send to Slack", + "type": "main", + "index": 0 + } + ], + [] + ] + } + } + }, + "workflowInfo": { + "nodeCount": 4, + "nodeTypes": { + "n8n-nodes-base.if": { + "count": 1 + }, + "n8n-nodes-base.slack": { + "count": 1 + }, + "n8n-nodes-base.wooCommerceTrigger": { + "count": 1 + } + } + }, + "user": { + "username": "jon-n8n" + }, + "nodes": [ + { + "id": 20, + "icon": "fa:map-signs", + "name": "n8n-nodes-base.if", + "defaults": { + "name": "IF", + "color": "#408000" + }, + "iconData": { + "icon": "map-signs", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "IF", + "typeVersion": 1 + }, + { + "id": 40, + "icon": "file:slack.svg", + "name": "n8n-nodes-base.slack", + "defaults": { + "name": "Slack" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMTUwLjg1MiAxNTAuODUyIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48dXNlIHhsaW5rOmhyZWY9IiNhIiB4PSIuOTI2IiB5PSIuOTI2Ii8+PHN5bWJvbCBpZD0iYSIgb3ZlcmZsb3c9InZpc2libGUiPjxnIHN0cm9rZS13aWR0aD0iMS44NTIiPjxwYXRoIGQ9Ik00MC43NDEgOTMuNTVjMC04LjczNSA2LjYwNy0xNS43NzIgMTQuODE1LTE1Ljc3MnMxNC44MTUgNy4wMzcgMTQuODE1IDE1Ljc3MnYzOC44MjRjMCA4LjczNy02LjYwNyAxNS43NzQtMTQuODE1IDE1Ljc3NHMtMTQuODE1LTcuMDM3LTE0LjgxNS0xNS43NzJ6IiBmaWxsPSIjZTAxZTVhIiBzdHJva2U9IiNlMDFlNWEiLz48cGF0aCBkPSJNOTMuNTUgMTA3LjQwOGMtOC43MzUgMC0xNS43NzItNi42MDctMTUuNzcyLTE0LjgxNXM3LjAzNy0xNC44MTUgMTUuNzcyLTE0LjgxNWgzOC44MjZjOC43MzUgMCAxNS43NzIgNi42MDcgMTUuNzcyIDE0LjgxNXMtNy4wMzcgMTQuODE1LTE1Ljc3MiAxNC44MTV6IiBmaWxsPSIjZWNiMjJkIiBzdHJva2U9IiNlY2IyMmQiLz48cGF0aCBkPSJNNzcuNzc4IDE1Ljc3MkM3Ny43NzggNy4wMzcgODQuMzg1IDAgOTIuNTkzIDBzMTQuODE1IDcuMDM3IDE0LjgxNSAxNS43NzJ2MzguODI2YzAgOC43MzUtNi42MDcgMTUuNzcyLTE0LjgxNSAxNS43NzJzLTE0LjgxNS03LjAzNy0xNC44MTUtMTUuNzcyeiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE1Ljc3MiA3MC4zNzFDNy4wMzcgNzAuMzcxIDAgNjMuNzYzIDAgNTUuNTU2czcuMDM3LTE0LjgxNSAxNS43NzItMTQuODE1aDM4LjgyNmM4LjczNSAwIDE1Ljc3MiA2LjYwNyAxNS43NzIgMTQuODE1cy03LjAzNyAxNC44MTUtMTUuNzcyIDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjxnIHN0cm9rZS1saW5lam9pbj0ibWl0ZXIiPjxwYXRoIGQ9Ik03Ny43NzggMTMzLjMzM2MwIDguMjA4IDYuNjA3IDE0LjgxNSAxNC44MTUgMTQuODE1czE0LjgxNS02LjYwNyAxNC44MTUtMTQuODE1LTYuNjA3LTE0LjgxNS0xNC44MTUtMTQuODE1SDc3Ljc3OHoiIGZpbGw9IiNlY2IyMmQiIHN0cm9rZT0iI2VjYjIyZCIvPjxwYXRoIGQ9Ik0xMzMuMzM0IDcwLjM3MWgtMTQuODE1VjU1LjU1NmMwLTguMjA3IDYuNjA3LTE0LjgxNSAxNC44MTUtMTQuODE1czE0LjgxNSA2LjYwNyAxNC44MTUgMTQuODE1LTYuNjA3IDE0LjgxNS0xNC44MTUgMTQuODE1eiIgZmlsbD0iIzJmYjY3YyIgc3Ryb2tlPSIjMmZiNjdjIi8+PHBhdGggZD0iTTE0LjgxNSA3Ny43NzhIMjkuNjN2MTQuODE1YzAgOC4yMDctNi42MDcgMTQuODE1LTE0LjgxNSAxNC44MTVTMCAxMDAuOCAwIDkyLjU5M3M2LjYwNy0xNC44MTUgMTQuODE1LTE0LjgxNXoiIGZpbGw9IiNlMDFlNWEiIHN0cm9rZT0iI2UwMWU1YSIvPjxwYXRoIGQ9Ik03MC4zNzEgMTQuODE1VjI5LjYzSDU1LjU1NmMtOC4yMDcgMC0xNC44MTUtNi42MDctMTQuODE1LTE0LjgxNVM0Ny4zNDggMCA1NS41NTYgMHMxNC44MTUgNi42MDcgMTQuODE1IDE0LjgxNXoiIGZpbGw9IiMzNmM1ZjEiIHN0cm9rZT0iIzM2YzVmMSIvPjwvZz48L2c+PC9zeW1ib2w+PC9zdmc+" + }, + "categories": [ + { + "id": 6, + "name": "Communication" + } + ], + "displayName": "Slack", + "typeVersion": 2 + }, + { + "id": 42, + "icon": "fa:play", + "name": "n8n-nodes-base.start", + "defaults": { + "name": "Start", + "color": "#00e000" + }, + "iconData": { + "icon": "play", + "type": "icon" + }, + "categories": [ + { + "id": 9, + "name": "Core Nodes" + } + ], + "displayName": "Start", + "typeVersion": 1 + }, + { + "id": 235, + "icon": "file:wooCommerce.svg", + "name": "n8n-nodes-base.wooCommerceTrigger", + "defaults": { + "name": "WooCommerce Trigger" + }, + "iconData": { + "type": "file", + "fileBuffer": "data:image/svg+xml;base64,PHN2ZyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIC01MCAyNTYgMjUzIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KPHRpdGxlPldvb0NvbW1lcmNlIExvZ288L3RpdGxlPgo8bWV0YWRhdGE+CjxyZGY6UkRGPgo8Y2M6V29yayByZGY6YWJvdXQ9IiI+CjxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0Pgo8ZGM6dHlwZSByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIi8+CjxkYzp0aXRsZS8+CjwvY2M6V29yaz4KPC9yZGY6UkRGPgo8L21ldGFkYXRhPgo8cGF0aCBkPSJtMjMuNzU5IDBoMjA4LjM4YzEzLjE4NyAwIDIzLjg2MyAxMC42NzUgMjMuODYzIDIzLjg2M3Y3OS41NDJjMCAxMy4xODctMTAuNjc1IDIzLjg2My0yMy44NjMgMjMuODYzaC03NC43MjdsMTAuMjU3IDI1LjExOC00NS4xMDktMjUuMTE4aC05OC42OTVjLTEzLjE4NyAwLTIzLjg2My0xMC42NzUtMjMuODYzLTIzLjg2M3YtNzkuNTQyYy0wLjEwNDY2LTEzLjA4MyAxMC41NzEtMjMuODYzIDIzLjc1OC0yMy44NjN6IiBmaWxsPSIjN2Y1NGIzIi8+CjxwYXRoIGQ9Im0xNC41NzggMjEuNzVjMS40NTY5LTEuOTc3MiAzLjY0MjMtMy4wMTc5IDYuNTU2MS0zLjIyNiA1LjMwNzMtMC40MTYyNiA4LjMyNTIgMi4wODEzIDkuMDUzNyA3LjQ5MjcgMy4yMjYgMjEuNzUgNi43NjQyIDQwLjE2OSAxMC41MTEgNTUuMjU5bDIyLjc5LTQzLjM5NWMyLjA4MTMtMy45NTQ1IDQuNjgyOS02LjAzNTggNy44MDQ5LTYuMjQzOSA0LjU3ODktMC4zMTIyIDcuMzg4NiAyLjYwMTYgOC41MzMzIDguNzQxNSAyLjYwMTYgMTMuODQxIDUuOTMxNyAyNS42IDkuODg2MiAzNS41OSAyLjcwNTctMjYuNDMzIDcuMjg0Ni00NS40NzYgMTMuNzM3LTU3LjIzNiAxLjU2MS0yLjkxMzggMy44NTA0LTQuMzcwNyA2Ljg2ODMtNC41Nzg5IDIuMzkzNS0wLjIwODEzIDQuNTc4OSAwLjUyMDMzIDYuNTU2MSAyLjA4MTMgMS45NzcyIDEuNTYxIDMuMDE3OSAzLjUzODIgMy4yMjYgNS45MzE3IDAuMTA0MDYgMS44NzMyLTAuMjA4MTMgMy40MzQxLTEuMDQwNyA0Ljk5NTEtNC4wNTg1IDcuNDkyNy03LjM4ODYgMjAuMDg1LTEwLjA5NCAzNy41NjctMi42MDE2IDE2Ljk2My0zLjUzODIgMzAuMTc5LTIuOTEzOCAzOS42NDkgMC4yMDgxMyAyLjYwMTYtMC4yMDgxMyA0Ljg5MTEtMS4yNDg4IDYuODY4My0xLjI0ODggMi4yODk0LTMuMTIyIDMuNTM4Mi01LjUxNTQgMy43NDYzLTIuNzA1NyAwLjIwODEzLTUuNTE1NC0xLjA0MDYtOC4yMjExLTMuODUwNC05LjY3OC05Ljg4NjItMTcuMzc5LTI0LjY2My0yMi45OTgtNDQuMzMyLTYuNzY0MiAxMy4zMi0xMS43NTkgMjMuMzExLTE0Ljk4NSAyOS45NzEtNi4xMzk4IDExLjc1OS0xMS4zNDMgMTcuNzk1LTE1LjcxNCAxOC4xMDctMi44MDk4IDAuMjA4MTMtNS4yMDMzLTIuMTg1NC03LjI4NDYtNy4xODA1LTUuMzA3My0xMy42MzMtMTEuMDMxLTM5Ljk2MS0xNy4xNzEtNzguOTg1LTAuNDE2MjYtMi43MDU3IDAuMjA4MTMtNS4wOTkyIDEuNjY1LTYuOTcyNHptMjIzLjY0IDE2LjMzOGMtMy43NDYzLTYuNTU2MS05LjI2MTgtMTAuNTExLTE2LjY1LTEyLjA3Mi0xLjk3NzItMC40MTYyNi0zLjg1MDQtMC42MjQzOS01LjYxOTUtMC42MjQzOS05Ljk5MDIgMC0xOC4xMDcgNS4yMDMzLTI0LjQ1NSAxNS42MS01LjQxMTQgOC44NDU1LTguMTE3MSAxOC42MjgtOC4xMTcxIDI5LjM0NiAwIDguMDEzIDEuNjY1IDE0Ljg4MSA0Ljk5NTEgMjAuNjA1IDMuNzQ2MyA2LjU1NjEgOS4yNjE4IDEwLjUxMSAxNi42NSAxMi4wNzIgMS45NzcyIDAuNDE2MjYgMy44NTA0IDAuNjI0MzkgNS42MTk1IDAuNjI0MzkgMTAuMDk0IDAgMTguMjExLTUuMjAzMyAyNC40NTUtMTUuNjEgNS40MTE0LTguOTQ5NiA4LjExNzEtMTguNzMyIDguMTE3MS0yOS40NSAwLjEwNDA2LTguMTE3MS0xLjY2NS0xNC44ODEtNC45OTUxLTIwLjUwMXptLTEzLjExMiAyOC44MjZjLTEuNDU2OSA2Ljg2ODMtNC4wNTg1IDExLjk2Ny03LjkwODkgMTUuNDAyLTMuMDE3OSAyLjcwNTctNS44Mjc2IDMuODUwNC04LjQyOTMgMy4zMzAxLTIuNDk3Ni0wLjUyMDMzLTQuNTc4OS0yLjcwNTctNi4xMzk4LTYuNzY0Mi0xLjI0ODgtMy4yMjYtMS44NzMyLTYuNDUyLTEuODczMi05LjQ2OTkgMC0yLjYwMTYgMC4yMDgxMy01LjIwMzMgMC43Mjg0Ni03LjU5NjcgMC45MzY1OS00LjI2NjcgMi43MDU3LTguNDI5MyA1LjUxNTQtMTIuMzg0IDMuNDM0MS01LjA5OTIgNy4wNzY0LTcuMTgwNSAxMC44MjMtNi40NTIgMi40OTc2IDAuNTIwMzMgNC41Nzg5IDIuNzA1NyA2LjEzOTggNi43NjQyIDEuMjQ4OCAzLjIyNiAxLjg3MzIgNi40NTIgMS44NzMyIDkuNDY5OSAwIDIuNzA1Ny0wLjIwODEzIDUuMzA3My0wLjcyODQ2IDcuNzAwOHptLTUyLjAzMy0yOC44MjZjLTMuNzQ2My02LjU1NjEtOS4zNjU5LTEwLjUxMS0xNi42NS0xMi4wNzItMS45NzcyLTAuNDE2MjYtMy44NTA0LTAuNjI0MzktNS42MTk1LTAuNjI0MzktOS45OTAyIDAtMTguMTA3IDUuMjAzMy0yNC40NTUgMTUuNjEtNS40MTE0IDguODQ1NS04LjExNzEgMTguNjI4LTguMTE3MSAyOS4zNDYgMCA4LjAxMyAxLjY2NSAxNC44ODEgNC45OTUxIDIwLjYwNSAzLjc0NjMgNi41NTYxIDkuMjYxOCAxMC41MTEgMTYuNjUgMTIuMDcyIDEuOTc3MiAwLjQxNjI2IDMuODUwNCAwLjYyNDM5IDUuNjE5NSAwLjYyNDM5IDEwLjA5NCAwIDE4LjIxMS01LjIwMzMgMjQuNDU1LTE1LjYxIDUuNDExNC04Ljk0OTYgOC4xMTcxLTE4LjczMiA4LjExNzEtMjkuNDUgMC04LjExNzEtMS42NjUtMTQuODgxLTQuOTk1MS0yMC41MDF6bS0xMy4yMTYgMjguODI2Yy0xLjQ1NjkgNi44NjgzLTQuMDU4NSAxMS45NjctNy45MDg5IDE1LjQwMi0zLjAxNzkgMi43MDU3LTUuODI3NiAzLjg1MDQtOC40MjkzIDMuMzMwMS0yLjQ5NzYtMC41MjAzMy00LjU3ODktMi43MDU3LTYuMTM5OC02Ljc2NDItMS4yNDg4LTMuMjI2LTEuODczMi02LjQ1Mi0xLjg3MzItOS40Njk5IDAtMi42MDE2IDAuMjA4MTMtNS4yMDMzIDAuNzI4NDYtNy41OTY3IDAuOTM2NTgtNC4yNjY3IDIuNzA1Ny04LjQyOTMgNS41MTU0LTEyLjM4NCAzLjQzNDEtNS4wOTkyIDcuMDc2NC03LjE4MDUgMTAuODIzLTYuNDUyIDIuNDk3NiAwLjUyMDMzIDQuNTc4OSAyLjcwNTcgNi4xMzk4IDYuNzY0MiAxLjI0ODggMy4yMjYgMS44NzMyIDYuNDUyIDEuODczMiA5LjQ2OTkgMC4xMDQwNiAyLjcwNTctMC4yMDgxMyA1LjMwNzMtMC43Mjg0NiA3LjcwMDh6IiBmaWxsPSIjZmZmIi8+Cjwvc3ZnPgo=" + }, + "categories": [ + { + "id": 2, + "name": "Sales" + } + ], + "displayName": "WooCommerce Trigger", + "typeVersion": 1 + } + ], + "categories": [ + { + "id": 2, + "name": "Sales" + }, + { + "id": 8, + "name": "Finance & Accounting" + } + ], + "image": [] + } + ], + "image": [] + } +} diff --git a/cypress/fixtures/Test_Template_1.json b/cypress/fixtures/Test_Template_1.json index f15970677e1d5..1995beca5291a 100644 --- a/cypress/fixtures/Test_Template_1.json +++ b/cypress/fixtures/Test_Template_1.json @@ -1,7 +1,7 @@ { "workflow": { "id": 1205, - "name": "Promote new Shopify products on Twitter and Telegram", + "name": "Promote new Shopify products", "views": 478, "recentViews": 9880, "totalViews": 478, diff --git a/cypress/fixtures/templates_search/sales_templates_search_response.json b/cypress/fixtures/templates_search/sales_templates_search_response.json index 4efbb3585b504..d4f90991b31c8 100644 --- a/cypress/fixtures/templates_search/sales_templates_search_response.json +++ b/cypress/fixtures/templates_search/sales_templates_search_response.json @@ -1202,7 +1202,7 @@ }, { "id": 1205, - "name": "Promote New Shopify Products on Social Media (Twitter and Telegram)", + "name": "Promote New Shopify Products", "totalViews": 219, "recentViews": 0, "user": { diff --git a/cypress/pages/credentials.ts b/cypress/pages/credentials.ts index 9b20b48ec443e..d5fa9cc0b1a54 100644 --- a/cypress/pages/credentials.ts +++ b/cypress/pages/credentials.ts @@ -5,7 +5,49 @@ export class CredentialsPage extends BasePage { getters = { emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'), - createCredentialButton: () => cy.getByTestId('resources-list-add'), + createCredentialButton: () => { + cy.getByTestId('resource-add').should('be.visible').click(); + cy.getByTestId('resource-add') + .find('.el-sub-menu__title') + .as('menuitem') + .should('have.attr', 'aria-describedby'); + + cy.get('@menuitem') + .should('be.visible') + .invoke('attr', 'aria-describedby') + .then((el) => cy.get(`[id="${el}"]`)) + .as('submenu'); + + cy.get('@submenu') + .should('be.visible') + .within((submenu) => { + // If submenu has another submenu + if (submenu.find('[data-test-id="navigation-submenu"]').length) { + cy.wrap(submenu) + .find('[data-test-id="navigation-submenu"]') + .should('be.visible') + .filter(':contains("Credential")') + .as('child') + .click(); + + cy.get('@child') + .should('be.visible') + .find('[data-test-id="navigation-submenu-item"]') + .should('be.visible') + .filter(':contains("Personal")') + .as('button'); + } else { + cy.wrap(submenu) + .find('[data-test-id="navigation-menu-item"]') + .filter(':contains("Credential")') + .as('button'); + } + }); + + return cy.get('@button').should('be.visible'); + }, + + // cy.getByTestId('resources-list-add'), searchInput: () => cy.getByTestId('resources-list-search'), emptyList: () => cy.getByTestId('resources-list-empty'), credentialCards: () => cy.getByTestId('resources-list-item'), diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 5829ecb863832..199cc3d31c409 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -7,7 +7,47 @@ export class WorkflowsPage extends BasePage { newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'), newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'), searchBar: () => cy.getByTestId('resources-list-search'), - createWorkflowButton: () => cy.getByTestId('resources-list-add'), + createWorkflowButton: () => { + cy.getByTestId('resource-add').should('be.visible').click(); + cy.getByTestId('resource-add') + .find('.el-sub-menu__title') + .as('menuitem') + .should('have.attr', 'aria-describedby'); + + cy.get('@menuitem') + .should('be.visible') + .invoke('attr', 'aria-describedby') + .then((el) => cy.get(`[id="${el}"]`)) + .as('submenu'); + + cy.get('@submenu') + .should('be.visible') + .within((submenu) => { + // If submenu has another submenu + if (submenu.find('[data-test-id="navigation-submenu"]').length) { + cy.wrap(submenu) + .find('[data-test-id="navigation-submenu"]') + .should('be.visible') + .filter(':contains("Workflow")') + .as('child') + .click(); + + cy.get('@child') + .should('be.visible') + .find('[data-test-id="navigation-submenu-item"]') + .should('be.visible') + .filter(':contains("Personal")') + .as('button'); + } else { + cy.wrap(submenu) + .find('[data-test-id="navigation-menu-item"]') + .filter(':contains("Workflow")') + .as('button'); + } + }); + + return cy.get('@button').should('be.visible'); + }, workflowCards: () => cy.getByTestId('resources-list-item'), workflowCard: (workflowName: string) => this.getters diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index 78eedaa2c3edb..797e78b3c6442 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -33,27 +33,22 @@ COPY docker/images/n8n/docker-entrypoint.sh / # Setup the Task Runner Launcher ARG TARGETPLATFORM -ARG LAUNCHER_VERSION=0.1.1 -ENV N8N_RUNNERS_MODE=internal_launcher \ - N8N_RUNNERS_LAUNCHER_PATH=/usr/local/bin/task-runner-launcher +ARG LAUNCHER_VERSION=0.3.0-rc COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json -# First, download, verify, then extract the launcher binary -# Second, chmod with 4555 to allow the use of setuid -# Third, create a new user and group to execute the Task Runners under +# Download, verify, then extract the launcher binary RUN \ - if [[ "$TARGETPLATFORM" = "linux/amd64" ]]; then export ARCH_NAME="x86_64"; \ - elif [[ "$TARGETPLATFORM" = "linux/arm64" ]]; then export ARCH_NAME="aarch64"; fi; \ + if [[ "$TARGETPLATFORM" = "linux/amd64" ]]; then export ARCH_NAME="amd64"; \ + elif [[ "$TARGETPLATFORM" = "linux/arm64" ]]; then export ARCH_NAME="arm64"; fi; \ mkdir /launcher-temp && \ cd /launcher-temp && \ - wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip && \ - wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \ - sha256sum -c task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \ - unzip -d $(dirname ${N8N_RUNNERS_LAUNCHER_PATH}) task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip task-runner-launcher && \ + wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz && \ + wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256 && \ + # The .sha256 does not contain the filename --> Form the correct checksum file + echo "$(cat task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256) task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz" > checksum.sha256 && \ + sha256sum -c checksum.sha256 && \ + tar xvf task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz --directory=/usr/local/bin && \ cd - && \ - rm -r /launcher-temp && \ - chmod 4555 ${N8N_RUNNERS_LAUNCHER_PATH} && \ - addgroup -g 2000 task-runner && \ - adduser -D -u 2000 -g "Task Runner User" -G task-runner task-runner + rm -r /launcher-temp RUN \ cd /usr/local/lib/node_modules/n8n && \ diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index 8a94d0c9ec56f..8acfc411cf52a 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -24,27 +24,22 @@ RUN set -eux; \ # Setup the Task Runner Launcher ARG TARGETPLATFORM -ARG LAUNCHER_VERSION=0.1.1 -ENV N8N_RUNNERS_MODE=internal_launcher \ - N8N_RUNNERS_LAUNCHER_PATH=/usr/local/bin/task-runner-launcher +ARG LAUNCHER_VERSION=0.3.0-rc COPY n8n-task-runners.json /etc/n8n-task-runners.json -# First, download, verify, then extract the launcher binary -# Second, chmod with 4555 to allow the use of setuid -# Third, create a new user and group to execute the Task Runners under +# Download, verify, then extract the launcher binary RUN \ - if [[ "$TARGETPLATFORM" = "linux/amd64" ]]; then export ARCH_NAME="x86_64"; \ - elif [[ "$TARGETPLATFORM" = "linux/arm64" ]]; then export ARCH_NAME="aarch64"; fi; \ + if [[ "$TARGETPLATFORM" = "linux/amd64" ]]; then export ARCH_NAME="amd64"; \ + elif [[ "$TARGETPLATFORM" = "linux/arm64" ]]; then export ARCH_NAME="arm64"; fi; \ mkdir /launcher-temp && \ cd /launcher-temp && \ - wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip && \ - wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \ - sha256sum -c task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \ - unzip -d $(dirname ${N8N_RUNNERS_LAUNCHER_PATH}) task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip task-runner-launcher && \ + wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz && \ + wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256 && \ + # The .sha256 does not contain the filename --> Form the correct checksum file + echo "$(cat task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256) task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz" > checksum.sha256 && \ + sha256sum -c checksum.sha256 && \ + tar xvf task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz --directory=/usr/local/bin && \ cd - && \ - rm -r /launcher-temp && \ - chmod 4555 ${N8N_RUNNERS_LAUNCHER_PATH} && \ - addgroup -g 2000 task-runner && \ - adduser -D -u 2000 -g "Task Runner User" -G task-runner task-runner + rm -r /launcher-temp COPY docker-entrypoint.sh / diff --git a/docker/images/n8n/n8n-task-runners.json b/docker/images/n8n/n8n-task-runners.json index 699794d504183..1d4e34b1a992c 100644 --- a/docker/images/n8n/n8n-task-runners.json +++ b/docker/images/n8n/n8n-task-runners.json @@ -2,7 +2,7 @@ "task-runners": [ { "runner-type": "javascript", - "workdir": "/home/task-runner", + "workdir": "/home/node", "command": "/usr/local/bin/node", "args": ["/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"], "allowed-env": [ @@ -13,10 +13,12 @@ "N8N_RUNNERS_MAX_CONCURRENCY", "NODE_FUNCTION_ALLOW_BUILTIN", "NODE_FUNCTION_ALLOW_EXTERNAL", - "NODE_OPTIONS" - ], - "uid": 2000, - "gid": 2000 + "NODE_OPTIONS", + "N8N_SENTRY_DSN", + "N8N_VERSION", + "ENVIRONMENT", + "DEPLOYMENT_NAME" + ] } ] } diff --git a/packages/@n8n/api-types/src/push/execution.ts b/packages/@n8n/api-types/src/push/execution.ts index 3c7459dec5715..9c723e2817f8a 100644 --- a/packages/@n8n/api-types/src/push/execution.ts +++ b/packages/@n8n/api-types/src/push/execution.ts @@ -12,6 +12,13 @@ type ExecutionStarted = { }; }; +type ExecutionWaiting = { + type: 'executionWaiting'; + data: { + executionId: string; + }; +}; + type ExecutionFinished = { type: 'executionFinished'; data: { @@ -45,6 +52,7 @@ type NodeExecuteAfter = { export type ExecutionPushMessage = | ExecutionStarted + | ExecutionWaiting | ExecutionFinished | ExecutionRecovered | NodeExecuteBefore diff --git a/packages/@n8n/config/src/configs/diagnostics.config.ts b/packages/@n8n/config/src/configs/diagnostics.config.ts new file mode 100644 index 0000000000000..5ff6abf83740a --- /dev/null +++ b/packages/@n8n/config/src/configs/diagnostics.config.ts @@ -0,0 +1,30 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +class PostHogConfig { + /** API key for PostHog. */ + @Env('N8N_DIAGNOSTICS_POSTHOG_API_KEY') + apiKey: string = 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo'; + + /** API host for PostHog. */ + @Env('N8N_DIAGNOSTICS_POSTHOG_API_HOST') + apiHost: string = 'https://ph.n8n.io'; +} + +@Config +export class DiagnosticsConfig { + /** Whether diagnostics are enabled. */ + @Env('N8N_DIAGNOSTICS_ENABLED') + enabled: boolean = true; + + /** Diagnostics config for frontend. */ + @Env('N8N_DIAGNOSTICS_CONFIG_FRONTEND') + frontendConfig: string = '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io'; + + /** Diagnostics config for backend. */ + @Env('N8N_DIAGNOSTICS_CONFIG_BACKEND') + backendConfig: string = '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io'; + + @Nested + posthogConfig: PostHogConfig; +} diff --git a/packages/@n8n/config/src/configs/pruning.config.ts b/packages/@n8n/config/src/configs/executions.config.ts similarity index 70% rename from packages/@n8n/config/src/configs/pruning.config.ts rename to packages/@n8n/config/src/configs/executions.config.ts index 109dffc462553..8c5d91b3c8f9c 100644 --- a/packages/@n8n/config/src/configs/pruning.config.ts +++ b/packages/@n8n/config/src/configs/executions.config.ts @@ -1,21 +1,32 @@ -import { Config, Env } from '../decorators'; +import { Config, Env, Nested } from '../decorators'; @Config -export class PruningConfig { +class PruningIntervalsConfig { + /** How often (minutes) execution data should be hard-deleted. */ + @Env('EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL') + hardDelete: number = 15; + + /** How often (minutes) execution data should be soft-deleted */ + @Env('EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL') + softDelete: number = 60; +} + +@Config +export class ExecutionsConfig { /** Whether to delete past executions on a rolling basis. */ @Env('EXECUTIONS_DATA_PRUNE') - isEnabled: boolean = true; + pruneData: boolean = true; /** How old (hours) a finished execution must be to qualify for soft-deletion. */ @Env('EXECUTIONS_DATA_MAX_AGE') - maxAge: number = 336; + pruneDataMaxAge: number = 336; /** * Max number of finished executions to keep in database. Does not necessarily * prune to the exact max number. `0` for unlimited. */ @Env('EXECUTIONS_DATA_PRUNE_MAX_COUNT') - maxCount: number = 10_000; + pruneDataMaxCount: number = 10_000; /** * How old (hours) a finished execution must be to qualify for hard-deletion. @@ -23,13 +34,8 @@ export class PruningConfig { * them while building a workflow. */ @Env('EXECUTIONS_DATA_HARD_DELETE_BUFFER') - hardDeleteBuffer: number = 1; - - /** How often (minutes) execution data should be hard-deleted. */ - @Env('EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL') - hardDeleteInterval: number = 15; + pruneDataHardDeleteBuffer: number = 1; - /** How often (minutes) execution data should be soft-deleted */ - @Env('EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL') - softDeleteInterval: number = 60; + @Nested + pruneDataIntervals: PruningIntervalsConfig; } diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index 5a6969ba6f28c..406512f832404 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -2,11 +2,10 @@ import { Config, Env } from '../decorators'; /** * Whether to enable task runners and how to run them - * - internal_childprocess: Task runners are run as a child process and launched by n8n - * - internal_launcher: Task runners are run as a child process and launched by n8n using a separate launch program + * - internal: Task runners are run as a child process and launched by n8n * - external: Task runners are run as a separate program not launched by n8n */ -export type TaskRunnerMode = 'internal_childprocess' | 'internal_launcher' | 'external'; +export type TaskRunnerMode = 'internal' | 'external'; @Config export class TaskRunnersConfig { @@ -15,8 +14,9 @@ export class TaskRunnersConfig { // Defaults to true for now @Env('N8N_RUNNERS_MODE') - mode: TaskRunnerMode = 'internal_childprocess'; + mode: TaskRunnerMode = 'internal'; + /** Endpoint which task runners connect to */ @Env('N8N_RUNNERS_PATH') path: string = '/runners'; @@ -35,13 +35,6 @@ export class TaskRunnersConfig { @Env('N8N_RUNNERS_MAX_PAYLOAD') maxPayload: number = 1024 * 1024 * 1024; - @Env('N8N_RUNNERS_LAUNCHER_PATH') - launcherPath: string = ''; - - /** Which task runner to launch from the config */ - @Env('N8N_RUNNERS_LAUNCHER_RUNNER') - launcherRunner: string = 'javascript'; - /** The --max-old-space-size option to use for the runner (in MB). Default means node.js will determine it based on the available memory. */ @Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE') maxOldSpaceSize: string = ''; @@ -53,4 +46,12 @@ export class TaskRunnersConfig { /** Should the output of deduplication be asserted for correctness */ @Env('N8N_RUNNERS_ASSERT_DEDUPLICATION_OUTPUT') assertDeduplicationOutput: boolean = false; + + /** How long (in seconds) a task is allowed to take for completion, else the task will be aborted and the runner restarted. Must be greater than 0. */ + @Env('N8N_RUNNERS_TASK_TIMEOUT') + taskTimeout: number = 60; + + /** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted and the runner restarted. Must be greater than 0. */ + @Env('N8N_RUNNERS_HEARTBEAT_INTERVAL') + heartbeatInterval: number = 30; } diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 0a89535ee32ab..a5144d419602f 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -1,8 +1,10 @@ import { CacheConfig } from './configs/cache.config'; import { CredentialsConfig } from './configs/credentials.config'; import { DatabaseConfig } from './configs/database.config'; +import { DiagnosticsConfig } from './configs/diagnostics.config'; import { EndpointsConfig } from './configs/endpoints.config'; import { EventBusConfig } from './configs/event-bus.config'; +import { ExecutionsConfig } from './configs/executions.config'; import { ExternalSecretsConfig } from './configs/external-secrets.config'; import { ExternalStorageConfig } from './configs/external-storage.config'; import { GenericConfig } from './configs/generic.config'; @@ -10,7 +12,6 @@ import { LicenseConfig } from './configs/license.config'; import { LoggingConfig } from './configs/logging.config'; import { MultiMainSetupConfig } from './configs/multi-main-setup.config'; import { NodesConfig } from './configs/nodes.config'; -import { PruningConfig } from './configs/pruning.config'; import { PublicApiConfig } from './configs/public-api.config'; import { TaskRunnersConfig } from './configs/runners.config'; import { ScalingModeConfig } from './configs/scaling-mode.config'; @@ -25,7 +26,7 @@ import { Config, Env, Nested } from './decorators'; export { Config, Env, Nested } from './decorators'; export { TaskRunnersConfig } from './configs/runners.config'; export { SecurityConfig } from './configs/security.config'; -export { PruningConfig } from './configs/pruning.config'; +export { ExecutionsConfig } from './configs/executions.config'; export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config'; export { LOG_SCOPES } from './configs/logging.config'; export type { LogScope } from './configs/logging.config'; @@ -116,5 +117,8 @@ export class GlobalConfig { security: SecurityConfig; @Nested - pruning: PruningConfig; + executions: ExecutionsConfig; + + @Nested + diagnostics: DiagnosticsConfig; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index eeb98269dedd8..c5dc2a35b5545 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -223,17 +223,17 @@ describe('GlobalConfig', () => { }, taskRunners: { enabled: false, - mode: 'internal_childprocess', + mode: 'internal', path: '/runners', authToken: '', listenAddress: '127.0.0.1', maxPayload: 1024 * 1024 * 1024, port: 5679, - launcherPath: '', - launcherRunner: 'javascript', maxOldSpaceSize: '', maxConcurrency: 5, assertDeduplicationOutput: false, + taskTimeout: 60, + heartbeatInterval: 30, }, sentry: { backendDsn: '', @@ -272,13 +272,24 @@ describe('GlobalConfig', () => { blockFileAccessToN8nFiles: true, daysAbandonedWorkflow: 90, }, - pruning: { - isEnabled: true, - maxAge: 336, - maxCount: 10_000, - hardDeleteBuffer: 1, - hardDeleteInterval: 15, - softDeleteInterval: 60, + executions: { + pruneData: true, + pruneDataMaxAge: 336, + pruneDataMaxCount: 10_000, + pruneDataHardDeleteBuffer: 1, + pruneDataIntervals: { + hardDelete: 15, + softDelete: 60, + }, + }, + diagnostics: { + enabled: true, + frontendConfig: '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io', + backendConfig: '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io', + posthogConfig: { + apiKey: 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo', + apiHost: 'https://ph.n8n.io', + }, }, }; diff --git a/packages/@n8n/nodes-langchain/credentials/AnthropicApi.credentials.ts b/packages/@n8n/nodes-langchain/credentials/AnthropicApi.credentials.ts index 2ee2aa94dc3ce..80bea68713096 100644 --- a/packages/@n8n/nodes-langchain/credentials/AnthropicApi.credentials.ts +++ b/packages/@n8n/nodes-langchain/credentials/AnthropicApi.credentials.ts @@ -35,15 +35,15 @@ export class AnthropicApi implements ICredentialType { test: ICredentialTestRequest = { request: { baseURL: 'https://api.anthropic.com', - url: '/v1/complete', + url: '/v1/messages', method: 'POST', headers: { 'anthropic-version': '2023-06-01', }, body: { - model: 'claude-2', - prompt: '\n\nHuman: Hello, world!\n\nAssistant:', - max_tokens_to_sample: 256, + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Hey' }], + max_tokens: 1, }, }, }; diff --git a/packages/@n8n/nodes-langchain/credentials/AzureOpenAiApi.credentials.ts b/packages/@n8n/nodes-langchain/credentials/AzureOpenAiApi.credentials.ts index 28608524c8e1c..1dbc62b4ed56a 100644 --- a/packages/@n8n/nodes-langchain/credentials/AzureOpenAiApi.credentials.ts +++ b/packages/@n8n/nodes-langchain/credentials/AzureOpenAiApi.credentials.ts @@ -30,6 +30,13 @@ export class AzureOpenAiApi implements ICredentialType { required: true, default: '2023-07-01-preview', }, + { + displayName: 'Endpoint', + name: 'endpoint', + type: 'string', + default: undefined, + placeholder: 'https://westeurope.api.cognitive.microsoft.com', + }, ]; authenticate: IAuthenticateGeneric = { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts index 2ad4f1c0755e9..09e04c0b76326 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ConversationalAgent/execute.ts @@ -31,7 +31,7 @@ export async function conversationalAgentExecute( | BaseChatMemory | undefined; - const tools = await getConnectedTools(this, nodeVersion >= 1.5); + const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true); const outputParsers = await getOptionalOutputParsers(this); await checkForStructuredTools(tools, this.getNode(), 'Conversational Agent'); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts index a8f607f950dbd..d2dc152ebbf8d 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/PlanAndExecuteAgent/execute.ts @@ -26,7 +26,7 @@ export async function planAndExecuteAgentExecute( 0, )) as BaseChatModel; - const tools = await getConnectedTools(this, nodeVersion >= 1.5); + const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true); await checkForStructuredTools(tools, this.getNode(), 'Plan & Execute Agent'); const outputParsers = await getOptionalOutputParsers(this); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts index 224e727102936..b671a8189ced4 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts @@ -31,7 +31,7 @@ export async function reActAgentAgentExecute( | BaseLanguageModel | BaseChatModel; - const tools = await getConnectedTools(this, nodeVersion >= 1.5); + const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true); await checkForStructuredTools(tools, this.getNode(), 'ReAct Agent'); diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts index a75a93c9f4058..bf101292f2e05 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsAzureOpenAi/EmbeddingsAzureOpenAi.node.ts @@ -87,6 +87,36 @@ export class EmbeddingsAzureOpenAi implements INodeType { 'Maximum amount of time a request is allowed to take in seconds. Set to -1 for no timeout.', type: 'number', }, + { + displayName: 'Dimensions', + name: 'dimensions', + default: undefined, + description: + 'The number of dimensions the resulting output embeddings should have. Only supported in text-embedding-3 and later models.', + type: 'options', + options: [ + { + name: '256', + value: 256, + }, + { + name: '512', + value: 512, + }, + { + name: '1024', + value: 1024, + }, + { + name: '1536', + value: 1536, + }, + { + name: '3072', + value: 3072, + }, + ], + }, ], }, ], @@ -98,6 +128,7 @@ export class EmbeddingsAzureOpenAi implements INodeType { apiKey: string; resourceName: string; apiVersion: string; + endpoint?: string; }>('azureOpenAiApi'); const modelName = this.getNodeParameter('model', itemIndex) as string; @@ -105,6 +136,7 @@ export class EmbeddingsAzureOpenAi implements INodeType { batchSize?: number; stripNewLines?: boolean; timeout?: number; + dimensions?: number | undefined; }; if (options.timeout === -1) { @@ -113,9 +145,15 @@ export class EmbeddingsAzureOpenAi implements INodeType { const embeddings = new OpenAIEmbeddings({ azureOpenAIApiDeploymentName: modelName, - azureOpenAIApiInstanceName: credentials.resourceName, + // instance name only needed to set base url + azureOpenAIApiInstanceName: !credentials.endpoint ? credentials.resourceName : undefined, azureOpenAIApiKey: credentials.apiKey, azureOpenAIApiVersion: credentials.apiVersion, + // azureOpenAIEndpoint and configuration.baseURL are both ignored here + // only setting azureOpenAIBasePath worked + azureOpenAIBasePath: credentials.endpoint + ? `${credentials.endpoint}/openai/deployments` + : undefined, ...options, }); diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts index 167581ed2ec37..aececc09aed74 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts @@ -135,6 +135,36 @@ export class EmbeddingsOpenAi implements INodeType { type: 'collection', default: {}, options: [ + { + displayName: 'Dimensions', + name: 'dimensions', + default: undefined, + description: + 'The number of dimensions the resulting output embeddings should have. Only supported in text-embedding-3 and later models.', + type: 'options', + options: [ + { + name: '256', + value: 256, + }, + { + name: '512', + value: 512, + }, + { + name: '1024', + value: 1024, + }, + { + name: '1536', + value: 1536, + }, + { + name: '3072', + value: 3072, + }, + ], + }, { displayName: 'Base URL', name: 'baseURL', @@ -179,6 +209,7 @@ export class EmbeddingsOpenAi implements INodeType { batchSize?: number; stripNewLines?: boolean; timeout?: number; + dimensions?: number | undefined; }; if (options.timeout === -1) { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts index ffa7f4d58ff35..e2292abc772aa 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts @@ -168,6 +168,7 @@ export class LmChatAzureOpenAi implements INodeType { apiKey: string; resourceName: string; apiVersion: string; + endpoint?: string; }>('azureOpenAiApi'); const modelName = this.getNodeParameter('model', itemIndex) as string; @@ -184,9 +185,11 @@ export class LmChatAzureOpenAi implements INodeType { const model = new ChatOpenAI({ azureOpenAIApiDeploymentName: modelName, - azureOpenAIApiInstanceName: credentials.resourceName, + // instance name only needed to set base url + azureOpenAIApiInstanceName: !credentials.endpoint ? credentials.resourceName : undefined, azureOpenAIApiKey: credentials.apiKey, azureOpenAIApiVersion: credentials.apiVersion, + azureOpenAIEndpoint: credentials.endpoint, ...options, timeout: options.timeout ?? 60000, maxRetries: options.maxRetries ?? 2, diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/error-handling.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/error-handling.test.ts new file mode 100644 index 0000000000000..97aee1d943bc3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/error-handling.test.ts @@ -0,0 +1,55 @@ +import { RateLimitError } from 'openai'; +import { OpenAIError } from 'openai/error'; + +import { openAiFailedAttemptHandler, getCustomErrorMessage, isOpenAiError } from './error-handling'; + +describe('error-handling', () => { + describe('getCustomErrorMessage', () => { + it('should return the correct custom error message for known error codes', () => { + expect(getCustomErrorMessage('insufficient_quota')).toBe( + 'Insufficient quota detected. Learn more about resolving this issue', + ); + expect(getCustomErrorMessage('rate_limit_exceeded')).toBe('OpenAI: Rate limit reached'); + }); + + it('should return undefined for unknown error codes', () => { + expect(getCustomErrorMessage('unknown_error_code')).toBeUndefined(); + }); + }); + + describe('isOpenAiError', () => { + it('should return true if the error is an instance of OpenAIError', () => { + const error = new OpenAIError('Test error'); + expect(isOpenAiError(error)).toBe(true); + }); + + it('should return false if the error is not an instance of OpenAIError', () => { + const error = new Error('Test error'); + expect(isOpenAiError(error)).toBe(false); + }); + }); + + describe('openAiFailedAttemptHandler', () => { + it('should handle RateLimitError and modify the error message', () => { + const error = new RateLimitError( + 429, + { code: 'rate_limit_exceeded' }, + 'Rate limit exceeded', + {}, + ); + + try { + openAiFailedAttemptHandler(error); + } catch (e) { + expect(e).toBe(error); + expect(e.message).toBe('OpenAI: Rate limit reached'); + } + }); + + it('should throw the error if it is not a RateLimitError', () => { + const error = new Error('Test error'); + + expect(() => openAiFailedAttemptHandler(error)).not.toThrow(); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/error-handling.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/error-handling.ts index 5cea5eaf5109b..4fbb140def424 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/error-handling.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/error-handling.ts @@ -1,8 +1,9 @@ -import { OpenAIError } from 'openai/error'; import { RateLimitError } from 'openai'; +import { OpenAIError } from 'openai/error'; const errorMap: Record = { - insufficient_quota: 'OpenAI: Insufficient quota', + insufficient_quota: + 'Insufficient quota detected. Learn more about resolving this issue', rate_limit_exceeded: 'OpenAI: Rate limit reached', }; @@ -23,7 +24,10 @@ export const openAiFailedAttemptHandler = (error: any) => { const customErrorMessage = getCustomErrorMessage(errorCode); if (customErrorMessage) { - error.message = customErrorMessage; + if (error.error) { + (error.error as { message: string }).message = customErrorMessage; + error.message = customErrorMessage; + } } } diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index f1e02e9c9f456..f19cf67153526 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -165,10 +165,29 @@ export function serializeChatHistory(chatHistory: BaseMessage[]): string { .join('\n'); } +export function escapeSingleCurlyBrackets(text?: string): string | undefined { + if (text === undefined) return undefined; + + let result = text; + + result = result + // First handle triple brackets to avoid interference with double brackets + .replace(/(? { const connectedTools = ((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || []; @@ -189,6 +208,10 @@ export const getConnectedTools = async ( } seenNames.add(name); + if (escapeCurlyBrackets) { + tool.description = escapeSingleCurlyBrackets(tool.description) ?? tool.description; + } + if (convertStructuredTool && tool instanceof N8nTool) { finalTools.push(tool.asDynamicTool()); } else { diff --git a/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts b/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts new file mode 100644 index 0000000000000..d63ccbb81e3a3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/tests/helpers.test.ts @@ -0,0 +1,245 @@ +import { DynamicTool, type Tool } from '@langchain/core/tools'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import { NodeOperationError } from 'n8n-workflow'; +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { z } from 'zod'; + +import { escapeSingleCurlyBrackets, getConnectedTools } from '../helpers'; +import { N8nTool } from '../N8nTool'; + +describe('escapeSingleCurlyBrackets', () => { + it('should return undefined when input is undefined', () => { + expect(escapeSingleCurlyBrackets(undefined)).toBeUndefined(); + }); + + it('should escape single curly brackets', () => { + expect(escapeSingleCurlyBrackets('Hello {world}')).toBe('Hello {{world}}'); + expect(escapeSingleCurlyBrackets('Test {value} here')).toBe('Test {{value}} here'); + }); + + it('should not escape already double curly brackets', () => { + expect(escapeSingleCurlyBrackets('Hello {{world}}')).toBe('Hello {{world}}'); + expect(escapeSingleCurlyBrackets('Test {{value}} here')).toBe('Test {{value}} here'); + }); + + it('should handle mixed single and double curly brackets', () => { + expect(escapeSingleCurlyBrackets('Hello {{world}} and {earth}')).toBe( + 'Hello {{world}} and {{earth}}', + ); + }); + + it('should handle empty string', () => { + expect(escapeSingleCurlyBrackets('')).toBe(''); + }); + it('should handle string with no curly brackets', () => { + expect(escapeSingleCurlyBrackets('Hello world')).toBe('Hello world'); + }); + + it('should handle string with only opening curly bracket', () => { + expect(escapeSingleCurlyBrackets('Hello { world')).toBe('Hello {{ world'); + }); + + it('should handle string with only closing curly bracket', () => { + expect(escapeSingleCurlyBrackets('Hello world }')).toBe('Hello world }}'); + }); + + it('should handle string with multiple single curly brackets', () => { + expect(escapeSingleCurlyBrackets('{Hello} {world}')).toBe('{{Hello}} {{world}}'); + }); + + it('should handle string with alternating single and double curly brackets', () => { + expect(escapeSingleCurlyBrackets('{a} {{b}} {c} {{d}}')).toBe('{{a}} {{b}} {{c}} {{d}}'); + }); + + it('should handle string with curly brackets at the start and end', () => { + expect(escapeSingleCurlyBrackets('{start} middle {end}')).toBe('{{start}} middle {{end}}'); + }); + + it('should handle string with special characters', () => { + expect(escapeSingleCurlyBrackets('Special {!@#$%^&*} chars')).toBe( + 'Special {{!@#$%^&*}} chars', + ); + }); + + it('should handle string with numbers in curly brackets', () => { + expect(escapeSingleCurlyBrackets('Numbers {123} here')).toBe('Numbers {{123}} here'); + }); + + it('should handle string with whitespace in curly brackets', () => { + expect(escapeSingleCurlyBrackets('Whitespace { } here')).toBe('Whitespace {{ }} here'); + }); + it('should handle multi-line input with single curly brackets', () => { + const input = ` + Line 1 {test} + Line 2 {another test} + Line 3 + `; + const expected = ` + Line 1 {{test}} + Line 2 {{another test}} + Line 3 + `; + expect(escapeSingleCurlyBrackets(input)).toBe(expected); + }); + + it('should handle multi-line input with mixed single and double curly brackets', () => { + const input = ` + {Line 1} + {{Line 2}} + Line {3} {{4}} + `; + const expected = ` + {{Line 1}} + {{Line 2}} + Line {{3}} {{4}} + `; + expect(escapeSingleCurlyBrackets(input)).toBe(expected); + }); + + it('should handle multi-line input with curly brackets at line starts and ends', () => { + const input = ` + {Start of line 1 + End of line 2} + {3} Line 3 {3} + `; + const expected = ` + {{Start of line 1 + End of line 2}} + {{3}} Line 3 {{3}} + `; + expect(escapeSingleCurlyBrackets(input)).toBe(expected); + }); + + it('should handle multi-line input with nested curly brackets', () => { + const input = ` + Outer { + Inner {nested} + } + `; + const expected = ` + Outer {{ + Inner {{nested}} + }} + `; + expect(escapeSingleCurlyBrackets(input)).toBe(expected); + }); + it('should handle string with triple uneven curly brackets - opening', () => { + expect(escapeSingleCurlyBrackets('Hello {{{world}')).toBe('Hello {{{{world}}'); + }); + + it('should handle string with triple uneven curly brackets - closing', () => { + expect(escapeSingleCurlyBrackets('Hello world}}}')).toBe('Hello world}}}}'); + }); + + it('should handle string with triple uneven curly brackets - mixed opening and closing', () => { + expect(escapeSingleCurlyBrackets('{{{Hello}}} {world}}}')).toBe('{{{{Hello}}}} {{world}}}}'); + }); + + it('should handle string with triple uneven curly brackets - multiple occurrences', () => { + expect(escapeSingleCurlyBrackets('{{{a}}} {{b}}} {{{c}')).toBe('{{{{a}}}} {{b}}}} {{{{c}}'); + }); + + it('should handle multi-line input with triple uneven curly brackets', () => { + const input = ` + {{{Line 1} + Line 2}}} + {{{3}}} Line 3 {{{4 + `; + const expected = ` + {{{{Line 1}} + Line 2}}}} + {{{{3}}}} Line 3 {{{{4 + `; + expect(escapeSingleCurlyBrackets(input)).toBe(expected); + }); +}); + +describe('getConnectedTools', () => { + let mockExecuteFunctions: IExecuteFunctions; + let mockNode: INode; + let mockN8nTool: N8nTool; + + beforeEach(() => { + mockNode = { + id: 'test-node', + name: 'Test Node', + type: 'test', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + mockExecuteFunctions = createMockExecuteFunction({}, mockNode); + + mockN8nTool = new N8nTool(mockExecuteFunctions, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func: jest.fn(), + schema: z.object({ + foo: z.string(), + }), + }); + }); + + it('should return empty array when no tools are connected', async () => { + mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([]); + + const tools = await getConnectedTools(mockExecuteFunctions, true); + expect(tools).toEqual([]); + }); + + it('should return tools without modification when enforceUniqueNames is false', async () => { + const mockTools = [ + { name: 'tool1', description: 'desc1' }, + { name: 'tool1', description: 'desc2' }, // Duplicate name + ]; + + mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools); + + const tools = await getConnectedTools(mockExecuteFunctions, false); + expect(tools).toEqual(mockTools); + }); + + it('should throw error when duplicate tool names exist and enforceUniqueNames is true', async () => { + const mockTools = [ + { name: 'tool1', description: 'desc1' }, + { name: 'tool1', description: 'desc2' }, + ]; + + mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools); + + await expect(getConnectedTools(mockExecuteFunctions, true)).rejects.toThrow(NodeOperationError); + }); + + it('should escape curly brackets in tool descriptions when escapeCurlyBrackets is true', async () => { + const mockTools = [{ name: 'tool1', description: 'Test {value}' }] as Tool[]; + + mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools); + + const tools = await getConnectedTools(mockExecuteFunctions, true, false, true); + expect(tools[0].description).toBe('Test {{value}}'); + }); + + it('should convert N8nTool to dynamic tool when convertStructuredTool is true', async () => { + const mockDynamicTool = new DynamicTool({ + name: 'dynamicTool', + description: 'desc', + func: jest.fn(), + }); + const asDynamicToolSpy = jest.fn().mockReturnValue(mockDynamicTool); + mockN8nTool.asDynamicTool = asDynamicToolSpy; + + mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]); + + const tools = await getConnectedTools(mockExecuteFunctions, true, true); + expect(asDynamicToolSpy).toHaveBeenCalled(); + expect(tools[0]).toEqual(mockDynamicTool); + }); + + it('should not convert N8nTool when convertStructuredTool is false', async () => { + mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]); + + const tools = await getConnectedTools(mockExecuteFunctions, true, false); + expect(tools[0]).toBe(mockN8nTool); + }); +}); diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index b595fefe6e4e8..dd9ee6ae1768f 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -35,6 +35,8 @@ }, "dependencies": { "@n8n/config": "workspace:*", + "@sentry/integrations": "catalog:", + "@sentry/node": "catalog:", "acorn": "8.14.0", "acorn-walk": "8.3.4", "n8n-core": "workspace:*", diff --git a/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts b/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts new file mode 100644 index 0000000000000..9345819329b2c --- /dev/null +++ b/packages/@n8n/task-runner/src/__tests__/error-reporter.test.ts @@ -0,0 +1,31 @@ +import { mock } from 'jest-mock-extended'; +import { ApplicationError } from 'n8n-workflow'; + +import { ErrorReporter } from '../error-reporter'; + +describe('ErrorReporter', () => { + const errorReporting = new ErrorReporter(mock()); + + describe('beforeSend', () => { + it('should return null if originalException is an ApplicationError with level warning', () => { + const hint = { originalException: new ApplicationError('Test error', { level: 'warning' }) }; + expect(errorReporting.beforeSend(mock(), hint)).toBeNull(); + }); + + it('should return event if originalException is an ApplicationError with level error', () => { + const hint = { originalException: new ApplicationError('Test error', { level: 'error' }) }; + expect(errorReporting.beforeSend(mock(), hint)).not.toBeNull(); + }); + + it('should return null if originalException is an Error with a non-unique stack', () => { + const hint = { originalException: new Error('Test error') }; + errorReporting.beforeSend(mock(), hint); + expect(errorReporting.beforeSend(mock(), hint)).toBeNull(); + }); + + it('should return event if originalException is an Error with a unique stack', () => { + const hint = { originalException: new Error('Test error') }; + expect(errorReporting.beforeSend(mock(), hint)).not.toBeNull(); + }); + }); +}); diff --git a/packages/@n8n/task-runner/src/config/base-runner-config.ts b/packages/@n8n/task-runner/src/config/base-runner-config.ts index 01e00c177acb1..e7949d9704214 100644 --- a/packages/@n8n/task-runner/src/config/base-runner-config.ts +++ b/packages/@n8n/task-runner/src/config/base-runner-config.ts @@ -1,4 +1,16 @@ -import { Config, Env } from '@n8n/config'; +import { Config, Env, Nested } from '@n8n/config'; + +@Config +class HealthcheckServerConfig { + @Env('N8N_RUNNERS_SERVER_ENABLED') + enabled: boolean = false; + + @Env('N8N_RUNNERS_SERVER_HOST') + host: string = '127.0.0.1'; + + @Env('N8N_RUNNERS_SERVER_PORT') + port: number = 5680; +} @Config export class BaseRunnerConfig { @@ -13,4 +25,7 @@ export class BaseRunnerConfig { @Env('N8N_RUNNERS_MAX_CONCURRENCY') maxConcurrency: number = 5; + + @Nested + healthcheckServer!: HealthcheckServerConfig; } diff --git a/packages/@n8n/task-runner/src/config/main-config.ts b/packages/@n8n/task-runner/src/config/main-config.ts index a290c0c3803a4..10b504f1d6f15 100644 --- a/packages/@n8n/task-runner/src/config/main-config.ts +++ b/packages/@n8n/task-runner/src/config/main-config.ts @@ -2,6 +2,7 @@ import { Config, Nested } from '@n8n/config'; import { BaseRunnerConfig } from './base-runner-config'; import { JsRunnerConfig } from './js-runner-config'; +import { SentryConfig } from './sentry-config'; @Config export class MainConfig { @@ -10,4 +11,7 @@ export class MainConfig { @Nested jsRunnerConfig!: JsRunnerConfig; + + @Nested + sentryConfig!: SentryConfig; } diff --git a/packages/@n8n/task-runner/src/config/sentry-config.ts b/packages/@n8n/task-runner/src/config/sentry-config.ts new file mode 100644 index 0000000000000..691f64244f0be --- /dev/null +++ b/packages/@n8n/task-runner/src/config/sentry-config.ts @@ -0,0 +1,21 @@ +import { Config, Env } from '@n8n/config'; + +@Config +export class SentryConfig { + /** Sentry DSN */ + @Env('N8N_SENTRY_DSN') + sentryDsn: string = ''; + + //#region Metadata about the environment + + @Env('N8N_VERSION') + n8nVersion: string = ''; + + @Env('ENVIRONMENT') + environment: string = ''; + + @Env('DEPLOYMENT_NAME') + deploymentName: string = ''; + + //#endregion +} diff --git a/packages/@n8n/task-runner/src/error-reporter.ts b/packages/@n8n/task-runner/src/error-reporter.ts new file mode 100644 index 0000000000000..167cc37c924b1 --- /dev/null +++ b/packages/@n8n/task-runner/src/error-reporter.ts @@ -0,0 +1,93 @@ +import { RewriteFrames } from '@sentry/integrations'; +import { init, setTag, captureException, close } from '@sentry/node'; +import type { ErrorEvent, EventHint } from '@sentry/types'; +import * as a from 'assert/strict'; +import { createHash } from 'crypto'; +import { ApplicationError } from 'n8n-workflow'; + +import type { SentryConfig } from '@/config/sentry-config'; + +/** + * Handles error reporting using Sentry + */ +export class ErrorReporter { + private isInitialized = false; + + /** Hashes of error stack traces, to deduplicate error reports. */ + private readonly seenErrors = new Set(); + + private get dsn() { + return this.sentryConfig.sentryDsn; + } + + constructor(private readonly sentryConfig: SentryConfig) { + a.ok(this.dsn, 'Sentry DSN is required to initialize Sentry'); + } + + async start() { + if (this.isInitialized) return; + + // Collect longer stacktraces + Error.stackTraceLimit = 50; + + process.on('uncaughtException', captureException); + + const ENABLED_INTEGRATIONS = [ + 'InboundFilters', + 'FunctionToString', + 'LinkedErrors', + 'OnUnhandledRejection', + 'ContextLines', + ]; + + setTag('server_type', 'task_runner'); + + init({ + dsn: this.dsn, + release: this.sentryConfig.n8nVersion, + environment: this.sentryConfig.environment, + enableTracing: false, + serverName: this.sentryConfig.deploymentName, + beforeBreadcrumb: () => null, + beforeSend: async (event, hint) => await this.beforeSend(event, hint), + integrations: (integrations) => [ + ...integrations.filter(({ name }) => ENABLED_INTEGRATIONS.includes(name)), + new RewriteFrames({ root: process.cwd() }), + ], + }); + + this.isInitialized = true; + } + + async stop() { + if (!this.isInitialized) { + return; + } + + await close(1000); + } + + async beforeSend(event: ErrorEvent, { originalException }: EventHint) { + if (!originalException) return null; + + if (originalException instanceof Promise) { + originalException = await originalException.catch((error) => error as Error); + } + + if (originalException instanceof ApplicationError) { + const { level, extra, tags } = originalException; + if (level === 'warning') return null; + event.level = level; + if (extra) event.extra = { ...event.extra, ...extra }; + if (tags) event.tags = { ...event.tags, ...tags }; + } + + if (originalException instanceof Error && originalException.stack) { + const eventHash = createHash('sha1').update(originalException.stack).digest('base64'); + if (this.seenErrors.has(eventHash)) return null; + this.seenErrors.add(eventHash); + } + + return event; + } +} diff --git a/packages/@n8n/task-runner/src/healthcheck-server.ts b/packages/@n8n/task-runner/src/healthcheck-server.ts new file mode 100644 index 0000000000000..c6d8965a86b94 --- /dev/null +++ b/packages/@n8n/task-runner/src/healthcheck-server.ts @@ -0,0 +1,38 @@ +import { ApplicationError } from 'n8n-workflow'; +import { createServer } from 'node:http'; + +export class HealthcheckServer { + private server = createServer((_, res) => { + res.writeHead(200); + res.end('OK'); + }); + + async start(host: string, port: number) { + return await new Promise((resolve, reject) => { + const portInUseErrorHandler = (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + reject(new ApplicationError(`Port ${port} is already in use`)); + } else { + reject(error); + } + }; + + this.server.on('error', portInUseErrorHandler); + + this.server.listen(port, host, () => { + this.server.removeListener('error', portInUseErrorHandler); + console.log(`Healthcheck server listening on ${host}, port ${port}`); + resolve(); + }); + }); + } + + async stop() { + return await new Promise((resolve, reject) => { + this.server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } +} diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index 621a9c81a7f7d..cd966ef8ac4c6 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -36,6 +36,12 @@ describe('JsTaskRunner', () => { ...defaultConfig.jsRunnerConfig, ...opts, }, + sentryConfig: { + sentryDsn: '', + deploymentName: '', + environment: '', + n8nVersion: '', + }, }); const defaultTaskRunner = createRunnerWithOpts(); diff --git a/packages/@n8n/task-runner/src/message-types.ts b/packages/@n8n/task-runner/src/message-types.ts index b5f17f965e1f7..71f236b52a3df 100644 --- a/packages/@n8n/task-runner/src/message-types.ts +++ b/packages/@n8n/task-runner/src/message-types.ts @@ -184,6 +184,12 @@ export namespace RunnerMessage { reason: string; } + /** Message where launcher (impersonating runner) requests broker to hold task until runner is ready. */ + export interface TaskDeferred { + type: 'runner:taskdeferred'; + taskId: string; + } + export interface TaskDone { type: 'runner:taskdone'; taskId: string; @@ -243,6 +249,7 @@ export namespace RunnerMessage { | TaskError | TaskAccepted | TaskRejected + | TaskDeferred | TaskOffer | RPC | TaskDataRequest diff --git a/packages/@n8n/task-runner/src/start.ts b/packages/@n8n/task-runner/src/start.ts index fcaab84d51d13..e09ddf33321df 100644 --- a/packages/@n8n/task-runner/src/start.ts +++ b/packages/@n8n/task-runner/src/start.ts @@ -2,10 +2,14 @@ import { ensureError } from 'n8n-workflow'; import Container from 'typedi'; import { MainConfig } from './config/main-config'; +import type { ErrorReporter } from './error-reporter'; +import type { HealthcheckServer } from './healthcheck-server'; import { JsTaskRunner } from './js-task-runner/js-task-runner'; +let healthcheckServer: HealthcheckServer | undefined; let runner: JsTaskRunner | undefined; let isShuttingDown = false; +let errorReporter: ErrorReporter | undefined; function createSignalHandler(signal: string) { return async function onSignal() { @@ -20,11 +24,18 @@ function createSignalHandler(signal: string) { if (runner) { await runner.stop(); runner = undefined; + void healthcheckServer?.stop(); + } + + if (errorReporter) { + await errorReporter.stop(); + errorReporter = undefined; } } catch (e) { const error = ensureError(e); console.error('Error stopping task runner', { error }); } finally { + console.log('Task runner stopped'); process.exit(0); } }; @@ -33,8 +44,22 @@ function createSignalHandler(signal: string) { void (async function start() { const config = Container.get(MainConfig); + if (config.sentryConfig.sentryDsn) { + const { ErrorReporter } = await import('@/error-reporter'); + errorReporter = new ErrorReporter(config.sentryConfig); + await errorReporter.start(); + } + runner = new JsTaskRunner(config); + const { enabled, host, port } = config.baseRunnerConfig.healthcheckServer; + + if (enabled) { + const { HealthcheckServer } = await import('./healthcheck-server'); + healthcheckServer = new HealthcheckServer(); + await healthcheckServer.start(host, port); + } + process.on('SIGINT', createSignalHandler('SIGINT')); process.on('SIGTERM', createSignalHandler('SIGTERM')); })().catch((e) => { diff --git a/packages/cli/package.json b/packages/cli/package.json index db203199f7e99..029e05e5c0f8f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,7 +28,7 @@ "test:dev": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest --watch", "test:sqlite": "N8N_LOG_LEVEL=silent DB_TYPE=sqlite jest", "test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --no-coverage", - "test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --no-coverage", + "test:mariadb": "N8N_LOG_LEVEL=silent DB_TYPE=mariadb DB_TABLE_PREFIX=test_ jest --no-coverage", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"" }, "bin": { @@ -98,8 +98,8 @@ "@n8n_io/license-sdk": "2.13.1", "@oclif/core": "4.0.7", "@rudderstack/rudder-sdk-node": "2.0.9", - "@sentry/integrations": "7.87.0", - "@sentry/node": "7.87.0", + "@sentry/integrations": "catalog:", + "@sentry/node": "catalog:", "aws4": "1.11.0", "axios": "catalog:", "bcryptjs": "2.4.3", diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 989396df84e04..492e22ab53331 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -44,6 +44,10 @@ const skipBrowserIdCheckEndpoints = [ // We need to exclude binary-data downloading endpoint because we can't send custom headers on `` tags `/${restEndpoint}/binary-data/`, + + // oAuth callback urls aren't called by the frontend. therefore we can't send custom header on these requests + `/${restEndpoint}/oauth1-credential/callback`, + `/${restEndpoint}/oauth2-credential/callback`, ]; @Service() diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 0291a9e416d97..64c5a34dae680 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -148,6 +148,12 @@ export class Worker extends BaseCommand { const envConcurrency = config.getEnv('executions.concurrency.productionLimit'); this.concurrency = envConcurrency !== -1 ? envConcurrency : flags.concurrency; + + if (this.concurrency < 5) { + this.logger.warn( + 'Concurrency is set to less than 5. THIS CAN LEAD TO AN UNSTABLE ENVIRONMENT. Please consider increasing it to at least 5 to make best use of the worker.', + ); + } } async initScalingService() { diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index c9e34355bac50..63497600fe300 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -21,6 +21,7 @@ if (inE2ETests) { process.env.N8N_PUBLIC_API_DISABLED = 'true'; process.env.SKIP_STATISTICS_EVENTS = 'true'; process.env.N8N_SECURE_COOKIE = 'false'; + process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'true'; } // Load schema after process.env has been overwritten diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 9f8bc452320e3..54fa07e7f5dcb 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -296,43 +296,6 @@ export const schema = { }, }, - diagnostics: { - enabled: { - doc: 'Whether diagnostic mode is enabled.', - format: Boolean, - default: true, - env: 'N8N_DIAGNOSTICS_ENABLED', - }, - config: { - posthog: { - apiKey: { - doc: 'API key for PostHog', - format: String, - default: 'phc_4URIAm1uYfJO7j8kWSe0J8lc8IqnstRLS7Jx8NcakHo', - env: 'N8N_DIAGNOSTICS_POSTHOG_API_KEY', - }, - apiHost: { - doc: 'API host for PostHog', - format: String, - default: 'https://ph.n8n.io', - env: 'N8N_DIAGNOSTICS_POSTHOG_API_HOST', - }, - }, - frontend: { - doc: 'Diagnostics config for frontend.', - format: String, - default: '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io', - env: 'N8N_DIAGNOSTICS_CONFIG_FRONTEND', - }, - backend: { - doc: 'Diagnostics config for backend.', - format: String, - default: '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io', - env: 'N8N_DIAGNOSTICS_CONFIG_BACKEND', - }, - }, - }, - defaultLocale: { doc: 'Default locale for the UI', format: String, diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index be26616fb640c..df52a36b9613f 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -110,25 +110,12 @@ export const UM_FIX_INSTRUCTION = 'Please fix the database by running ./packages/cli/bin/n8n user-management:reset'; /** - * Units of time in milliseconds - * @deprecated Please use constants.Time instead. - */ -export const TIME = { - SECOND: 1000, - MINUTE: 60 * 1000, - HOUR: 60 * 60 * 1000, - DAY: 24 * 60 * 60 * 1000, -} as const; - -/** - * Convert time from any unit to any other unit - * - * Please amend conversions as necessary. - * Eventually this will superseed `TIME` above + * Convert time from any time unit to any other unit */ export const Time = { milliseconds: { toMinutes: 1 / (60 * 1000), + toSeconds: 1 / 1000, }, seconds: { toMilliseconds: 1000, @@ -150,9 +137,9 @@ export const MIN_PASSWORD_CHAR_LENGTH = 8; export const MAX_PASSWORD_CHAR_LENGTH = 64; -export const TEST_WEBHOOK_TIMEOUT = 2 * TIME.MINUTE; +export const TEST_WEBHOOK_TIMEOUT = 2 * Time.minutes.toMilliseconds; -export const TEST_WEBHOOK_TIMEOUT_BUFFER = 30 * TIME.SECOND; +export const TEST_WEBHOOK_TIMEOUT_BUFFER = 30 * Time.seconds.toMilliseconds; export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ 'oAuth2Api', diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts index 68a86269d39f0..2d76642266c51 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts @@ -5,9 +5,10 @@ import { Cipher } from 'n8n-core'; import nock from 'nock'; import Container from 'typedi'; +import { Time } from '@/constants'; import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller'; import { CredentialsHelper } from '@/credentials-helper'; -import { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { User } from '@/databases/entities/user'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; @@ -47,8 +48,12 @@ describe('OAuth1CredentialController', () => { const controller = Container.get(OAuth1CredentialController); + const timestamp = 1706750625678; + jest.useFakeTimers({ advanceTimers: true }); + beforeEach(() => { - jest.resetAllMocks(); + jest.setSystemTime(new Date(timestamp)); + jest.clearAllMocks(); }); describe('getAuthUri', () => { @@ -76,13 +81,15 @@ describe('OAuth1CredentialController', () => { credentialsHelper.applyDefaultsAndOverwrites.mockReturnValueOnce({ requestTokenUrl: 'https://example.domain/oauth/request_token', authUrl: 'https://example.domain/oauth/authorize', + accessTokenUrl: 'https://example.domain/oauth/access_token', signatureMethod: 'HMAC-SHA1', }); nock('https://example.domain') .post('/oauth/request_token', { oauth_callback: - 'http://localhost:5678/rest/oauth1-credential/callback?state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSJ9', + 'http://localhost:5678/rest/oauth1-credential/callback?state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSIsImNyZWF0ZWRBdCI6MTcwNjc1MDYyNTY3OCwidXNlcklkIjoiMTIzIn0=', }) + .once() .reply(200, { oauth_token: 'random-token' }); cipher.encrypt.mockReturnValue('encrypted'); @@ -107,14 +114,23 @@ describe('OAuth1CredentialController', () => { JSON.stringify({ token: 'token', cid: '1', + createdAt: timestamp, }), ).toString('base64'); + const res = mock(); + const req = mock({ + query: { + oauth_verifier: 'verifier', + oauth_token: 'token', + state: validState, + }, + }); + it('should render the error page when required query params are missing', async () => { - const req = mock(); - const res = mock(); - req.query = { state: 'test' } as OAuthRequest.OAuth1Credential.Callback['query']; - await controller.handleCallback(req, res); + const invalidReq = mock(); + invalidReq.query = { state: 'test' } as OAuthRequest.OAuth1Credential.Callback['query']; + await controller.handleCallback(invalidReq, res); expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { error: { @@ -126,14 +142,14 @@ describe('OAuth1CredentialController', () => { }); it('should render the error page when `state` query param is invalid', async () => { - const req = mock(); - const res = mock(); - req.query = { - oauth_verifier: 'verifier', - oauth_token: 'token', - state: 'test', - } as OAuthRequest.OAuth1Credential.Callback['query']; - await controller.handleCallback(req, res); + const invalidReq = mock({ + query: { + oauth_verifier: 'verifier', + oauth_token: 'token', + state: 'test', + }, + }); + await controller.handleCallback(invalidReq, res); expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { error: { @@ -146,18 +162,11 @@ describe('OAuth1CredentialController', () => { it('should render the error page when credential is not found in DB', async () => { credentialsRepository.findOneBy.mockResolvedValueOnce(null); - const req = mock(); - const res = mock(); - req.query = { - oauth_verifier: 'verifier', - oauth_token: 'token', - state: validState, - } as OAuthRequest.OAuth1Credential.Callback['query']; await controller.handleCallback(req, res); expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { error: { - message: 'OAuth1 callback failed because of insufficient permissions', + message: 'OAuth callback failed because of insufficient permissions', }, }); expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1); @@ -165,24 +174,67 @@ describe('OAuth1CredentialController', () => { }); it('should render the error page when state differs from the stored state in the credential', async () => { - credentialsRepository.findOneBy.mockResolvedValue(new CredentialsEntity()); + credentialsRepository.findOneBy.mockResolvedValue(credential); credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret: 'invalid' }); - const req = mock(); - const res = mock(); - req.query = { - oauth_verifier: 'verifier', - oauth_token: 'token', - state: validState, - } as OAuthRequest.OAuth1Credential.Callback['query']; + await controller.handleCallback(req, res); + + expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { + error: { + message: 'The OAuth callback state is invalid!', + }, + }); + }); + + it('should render the error page when state is older than 5 minutes', async () => { + credentialsRepository.findOneBy.mockResolvedValue(credential); + credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret }); + jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); + + jest.advanceTimersByTime(10 * Time.minutes.toMilliseconds); await controller.handleCallback(req, res); expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { error: { - message: 'The OAuth1 callback state is invalid!', + message: 'The OAuth callback state is invalid!', }, }); }); + + it('should exchange the code for a valid token, and save it to DB', async () => { + credentialsRepository.findOneBy.mockResolvedValue(credential); + credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret }); + credentialsHelper.applyDefaultsAndOverwrites.mockReturnValueOnce({ + requestTokenUrl: 'https://example.domain/oauth/request_token', + accessTokenUrl: 'https://example.domain/oauth/access_token', + signatureMethod: 'HMAC-SHA1', + }); + jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); + nock('https://example.domain') + .post('/oauth/access_token', { + oauth_token: 'token', + oauth_verifier: 'verifier', + }) + .once() + .reply(200, 'access_token=new_token'); + cipher.encrypt.mockReturnValue('encrypted'); + + await controller.handleCallback(req, res); + + expect(cipher.encrypt).toHaveBeenCalledWith({ + oauthTokenData: { access_token: 'new_token' }, + }); + expect(credentialsRepository.update).toHaveBeenCalledWith( + '1', + expect.objectContaining({ + data: 'encrypted', + id: '1', + name: 'Test Credential', + type: 'oAuth1Api', + }), + ); + expect(res.render).toHaveBeenCalledWith('oauth-callback'); + }); }); }); diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 9fc98d55572e5..b2bd987fb0836 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -5,6 +5,7 @@ import { Cipher } from 'n8n-core'; import nock from 'nock'; import Container from 'typedi'; +import { Time } from '@/constants'; import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller'; import { CredentialsHelper } from '@/credentials-helper'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; @@ -47,8 +48,19 @@ describe('OAuth2CredentialController', () => { const controller = Container.get(OAuth2CredentialController); + const timestamp = 1706750625678; + jest.useFakeTimers({ advanceTimers: true }); + beforeEach(() => { - jest.resetAllMocks(); + jest.setSystemTime(new Date(timestamp)); + jest.clearAllMocks(); + + credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({ + clientId: 'test-client-id', + clientSecret: 'oauth-secret', + authUrl: 'https://example.domain/o/oauth2/v2/auth', + accessTokenUrl: 'https://example.domain/token', + }); }); describe('getAuthUri', () => { @@ -73,17 +85,20 @@ describe('OAuth2CredentialController', () => { jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token'); sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential); credentialsHelper.getDecrypted.mockResolvedValueOnce({}); - credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({ - clientId: 'test-client-id', - authUrl: 'https://example.domain/o/oauth2/v2/auth', - }); cipher.encrypt.mockReturnValue('encrypted'); const req = mock({ user, query: { id: '1' } }); const authUri = await controller.getAuthUri(req); expect(authUri).toEqual( - 'https://example.domain/o/oauth2/v2/auth?client_id=test-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback&response_type=code&state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSJ9&scope=openid', + 'https://example.domain/o/oauth2/v2/auth?client_id=test-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback&response_type=code&state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSIsImNyZWF0ZWRBdCI6MTcwNjc1MDYyNTY3OCwidXNlcklkIjoiMTIzIn0%3D&scope=openid', ); + const state = new URL(authUri).searchParams.get('state'); + expect(JSON.parse(Buffer.from(state!, 'base64').toString())).toEqual({ + token: 'token', + cid: '1', + createdAt: timestamp, + userId: '123', + }); expect(credentialsRepository.update).toHaveBeenCalledWith( '1', expect.objectContaining({ @@ -101,15 +116,21 @@ describe('OAuth2CredentialController', () => { JSON.stringify({ token: 'token', cid: '1', + createdAt: timestamp, }), ).toString('base64'); + const res = mock(); + const req = mock({ + query: { code: 'code', state: validState }, + originalUrl: '?code=code', + }); + it('should render the error page when required query params are missing', async () => { - const req = mock({ + const invalidReq = mock({ query: { code: undefined, state: undefined }, }); - const res = mock(); - await controller.handleCallback(req, res); + await controller.handleCallback(invalidReq, res); expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { error: { @@ -121,11 +142,11 @@ describe('OAuth2CredentialController', () => { }); it('should render the error page when `state` query param is invalid', async () => { - const req = mock({ + const invalidReq = mock({ query: { code: 'code', state: 'invalid-state' }, }); - const res = mock(); - await controller.handleCallback(req, res); + + await controller.handleCallback(invalidReq, res); expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { error: { @@ -138,15 +159,11 @@ describe('OAuth2CredentialController', () => { it('should render the error page when credential is not found in DB', async () => { credentialsRepository.findOneBy.mockResolvedValueOnce(null); - const req = mock({ - query: { code: 'code', state: validState }, - }); - const res = mock(); await controller.handleCallback(req, res); expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { error: { - message: 'OAuth2 callback failed because of insufficient permissions', + message: 'OAuth callback failed because of insufficient permissions', }, }); expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1); @@ -158,27 +175,57 @@ describe('OAuth2CredentialController', () => { credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(false); - const req = mock({ - query: { code: 'code', state: validState }, + await controller.handleCallback(req, res); + expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { + error: { + message: 'The OAuth callback state is invalid!', + }, }); - const res = mock(); + expect(externalHooks.run).not.toHaveBeenCalled(); + }); + + it('should render the error page when state is older than 5 minutes', async () => { + credentialsRepository.findOneBy.mockResolvedValueOnce(credential); + credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); + jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); + + jest.advanceTimersByTime(10 * Time.minutes.toMilliseconds); + await controller.handleCallback(req, res); + expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { error: { - message: 'The OAuth2 callback state is invalid!', + message: 'The OAuth callback state is invalid!', }, }); expect(externalHooks.run).not.toHaveBeenCalled(); }); - it('should exchange the code for a valid token, and save it to DB', async () => { + it('should render the error page when code exchange fails', async () => { credentialsRepository.findOneBy.mockResolvedValueOnce(credential); credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); - credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({ - clientId: 'test-client-id', - clientSecret: 'oauth-secret', - accessTokenUrl: 'https://example.domain/token', + jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); + nock('https://example.domain') + .post( + '/token', + 'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback', + ) + .reply(403, { error: 'Code could not be exchanged' }); + + await controller.handleCallback(req, res); + + expect(externalHooks.run).toHaveBeenCalled(); + expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { + error: { + message: 'Code could not be exchanged', + reason: '{"error":"Code could not be exchanged"}', + }, }); + }); + + it('should exchange the code for a valid token, and save it to DB', async () => { + credentialsRepository.findOneBy.mockResolvedValueOnce(credential); + credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); nock('https://example.domain') .post( @@ -188,11 +235,6 @@ describe('OAuth2CredentialController', () => { .reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' }); cipher.encrypt.mockReturnValue('encrypted'); - const req = mock({ - query: { code: 'code', state: validState }, - originalUrl: '?code=code', - }); - const res = mock(); await controller.handleCallback(req, res); expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [ diff --git a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts index 6e162af98853d..3f5c20dfc3c8c 100644 --- a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts @@ -6,24 +6,37 @@ import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } f import { jsonParse, ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; -import { RESPONSE_ERROR_MESSAGES } from '@/constants'; +import { RESPONSE_ERROR_MESSAGES, Time } from '@/constants'; import { CredentialsHelper } from '@/credentials-helper'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; +import { AuthError } from '@/errors/response-errors/auth.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExternalHooks } from '@/external-hooks'; import type { ICredentialsDb } from '@/interfaces'; import { Logger } from '@/logging/logger.service'; -import type { OAuthRequest } from '@/requests'; +import type { AuthenticatedRequest, OAuthRequest } from '@/requests'; import { UrlService } from '@/services/url.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; -export interface CsrfStateParam { +type CsrfStateParam = { + /** Id of the oAuth credential in the DB */ cid: string; + /** Random CSRF token, used to verify the signature of the CSRF state */ token: string; -} + /** Creation timestamp of the CSRF state. Used for expiration. */ + createdAt: number; + /** User who initiated OAuth flow, included to prevent cross-user credential hijacking. Optional only if `skipAuthOnOAuthCallback` is enabled. */ + userId?: string; +}; + +const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds; + +// TODO: Flip this flag in v2 +// https://linear.app/n8n/issue/CAT-329 +export const skipAuthOnOAuthCallback = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK !== 'true'; @Service() export abstract class AbstractOAuthController { @@ -118,33 +131,71 @@ export abstract class AbstractOAuthController { return await this.credentialsRepository.findOneBy({ id: credentialId }); } - createCsrfState(credentialsId: string): [string, string] { + createCsrfState(credentialsId: string, userId?: string): [string, string] { const token = new Csrf(); const csrfSecret = token.secretSync(); const state: CsrfStateParam = { token: token.create(csrfSecret), cid: credentialsId, + createdAt: Date.now(), + userId, }; return [csrfSecret, Buffer.from(JSON.stringify(state)).toString('base64')]; } - protected decodeCsrfState(encodedState: string): CsrfStateParam { + protected decodeCsrfState(encodedState: string, req: AuthenticatedRequest): CsrfStateParam { const errorMessage = 'Invalid state format'; const decoded = jsonParse(Buffer.from(encodedState, 'base64').toString(), { errorMessage, }); + if (typeof decoded.cid !== 'string' || typeof decoded.token !== 'string') { throw new ApplicationError(errorMessage); } + + if (decoded.userId !== req.user?.id) { + throw new AuthError('Unauthorized'); + } + return decoded; } - protected verifyCsrfState(decrypted: ICredentialDataDecryptedObject, state: CsrfStateParam) { + protected verifyCsrfState( + decrypted: ICredentialDataDecryptedObject & { csrfSecret?: string }, + state: CsrfStateParam, + ) { const token = new Csrf(); + return ( - decrypted.csrfSecret === undefined || - !token.verify(decrypted.csrfSecret as string, state.token) + Date.now() - state.createdAt <= MAX_CSRF_AGE && + decrypted.csrfSecret !== undefined && + token.verify(decrypted.csrfSecret, state.token) + ); + } + + protected async resolveCredential( + req: OAuthRequest.OAuth1Credential.Callback | OAuthRequest.OAuth2Credential.Callback, + ): Promise<[ICredentialsDb, ICredentialDataDecryptedObject, T]> { + const { state: encodedState } = req.query; + const state = this.decodeCsrfState(encodedState, req); + const credential = await this.getCredentialWithoutUser(state.cid); + if (!credential) { + throw new ApplicationError('OAuth callback failed because of insufficient permissions'); + } + + const additionalData = await this.getAdditionalData(); + const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); + const oauthCredentials = this.applyDefaultsAndOverwrites( + credential, + decryptedDataOriginal, + additionalData, ); + + if (!this.verifyCsrfState(decryptedDataOriginal, state)) { + throw new ApplicationError('The OAuth callback state is invalid!'); + } + + return [credential, decryptedDataOriginal, oauthCredentials]; } protected renderCallbackError(res: Response, message: string, reason?: string) { diff --git a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts index 1d21c2f33278a..0211c463a9d2a 100644 --- a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts @@ -2,15 +2,14 @@ import type { AxiosRequestConfig } from 'axios'; import axios from 'axios'; import { createHmac } from 'crypto'; import { Response } from 'express'; +import { ensureError, jsonStringify } from 'n8n-workflow'; import type { RequestOptions } from 'oauth-1.0a'; import clientOAuth1 from 'oauth-1.0a'; import { Get, RestController } from '@/decorators'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { OAuthRequest } from '@/requests'; -import { sendErrorResponse } from '@/response-helper'; -import { AbstractOAuthController, type CsrfStateParam } from './abstract-oauth.controller'; +import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller'; interface OAuth1CredentialData { signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1'; @@ -42,7 +41,10 @@ export class OAuth1CredentialController extends AbstractOAuthController { decryptedDataOriginal, additionalData, ); - const [csrfSecret, state] = this.createCsrfState(credential.id); + const [csrfSecret, state] = this.createCsrfState( + credential.id, + skipAuthOnOAuthCallback ? undefined : req.user.id, + ); const signatureMethod = oauthCredentials.signatureMethod; @@ -101,7 +103,7 @@ export class OAuth1CredentialController extends AbstractOAuthController { } /** Verify and store app code. Generate access tokens and store for respective credential */ - @Get('/callback', { usesTemplates: true, skipAuth: true }) + @Get('/callback', { usesTemplates: true, skipAuth: skipAuthOnOAuthCallback }) async handleCallback(req: OAuthRequest.OAuth1Credential.Callback, res: Response) { try { const { oauth_verifier, oauth_token, state: encodedState } = req.query; @@ -114,71 +116,36 @@ export class OAuth1CredentialController extends AbstractOAuthController { ); } - let state: CsrfStateParam; - try { - state = this.decodeCsrfState(encodedState); - } catch (error) { - return this.renderCallbackError(res, (error as Error).message); - } - - const credentialId = state.cid; - const credential = await this.getCredentialWithoutUser(credentialId); - if (!credential) { - const errorMessage = 'OAuth1 callback failed because of insufficient permissions'; - this.logger.error(errorMessage, { credentialId }); - return this.renderCallbackError(res, errorMessage); - } - - const additionalData = await this.getAdditionalData(); - const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); - const oauthCredentials = this.applyDefaultsAndOverwrites( - credential, - decryptedDataOriginal, - additionalData, - ); + const [credential, decryptedDataOriginal, oauthCredentials] = + await this.resolveCredential(req); - if (this.verifyCsrfState(decryptedDataOriginal, state)) { - const errorMessage = 'The OAuth1 callback state is invalid!'; - this.logger.debug(errorMessage, { credentialId }); - return this.renderCallbackError(res, errorMessage); - } - - const options: AxiosRequestConfig = { - method: 'POST', - url: oauthCredentials.accessTokenUrl, - params: { - oauth_token, - oauth_verifier, - }, - }; - - let oauthToken; - - try { - oauthToken = await axios.request(options); - } catch (error) { - this.logger.error('Unable to fetch tokens for OAuth1 callback', { credentialId }); - const errorResponse = new NotFoundError('Unable to get access tokens!'); - return sendErrorResponse(res, errorResponse); - } + const oauthToken = await axios.post(oauthCredentials.accessTokenUrl, { + oauth_token, + oauth_verifier, + }); // Response comes as x-www-form-urlencoded string so convert it to JSON - const paramParser = new URLSearchParams(oauthToken.data as string); + const paramParser = new URLSearchParams(oauthToken.data); const oauthTokenJson = Object.fromEntries(paramParser.entries()); + delete decryptedDataOriginal.csrfSecret; decryptedDataOriginal.oauthTokenData = oauthTokenJson; await this.encryptAndSaveData(credential, decryptedDataOriginal); this.logger.debug('OAuth1 callback successful for new credential', { - credentialId, + credentialId: credential.id, }); return res.render('oauth-callback'); - } catch (error) { - this.logger.error('OAuth1 callback failed because of insufficient user permissions'); - return sendErrorResponse(res, error as Error); + } catch (e) { + const error = ensureError(e); + return this.renderCallbackError( + res, + error.message, + 'body' in error ? jsonStringify(error.body) : undefined, + ); } } } diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index fdce783c6eb0d..0f563993fff20 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -8,11 +8,11 @@ import { jsonStringify } from 'n8n-workflow'; import pkceChallenge from 'pkce-challenge'; import * as qs from 'querystring'; +import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '@/constants'; import { Get, RestController } from '@/decorators'; import { OAuthRequest } from '@/requests'; -import { AbstractOAuthController, type CsrfStateParam } from './abstract-oauth.controller'; -import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '../../constants'; +import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller'; @RestController('/oauth2-credential') export class OAuth2CredentialController extends AbstractOAuthController { @@ -44,7 +44,10 @@ export class OAuth2CredentialController extends AbstractOAuthController { ); // Generate a CSRF prevention token and send it as an OAuth2 state string - const [csrfSecret, state] = this.createCsrfState(credential.id); + const [csrfSecret, state] = this.createCsrfState( + credential.id, + skipAuthOnOAuthCallback ? undefined : req.user.id, + ); const oAuthOptions = { ...this.convertCredentialToOptions(oauthCredentials), @@ -82,7 +85,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { } /** Verify and store app code. Generate access tokens and store for respective credential */ - @Get('/callback', { usesTemplates: true, skipAuth: true }) + @Get('/callback', { usesTemplates: true, skipAuth: skipAuthOnOAuthCallback }) async handleCallback(req: OAuthRequest.OAuth2Credential.Callback, res: Response) { try { const { code, state: encodedState } = req.query; @@ -94,34 +97,8 @@ export class OAuth2CredentialController extends AbstractOAuthController { ); } - let state: CsrfStateParam; - try { - state = this.decodeCsrfState(encodedState); - } catch (error) { - return this.renderCallbackError(res, (error as Error).message); - } - - const credentialId = state.cid; - const credential = await this.getCredentialWithoutUser(credentialId); - if (!credential) { - const errorMessage = 'OAuth2 callback failed because of insufficient permissions'; - this.logger.error(errorMessage, { credentialId }); - return this.renderCallbackError(res, errorMessage); - } - - const additionalData = await this.getAdditionalData(); - const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); - const oauthCredentials = this.applyDefaultsAndOverwrites( - credential, - decryptedDataOriginal, - additionalData, - ); - - if (this.verifyCsrfState(decryptedDataOriginal, state)) { - const errorMessage = 'The OAuth2 callback state is invalid!'; - this.logger.debug(errorMessage, { credentialId }); - return this.renderCallbackError(res, errorMessage); - } + const [credential, decryptedDataOriginal, oauthCredentials] = + await this.resolveCredential(req); let options: Partial = {}; @@ -156,12 +133,6 @@ export class OAuth2CredentialController extends AbstractOAuthController { set(oauthToken.data, 'callbackQueryString', omit(req.query, 'state', 'code')); } - if (oauthToken === undefined) { - const errorMessage = 'Unable to get OAuth2 access tokens!'; - this.logger.error(errorMessage, { credentialId }); - return this.renderCallbackError(res, errorMessage); - } - if (decryptedDataOriginal.oauthTokenData) { // Only overwrite supplied data as some providers do for example just return the // refresh_token on the very first request and not on subsequent ones. @@ -175,7 +146,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { await this.encryptAndSaveData(credential, decryptedDataOriginal); this.logger.debug('OAuth2 callback successful for credential', { - credentialId, + credentialId: credential.id, }); return res.render('oauth-callback'); diff --git a/packages/cli/src/databases/migrations/mysqldb/1690000000001-MigrateIntegerKeysToString.ts b/packages/cli/src/databases/migrations/mysqldb/1690000000001-MigrateIntegerKeysToString.ts index 1f6bf77123c66..ec4389e416454 100644 --- a/packages/cli/src/databases/migrations/mysqldb/1690000000001-MigrateIntegerKeysToString.ts +++ b/packages/cli/src/databases/migrations/mysqldb/1690000000001-MigrateIntegerKeysToString.ts @@ -29,7 +29,7 @@ export class MigrateIntegerKeysToString1690000000001 implements IrreversibleMigr ); await queryRunner.query(`UPDATE ${tablePrefix}workflow_entity SET id = CONVERT(tmp_id, CHAR);`); await queryRunner.query( - `CREATE INDEX \`TMP_idx_${tablePrefix}workflow_entity_id\` ON ${tablePrefix}workflow_entity (\`id\`);`, + `CREATE UNIQUE INDEX \`TMP_idx_${tablePrefix}workflow_entity_id\` ON ${tablePrefix}workflow_entity (\`id\`);`, ); await queryRunner.query( @@ -40,7 +40,7 @@ export class MigrateIntegerKeysToString1690000000001 implements IrreversibleMigr ); await queryRunner.query(`UPDATE ${tablePrefix}tag_entity SET id = CONVERT(tmp_id, CHAR);`); await queryRunner.query( - `CREATE INDEX \`TMP_idx_${tablePrefix}tag_entity_id\` ON ${tablePrefix}tag_entity (\`id\`);`, + `CREATE UNIQUE INDEX \`TMP_idx_${tablePrefix}tag_entity_id\` ON ${tablePrefix}tag_entity (\`id\`);`, ); await queryRunner.query( @@ -65,7 +65,7 @@ export class MigrateIntegerKeysToString1690000000001 implements IrreversibleMigr `ALTER TABLE ${tablePrefix}workflows_tags DROP PRIMARY KEY, ADD PRIMARY KEY (\`workflowId\`, \`tagId\`);`, ); await queryRunner.query( - `CREATE INDEX \`idx_${tablePrefix}workflows_tags_workflowid\` ON ${tablePrefix}workflows_tags (\`workflowId\`);`, + `CREATE INDEX \`idx_${tablePrefix}workflows_tags_workflow_id\` ON ${tablePrefix}workflows_tags (\`workflowId\`);`, ); await queryRunner.query( `ALTER TABLE ${tablePrefix}workflows_tags DROP FOREIGN KEY \`FK_${tablePrefix}54b2f0343d6a2078fa137443869\`;`, @@ -207,7 +207,7 @@ export class MigrateIntegerKeysToString1690000000001 implements IrreversibleMigr `UPDATE ${tablePrefix}credentials_entity SET id = CONVERT(tmp_id, CHAR);`, ); await queryRunner.query( - `CREATE INDEX \`TMP_idx_${tablePrefix}credentials_entity_id\` ON ${tablePrefix}credentials_entity (\`id\`);`, + `CREATE UNIQUE INDEX \`TMP_idx_${tablePrefix}credentials_entity_id\` ON ${tablePrefix}credentials_entity (\`id\`);`, ); await queryRunner.query( @@ -259,7 +259,7 @@ export class MigrateIntegerKeysToString1690000000001 implements IrreversibleMigr `UPDATE ${tablePrefix}variables SET \`id\` = CONVERT(\`tmp_id\`, CHAR);`, ); await queryRunner.query( - `CREATE INDEX \`TMP_idx_${tablePrefix}variables_id\` ON ${tablePrefix}variables (\`id\`);`, + `CREATE UNIQUE INDEX \`TMP_idx_${tablePrefix}variables_id\` ON ${tablePrefix}variables (\`id\`);`, ); await queryRunner.query( `ALTER TABLE ${tablePrefix}variables CHANGE \`tmp_id\` \`tmp_id\` int NOT NULL;`, @@ -268,5 +268,8 @@ export class MigrateIntegerKeysToString1690000000001 implements IrreversibleMigr `ALTER TABLE ${tablePrefix}variables DROP PRIMARY KEY, ADD PRIMARY KEY (\`id\`);`, ); await queryRunner.query(`ALTER TABLE ${tablePrefix}variables DROP COLUMN \`tmp_id\`;`); + await queryRunner.query( + `DROP INDEX \`TMP_idx_${tablePrefix}variables_id\` ON ${tablePrefix}variables;`, + ); } } diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 3b93d15ca00b2..b37d118f3c478 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -459,7 +459,7 @@ export class ExecutionRepository extends Repository { } async softDeletePrunableExecutions() { - const { maxAge, maxCount } = this.globalConfig.pruning; + const { pruneDataMaxAge, pruneDataMaxCount } = this.globalConfig.executions; // Sub-query to exclude executions having annotations const annotatedExecutionsSubQuery = this.manager @@ -470,18 +470,18 @@ export class ExecutionRepository extends Repository { // Find ids of all executions that were stopped longer that pruneDataMaxAge ago const date = new Date(); - date.setHours(date.getHours() - maxAge); + date.setHours(date.getHours() - pruneDataMaxAge); const toPrune: Array> = [ // date reformatting needed - see https://github.com/typeorm/typeorm/issues/2286 { stoppedAt: LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date)) }, ]; - if (maxCount > 0) { + if (pruneDataMaxCount > 0) { const executions = await this.createQueryBuilder('execution') .select('execution.id') .where('execution.id NOT IN ' + annotatedExecutionsSubQuery.getQuery()) - .skip(maxCount) + .skip(pruneDataMaxCount) .take(1) .orderBy('execution.id', 'DESC') .getMany(); @@ -515,7 +515,7 @@ export class ExecutionRepository extends Repository { async findSoftDeletedExecutions() { const date = new Date(); - date.setHours(date.getHours() - this.globalConfig.pruning.hardDeleteBuffer); + date.setHours(date.getHours() - this.globalConfig.executions.pruneDataHardDeleteBuffer); const workflowIdsAndExecutionIds = ( await this.find({ diff --git a/packages/cli/src/databases/repositories/license-metrics.repository.ts b/packages/cli/src/databases/repositories/license-metrics.repository.ts index 6be6ee0e7b2b9..d6cc7c1409287 100644 --- a/packages/cli/src/databases/repositories/license-metrics.repository.ts +++ b/packages/cli/src/databases/repositories/license-metrics.repository.ts @@ -15,18 +15,8 @@ export class LicenseMetricsRepository extends Repository { } toTableName(name: string) { - const tablePrefix = this.globalConfig.database.tablePrefix; - - let tableName = - this.globalConfig.database.type === 'mysqldb' - ? `\`${tablePrefix}${name}\`` - : `"${tablePrefix}${name}"`; - - const pgSchema = this.globalConfig.database.postgresdb.schema; - - if (pgSchema !== 'public') tableName = [pgSchema, tablePrefix + name].join('.'); - - return tableName; + const { tablePrefix } = this.globalConfig.database; + return this.manager.connection.driver.escape(`${tablePrefix}${name}`); } async getLicenseRenewalMetrics() { diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 7e98877dc7360..58d694e5560ec 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -2,7 +2,6 @@ import type { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import type { IWorkflowBase } from 'n8n-workflow'; -import config from '@/config'; import { N8N_VERSION } from '@/constants'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; @@ -66,7 +65,7 @@ describe('TelemetryEventRelay', () => { }); beforeEach(() => { - config.set('diagnostics.enabled', true); + globalConfig.diagnostics.enabled = true; }); afterEach(() => { @@ -75,7 +74,7 @@ describe('TelemetryEventRelay', () => { describe('init', () => { it('with diagnostics enabled, should init telemetry and register listeners', async () => { - config.set('diagnostics.enabled', true); + globalConfig.diagnostics.enabled = true; const telemetryEventRelay = new TelemetryEventRelay( eventService, telemetry, @@ -96,7 +95,7 @@ describe('TelemetryEventRelay', () => { }); it('with diagnostics disabled, should neither init telemetry nor register listeners', async () => { - config.set('diagnostics.enabled', false); + globalConfig.diagnostics.enabled = false; const telemetryEventRelay = new TelemetryEventRelay( eventService, telemetry, diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 88f954ab93225..0a352087e5e41 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -37,7 +37,7 @@ export class TelemetryEventRelay extends EventRelay { } async init() { - if (!config.getEnv('diagnostics.enabled')) return; + if (!this.globalConfig.diagnostics.enabled) return; await this.telemetry.init(); @@ -771,8 +771,8 @@ export class TelemetryEventRelay extends EventRelay { executions_data_save_manual_executions: config.getEnv( 'executions.saveDataManualExecutions', ), - executions_data_prune: this.globalConfig.pruning.isEnabled, - executions_data_max_age: this.globalConfig.pruning.maxAge, + executions_data_prune: this.globalConfig.executions.pruneData, + executions_data_max_age: this.globalConfig.executions.pruneDataMaxAge, }, n8n_deployment_type: config.getEnv('deployment.type'), n8n_binary_data_mode: binaryDataConfig.mode, diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts b/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts index b0db5becac714..d89f2fb734d2f 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts +++ b/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts @@ -1,5 +1,4 @@ import { - deepCopy, ErrorReporterProxy, type IRunExecutionData, type ITaskData, @@ -87,37 +86,6 @@ test('should update execution when saving progress is enabled', async () => { expect(reporterSpy).not.toHaveBeenCalled(); }); -test('should update execution when saving progress is disabled, but waitTill is defined', async () => { - jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({ - ...commonSettings, - progress: false, - }); - - const reporterSpy = jest.spyOn(ErrorReporterProxy, 'error'); - - executionRepository.findSingleExecution.mockResolvedValue({} as IExecutionResponse); - - const args = deepCopy(commonArgs); - args[4].waitTill = new Date(); - await saveExecutionProgress(...args); - - expect(executionRepository.updateExistingExecution).toHaveBeenCalledWith('some-execution-id', { - data: { - executionData: undefined, - resultData: { - lastNodeExecuted: 'My Node', - runData: { - 'My Node': [{}], - }, - }, - startData: {}, - }, - status: 'running', - }); - - expect(reporterSpy).not.toHaveBeenCalled(); -}); - test('should report error on failure', async () => { jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({ ...commonSettings, diff --git a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts b/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts index 6cd1cfd08f78c..ca9899e1ec1a2 100644 --- a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts +++ b/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts @@ -16,7 +16,7 @@ export async function saveExecutionProgress( ) { const saveSettings = toSaveSettings(workflowData.settings); - if (!saveSettings.progress && !executionData.waitTill) return; + if (!saveSettings.progress) return; const logger = Container.get(Logger); diff --git a/packages/cli/src/execution-lifecycle-hooks/to-save-settings.ts b/packages/cli/src/execution-lifecycle-hooks/to-save-settings.ts index 7a25adaeba912..a7af8f3ddc233 100644 --- a/packages/cli/src/execution-lifecycle-hooks/to-save-settings.ts +++ b/packages/cli/src/execution-lifecycle-hooks/to-save-settings.ts @@ -18,20 +18,20 @@ export function toSaveSettings(workflowSettings: IWorkflowSettings = {}) { PROGRESS: config.getEnv('executions.saveExecutionProgress'), }; + const { + saveDataErrorExecution = DEFAULTS.ERROR, + saveDataSuccessExecution = DEFAULTS.SUCCESS, + saveManualExecutions = DEFAULTS.MANUAL, + saveExecutionProgress = DEFAULTS.PROGRESS, + } = workflowSettings; + return { - error: workflowSettings.saveDataErrorExecution - ? workflowSettings.saveDataErrorExecution !== 'none' - : DEFAULTS.ERROR !== 'none', - success: workflowSettings.saveDataSuccessExecution - ? workflowSettings.saveDataSuccessExecution !== 'none' - : DEFAULTS.SUCCESS !== 'none', - manual: - workflowSettings === undefined || workflowSettings.saveManualExecutions === 'DEFAULT' - ? DEFAULTS.MANUAL - : (workflowSettings.saveManualExecutions ?? DEFAULTS.MANUAL), - progress: - workflowSettings === undefined || workflowSettings.saveExecutionProgress === 'DEFAULT' - ? DEFAULTS.PROGRESS - : (workflowSettings.saveExecutionProgress ?? DEFAULTS.PROGRESS), + error: saveDataErrorExecution === 'DEFAULT' ? DEFAULTS.ERROR : saveDataErrorExecution === 'all', + success: + saveDataSuccessExecution === 'DEFAULT' + ? DEFAULTS.SUCCESS + : saveDataSuccessExecution === 'all', + manual: saveManualExecutions === 'DEFAULT' ? DEFAULTS.MANUAL : saveManualExecutions, + progress: saveExecutionProgress === 'DEFAULT' ? DEFAULTS.PROGRESS : saveExecutionProgress, }; } diff --git a/packages/cli/src/posthog/__tests__/posthog.test.ts b/packages/cli/src/posthog/__tests__/posthog.test.ts index 5c8fe282bfcbf..5e11d247733a8 100644 --- a/packages/cli/src/posthog/__tests__/posthog.test.ts +++ b/packages/cli/src/posthog/__tests__/posthog.test.ts @@ -3,7 +3,6 @@ import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; import { PostHog } from 'posthog-node'; -import config from '@/config'; import { PostHogClient } from '@/posthog'; import { mockInstance } from '@test/mocking'; @@ -20,12 +19,11 @@ describe('PostHog', () => { const globalConfig = mock({ logging: { level: 'debug' } }); beforeAll(() => { - config.set('diagnostics.config.posthog.apiKey', apiKey); - config.set('diagnostics.config.posthog.apiHost', apiHost); + globalConfig.diagnostics.posthogConfig = { apiKey, apiHost }; }); beforeEach(() => { - config.set('diagnostics.enabled', true); + globalConfig.diagnostics.enabled = true; jest.resetAllMocks(); }); @@ -37,7 +35,7 @@ describe('PostHog', () => { }); it('does not initialize or track if diagnostics are not enabled', async () => { - config.set('diagnostics.enabled', false); + globalConfig.diagnostics.enabled = false; const ph = new PostHogClient(instanceSettings, globalConfig); await ph.init(); diff --git a/packages/cli/src/posthog/index.ts b/packages/cli/src/posthog/index.ts index 8dec9755b38bb..be025c8a85050 100644 --- a/packages/cli/src/posthog/index.ts +++ b/packages/cli/src/posthog/index.ts @@ -4,7 +4,6 @@ import type { FeatureFlags, ITelemetryTrackProperties } from 'n8n-workflow'; import type { PostHog } from 'posthog-node'; import { Service } from 'typedi'; -import config from '@/config'; import type { PublicUser } from '@/interfaces'; @Service() @@ -17,14 +16,14 @@ export class PostHogClient { ) {} async init() { - const enabled = config.getEnv('diagnostics.enabled'); + const { enabled, posthogConfig } = this.globalConfig.diagnostics; if (!enabled) { return; } const { PostHog } = await import('posthog-node'); - this.postHog = new PostHog(config.getEnv('diagnostics.config.posthog.apiKey'), { - host: config.getEnv('diagnostics.config.posthog.apiHost'), + this.postHog = new PostHog(posthogConfig.apiKey, { + host: posthogConfig.apiHost, }); const logLevel = this.globalConfig.logging.level; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 4765ac1fad011..f233d7db46584 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -335,7 +335,7 @@ export declare namespace MFA { export declare namespace OAuthRequest { namespace OAuth1Credential { type Auth = AuthenticatedRequest<{}, {}, {}, { id: string }>; - type Callback = AuthlessRequest< + type Callback = AuthenticatedRequest< {}, {}, {}, @@ -347,7 +347,7 @@ export declare namespace OAuthRequest { namespace OAuth2Credential { type Auth = AuthenticatedRequest<{}, {}, {}, { id: string }>; - type Callback = AuthlessRequest<{}, {}, {}, { code: string; state: string }>; + type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>; } } diff --git a/packages/cli/src/runners/__tests__/task-broker.test.ts b/packages/cli/src/runners/__tests__/task-broker.test.ts index 614d04c3b5dd2..2f9a6a26e73c8 100644 --- a/packages/cli/src/runners/__tests__/task-broker.test.ts +++ b/packages/cli/src/runners/__tests__/task-broker.test.ts @@ -1,8 +1,12 @@ +import type { TaskRunnersConfig } from '@n8n/config'; import type { RunnerMessage, TaskResultData } from '@n8n/task-runner'; import { mock } from 'jest-mock-extended'; -import type { INodeTypeBaseDescription } from 'n8n-workflow'; +import { ApplicationError, type INodeTypeBaseDescription } from 'n8n-workflow'; + +import { Time } from '@/constants'; import { TaskRejectError } from '../errors'; +import type { RunnerLifecycleEvents } from '../runner-lifecycle-events'; import { TaskBroker } from '../task-broker.service'; import type { TaskOffer, TaskRequest, TaskRunner } from '../task-broker.service'; @@ -12,7 +16,7 @@ describe('TaskBroker', () => { let taskBroker: TaskBroker; beforeEach(() => { - taskBroker = new TaskBroker(mock()); + taskBroker = new TaskBroker(mock(), mock(), mock()); jest.restoreAllMocks(); }); @@ -51,6 +55,35 @@ describe('TaskBroker', () => { expect(offers).toHaveLength(1); expect(offers[0]).toEqual(validOffer); }); + + it('should not expire non-expiring task offers', () => { + const nonExpiringOffer: TaskOffer = { + offerId: 'nonExpiring', + runnerId: 'runner1', + taskType: 'taskType1', + validFor: -1, + validUntil: 0n, // sentinel value for non-expiring offer + }; + + const expiredOffer: TaskOffer = { + offerId: 'expired', + runnerId: 'runner2', + taskType: 'taskType1', + validFor: 1000, + validUntil: createValidUntil(-1000), // 1 second in the past + }; + + taskBroker.setPendingTaskOffers([ + nonExpiringOffer, // will not be removed + expiredOffer, // will be removed + ]); + + taskBroker.expireTasks(); + + const offers = taskBroker.getPendingTaskOffers(); + expect(offers).toHaveLength(1); + expect(offers[0]).toEqual(nonExpiringOffer); + }); }); describe('registerRunner', () => { @@ -591,6 +624,66 @@ describe('TaskBroker', () => { requestParams, }); }); + + it('should handle `runner:taskoffer` message with expiring offer', async () => { + const runnerId = 'runner1'; + const validFor = 1000; // 1 second + const message: RunnerMessage.ToBroker.TaskOffer = { + type: 'runner:taskoffer', + offerId: 'offer1', + taskType: 'taskType1', + validFor, + }; + + const beforeTime = process.hrtime.bigint(); + taskBroker.registerRunner(mock({ id: runnerId }), jest.fn()); + + await taskBroker.onRunnerMessage(runnerId, message); + + const afterTime = process.hrtime.bigint(); + + const offers = taskBroker.getPendingTaskOffers(); + expect(offers).toHaveLength(1); + + const expectedMinValidUntil = beforeTime + BigInt(validFor * 1_000_000); + const expectedMaxValidUntil = afterTime + BigInt(validFor * 1_000_000); + + expect(offers[0].validUntil).toBeGreaterThanOrEqual(expectedMinValidUntil); + expect(offers[0].validUntil).toBeLessThanOrEqual(expectedMaxValidUntil); + expect(offers[0]).toEqual( + expect.objectContaining({ + runnerId, + taskType: message.taskType, + offerId: message.offerId, + validFor, + }), + ); + }); + + it('should handle `runner:taskoffer` message with non-expiring offer', async () => { + const runnerId = 'runner1'; + const message: RunnerMessage.ToBroker.TaskOffer = { + type: 'runner:taskoffer', + offerId: 'offer1', + taskType: 'taskType1', + validFor: -1, + }; + + taskBroker.registerRunner(mock({ id: runnerId }), jest.fn()); + + await taskBroker.onRunnerMessage(runnerId, message); + + const offers = taskBroker.getPendingTaskOffers(); + + expect(offers).toHaveLength(1); + expect(offers[0]).toEqual({ + runnerId, + taskType: message.taskType, + offerId: message.offerId, + validFor: -1, + validUntil: 0n, + }); + }); }); describe('onRequesterMessage', () => { @@ -618,4 +711,131 @@ describe('TaskBroker', () => { }); }); }); + + describe('task timeouts', () => { + let taskBroker: TaskBroker; + let config: TaskRunnersConfig; + let runnerLifecycleEvents = mock(); + + beforeAll(() => { + jest.useFakeTimers(); + config = mock({ taskTimeout: 30 }); + taskBroker = new TaskBroker(mock(), config, runnerLifecycleEvents); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('on sending task, we should set up task timeout', async () => { + jest.spyOn(global, 'setTimeout'); + + const taskId = 'task1'; + const runnerId = 'runner1'; + const runner = mock({ id: runnerId }); + const runnerMessageCallback = jest.fn(); + + taskBroker.registerRunner(runner, runnerMessageCallback); + taskBroker.setTasks({ + [taskId]: { id: taskId, runnerId, requesterId: 'requester1', taskType: 'test' }, + }); + + await taskBroker.sendTaskSettings(taskId, {}); + + expect(setTimeout).toHaveBeenCalledWith( + expect.any(Function), + config.taskTimeout * Time.seconds.toMilliseconds, + ); + }); + + it('on task completion, we should clear timeout', async () => { + jest.spyOn(global, 'clearTimeout'); + + const taskId = 'task1'; + const runnerId = 'runner1'; + const requesterId = 'requester1'; + const requesterCallback = jest.fn(); + + taskBroker.registerRequester(requesterId, requesterCallback); + taskBroker.setTasks({ + [taskId]: { + id: taskId, + runnerId, + requesterId, + taskType: 'test', + timeout: setTimeout(() => {}, config.taskTimeout * Time.seconds.toMilliseconds), + }, + }); + + await taskBroker.taskDoneHandler(taskId, { result: [] }); + + expect(clearTimeout).toHaveBeenCalled(); + expect(taskBroker.getTasks().get(taskId)).toBeUndefined(); + }); + + it('on task error, we should clear timeout', async () => { + jest.spyOn(global, 'clearTimeout'); + + const taskId = 'task1'; + const runnerId = 'runner1'; + const requesterId = 'requester1'; + const requesterCallback = jest.fn(); + + taskBroker.registerRequester(requesterId, requesterCallback); + taskBroker.setTasks({ + [taskId]: { + id: taskId, + runnerId, + requesterId, + taskType: 'test', + timeout: setTimeout(() => {}, config.taskTimeout * Time.seconds.toMilliseconds), + }, + }); + + await taskBroker.taskErrorHandler(taskId, new Error('Test error')); + + expect(clearTimeout).toHaveBeenCalled(); + expect(taskBroker.getTasks().get(taskId)).toBeUndefined(); + }); + + it('on timeout, we should emit `runner:timed-out-during-task` event and send error to requester', async () => { + jest.spyOn(global, 'clearTimeout'); + + const taskId = 'task1'; + const runnerId = 'runner1'; + const requesterId = 'requester1'; + const runner = mock({ id: runnerId }); + const runnerCallback = jest.fn(); + const requesterCallback = jest.fn(); + + taskBroker.registerRunner(runner, runnerCallback); + taskBroker.registerRequester(requesterId, requesterCallback); + + taskBroker.setTasks({ + [taskId]: { id: taskId, runnerId, requesterId, taskType: 'test' }, + }); + + await taskBroker.sendTaskSettings(taskId, {}); + + jest.runAllTimers(); + + await Promise.resolve(); + + expect(runnerLifecycleEvents.emit).toHaveBeenCalledWith('runner:timed-out-during-task'); + + await Promise.resolve(); + + expect(clearTimeout).toHaveBeenCalled(); + + expect(requesterCallback).toHaveBeenCalledWith({ + type: 'broker:taskerror', + taskId, + error: new ApplicationError(`Task execution timed out after ${config.taskTimeout} seconds`), + }); + + await Promise.resolve(); + + expect(taskBroker.getTasks().get(taskId)).toBeUndefined(); + }); + }); }); diff --git a/packages/cli/src/runners/__tests__/task-runner-process.test.ts b/packages/cli/src/runners/__tests__/task-runner-process.test.ts index fbab9ee1e3fd5..447a57d3c7491 100644 --- a/packages/cli/src/runners/__tests__/task-runner-process.test.ts +++ b/packages/cli/src/runners/__tests__/task-runner-process.test.ts @@ -7,6 +7,8 @@ import type { TaskRunnerAuthService } from '@/runners/auth/task-runner-auth.serv import { TaskRunnerProcess } from '@/runners/task-runner-process'; import { mockInstance } from '@test/mocking'; +import type { RunnerLifecycleEvents } from '../runner-lifecycle-events'; + const spawnMock = jest.fn(() => mock({ stdout: { @@ -23,9 +25,9 @@ describe('TaskRunnerProcess', () => { const logger = mockInstance(Logger); const runnerConfig = mockInstance(TaskRunnersConfig); runnerConfig.enabled = true; - runnerConfig.mode = 'internal_childprocess'; + runnerConfig.mode = 'internal'; const authService = mock(); - let taskRunnerProcess = new TaskRunnerProcess(logger, runnerConfig, authService); + let taskRunnerProcess = new TaskRunnerProcess(logger, runnerConfig, authService, mock()); afterEach(async () => { spawnMock.mockClear(); @@ -35,34 +37,59 @@ describe('TaskRunnerProcess', () => { it('should throw if runner mode is external', () => { runnerConfig.mode = 'external'; - expect(() => new TaskRunnerProcess(logger, runnerConfig, authService)).toThrow(); + expect(() => new TaskRunnerProcess(logger, runnerConfig, authService, mock())).toThrow(); + + runnerConfig.mode = 'internal'; + }); + + it('should register listener for `runner:failed-heartbeat-check` event', () => { + const runnerLifecycleEvents = mock(); + new TaskRunnerProcess(logger, runnerConfig, authService, runnerLifecycleEvents); + + expect(runnerLifecycleEvents.on).toHaveBeenCalledWith( + 'runner:failed-heartbeat-check', + expect.any(Function), + ); + }); + + it('should register listener for `runner:timed-out-during-task` event', () => { + const runnerLifecycleEvents = mock(); + new TaskRunnerProcess(logger, runnerConfig, authService, runnerLifecycleEvents); - runnerConfig.mode = 'internal_childprocess'; + expect(runnerLifecycleEvents.on).toHaveBeenCalledWith( + 'runner:timed-out-during-task', + expect.any(Function), + ); }); }); describe('start', () => { beforeEach(() => { - taskRunnerProcess = new TaskRunnerProcess(logger, runnerConfig, authService); + taskRunnerProcess = new TaskRunnerProcess(logger, runnerConfig, authService, mock()); }); - test.each(['PATH', 'NODE_FUNCTION_ALLOW_BUILTIN', 'NODE_FUNCTION_ALLOW_EXTERNAL'])( - 'should propagate %s from env as is', - async (envVar) => { - jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken'); - process.env[envVar] = 'custom value'; - - await taskRunnerProcess.start(); - - // @ts-expect-error The type is not correct - const options = spawnMock.mock.calls[0][2] as SpawnOptions; - expect(options.env).toEqual( - expect.objectContaining({ - [envVar]: 'custom value', - }), - ); - }, - ); + test.each([ + 'PATH', + 'NODE_FUNCTION_ALLOW_BUILTIN', + 'NODE_FUNCTION_ALLOW_EXTERNAL', + 'N8N_SENTRY_DSN', + 'N8N_VERSION', + 'ENVIRONMENT', + 'DEPLOYMENT_NAME', + ])('should propagate %s from env as is', async (envVar) => { + jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken'); + process.env[envVar] = 'custom value'; + + await taskRunnerProcess.start(); + + // @ts-expect-error The type is not correct + const options = spawnMock.mock.calls[0][2] as SpawnOptions; + expect(options.env).toEqual( + expect.objectContaining({ + [envVar]: 'custom value', + }), + ); + }); it('should pass NODE_OPTIONS env if maxOldSpaceSize is configured', async () => { jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken'); diff --git a/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts b/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts new file mode 100644 index 0000000000000..003e2c5f4e521 --- /dev/null +++ b/packages/cli/src/runners/__tests__/task-runner-ws-server.test.ts @@ -0,0 +1,48 @@ +import type { TaskRunnersConfig } from '@n8n/config'; +import { mock } from 'jest-mock-extended'; + +import { Time } from '@/constants'; +import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; + +describe('TaskRunnerWsServer', () => { + describe('heartbeat timer', () => { + it('should set up heartbeat timer on server start', async () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + const server = new TaskRunnerWsServer( + mock(), + mock(), + mock(), + mock({ path: '/runners', heartbeatInterval: 30 }), + mock(), + ); + + server.start(); + + expect(setIntervalSpy).toHaveBeenCalledWith( + expect.any(Function), + 30 * Time.seconds.toMilliseconds, + ); + + await server.stop(); + }); + + it('should clear heartbeat timer on server stop', async () => { + jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const server = new TaskRunnerWsServer( + mock(), + mock(), + mock(), + mock({ path: '/runners', heartbeatInterval: 30 }), + mock(), + ); + server.start(); + + await server.stop(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts b/packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts index e101c65e28163..d61179372b89c 100644 --- a/packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts +++ b/packages/cli/src/runners/default-task-runner-disconnect-analyzer.ts @@ -1,8 +1,10 @@ import { Service } from 'typedi'; +import config from '@/config'; + import { TaskRunnerDisconnectedError } from './errors/task-runner-disconnected-error'; -import type { DisconnectAnalyzer } from './runner-types'; -import type { TaskRunner } from './task-broker.service'; +import { TaskRunnerFailedHeartbeatError } from './errors/task-runner-failed-heartbeat.error'; +import type { DisconnectAnalyzer, DisconnectErrorOptions } from './runner-types'; /** * Analyzes the disconnect reason of a task runner to provide a more @@ -10,7 +12,16 @@ import type { TaskRunner } from './task-broker.service'; */ @Service() export class DefaultTaskRunnerDisconnectAnalyzer implements DisconnectAnalyzer { - async determineDisconnectReason(runnerId: TaskRunner['id']): Promise { - return new TaskRunnerDisconnectedError(runnerId); + async toDisconnectError(opts: DisconnectErrorOptions): Promise { + const { reason, heartbeatInterval } = opts; + + if (reason === 'failed-heartbeat-check' && heartbeatInterval) { + return new TaskRunnerFailedHeartbeatError( + heartbeatInterval, + config.get('deployment.type') !== 'cloud', + ); + } + + return new TaskRunnerDisconnectedError(opts.runnerId ?? 'Unknown runner ID'); } } diff --git a/packages/cli/src/runners/errors.ts b/packages/cli/src/runners/errors.ts index cc53e18fd41a2..c530e5a95d344 100644 --- a/packages/cli/src/runners/errors.ts +++ b/packages/cli/src/runners/errors.ts @@ -6,4 +6,10 @@ export class TaskRejectError extends ApplicationError { } } +export class TaskDeferredError extends ApplicationError { + constructor() { + super('Task deferred until runner is ready', { level: 'info' }); + } +} + export class TaskError extends ApplicationError {} diff --git a/packages/cli/src/runners/errors/missing-auth-token.error.ts b/packages/cli/src/runners/errors/missing-auth-token.error.ts new file mode 100644 index 0000000000000..3c99a09edb54d --- /dev/null +++ b/packages/cli/src/runners/errors/missing-auth-token.error.ts @@ -0,0 +1,7 @@ +export class MissingAuthTokenError extends Error { + constructor() { + super( + 'Missing auth token. When `N8N_RUNNERS_MODE` is `external`, it is required to set `N8N_RUNNERS_AUTH_TOKEN`. Its value should be a shared secret between the main instance and the launcher.', + ); + } +} diff --git a/packages/cli/src/runners/errors/task-runner-failed-heartbeat.error.ts b/packages/cli/src/runners/errors/task-runner-failed-heartbeat.error.ts new file mode 100644 index 0000000000000..55b94485740ff --- /dev/null +++ b/packages/cli/src/runners/errors/task-runner-failed-heartbeat.error.ts @@ -0,0 +1,32 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class TaskRunnerFailedHeartbeatError extends ApplicationError { + description: string; + + constructor(heartbeatInterval: number, isSelfHosted: boolean) { + super('Task execution aborted because runner became unresponsive'); + + const subtitle = + 'The task runner failed to respond as expected, so it was considered unresponsive, and the task was aborted. You can try the following:'; + + const fixes = { + optimizeScript: + 'Optimize your script to prevent CPU-intensive operations, e.g. by breaking them down into smaller chunks or batch processing.', + ensureTermination: + 'Ensure that all paths in your script are able to terminate, i.e. no infinite loops.', + increaseInterval: `If your task can reasonably keep the task runner busy for more than ${heartbeatInterval} ${heartbeatInterval === 1 ? 'second' : 'seconds'}, increase the heartbeat interval using the N8N_RUNNERS_HEARTBEAT_INTERVAL environment variable.`, + }; + + const suggestions = [fixes.optimizeScript, fixes.ensureTermination]; + + if (isSelfHosted) suggestions.push(fixes.increaseInterval); + + const suggestionsText = suggestions + .map((suggestion, index) => `${index + 1}. ${suggestion}`) + .join('
'); + + const description = `${subtitle}

${suggestionsText}`; + + this.description = description; + } +} diff --git a/packages/cli/src/runners/errors/task-runner-timeout.error.ts b/packages/cli/src/runners/errors/task-runner-timeout.error.ts new file mode 100644 index 0000000000000..88f3533028725 --- /dev/null +++ b/packages/cli/src/runners/errors/task-runner-timeout.error.ts @@ -0,0 +1,34 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class TaskRunnerTimeoutError extends ApplicationError { + description: string; + + constructor(taskTimeout: number, isSelfHosted: boolean) { + super( + `Task execution timed out after ${taskTimeout} ${taskTimeout === 1 ? 'second' : 'seconds'}`, + ); + + const subtitle = + 'The task runner was taking too long on this task, so it was suspected of being unresponsive and restarted, and the task was aborted. You can try the following:'; + + const fixes = { + optimizeScript: + 'Optimize your script to prevent long-running tasks, e.g. by processing data in smaller batches.', + ensureTermination: + 'Ensure that all paths in your script are able to terminate, i.e. no infinite loops.', + increaseTimeout: `If your task can reasonably take more than ${taskTimeout} ${taskTimeout === 1 ? 'second' : 'seconds'}, increase the timeout using the N8N_RUNNERS_TASK_TIMEOUT environment variable.`, + }; + + const suggestions = [fixes.optimizeScript, fixes.ensureTermination]; + + if (isSelfHosted) suggestions.push(fixes.increaseTimeout); + + const suggestionsText = suggestions + .map((suggestion, index) => `${index + 1}. ${suggestion}`) + .join('
'); + + const description = `${subtitle}

${suggestionsText}`; + + this.description = description; + } +} diff --git a/packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts b/packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts index e3b9520f776eb..e27f76b628967 100644 --- a/packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts +++ b/packages/cli/src/runners/internal-task-runner-disconnect-analyzer.ts @@ -5,8 +5,8 @@ import config from '@/config'; import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer'; import { TaskRunnerOomError } from './errors/task-runner-oom-error'; +import type { DisconnectErrorOptions } from './runner-types'; import { SlidingWindowSignal } from './sliding-window-signal'; -import type { TaskRunner } from './task-broker.service'; import type { ExitReason, TaskRunnerProcessEventMap } from './task-runner-process'; import { TaskRunnerProcess } from './task-runner-process'; @@ -38,13 +38,13 @@ export class InternalTaskRunnerDisconnectAnalyzer extends DefaultTaskRunnerDisco }); } - async determineDisconnectReason(runnerId: TaskRunner['id']): Promise { + async toDisconnectError(opts: DisconnectErrorOptions): Promise { const exitCode = await this.awaitExitSignal(); if (exitCode === 'oom') { - return new TaskRunnerOomError(runnerId, this.isCloudDeployment); + return new TaskRunnerOomError(opts.runnerId ?? 'Unknown runner ID', this.isCloudDeployment); } - return await super.determineDisconnectReason(runnerId); + return await super.toDisconnectError(opts); } private async awaitExitSignal(): Promise { diff --git a/packages/cli/src/runners/runner-lifecycle-events.ts b/packages/cli/src/runners/runner-lifecycle-events.ts new file mode 100644 index 0000000000000..8ea2da38b183d --- /dev/null +++ b/packages/cli/src/runners/runner-lifecycle-events.ts @@ -0,0 +1,11 @@ +import { Service } from 'typedi'; + +import { TypedEmitter } from '@/typed-emitter'; + +type RunnerLifecycleEventMap = { + 'runner:failed-heartbeat-check': never; + 'runner:timed-out-during-task': never; +}; + +@Service() +export class RunnerLifecycleEvents extends TypedEmitter {} diff --git a/packages/cli/src/runners/runner-types.ts b/packages/cli/src/runners/runner-types.ts index b373d3051e5a9..132d688e98a04 100644 --- a/packages/cli/src/runners/runner-types.ts +++ b/packages/cli/src/runners/runner-types.ts @@ -6,7 +6,7 @@ import type { TaskRunner } from './task-broker.service'; import type { AuthlessRequest } from '../requests'; export interface DisconnectAnalyzer { - determineDisconnectReason(runnerId: TaskRunner['id']): Promise; + toDisconnectError(opts: DisconnectErrorOptions): Promise; } export type DataRequestType = 'input' | 'node' | 'all'; @@ -22,3 +22,11 @@ export interface TaskRunnerServerInitRequest } export type TaskRunnerServerInitResponse = Response & { req: TaskRunnerServerInitRequest }; + +export type DisconnectReason = 'shutting-down' | 'failed-heartbeat-check' | 'unknown'; + +export type DisconnectErrorOptions = { + runnerId?: TaskRunner['id']; + reason?: DisconnectReason; + heartbeatInterval?: number; +}; diff --git a/packages/cli/src/runners/runner-ws-server.ts b/packages/cli/src/runners/runner-ws-server.ts index c6914625589a5..195490589d4d7 100644 --- a/packages/cli/src/runners/runner-ws-server.ts +++ b/packages/cli/src/runners/runner-ws-server.ts @@ -1,12 +1,17 @@ +import { TaskRunnersConfig } from '@n8n/config'; import type { BrokerMessage, RunnerMessage } from '@n8n/task-runner'; +import { ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; import type WebSocket from 'ws'; +import { Time } from '@/constants'; import { Logger } from '@/logging/logger.service'; import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer'; +import { RunnerLifecycleEvents } from './runner-lifecycle-events'; import type { DisconnectAnalyzer, + DisconnectReason, TaskRunnerServerInitRequest, TaskRunnerServerInitResponse, } from './runner-types'; @@ -16,16 +21,67 @@ function heartbeat(this: WebSocket) { this.isAlive = true; } +const enum WsStatusCode { + CloseNormal = 1000, + CloseGoingAway = 1001, + CloseProtocolError = 1002, + CloseUnsupportedData = 1003, + CloseNoStatus = 1005, + CloseAbnormal = 1006, + CloseInvalidData = 1007, +} + @Service() export class TaskRunnerWsServer { runnerConnections: Map = new Map(); + private heartbeatTimer: NodeJS.Timer | undefined; + constructor( private readonly logger: Logger, private readonly taskBroker: TaskBroker, private disconnectAnalyzer: DefaultTaskRunnerDisconnectAnalyzer, + private readonly taskTunnersConfig: TaskRunnersConfig, + private readonly runnerLifecycleEvents: RunnerLifecycleEvents, ) {} + start() { + this.startHeartbeatChecks(); + } + + private startHeartbeatChecks() { + const { heartbeatInterval } = this.taskTunnersConfig; + + if (heartbeatInterval <= 0) { + throw new ApplicationError('Heartbeat interval must be greater than 0'); + } + + this.heartbeatTimer = setInterval(() => { + for (const [runnerId, connection] of this.runnerConnections.entries()) { + if (!connection.isAlive) { + void this.removeConnection( + runnerId, + 'failed-heartbeat-check', + WsStatusCode.CloseNoStatus, + ); + this.runnerLifecycleEvents.emit('runner:failed-heartbeat-check'); + return; + } + connection.isAlive = false; + connection.ping(); + } + }, heartbeatInterval * Time.seconds.toMilliseconds); + } + + async stop() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + + await this.stopConnectedRunners(); + } + setDisconnectAnalyzer(disconnectAnalyzer: DisconnectAnalyzer) { this.disconnectAnalyzer = disconnectAnalyzer; } @@ -97,12 +153,20 @@ export class TaskRunnerWsServer { ); } - async removeConnection(id: TaskRunner['id']) { + async removeConnection( + id: TaskRunner['id'], + reason: DisconnectReason = 'unknown', + code?: WsStatusCode, + ) { const connection = this.runnerConnections.get(id); if (connection) { - const disconnectReason = await this.disconnectAnalyzer.determineDisconnectReason(id); - this.taskBroker.deregisterRunner(id, disconnectReason); - connection.close(); + const disconnectError = await this.disconnectAnalyzer.toDisconnectError({ + runnerId: id, + reason, + heartbeatInterval: this.taskTunnersConfig.heartbeatInterval, + }); + this.taskBroker.deregisterRunner(id, disconnectError); + connection.close(code); this.runnerConnections.delete(id); } } @@ -110,4 +174,14 @@ export class TaskRunnerWsServer { handleRequest(req: TaskRunnerServerInitRequest, _res: TaskRunnerServerInitResponse) { this.add(req.query.id, req.ws); } + + private async stopConnectedRunners() { + // TODO: We should give runners some time to finish their tasks before + // shutting them down + await Promise.all( + Array.from(this.runnerConnections.keys()).map( + async (id) => await this.removeConnection(id, 'shutting-down', WsStatusCode.CloseGoingAway), + ), + ); + } } diff --git a/packages/cli/src/runners/task-broker.service.ts b/packages/cli/src/runners/task-broker.service.ts index daa5b48c07f07..af76fb6cac3c5 100644 --- a/packages/cli/src/runners/task-broker.service.ts +++ b/packages/cli/src/runners/task-broker.service.ts @@ -1,3 +1,4 @@ +import { TaskRunnersConfig } from '@n8n/config'; import type { BrokerMessage, RequesterMessage, @@ -8,9 +9,13 @@ import { ApplicationError } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import { Service } from 'typedi'; +import config from '@/config'; +import { Time } from '@/constants'; import { Logger } from '@/logging/logger.service'; -import { TaskRejectError } from './errors'; +import { TaskDeferredError, TaskRejectError } from './errors'; +import { TaskRunnerTimeoutError } from './errors/task-runner-timeout.error'; +import { RunnerLifecycleEvents } from './runner-lifecycle-events'; export interface TaskRunner { id: string; @@ -24,12 +29,15 @@ export interface Task { runnerId: TaskRunner['id']; requesterId: string; taskType: string; + timeout?: NodeJS.Timeout; } export interface TaskOffer { offerId: string; runnerId: TaskRunner['id']; taskType: string; + + /** How long (in milliseconds) the task offer is valid for. `-1` for non-expiring offer from launcher. */ validFor: number; validUntil: bigint; } @@ -51,7 +59,7 @@ type RunnerAcceptCallback = () => void; type RequesterAcceptCallback = ( settings: RequesterMessage.ToBroker.TaskSettings['settings'], ) => void; -type TaskRejectCallback = (reason: TaskRejectError) => void; +type TaskRejectCallback = (reason: TaskRejectError | TaskDeferredError) => void; @Service() export class TaskBroker { @@ -78,12 +86,22 @@ export class TaskBroker { private pendingTaskRequests: TaskRequest[] = []; - constructor(private readonly logger: Logger) {} + constructor( + private readonly logger: Logger, + private readonly taskRunnersConfig: TaskRunnersConfig, + private readonly runnerLifecycleEvents: RunnerLifecycleEvents, + ) { + if (this.taskRunnersConfig.taskTimeout <= 0) { + throw new ApplicationError('Task timeout must be greater than 0'); + } + } expireTasks() { const now = process.hrtime.bigint(); for (let i = this.pendingTaskOffers.length - 1; i >= 0; i--) { - if (this.pendingTaskOffers[i].validUntil < now) { + const offer = this.pendingTaskOffers[i]; + if (offer.validFor === -1) continue; // non-expiring offer + if (offer.validUntil < now) { this.pendingTaskOffers.splice(i, 1); } } @@ -144,13 +162,19 @@ export class TaskBroker { case 'runner:taskrejected': this.handleRunnerReject(message.taskId, message.reason); break; + case 'runner:taskdeferred': + this.handleRunnerDeferred(message.taskId); + break; case 'runner:taskoffer': this.taskOffered({ runnerId, taskType: message.taskType, offerId: message.offerId, validFor: message.validFor, - validUntil: process.hrtime.bigint() + BigInt(message.validFor * 1_000_000), + validUntil: + message.validFor === -1 + ? 0n // sentinel value for non-expiring offer + : process.hrtime.bigint() + BigInt(message.validFor * 1_000_000), }); break; case 'runner:taskdone': @@ -209,6 +233,14 @@ export class TaskBroker { } } + handleRunnerDeferred(taskId: Task['id']) { + const acceptReject = this.runnerAcceptRejects.get(taskId); + if (acceptReject) { + acceptReject.reject(new TaskDeferredError()); + this.runnerAcceptRejects.delete(taskId); + } + } + async handleDataRequest( taskId: Task['id'], requestId: RunnerMessage.ToBroker.TaskDataRequest['requestId'], @@ -408,6 +440,14 @@ export class TaskBroker { async sendTaskSettings(taskId: Task['id'], settings: unknown) { const runner = await this.getRunnerOrFailTask(taskId); + + const task = this.tasks.get(taskId); + if (!task) return; + + task.timeout = setTimeout(async () => { + await this.handleTaskTimeout(taskId); + }, this.taskRunnersConfig.taskTimeout * Time.seconds.toMilliseconds); + await this.messageRunner(runner.id, { type: 'broker:tasksettings', taskId, @@ -415,11 +455,27 @@ export class TaskBroker { }); } + private async handleTaskTimeout(taskId: Task['id']) { + const task = this.tasks.get(taskId); + if (!task) return; + + this.runnerLifecycleEvents.emit('runner:timed-out-during-task'); + + await this.taskErrorHandler( + taskId, + new TaskRunnerTimeoutError( + this.taskRunnersConfig.taskTimeout, + config.getEnv('deployment.type') !== 'cloud', + ), + ); + } + async taskDoneHandler(taskId: Task['id'], data: TaskResultData) { const task = this.tasks.get(taskId); - if (!task) { - return; - } + if (!task) return; + + clearTimeout(task.timeout); + await this.requesters.get(task.requesterId)?.({ type: 'broker:taskdone', taskId: task.id, @@ -430,9 +486,10 @@ export class TaskBroker { async taskErrorHandler(taskId: Task['id'], error: unknown) { const task = this.tasks.get(taskId); - if (!task) { - return; - } + if (!task) return; + + clearTimeout(task.timeout); + await this.requesters.get(task.requesterId)?.({ type: 'broker:taskerror', taskId: task.id, @@ -467,6 +524,11 @@ export class TaskBroker { this.logger.info(`Task (${taskId}) rejected by Runner with reason "${e.reason}"`); return; } + if (e instanceof TaskDeferredError) { + this.logger.info(`Task (${taskId}) deferred until runner is ready`); + this.pendingTaskRequests.push(request); // will settle on receiving task offer from runner + return; + } throw e; } diff --git a/packages/cli/src/runners/task-runner-module.ts b/packages/cli/src/runners/task-runner-module.ts index fe476ad3415c6..1502dd1f07a7c 100644 --- a/packages/cli/src/runners/task-runner-module.ts +++ b/packages/cli/src/runners/task-runner-module.ts @@ -2,8 +2,10 @@ import { TaskRunnersConfig } from '@n8n/config'; import * as a from 'node:assert/strict'; import Container, { Service } from 'typedi'; +import { OnShutdown } from '@/decorators/on-shutdown'; import type { TaskRunnerProcess } from '@/runners/task-runner-process'; +import { MissingAuthTokenError } from './errors/missing-auth-token.error'; import { TaskRunnerWsServer } from './runner-ws-server'; import type { LocalTaskManager } from './task-managers/local-task-manager'; import type { TaskRunnerServer } from './task-runner-server'; @@ -28,27 +30,35 @@ export class TaskRunnerModule { async start() { a.ok(this.runnerConfig.enabled, 'Task runner is disabled'); + const { mode, authToken } = this.runnerConfig; + + if (mode === 'external' && !authToken) throw new MissingAuthTokenError(); + await this.loadTaskManager(); await this.loadTaskRunnerServer(); - if ( - this.runnerConfig.mode === 'internal_childprocess' || - this.runnerConfig.mode === 'internal_launcher' - ) { + if (mode === 'internal') { await this.startInternalTaskRunner(); } } + @OnShutdown() async stop() { - if (this.taskRunnerProcess) { - await this.taskRunnerProcess.stop(); - this.taskRunnerProcess = undefined; - } - - if (this.taskRunnerHttpServer) { - await this.taskRunnerHttpServer.stop(); - this.taskRunnerHttpServer = undefined; - } + const stopRunnerProcessTask = (async () => { + if (this.taskRunnerProcess) { + await this.taskRunnerProcess.stop(); + this.taskRunnerProcess = undefined; + } + })(); + + const stopRunnerServerTask = (async () => { + if (this.taskRunnerHttpServer) { + await this.taskRunnerHttpServer.stop(); + this.taskRunnerHttpServer = undefined; + } + })(); + + await Promise.all([stopRunnerProcessTask, stopRunnerServerTask]); } private async loadTaskManager() { diff --git a/packages/cli/src/runners/task-runner-process.ts b/packages/cli/src/runners/task-runner-process.ts index 9e731a99c5df7..01e351c0e409d 100644 --- a/packages/cli/src/runners/task-runner-process.ts +++ b/packages/cli/src/runners/task-runner-process.ts @@ -10,6 +10,7 @@ import { Logger } from '@/logging/logger.service'; import { TaskRunnerAuthService } from './auth/task-runner-auth.service'; import { forwardToLogger } from './forward-to-logger'; import { NodeProcessOomDetector } from './node-process-oom-detector'; +import { RunnerLifecycleEvents } from './runner-lifecycle-events'; import { TypedEmitter } from '../typed-emitter'; type ChildProcess = ReturnType; @@ -41,10 +42,6 @@ export class TaskRunnerProcess extends TypedEmitter { return this._runPromise; } - private get useLauncher() { - return this.runnerConfig.mode === 'internal_launcher'; - } - private process: ChildProcess | null = null; private _runPromise: Promise | null = null; @@ -59,12 +56,18 @@ export class TaskRunnerProcess extends TypedEmitter { 'PATH', 'NODE_FUNCTION_ALLOW_BUILTIN', 'NODE_FUNCTION_ALLOW_EXTERNAL', + 'N8N_SENTRY_DSN', + // Metadata about the environment + 'N8N_VERSION', + 'ENVIRONMENT', + 'DEPLOYMENT_NAME', ] as const; constructor( logger: Logger, private readonly runnerConfig: TaskRunnersConfig, private readonly authService: TaskRunnerAuthService, + private readonly runnerLifecycleEvents: RunnerLifecycleEvents, ) { super(); @@ -74,6 +77,16 @@ export class TaskRunnerProcess extends TypedEmitter { ); this.logger = logger.scoped('task-runner'); + + this.runnerLifecycleEvents.on('runner:failed-heartbeat-check', () => { + this.logger.warn('Task runner failed heartbeat check, restarting...'); + void this.forceRestart(); + }); + + this.runnerLifecycleEvents.on('runner:timed-out-during-task', () => { + this.logger.warn('Task runner timed out during task, restarting...'); + void this.forceRestart(); + }); } async start() { @@ -82,9 +95,7 @@ export class TaskRunnerProcess extends TypedEmitter { const grantToken = await this.authService.createGrantToken(); const n8nUri = `127.0.0.1:${this.runnerConfig.port}`; - this.process = this.useLauncher - ? this.startLauncher(grantToken, n8nUri) - : this.startNode(grantToken, n8nUri); + this.process = this.startNode(grantToken, n8nUri); forwardToLogger(this.logger, this.process, '[Task Runner]: '); @@ -99,58 +110,32 @@ export class TaskRunnerProcess extends TypedEmitter { }); } - startLauncher(grantToken: string, n8nUri: string) { - return spawn(this.runnerConfig.launcherPath, ['launch', this.runnerConfig.launcherRunner], { - env: { - ...this.getProcessEnvVars(grantToken, n8nUri), - // For debug logging if enabled - RUST_LOG: process.env.RUST_LOG, - }, - }); - } - @OnShutdown() async stop() { - if (!this.process) { - return; - } + if (!this.process) return; this.isShuttingDown = true; // TODO: Timeout & force kill - if (this.useLauncher) { - await this.killLauncher(); - } else { - this.killNode(); - } + this.killNode(); await this._runPromise; this.isShuttingDown = false; } - killNode() { - if (!this.process) { - return; - } - this.process.kill(); - } + /** Force-restart a runner suspected of being unresponsive. */ + async forceRestart() { + if (!this.process) return; - async killLauncher() { - if (!this.process?.pid) { - return; - } + this.process.kill('SIGKILL'); - const killProcess = spawn(this.runnerConfig.launcherPath, [ - 'kill', - this.runnerConfig.launcherRunner, - this.process.pid.toString(), - ]); + await this._runPromise; + } - await new Promise((resolve) => { - killProcess.on('exit', () => { - resolve(); - }); - }); + killNode() { + if (!this.process) return; + + this.process.kill(); } private monitorProcess(taskRunnerProcess: ChildProcess) { @@ -168,7 +153,6 @@ export class TaskRunnerProcess extends TypedEmitter { this.emit('exit', { reason: this.oomDetector?.didProcessOom ? 'oom' : 'unknown' }); resolveFn(); - // If we are not shutting down, restart the process if (!this.isShuttingDown) { setImmediate(async () => await this.start()); } diff --git a/packages/cli/src/runners/task-runner-server.ts b/packages/cli/src/runners/task-runner-server.ts index 56c56e02aec88..a3b13fb8c4ac0 100644 --- a/packages/cli/src/runners/task-runner-server.ts +++ b/packages/cli/src/runners/task-runner-server.ts @@ -9,8 +9,7 @@ import { parse as parseUrl } from 'node:url'; import { Service } from 'typedi'; import { Server as WSServer } from 'ws'; -import { inTest, LOWEST_SHUTDOWN_PRIORITY } from '@/constants'; -import { OnShutdown } from '@/decorators/on-shutdown'; +import { inTest } from '@/constants'; import { Logger } from '@/logging/logger.service'; import { bodyParser, rawBodyReader } from '@/middlewares'; import { send } from '@/response-helper'; @@ -44,7 +43,7 @@ export class TaskRunnerServer { private readonly logger: Logger, private readonly globalConfig: GlobalConfig, private readonly taskRunnerAuthController: TaskRunnerAuthController, - private readonly taskRunnerService: TaskRunnerWsServer, + private readonly taskRunnerWsServer: TaskRunnerWsServer, ) { this.app = express(); this.app.disable('x-powered-by'); @@ -69,16 +68,22 @@ export class TaskRunnerServer { this.configureRoutes(); } - @OnShutdown(LOWEST_SHUTDOWN_PRIORITY) async stop(): Promise { if (this.wsServer) { this.wsServer.close(); this.wsServer = undefined; } - if (this.server) { - await new Promise((resolve) => this.server?.close(() => resolve())); - this.server = undefined; - } + + const stopHttpServerTask = (async () => { + if (this.server) { + await new Promise((resolve) => this.server?.close(() => resolve())); + this.server = undefined; + } + })(); + + const stopWsServerTask = this.taskRunnerWsServer.stop(); + + await Promise.all([stopHttpServerTask, stopWsServerTask]); } /** Creates an HTTP server and listens to the configured port */ @@ -119,6 +124,8 @@ export class TaskRunnerServer { maxPayload: this.globalConfig.taskRunners.maxPayload, }); this.server.on('upgrade', this.handleUpgradeRequest); + + this.taskRunnerWsServer.start(); } private async setupErrorHandlers() { @@ -148,7 +155,7 @@ export class TaskRunnerServer { // eslint-disable-next-line @typescript-eslint/unbound-method this.taskRunnerAuthController.authMiddleware, (req: TaskRunnerServerInitRequest, res: TaskRunnerServerInitResponse) => - this.taskRunnerService.handleRequest(req, res), + this.taskRunnerWsServer.handleRequest(req, res), ); const authEndpoint = `${this.getEndpointBasePath()}/auth`; @@ -181,7 +188,10 @@ export class TaskRunnerServer { const response = new ServerResponse(request); response.writeHead = (statusCode) => { - if (statusCode > 200) ws.close(); + if (statusCode > 200) { + this.logger.error(`Task runner connection attempt failed with status code ${statusCode}`); + ws.close(); + } return response; }; diff --git a/packages/cli/src/scaling/multi-main-setup.ee.ts b/packages/cli/src/scaling/multi-main-setup.ee.ts index 8be7f4ae51ca2..dab9f17cc6dc9 100644 --- a/packages/cli/src/scaling/multi-main-setup.ee.ts +++ b/packages/cli/src/scaling/multi-main-setup.ee.ts @@ -3,7 +3,7 @@ import { InstanceSettings } from 'n8n-core'; import { Service } from 'typedi'; import config from '@/config'; -import { TIME } from '@/constants'; +import { Time } from '@/constants'; import { Logger } from '@/logging/logger.service'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { RedisClientService } from '@/services/redis-client.service'; @@ -54,7 +54,7 @@ export class MultiMainSetup extends TypedEmitter { this.leaderCheckInterval = setInterval(async () => { await this.checkLeader(); - }, this.globalConfig.multiMainSetup.interval * TIME.SECOND); + }, this.globalConfig.multiMainSetup.interval * Time.seconds.toMilliseconds); } async shutdown() { diff --git a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts index b0d6ccfad345c..fafad308ad662 100644 --- a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts @@ -103,7 +103,7 @@ export class InstanceRiskReporter implements RiskReporter { }; settings.telemetry = { - diagnosticsEnabled: config.getEnv('diagnostics.enabled'), + diagnosticsEnabled: this.globalConfig.diagnostics.enabled, }; return settings; diff --git a/packages/cli/src/services/__tests__/community-packages.service.test.ts b/packages/cli/src/services/__tests__/community-packages.service.test.ts index df4591cd08afc..7298e73a630ca 100644 --- a/packages/cli/src/services/__tests__/community-packages.service.test.ts +++ b/packages/cli/src/services/__tests__/community-packages.service.test.ts @@ -410,6 +410,12 @@ describe('CommunityPackagesService', () => { expect.any(Object), expect.any(Function), ); + expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledWith( + installedPackage.packageName, + ); + expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith( + installedPackage.packageName, + ); }); test('should throw when not licensed', async () => { diff --git a/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts b/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts index fdecb7ae5abb0..6d28dbe563485 100644 --- a/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts +++ b/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts @@ -39,7 +39,7 @@ describe('WorkflowStatisticsService', () => { }); Object.assign(entityManager, { connection: dataSource }); - config.set('diagnostics.enabled', true); + globalConfig.diagnostics.enabled = true; config.set('deployment.type', 'n8n-testing'); mocked(ownershipService.getWorkflowProjectCached).mockResolvedValue(fakeProject); mocked(ownershipService.getPersonalProjectOwnerCached).mockResolvedValue(fakeUser); diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index aefe9310fc20f..f82bac3d02e7f 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -4,7 +4,7 @@ import { ApplicationError, jsonStringify } from 'n8n-workflow'; import Container, { Service } from 'typedi'; import config from '@/config'; -import { TIME } from '@/constants'; +import { Time } from '@/constants'; import { MalformedRefreshValueError } from '@/errors/cache-errors/malformed-refresh-value.error'; import { UncacheableValueError } from '@/errors/cache-errors/uncacheable-value.error'; import type { @@ -160,7 +160,7 @@ export class CacheService extends TypedEmitter { }); } - await this.cache.store.expire(key, ttlMs / TIME.SECOND); + await this.cache.store.expire(key, ttlMs * Time.milliseconds.toSeconds); } // ---------------------------------- diff --git a/packages/cli/src/services/community-packages.service.ts b/packages/cli/src/services/community-packages.service.ts index 4906a6ef33cbe..9f09d0c310def 100644 --- a/packages/cli/src/services/community-packages.service.ts +++ b/packages/cli/src/services/community-packages.service.ts @@ -354,6 +354,7 @@ export class CommunityPackagesService { let loader: PackageDirectoryLoader; try { + await this.loadNodesAndCredentials.unloadPackage(packageName); loader = await this.loadNodesAndCredentials.loadPackage(packageName); } catch (error) { // Remove this package since loading it failed diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 081ef5fd4a820..5fad80d83c610 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -66,11 +66,11 @@ export class FrontendService { const restEndpoint = this.globalConfig.endpoints.rest; const telemetrySettings: ITelemetrySettings = { - enabled: config.getEnv('diagnostics.enabled'), + enabled: this.globalConfig.diagnostics.enabled, }; if (telemetrySettings.enabled) { - const conf = config.getEnv('diagnostics.config.frontend'); + const conf = this.globalConfig.diagnostics.frontendConfig; const [key, url] = conf.split(';'); if (!key || !url) { @@ -122,15 +122,15 @@ export class FrontendService { instanceId: this.instanceSettings.instanceId, telemetry: telemetrySettings, posthog: { - enabled: config.getEnv('diagnostics.enabled'), - apiHost: config.getEnv('diagnostics.config.posthog.apiHost'), - apiKey: config.getEnv('diagnostics.config.posthog.apiKey'), + enabled: this.globalConfig.diagnostics.enabled, + apiHost: this.globalConfig.diagnostics.posthogConfig.apiHost, + apiKey: this.globalConfig.diagnostics.posthogConfig.apiKey, autocapture: false, disableSessionRecording: config.getEnv('deployment.type') !== 'cloud', debug: this.globalConfig.logging.level === 'debug', }, personalizationSurveyEnabled: - config.getEnv('personalization.enabled') && config.getEnv('diagnostics.enabled'), + config.getEnv('personalization.enabled') && this.globalConfig.diagnostics.enabled, defaultLocale: config.getEnv('defaultLocale'), userManagement: { quota: this.license.getUsersLimit(), @@ -223,9 +223,9 @@ export class FrontendService { licensePruneTime: -1, }, pruning: { - isEnabled: this.globalConfig.pruning.isEnabled, - maxAge: this.globalConfig.pruning.maxAge, - maxCount: this.globalConfig.pruning.maxCount, + isEnabled: this.globalConfig.executions.pruneData, + maxAge: this.globalConfig.executions.pruneDataMaxAge, + maxCount: this.globalConfig.executions.pruneDataMaxCount, }, security: { blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles, diff --git a/packages/cli/src/services/pruning/__tests__/pruning.service.test.ts b/packages/cli/src/services/pruning/__tests__/pruning.service.test.ts index 64cecd1c0681e..42fe73dd5eb24 100644 --- a/packages/cli/src/services/pruning/__tests__/pruning.service.test.ts +++ b/packages/cli/src/services/pruning/__tests__/pruning.service.test.ts @@ -1,4 +1,4 @@ -import type { PruningConfig } from '@n8n/config'; +import type { ExecutionsConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; @@ -92,7 +92,7 @@ describe('PruningService', () => { isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), - mock({ isEnabled: true }), + mock({ pruneData: true }), ); expect(pruningService.isEnabled).toBe(true); @@ -108,7 +108,7 @@ describe('PruningService', () => { isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), - mock({ isEnabled: false }), + mock({ pruneData: false }), ); expect(pruningService.isEnabled).toBe(false); @@ -124,7 +124,7 @@ describe('PruningService', () => { isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), - mock({ isEnabled: true }), + mock({ pruneData: true }), ); expect(pruningService.isEnabled).toBe(false); @@ -140,7 +140,7 @@ describe('PruningService', () => { isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), - mock({ isEnabled: true }), + mock({ pruneData: true }), ); expect(pruningService.isEnabled).toBe(false); @@ -158,7 +158,7 @@ describe('PruningService', () => { isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), - mock({ isEnabled: false }), + mock({ pruneData: false }), ); const scheduleRollingSoftDeletionsSpy = jest.spyOn( @@ -186,7 +186,7 @@ describe('PruningService', () => { isMultiMainSetupEnabled: true, multiMainSetup: mock(), }), - mock({ isEnabled: true }), + mock({ pruneData: true }), ); const scheduleRollingSoftDeletionsSpy = jest diff --git a/packages/cli/src/services/pruning/pruning.service.ts b/packages/cli/src/services/pruning/pruning.service.ts index 34be37cf21754..3006d3fbd91de 100644 --- a/packages/cli/src/services/pruning/pruning.service.ts +++ b/packages/cli/src/services/pruning/pruning.service.ts @@ -1,4 +1,4 @@ -import { PruningConfig } from '@n8n/config'; +import { ExecutionsConfig } from '@n8n/config'; import { BinaryDataService, InstanceSettings } from 'n8n-core'; import { ensureError } from 'n8n-workflow'; import { strict } from 'node:assert'; @@ -26,8 +26,8 @@ export class PruningService { private hardDeletionTimeout: NodeJS.Timeout | undefined; private readonly rates = { - softDeletion: this.pruningConfig.softDeleteInterval * Time.minutes.toMilliseconds, - hardDeletion: this.pruningConfig.hardDeleteInterval * Time.minutes.toMilliseconds, + softDeletion: this.executionsConfig.pruneDataIntervals.softDelete * Time.minutes.toMilliseconds, + hardDeletion: this.executionsConfig.pruneDataIntervals.hardDelete * Time.minutes.toMilliseconds, }; /** Max number of executions to hard-delete in a cycle. */ @@ -41,7 +41,7 @@ export class PruningService { private readonly executionRepository: ExecutionRepository, private readonly binaryDataService: BinaryDataService, private readonly orchestrationService: OrchestrationService, - private readonly pruningConfig: PruningConfig, + private readonly executionsConfig: ExecutionsConfig, ) { this.logger = this.logger.scoped('pruning'); } @@ -59,7 +59,7 @@ export class PruningService { get isEnabled() { return ( - this.pruningConfig.isEnabled && + this.executionsConfig.pruneData && this.instanceSettings.instanceType === 'main' && this.instanceSettings.isLeader ); diff --git a/packages/cli/src/telemetry/__tests__/telemetry.test.ts b/packages/cli/src/telemetry/__tests__/telemetry.test.ts index 04a6cecfcada7..d0f9fae3cdf1c 100644 --- a/packages/cli/src/telemetry/__tests__/telemetry.test.ts +++ b/packages/cli/src/telemetry/__tests__/telemetry.test.ts @@ -21,6 +21,10 @@ describe('Telemetry', () => { const instanceId = 'Telemetry unit test'; const testDateTime = new Date('2022-01-01 00:00:00'); const instanceSettings = mockInstance(InstanceSettings, { instanceId }); + const globalConfig = mock({ + diagnostics: { enabled: true }, + logging: { level: 'info', outputs: ['console'] }, + }); beforeAll(() => { // @ts-expect-error Spying on private method @@ -28,7 +32,6 @@ describe('Telemetry', () => { jest.useFakeTimers(); jest.setSystemTime(testDateTime); - config.set('diagnostics.enabled', true); config.set('deployment.type', 'n8n-testing'); }); @@ -45,14 +48,7 @@ describe('Telemetry', () => { const postHog = new PostHogClient(instanceSettings, mock()); await postHog.init(); - telemetry = new Telemetry( - mock(), - postHog, - mock(), - instanceSettings, - mock(), - mock({ logging: { level: 'info', outputs: ['console'] } }), - ); + telemetry = new Telemetry(mock(), postHog, mock(), instanceSettings, mock(), globalConfig); // @ts-expect-error Assigning to private property telemetry.rudderStack = mockRudderStack; }); diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index d9a8e590f4fb3..a8d39d898ee8a 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -5,7 +5,6 @@ import { InstanceSettings } from 'n8n-core'; import type { ITelemetryTrackProperties } from 'n8n-workflow'; import { Container, Service } from 'typedi'; -import config from '@/config'; import { LOWEST_SHUTDOWN_PRIORITY, N8N_VERSION } from '@/constants'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; @@ -54,10 +53,9 @@ export class Telemetry { ) {} async init() { - const enabled = config.getEnv('diagnostics.enabled'); + const { enabled, backendConfig } = this.globalConfig.diagnostics; if (enabled) { - const conf = config.getEnv('diagnostics.config.backend'); - const [key, dataPlaneUrl] = conf.split(';'); + const [key, dataPlaneUrl] = backendConfig.split(';'); if (!key || !dataPlaneUrl) { this.logger.warn('Diagnostics backend config is invalid'); diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 72628b8351695..6110584f7e555 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -464,6 +464,11 @@ export async function executeWebhook( projectId: project?.id, }; + // When resuming from a wait node, copy over the pushRef from the execution-data + if (!runData.pushRef) { + runData.pushRef = runExecutionData.pushRef; + } + let responsePromise: IDeferredPromise | undefined; if (responseMode === 'responseNode') { responsePromise = createDeferredPromise(); diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 08d6ba09e41a6..97322f4fe0230 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -307,7 +307,7 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { }, ], workflowExecuteAfter: [ - async function (this: WorkflowHooks): Promise { + async function (this: WorkflowHooks, fullRunData: IRun): Promise { const { pushRef, executionId } = this; if (pushRef === undefined) return; @@ -318,7 +318,9 @@ function hookFunctionsPush(): IWorkflowExecuteHooks { workflowId, }); - pushInstance.send('executionFinished', { executionId }, pushRef); + const pushType = + fullRunData.status === 'waiting' ? 'executionWaiting' : 'executionFinished'; + pushInstance.send(pushType, { executionId }, pushRef); }, ], }; @@ -430,22 +432,21 @@ function hookFunctionsSave(): IWorkflowExecuteHooks { (executionStatus === 'success' && !saveSettings.success) || (executionStatus !== 'success' && !saveSettings.error); - if (shouldNotSave && !fullRunData.waitTill) { - if (!fullRunData.waitTill && !isManualMode) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - await Container.get(ExecutionRepository).hardDelete({ - workflowId: this.workflowData.id, - executionId: this.executionId, - }); + if (shouldNotSave && !fullRunData.waitTill && !isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); - return; - } + await Container.get(ExecutionRepository).hardDelete({ + workflowId: this.workflowData.id, + executionId: this.executionId, + }); + + return; } // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive @@ -1110,6 +1111,9 @@ export function getWorkflowHooksWorkerMain( hookFunctions.nodeExecuteAfter = []; hookFunctions.workflowExecuteAfter = [ async function (this: WorkflowHooks, fullRunData: IRun): Promise { + // Don't delete executions before they are finished + if (!fullRunData.finished) return; + const executionStatus = determineFinalExecutionStatus(fullRunData); const saveSettings = toSaveSettings(this.workflowData.settings); diff --git a/packages/cli/src/workflows/workflow-history/constants.ts b/packages/cli/src/workflows/workflow-history/constants.ts deleted file mode 100644 index dc4f7c78678b3..0000000000000 --- a/packages/cli/src/workflows/workflow-history/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TIME } from '@/constants'; - -export const WORKFLOW_HISTORY_PRUNE_INTERVAL = 1 * TIME.HOUR; diff --git a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts b/packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts index e710637441b2f..f3a25bfb16fc3 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history-manager.ee.ts @@ -1,9 +1,9 @@ import { DateTime } from 'luxon'; import { Service } from 'typedi'; +import { Time } from '@/constants'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; -import { WORKFLOW_HISTORY_PRUNE_INTERVAL } from './constants'; import { getWorkflowHistoryPruneTime, isWorkflowHistoryEnabled, @@ -20,7 +20,7 @@ export class WorkflowHistoryManager { clearInterval(this.pruneTimer); } - this.pruneTimer = setInterval(async () => await this.prune(), WORKFLOW_HISTORY_PRUNE_INTERVAL); + this.pruneTimer = setInterval(async () => await this.prune(), 1 * Time.hours.toMilliseconds); } shutdown() { diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 57d93cb29144b..02611f5b5b285 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -740,14 +740,6 @@ } return; - }).then(() => { - window.addEventListener('storage', function(event) { - if (event.key === 'n8n_redirect_to_next_form_test_page' && event.newValue) { - const newUrl = event.newValue; - localStorage.removeItem('n8n_redirect_to_next_form_test_page'); - window.location.replace(newUrl); - } - }); }) .catch(function (error) { console.error('Error:', error); diff --git a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts index 6712b4f231294..59a853cee1db3 100644 --- a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts +++ b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts @@ -8,7 +8,7 @@ import { CredentialsHelper } from '@/credentials-helper'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import type { User } from '@/databases/entities/user'; import { saveCredential } from '@test-integration/db/credentials'; -import { createOwner } from '@test-integration/db/users'; +import { createMember, createOwner } from '@test-integration/db/users'; import * as testDb from '@test-integration/test-db'; import type { SuperAgentTest } from '@test-integration/types'; import { setupTestServer } from '@test-integration/utils'; @@ -17,6 +17,7 @@ describe('OAuth2 API', () => { const testServer = setupTestServer({ endpointGroups: ['oauth2'] }); let owner: User; + let anotherUser: User; let ownerAgent: SuperAgentTest; let credential: CredentialsEntity; const credentialData = { @@ -32,6 +33,7 @@ describe('OAuth2 API', () => { beforeAll(async () => { owner = await createOwner(); + anotherUser = await createMember(); ownerAgent = testServer.authAgentFor(owner); }); @@ -74,6 +76,28 @@ describe('OAuth2 API', () => { }); }); + it('should fail on auth when callback is called as another user', async () => { + const controller = Container.get(OAuth2CredentialController); + const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear(); + const renderSpy = (Response.render = jest.fn(function () { + this.end(); + })); + + await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200); + + const [_, state] = csrfSpy.mock.results[0].value; + + await testServer + .authAgentFor(anotherUser) + .get('/oauth2-credential/callback') + .query({ code: 'auth_code', state }) + .expect(200); + + expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', { + error: { message: 'Unauthorized' }, + }); + }); + it('should handle a valid callback without auth', async () => { const controller = Container.get(OAuth2CredentialController); const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear(); @@ -87,7 +111,7 @@ describe('OAuth2 API', () => { nock('https://test.domain').post('/oauth2/token').reply(200, { access_token: 'updated_token' }); - await testServer.authlessAgent + await ownerAgent .get('/oauth2-credential/callback') .query({ code: 'auth_code', state }) .expect(200); diff --git a/packages/cli/test/integration/pruning.service.test.ts b/packages/cli/test/integration/pruning.service.test.ts index 4ea8455b9449d..4f34048a1a465 100644 --- a/packages/cli/test/integration/pruning.service.test.ts +++ b/packages/cli/test/integration/pruning.service.test.ts @@ -1,10 +1,10 @@ -import { PruningConfig } from '@n8n/config'; +import { ExecutionsConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import { BinaryDataService, InstanceSettings } from 'n8n-core'; import type { ExecutionStatus } from 'n8n-workflow'; import Container from 'typedi'; -import { TIME } from '@/constants'; +import { Time } from '@/constants'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -25,21 +25,21 @@ describe('softDeleteOnPruningCycle()', () => { instanceSettings.markAsLeader(); const now = new Date(); - const yesterday = new Date(Date.now() - TIME.DAY); + const yesterday = new Date(Date.now() - 1 * Time.days.toMilliseconds); let workflow: WorkflowEntity; - let pruningConfig: PruningConfig; + let executionsConfig: ExecutionsConfig; beforeAll(async () => { await testDb.init(); - pruningConfig = Container.get(PruningConfig); + executionsConfig = Container.get(ExecutionsConfig); pruningService = new PruningService( mockLogger(), instanceSettings, Container.get(ExecutionRepository), mockInstance(BinaryDataService), mock(), - pruningConfig, + executionsConfig, ); workflow = await createWorkflow(); @@ -62,8 +62,8 @@ describe('softDeleteOnPruningCycle()', () => { describe('when EXECUTIONS_DATA_PRUNE_MAX_COUNT is set', () => { beforeAll(() => { - pruningConfig.maxAge = 336; - pruningConfig.maxCount = 1; + executionsConfig.pruneDataMaxAge = 336; + executionsConfig.pruneDataMaxCount = 1; }); test('should mark as deleted based on EXECUTIONS_DATA_PRUNE_MAX_COUNT', async () => { @@ -163,8 +163,8 @@ describe('softDeleteOnPruningCycle()', () => { describe('when EXECUTIONS_DATA_MAX_AGE is set', () => { beforeAll(() => { - pruningConfig.maxAge = 1; - pruningConfig.maxCount = 0; + executionsConfig.pruneDataMaxAge = 1; + executionsConfig.pruneDataMaxCount = 0; }); test('should mark as deleted based on EXECUTIONS_DATA_MAX_AGE', async () => { diff --git a/packages/cli/test/integration/runners/task-runner-module.external.test.ts b/packages/cli/test/integration/runners/task-runner-module.external.test.ts index 4974abfb3972f..bdabdf56aea55 100644 --- a/packages/cli/test/integration/runners/task-runner-module.external.test.ts +++ b/packages/cli/test/integration/runners/task-runner-module.external.test.ts @@ -1,6 +1,7 @@ import { TaskRunnersConfig } from '@n8n/config'; import Container from 'typedi'; +import { MissingAuthTokenError } from '@/runners/errors/missing-auth-token.error'; import { TaskRunnerModule } from '@/runners/task-runner-module'; import { DefaultTaskRunnerDisconnectAnalyzer } from '../../../src/runners/default-task-runner-disconnect-analyzer'; @@ -10,6 +11,7 @@ describe('TaskRunnerModule in external mode', () => { const runnerConfig = Container.get(TaskRunnersConfig); runnerConfig.mode = 'external'; runnerConfig.port = 0; + runnerConfig.authToken = 'test'; const module = Container.get(TaskRunnerModule); afterEach(async () => { @@ -24,6 +26,17 @@ describe('TaskRunnerModule in external mode', () => { await expect(module.start()).rejects.toThrow('Task runner is disabled'); }); + it('should throw if auth token is missing', async () => { + const runnerConfig = new TaskRunnersConfig(); + runnerConfig.mode = 'external'; + runnerConfig.enabled = true; + runnerConfig.authToken = ''; + + const module = new TaskRunnerModule(runnerConfig); + + await expect(module.start()).rejects.toThrowError(MissingAuthTokenError); + }); + it('should start the task runner', async () => { runnerConfig.enabled = true; diff --git a/packages/cli/test/integration/runners/task-runner-module.internal.test.ts b/packages/cli/test/integration/runners/task-runner-module.internal.test.ts index 444d576e87829..cb993f0a87c40 100644 --- a/packages/cli/test/integration/runners/task-runner-module.internal.test.ts +++ b/packages/cli/test/integration/runners/task-runner-module.internal.test.ts @@ -6,10 +6,10 @@ import { TaskRunnerModule } from '@/runners/task-runner-module'; import { InternalTaskRunnerDisconnectAnalyzer } from '../../../src/runners/internal-task-runner-disconnect-analyzer'; import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server'; -describe('TaskRunnerModule in internal_childprocess mode', () => { +describe('TaskRunnerModule in internal mode', () => { const runnerConfig = Container.get(TaskRunnersConfig); runnerConfig.port = 0; // Random port - runnerConfig.mode = 'internal_childprocess'; + runnerConfig.mode = 'internal'; const module = Container.get(TaskRunnerModule); afterEach(async () => { diff --git a/packages/cli/test/integration/runners/task-runner-process.test.ts b/packages/cli/test/integration/runners/task-runner-process.test.ts index 8c5289a5c7d65..df2fe8fc75b6e 100644 --- a/packages/cli/test/integration/runners/task-runner-process.test.ts +++ b/packages/cli/test/integration/runners/task-runner-process.test.ts @@ -11,7 +11,7 @@ describe('TaskRunnerProcess', () => { const authToken = 'token'; const runnerConfig = Container.get(TaskRunnersConfig); runnerConfig.enabled = true; - runnerConfig.mode = 'internal_childprocess'; + runnerConfig.mode = 'internal'; runnerConfig.authToken = authToken; runnerConfig.port = 0; // Use any port const taskRunnerServer = Container.get(TaskRunnerServer); @@ -20,11 +20,6 @@ describe('TaskRunnerProcess', () => { const taskBroker = Container.get(TaskBroker); const taskRunnerService = Container.get(TaskRunnerWsServer); - const startLauncherSpy = jest.spyOn(runnerProcess, 'startLauncher'); - const startNodeSpy = jest.spyOn(runnerProcess, 'startNode'); - const killLauncherSpy = jest.spyOn(runnerProcess, 'killLauncher'); - const killNodeSpy = jest.spyOn(runnerProcess, 'killNode'); - beforeAll(async () => { await taskRunnerServer.start(); // Set the port to the actually used port @@ -37,11 +32,6 @@ describe('TaskRunnerProcess', () => { afterEach(async () => { await runnerProcess.stop(); - - startLauncherSpy.mockClear(); - startNodeSpy.mockClear(); - killLauncherSpy.mockClear(); - killNodeSpy.mockClear(); }); const getNumConnectedRunners = () => taskRunnerService.runnerConnections.size; @@ -100,46 +90,4 @@ describe('TaskRunnerProcess', () => { expect(getNumRegisteredRunners()).toBe(1); expect(runnerProcess.pid).not.toBe(processId); }); - - it('should launch runner directly if not using a launcher', async () => { - runnerConfig.mode = 'internal_childprocess'; - - await runnerProcess.start(); - - expect(startLauncherSpy).toBeCalledTimes(0); - expect(startNodeSpy).toBeCalledTimes(1); - }); - - it('should use a launcher if configured', async () => { - runnerConfig.mode = 'internal_launcher'; - runnerConfig.launcherPath = 'node'; - - await runnerProcess.start(); - - expect(startLauncherSpy).toBeCalledTimes(1); - expect(startNodeSpy).toBeCalledTimes(0); - runnerConfig.mode = 'internal_childprocess'; - }); - - it('should kill the process directly if not using a launcher', async () => { - runnerConfig.mode = 'internal_childprocess'; - - await runnerProcess.start(); - await runnerProcess.stop(); - - expect(killLauncherSpy).toBeCalledTimes(0); - expect(killNodeSpy).toBeCalledTimes(1); - }); - - it('should kill the process using a launcher if configured', async () => { - runnerConfig.mode = 'internal_launcher'; - runnerConfig.launcherPath = 'node'; - - await runnerProcess.start(); - await runnerProcess.stop(); - - expect(killLauncherSpy).toBeCalledTimes(1); - expect(killNodeSpy).toBeCalledTimes(0); - runnerConfig.mode = 'internal_childprocess'; - }); }); diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index 928667b518d77..58a2a2c9a8793 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -1,9 +1,9 @@ +import { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import { NodeConnectionType } from 'n8n-workflow'; import Container from 'typedi'; import { v4 as uuid } from 'uuid'; -import config from '@/config'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { generateNanoId } from '@/databases/utils/generators'; import { INSTANCE_REPORT, WEBHOOK_VALIDATOR_NODE_TYPES } from '@/security-audit/constants'; @@ -239,8 +239,7 @@ test('should not report outdated instance when up to date', async () => { }); test('should report security settings', async () => { - config.set('diagnostics.enabled', true); - + Container.get(GlobalConfig).diagnostics.enabled = true; const testAudit = await securityAuditService.run(['instance']); const section = getRiskSection( diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/BinaryData/FileSystem.manager.ts index f49e6e5c02a86..66eecd626a778 100644 --- a/packages/core/src/BinaryData/FileSystem.manager.ts +++ b/packages/core/src/BinaryData/FileSystem.manager.ts @@ -93,7 +93,7 @@ export class FileSystemManager implements BinaryData.Manager { // binary files stored in nested dirs - `filesystem-v2` const binaryDataDirs = ids.map(({ workflowId, executionId }) => - this.resolvePath(`workflows/${workflowId}/executions/${executionId}/binary_data/`), + this.resolvePath(`workflows/${workflowId}/executions/${executionId}`), ); await Promise.all( diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 2ae12908aca69..c6e0316038cc7 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -916,7 +916,6 @@ export class WorkflowExecute { let nodeSuccessData: INodeExecutionData[][] | null | undefined; let runIndex: number; let startTime: number; - let taskData: ITaskData; if (this.runExecutionData.startData === undefined) { this.runExecutionData.startData = {}; @@ -1446,13 +1445,13 @@ export class WorkflowExecute { this.runExecutionData.resultData.runData[executionNode.name] = []; } - taskData = { + const taskData: ITaskData = { hints: executionHints, startTime, executionTime: new Date().getTime() - startTime, source: !executionData.source ? [] : executionData.source.main, metadata: executionData.metadata, - executionStatus: 'success', + executionStatus: this.runExecutionData.waitTill ? 'waiting' : 'success', }; if (executionError !== undefined) { diff --git a/packages/core/test/FileSystem.manager.test.ts b/packages/core/test/FileSystem.manager.test.ts index 581974c0e9093..edb6bd5e77f7c 100644 --- a/packages/core/test/FileSystem.manager.test.ts +++ b/packages/core/test/FileSystem.manager.test.ts @@ -147,6 +147,11 @@ describe('copyByFilePath()', () => { }); describe('deleteMany()', () => { + const rmOptions = { + force: true, + recursive: true, + }; + it('should delete many files by workflow ID and execution ID', async () => { const ids = [ { workflowId, executionId }, @@ -160,6 +165,16 @@ describe('deleteMany()', () => { await expect(promise).resolves.not.toThrow(); expect(fsp.rm).toHaveBeenCalledTimes(2); + expect(fsp.rm).toHaveBeenNthCalledWith( + 1, + `${storagePath}/workflows/${workflowId}/executions/${executionId}`, + rmOptions, + ); + expect(fsp.rm).toHaveBeenNthCalledWith( + 2, + `${storagePath}/workflows/${otherWorkflowId}/executions/${otherExecutionId}`, + rmOptions, + ); }); it('should suppress error on non-existing filepath', async () => { diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index 3cd62205f680a..aa677b037daaf 100644 --- a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -344,6 +344,8 @@ async function onCopyButtonClick(content: string, e: MouseEvent) { .container { height: 100%; position: relative; + display: grid; + grid-template-rows: auto 1fr auto; } p { @@ -373,10 +375,6 @@ p { background-color: var(--color-background-light); border: var(--border-base); border-top: 0; - height: 100%; - overflow-x: hidden; - overflow-y: auto; - padding-bottom: 250px; // make scrollable at the end position: relative; pre, @@ -390,7 +388,13 @@ p { } .messages { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; padding: var(--spacing-xs); + overflow-y: auto; & + & { padding-top: 0; @@ -533,13 +537,11 @@ code[class^='language-'] { } .inputWrapper { - position: absolute; display: flex; - bottom: 0; background-color: var(--color-foreground-xlight); border: var(--border-base); width: 100%; - padding-top: 1px; + border-top: 0; textarea { border: none; diff --git a/packages/design-system/src/components/N8nMenu/Menu.vue b/packages/design-system/src/components/N8nMenu/Menu.vue index 596206977f2e7..04d25f08642d2 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.vue +++ b/packages/design-system/src/components/N8nMenu/Menu.vue @@ -132,6 +132,7 @@ const onSelect = (item: IMenuItem): void => { display: flex; flex-direction: column; background-color: var(--menu-background, var(--color-background-xlight)); + overflow: hidden; } .menuHeader { diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts index adb73aa6d1c9e..a7d9936e08fae 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.test.ts @@ -29,9 +29,10 @@ describe('N8nNavigationDropdown', () => { await router.isReady(); }); + it('default slot should trigger first level', async () => { const { getByTestId, queryByTestId } = render(NavigationDropdown, { - slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) }, + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] }, global: { plugins: [router], @@ -40,13 +41,13 @@ describe('N8nNavigationDropdown', () => { expect(getByTestId('test-trigger')).toBeVisible(); expect(queryByTestId('navigation-menu-item')).not.toBeVisible(); - await userEvent.hover(getByTestId('test-trigger')); + await userEvent.click(getByTestId('test-trigger')); await waitFor(() => expect(queryByTestId('navigation-menu-item')).toBeVisible()); }); it('redirect to route', async () => { const { getByTestId, queryByTestId } = render(NavigationDropdown, { - slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) }, + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, props: { menu: [ { @@ -64,7 +65,7 @@ describe('N8nNavigationDropdown', () => { expect(getByTestId('test-trigger')).toBeVisible(); expect(queryByTestId('navigation-submenu')).not.toBeVisible(); - await userEvent.hover(getByTestId('test-trigger')); + await userEvent.click(getByTestId('test-trigger')); await waitFor(() => expect(getByTestId('navigation-submenu')).toBeVisible()); @@ -75,7 +76,7 @@ describe('N8nNavigationDropdown', () => { it('should render icons in submenu when provided', () => { const { getByTestId } = render(NavigationDropdown, { - slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) }, + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, props: { menu: [ { @@ -95,7 +96,7 @@ describe('N8nNavigationDropdown', () => { it('should propagate events', async () => { const { getByTestId, emitted } = render(NavigationDropdown, { - slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) }, + slots: { default: h('button', { 'data-test-id': 'test-trigger' }) }, props: { menu: [ { diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index 7657d732111ca..ba905e75cffb1 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -1,5 +1,6 @@