diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 0857e40be26..a4a07e30816 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -435,6 +435,7 @@ export class WorkflowRepository extends Repository { this.applyTagsFilter(qb, filter); this.applyProjectFilter(qb, filter); this.applyParentFolderFilter(qb, filter); + this.applyNodeTypesFilter(qb, filter); this.applyAvailableInMCPFilter(qb, filter); } @@ -543,6 +544,22 @@ export class WorkflowRepository extends Repository { } } + private applyNodeTypesFilter( + qb: SelectQueryBuilder, + filter: ListQuery.Options['filter'], + ): void { + const nodeTypes = isStringArray(filter?.nodeTypes) ? filter.nodeTypes : []; + + if (!nodeTypes.length) return; + + const { whereClause, parameters } = buildWorkflowsByNodesQuery( + nodeTypes, + this.globalConfig.database.type, + ); + + qb.andWhere(whereClause, parameters); + } + private applyOwnedByRelation(qb: SelectQueryBuilder): void { // Check if 'shared' join already exists from project filter if (!qb.expressionMap.aliases.find((alias) => alias.name === 'shared')) { diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts index 299f63a67ad..46f80be7604 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts @@ -40,6 +40,12 @@ export class WorkflowFilter extends BaseFilter { @Expose() availableInMCP?: boolean; + @IsArray() + @IsString({ each: true }) + @IsOptional() + @Expose() + nodeTypes?: string[]; + static async fromString(rawFilter: string) { return await this.toFilter(rawFilter, WorkflowFilter); } diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts index 4ce2971f073..5b842872243 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts @@ -12,6 +12,7 @@ export class WorkflowSelect extends BaseSelect { 'versionId', 'ownedBy', // non-entity field 'parentFolder', + 'nodes', ]); } diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 15030bf860b..64af46e433e 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -18,16 +18,11 @@ import { } from '@n8n/db'; import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; +import { createFolder } from '@test-integration/db/folders'; import { DateTime } from 'luxon'; import { PROJECT_ROOT, type INode, type IPinData, type IWorkflowBase } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import { License } from '@/license'; -import { ProjectService } from '@/services/project.service.ee'; -import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; -import { createFolder } from '@test-integration/db/folders'; - import { saveCredential } from '../shared/db/credentials'; import { assignTagToWorkflow, createTag } from '../shared/db/tags'; import { createManyUsers, createMember, createOwner } from '../shared/db/users'; @@ -35,6 +30,11 @@ import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; import { makeWorkflow, MOCK_PINDATA } from '../shared/utils/'; +import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { License } from '@/license'; +import { ProjectService } from '@/services/project.service.ee'; +import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; + let owner: User; let member: User; let anotherMember: User; @@ -1014,6 +1014,126 @@ describe('GET /workflows', () => { expect(response2.body.data).toHaveLength(1); expect(response2.body.data[0].id).toBe(workflow2.id); }); + + test('should filter workflows by nodeTypes', async () => { + const httpWorkflow = await createWorkflow( + { + name: 'HTTP Workflow', + nodes: [ + { + id: uuid(), + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + ], + }, + owner, + ); + + const slackWorkflow = await createWorkflow( + { + name: 'Slack Workflow', + nodes: [ + { + id: uuid(), + name: 'Slack', + type: 'n8n-nodes-base.slack', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + ], + }, + owner, + ); + + const mixedWorkflow = await createWorkflow( + { + name: 'Mixed Workflow', + nodes: [ + { + id: uuid(), + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + { + id: uuid(), + name: 'Slack', + type: 'n8n-nodes-base.slack', + parameters: {}, + typeVersion: 1, + position: [100, 0], + }, + ], + }, + owner, + ); + + // Filter by single node type + const httpResponse = await authOwnerAgent + .get('/workflows') + .query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest"] }&select=["nodes"]') + .expect(200); + + expect(httpResponse.body.data).toHaveLength(2); + const httpWorkflowIds = httpResponse.body.data.map((w: any) => w.id); + expect(httpWorkflowIds).toContain(httpWorkflow.id); + expect(httpWorkflowIds).toContain(mixedWorkflow.id); + expect(httpResponse.body.data[0].nodes).toHaveLength(1); + expect(httpResponse.body.data[1].nodes).toHaveLength(2); + + // Filter by multiple node types (OR operation - returns workflows containing ANY of the specified node types) + const multipleResponse = await authOwnerAgent + .get('/workflows') + .query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest", "n8n-nodes-base.slack"] }') + .expect(200); + + expect(multipleResponse.body.data).toHaveLength(3); + const multipleWorkflowIds = multipleResponse.body.data.map((w: any) => w.id); + expect(multipleWorkflowIds).toContain(httpWorkflow.id); + expect(multipleWorkflowIds).toContain(slackWorkflow.id); + expect(multipleWorkflowIds).toContain(mixedWorkflow.id); + + // Filter by non-existent node type + const emptyResponse = await authOwnerAgent + .get('/workflows') + .query('filter={ "nodeTypes": ["n8n-nodes-base.nonExistent"] }') + .expect(200); + + expect(emptyResponse.body.data).toHaveLength(0); + }); + + test('should all workflows when filtering by empty nodeTypes array', async () => { + await createWorkflow( + { + name: 'Test Workflow', + nodes: [ + { + id: uuid(), + name: 'Start', + type: 'n8n-nodes-base.start', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + ], + }, + owner, + ); + + const response = await authOwnerAgent + .get('/workflows') + .query('filter={ "nodeTypes": [] }') + .expect(200); + + expect(response.body.data).toHaveLength(1); // Should return all workflows when nodeTypes is empty + }); }); describe('select', () => { @@ -1877,6 +1997,60 @@ describe('GET /workflows?includeFolders=true', () => { expect(response.body.data[1].id).toBe(workflow.id); expect(response.body.data[1].homeProject).not.toBeNull(); }); + + test('should filter workflows and folders by nodeTypes', async () => { + const pp = await getPersonalProject(owner); + + const httpWorkflow = await createWorkflow( + { + name: 'HTTP Workflow', + nodes: [ + { + id: uuid(), + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + ], + }, + owner, + ); + + await createWorkflow( + { + name: 'Slack Workflow', + nodes: [ + { + id: uuid(), + name: 'Slack', + type: 'n8n-nodes-base.slack', + parameters: {}, + typeVersion: 1, + position: [0, 0], + }, + ], + }, + owner, + ); + + const folder = await createFolder(pp, { name: 'Test Folder' }); + + const response = await authOwnerAgent + .get('/workflows') + .query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest"] }&includeFolders=true') + .expect(200); + + expect(response.body.data).toHaveLength(2); // 1 folder + 1 matching workflow + const workflowItems = response.body.data.filter((item: any) => item.resource === 'workflow'); + const folderItems = response.body.data.filter((item: any) => item.resource === 'folder'); + + expect(workflowItems).toHaveLength(1); + expect(workflowItems[0].id).toBe(httpWorkflow.id); + expect(folderItems).toHaveLength(1); + expect(folderItems[0].id).toBe(folder.id); + }); }); describe('sortBy', () => {