diff --git a/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts b/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts index 7da236099d..205baa0b99 100644 --- a/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/list-folder-query.dto.ts @@ -29,6 +29,7 @@ export const filterSchema = z parentFolderId: z.string().optional(), name: z.string().optional(), tags: z.array(z.string()).optional(), + excludeFolderIdAndDescendants: z.string().optional(), }) .strict(); diff --git a/packages/cli/src/databases/repositories/folder.repository.ts b/packages/cli/src/databases/repositories/folder.repository.ts index b0afe3d26e..155cde4766 100644 --- a/packages/cli/src/databases/repositories/folder.repository.ts +++ b/packages/cli/src/databases/repositories/folder.repository.ts @@ -136,6 +136,13 @@ export class FolderRepository extends Repository, + excludeFolderIdAndDescendants: string, + ): void { + // Exclude the specific folder by ID + query.andWhere('folder.id != :excludeFolderIdAndDescendants', { + excludeFolderIdAndDescendants, + }); + + // Use a WITH RECURSIVE CTE to find all child folders of the excluded folder + const baseQuery = this.createQueryBuilder('f') + .select('f.id', 'id') + .addSelect('f.parentFolderId', 'parentFolderId') + .where('f.id = :excludeFolderIdAndDescendants', { excludeFolderIdAndDescendants }); + + const recursiveQuery = this.createQueryBuilder('child') + .select('child.id', 'id') + .addSelect('child.parentFolderId', 'parentFolderId') + .innerJoin('folder_tree', 'parent', 'child.parentFolderId = parent.id'); + + const subQuery = this.createQueryBuilder() + .select('tree.id') + .addCommonTableExpression( + `${baseQuery.getQuery()} UNION ALL ${recursiveQuery.getQuery()}`, + 'folder_tree', + { recursive: true }, + ) + .from('folder_tree', 'tree') + .setParameters({ excludeFolderIdAndDescendants }); + + // Exclude all children of the specified folder + query.andWhere(`folder.id NOT IN (${subQuery.getQuery()})`); + } } diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index d2786507aa..70f67b4702 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -1014,6 +1014,39 @@ describe('GET /projects/:projectId/folders', () => { expect(response.body.data[0].name).toBe('Folder 3'); }); + test('should filter folders by excludeFolderIdAndDescendants', async () => { + const folder1 = await createFolder(ownerProject, { name: 'folder level 1' }); + await createFolder(ownerProject, { + name: 'folder level 1.1', + parentFolder: folder1, + }); + const folder12 = await createFolder(ownerProject, { + name: 'folder level 1.2', + parentFolder: folder1, + }); + await createFolder(ownerProject, { + name: 'folder level 1.2.1', + parentFolder: folder12, + }); + const folder122 = await createFolder(ownerProject, { + name: 'folder level 1.2.2', + parentFolder: folder12, + }); + await createFolder(ownerProject, { + name: 'folder level 1.2.2.1', + parentFolder: folder122, + }); + + const response = await authOwnerAgent + .get(`/projects/${ownerProject.id}/folders`) + .query({ filter: `{ "excludeFolderIdAndDescendants": "${folder122.id}" }` }); + + expect(response.body.data.length).toBe(4); + expect(response.body.data.map((f: any) => f.name).sort()).toEqual( + ['folder level 1', 'folder level 1.1', 'folder level 1.2.1', 'folder level 1.2'].sort(), + ); + }); + test('should apply pagination with take parameter', async () => { // Create folders with consistent timestamps for (let i = 1; i <= 5; i++) {