diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 36649929b06..5fcb4bf8136 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -19,6 +19,7 @@ import type { FolderWithWorkflowAndSubFolderCount, ListQuery, } from '../entities/types-db'; +import { buildWorkflowsByNodesQuery } from '../utils/build-workflows-by-nodes-query'; import { isStringArray } from '../utils/is-string-array'; import { TimedQuery } from '../utils/timed-query'; @@ -712,4 +713,22 @@ export class WorkflowRepository extends Repository { { parentFolder: toFolderId === PROJECT_ROOT ? null : { id: toFolderId } }, ); } + + async findWorkflowsWithNodeType(nodeTypes: string[]) { + if (!nodeTypes?.length) return []; + + const qb = this.createQueryBuilder('workflow'); + + const { whereClause, parameters } = buildWorkflowsByNodesQuery( + nodeTypes, + this.globalConfig.database.type, + ); + + const workflows: Array<{ id: string; name: string; active: boolean }> = await qb + .select(['workflow.id', 'workflow.name', 'workflow.active']) + .where(whereClause, parameters) + .getMany(); + + return workflows; + } } diff --git a/packages/@n8n/db/src/utils/__tests__/build-workflows-by-nodes-query.test.ts b/packages/@n8n/db/src/utils/__tests__/build-workflows-by-nodes-query.test.ts new file mode 100644 index 00000000000..737b0660fd4 --- /dev/null +++ b/packages/@n8n/db/src/utils/__tests__/build-workflows-by-nodes-query.test.ts @@ -0,0 +1,48 @@ +import { buildWorkflowsByNodesQuery } from '../build-workflows-by-nodes-query'; + +describe('WorkflowRepository', () => { + describe('filterWorkflowsByNodesConstructWhereClause', () => { + it('should return the correct WHERE clause and parameters for sqlite', () => { + const nodeTypes = ['HTTP Request', 'Set']; + const expectedInQuery = + "FROM json_each(workflow.nodes) WHERE json_extract(json_each.value, '$.type')"; + const expectedParameters = { + nodeType0: 'HTTP Request', + nodeType1: 'Set', + nodeTypes, + }; + + const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'sqlite'); + + expect(whereClause).toContain(expectedInQuery); + expect(parameters).toEqual(expectedParameters); + }); + + it('should return the correct WHERE clause and parameters for postgresdb', () => { + const nodeTypes = ['HTTP Request', 'Set']; + const expectedInQuery = 'FROM jsonb_array_elements(workflow.nodes::jsonb) AS node'; + const expectedParameters = { nodeTypes }; + + const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'postgresdb'); + + expect(whereClause).toContain(expectedInQuery); + expect(parameters).toEqual(expectedParameters); + }); + + it('should return the correct WHERE clause and parameters for mysqldb', () => { + const nodeTypes = ['HTTP Request', 'Set']; + const expectedWhereClause = + "(JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType0) IS NOT NULL OR JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType1) IS NOT NULL)"; + const expectedParameters = { + nodeType0: 'HTTP Request', + nodeType1: 'Set', + nodeTypes, + }; + + const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'mysqldb'); + + expect(whereClause).toEqual(expectedWhereClause); + expect(parameters).toEqual(expectedParameters); + }); + }); +}); diff --git a/packages/@n8n/db/src/utils/build-workflows-by-nodes-query.ts b/packages/@n8n/db/src/utils/build-workflows-by-nodes-query.ts new file mode 100644 index 00000000000..dd678005348 --- /dev/null +++ b/packages/@n8n/db/src/utils/build-workflows-by-nodes-query.ts @@ -0,0 +1,56 @@ +/** + * Builds the WHERE clause and parameters for a query to find workflows by node types + */ +export function buildWorkflowsByNodesQuery( + nodeTypes: string[], + dbType: 'postgresdb' | 'mysqldb' | 'mariadb' | 'sqlite', +) { + let whereClause: string; + + const parameters: Record = { nodeTypes }; + + switch (dbType) { + case 'postgresdb': + whereClause = `EXISTS ( + SELECT 1 + FROM jsonb_array_elements(workflow.nodes::jsonb) AS node + WHERE node->>'type' = ANY(:nodeTypes) + )`; + break; + case 'mysqldb': + case 'mariadb': { + const conditions = nodeTypes + .map( + (_, i) => + `JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType${i}) IS NOT NULL`, + ) + .join(' OR '); + + whereClause = `(${conditions})`; + + nodeTypes.forEach((nodeType, index) => { + parameters[`nodeType${index}`] = nodeType; + }); + break; + } + case 'sqlite': { + const conditions = nodeTypes + .map( + (_, i) => + `EXISTS (SELECT 1 FROM json_each(workflow.nodes) WHERE json_extract(json_each.value, '$.type') = :nodeType${i})`, + ) + .join(' OR '); + + whereClause = `(${conditions})`; + + nodeTypes.forEach((nodeType, index) => { + parameters[`nodeType${index}`] = nodeType; + }); + break; + } + default: + throw new Error('Unsupported database type'); + } + + return { whereClause, parameters }; +} diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 73504dbe922..009eeba5082 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -557,4 +557,25 @@ export class WorkflowService { })), ); } + + async getWorkflowsWithNodesIncluded(user: User, nodeTypes: string[]) { + const foundWorkflows = await this.workflowRepository.findWorkflowsWithNodeType(nodeTypes); + + let { workflows } = await this.workflowRepository.getManyAndCount( + foundWorkflows.map((w) => w.id), + ); + + if (hasSharing(workflows)) { + workflows = await this.processSharedWorkflows(workflows); + } + + workflows = await this.addUserScopes(workflows, user); + + this.cleanupSharedField(workflows); + + return workflows.map((workflow) => ({ + resourceType: 'workflow', + ...workflow, + })); + } } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 036a7941e99..618d03fc4c8 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -1,6 +1,7 @@ import { ImportWorkflowFromUrlDto, ManualRunQueryDto, + ROLE, TransferWorkflowBodyDto, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; @@ -559,4 +560,31 @@ export class WorkflowsController { body.destinationParentFolderId, ); } + + @Post('/with-node-types') + async getWorkflowsWithNodesIncluded(req: AuthenticatedRequest, res: express.Response) { + try { + const hasPermission = req.user.role === ROLE.Owner || req.user.role === ROLE.Admin; + + if (!hasPermission) { + res.json({ data: [], count: 0 }); + return; + } + + const { nodeTypes } = req.body as { nodeTypes: string[] }; + const workflows = await this.workflowService.getWorkflowsWithNodesIncluded( + req.user, + nodeTypes, + ); + + res.json({ + data: workflows, + count: workflows.length, + }); + } catch (maybeError) { + const error = utils.toError(maybeError); + ResponseHelper.reportError(error); + ResponseHelper.sendErrorResponse(res, error); + } + } } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 43af567c243..769d12d5ec6 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1949,16 +1949,20 @@ "settings.communityNodes.messages.update.success.title": "Package updated", "settings.communityNodes.messages.update.success.message": "{packageName} updated to version {version}", "settings.communityNodes.messages.update.error.title": "Problem updating package", - "settings.communityNodes.confirmModal.uninstall.title": "Uninstall package?", + "settings.communityNodes.confirmModal.uninstall.title": "Uninstall node package", "settings.communityNodes.confirmModal.uninstall.message": "Any workflows that use nodes from the {packageName} package won't be able to run. Are you sure?", - "settings.communityNodes.confirmModal.uninstall.buttonLabel": "Uninstall package", + "settings.communityNodes.confirmModal.uninstall.description": "Uninstalling the package will remove every instance of nodes included in this package. The following workflows will be effected:", + "settings.communityNodes.confirmModal.noWorkflowsUsingNodes": "Nodes from this package are not used in any workflows", + "settings.communityNodes.confirmModal.uninstall.buttonLabel": "Confirm uninstall", "settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel": "Uninstalling", - "settings.communityNodes.confirmModal.update.title": "Update community node package?", + "settings.communityNodes.confirmModal.update.title": "Update node package", "settings.communityNodes.confirmModal.update.message": "You are about to update {packageName} to version {version}", + "settings.communityNodes.confirmModal.includedNodes": "Package includes: {nodes}", "settings.communityNodes.confirmModal.update.warning": "This version has not been verified by n8n and may contain breaking changes or bugs.", - "settings.communityNodes.confirmModal.update.description": "We recommend you deactivate workflows that use any of the package's nodes and reactivate them once the update is completed", - "settings.communityNodes.confirmModal.update.buttonLabel": "Update package", + "settings.communityNodes.confirmModal.update.description": "Updating to the latest version will update every instance of these nodes. The following workflows will be effected:", + "settings.communityNodes.confirmModal.update.buttonLabel": "Confirm update", "settings.communityNodes.confirmModal.update.buttonLoadingLabel": "Updating...", + "settings.communityNodes.confirmModal.cancel": "Cancel", "settings.goBack": "Go back", "settings.personal": "Personal", "settings.personal.basicInformation": "Basic Information", diff --git a/packages/frontend/editor-ui/src/api/workflows.ts b/packages/frontend/editor-ui/src/api/workflows.ts index c145536cf87..3a6c99ec8d1 100644 --- a/packages/frontend/editor-ui/src/api/workflows.ts +++ b/packages/frontend/editor-ui/src/api/workflows.ts @@ -8,6 +8,7 @@ import type { IWorkflowDb, NewWorkflowResponse, WorkflowListResource, + WorkflowResource, } from '@/Interface'; import type { IRestApiContext } from '@n8n/rest-api-client'; import type { @@ -43,6 +44,17 @@ export async function getWorkflows(context: IRestApiContext, filter?: object, op }); } +export async function getWorkflowsWithNodesIncluded(context: IRestApiContext, nodeTypes: string[]) { + return await getFullApiResponse( + context, + 'POST', + '/workflows/with-node-types', + { + nodeTypes, + }, + ); +} + export async function getWorkflowsAndFolders( context: IRestApiContext, filter?: object, diff --git a/packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.test.ts b/packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.test.ts index eae72687a3d..ae695a4919a 100644 --- a/packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.test.ts +++ b/packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.test.ts @@ -9,6 +9,13 @@ import { createTestingPinia } from '@pinia/testing'; import { STORES } from '@n8n/stores'; import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY } from '@/constants'; +const fetchWorkflowsWithNodesIncluded = vi.fn(); +vi.mock('@/stores/workflows.store', () => ({ + useWorkflowsStore: vi.fn(() => ({ + fetchWorkflowsWithNodesIncluded, + })), +})); + const renderComponent = createComponentRenderer(CommunityPackageManageConfirmModal, { data() { return { @@ -28,6 +35,7 @@ const renderComponent = createComponentRenderer(CommunityPackageManageConfirmMod packageName: 'n8n-nodes-test', installedVersion: '1.0.0', updateAvailable: '2.0.0', + installedNodes: [{ name: 'TestNode' }], }, }, }, @@ -103,4 +111,93 @@ describe('CommunityPackageManageConfirmModal', () => { const testId = getByTestId('communityPackageManageConfirmModal-warning'); expect(testId).toBeInTheDocument(); }); + + it('should include table with affected workflows', async () => { + useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true }); + + nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined); + nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '1.5.0' }); + + fetchWorkflowsWithNodesIncluded.mockResolvedValue({ + data: [ + { + id: 'workflow-1', + name: 'Test Workflow 1', + resourceType: 'workflow', + active: true, + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z', + homeProject: { + id: 'project-1', + name: 'Test Project 1', + icon: { type: 'emoji', value: 'test' }, + type: 'personal', + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z', + }, + isArchived: false, + readOnly: false, + scopes: [], + tags: [], + }, + ], + }); + + const screen = renderComponent({ + props: { + modalName: 'test-modal', + activePackageName: 'n8n-nodes-test', + mode: 'update', + }, + global: { + stubs: { + 'router-link': { + template: '', + }, + }, + plugins: [createTestingPinia()], + }, + }); + + await flushPromises(); + + const testId = screen.getByTestId('communityPackageManageConfirmModal-warning'); + expect(testId).toBeInTheDocument(); + expect(screen.getByText('Test Workflow 1')).toBeInTheDocument(); + expect(screen.getByText('Test Project 1')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Confirm update')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument(); + }); + + it('should notinclude table with affected workflows', async () => { + useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true }); + + nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined); + nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '1.5.0' }); + + fetchWorkflowsWithNodesIncluded.mockResolvedValue({ + data: [], + }); + + const screen = renderComponent({ + props: { + modalName: 'test-modal', + activePackageName: 'n8n-nodes-test', + mode: 'update', + }, + }); + + await flushPromises(); + + const testId = screen.getByTestId('communityPackageManageConfirmModal-warning'); + expect(testId).toBeInTheDocument(); + + expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument(); + + expect( + screen.getByText('Nodes from this package are not used in any workflows'), + ).toBeInTheDocument(); + }); }); diff --git a/packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.vue b/packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.vue index 52bea3c6363..f45963a0b77 100644 --- a/packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.vue +++ b/packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.vue @@ -11,6 +11,10 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import type { CommunityNodeType } from '@n8n/api-types'; import { useSettingsStore } from '@/stores/settings.store'; import semver from 'semver'; +import { N8nText } from '@n8n/design-system'; +import { useUIStore } from '@/stores/ui.store'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import type { WorkflowResource } from '@/Interface'; export type CommunityPackageManageMode = 'uninstall' | 'update' | 'view-documentation'; @@ -25,6 +29,7 @@ const props = defineProps(); const communityNodesStore = useCommunityNodesStore(); const nodeTypesStore = useNodeTypesStore(); const settingsStore = useSettingsStore(); +const workflowsStore = useWorkflowsStore(); const modalBus = createEventBus(); @@ -34,6 +39,8 @@ const telemetry = useTelemetry(); const loading = ref(false); +const workflowsWithPackageNodes = ref([]); + const isUsingVerifiedAndUnverifiedPackages = settingsStore.isCommunityNodesFeatureEnabled && settingsStore.isUnverifiedPackagesEnabled; const isUsingVerifiedPackagesOnly = @@ -53,15 +60,22 @@ const isLatestPackageVerified = ref(true); const packageVersion = ref(communityStorePackage.value.updateAvailable ?? ''); +const includedNodes = computed(() => { + return communityStorePackage.value.installedNodes.map((node) => node.name).join(', '); +}); + const getModalContent = computed(() => { if (props.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) { return { title: i18n.baseText('settings.communityNodes.confirmModal.uninstall.title'), - message: i18n.baseText('settings.communityNodes.confirmModal.uninstall.message', { + message: i18n.baseText('settings.communityNodes.confirmModal.includedNodes', { interpolate: { - packageName: props.activePackageName, + nodes: includedNodes.value, }, }), + description: workflowsWithPackageNodes.value.length + ? i18n.baseText('settings.communityNodes.confirmModal.uninstall.description') + : i18n.baseText('settings.communityNodes.confirmModal.noWorkflowsUsingNodes'), buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.uninstall.buttonLabel'), buttonLoadingLabel: i18n.baseText( 'settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel', @@ -69,19 +83,16 @@ const getModalContent = computed(() => { }; } return { - title: i18n.baseText('settings.communityNodes.confirmModal.update.title', { + title: i18n.baseText('settings.communityNodes.confirmModal.update.title'), + message: i18n.baseText('settings.communityNodes.confirmModal.includedNodes', { interpolate: { - packageName: props.activePackageName, + nodes: includedNodes.value, }, }), - description: i18n.baseText('settings.communityNodes.confirmModal.update.description'), + description: workflowsWithPackageNodes.value.length + ? i18n.baseText('settings.communityNodes.confirmModal.update.description') + : i18n.baseText('settings.communityNodes.confirmModal.noWorkflowsUsingNodes'), warning: i18n.baseText('settings.communityNodes.confirmModal.update.warning'), - message: i18n.baseText('settings.communityNodes.confirmModal.update.message', { - interpolate: { - packageName: props.activePackageName, - version: packageVersion.value, - }, - }), buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.update.buttonLabel'), buttonLoadingLabel: i18n.baseText( 'settings.communityNodes.confirmModal.update.buttonLoadingLabel', @@ -200,11 +211,21 @@ function setPackageVersion() { } } +const onClick = async () => { + useUIStore().closeModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY); +}; + onMounted(async () => { if (props.activePackageName) { await fetchPackageInfo(props.activePackageName); } + if (communityStorePackage.value?.installedNodes.length) { + const nodeTypes = communityStorePackage.value.installedNodes.map((node) => node.type); + const response = await workflowsStore.fetchWorkflowsWithNodesIncluded(nodeTypes); + workflowsWithPackageNodes.value = response?.data ?? []; + } + setIsVerifiedLatestPackage(); setPackageVersion(); }); @@ -212,7 +233,7 @@ onMounted(async () => {