diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts index 0af6d16779..4ae355180a 100644 --- a/packages/cli/src/controllers/folder.controller.ts +++ b/packages/cli/src/controllers/folder.controller.ts @@ -1,7 +1,7 @@ import { CreateFolderDto } from '@n8n/api-types'; import { Response } from 'express'; -import { Post, RestController, ProjectScope, Body } from '@/decorators'; +import { Post, RestController, ProjectScope, Body, Get } from '@/decorators'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -29,4 +29,23 @@ export class ProjectController { throw new InternalServerError(); } } + + @Get('/:folderId/tree') + @ProjectScope('folder:read') + async getFolderTree( + req: AuthenticatedRequest<{ projectId: string; folderId: string }>, + _res: Response, + ) { + const { projectId, folderId } = req.params; + + try { + const tree = await this.folderService.getFolderTree(folderId, projectId); + return tree; + } catch (e) { + if (e instanceof FolderNotFoundError) { + throw new NotFoundError(e.message); + } + throw new InternalServerError(); + } + } } diff --git a/packages/cli/src/permissions.ee/project-roles.ts b/packages/cli/src/permissions.ee/project-roles.ts index 985c12e68b..5e3c41ac46 100644 --- a/packages/cli/src/permissions.ee/project-roles.ts +++ b/packages/cli/src/permissions.ee/project-roles.ts @@ -26,6 +26,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'project:update', 'project:delete', 'folder:create', + 'folder:read', ]; export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ @@ -47,6 +48,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'project:list', 'project:read', 'folder:create', + 'folder:read', ]; export const PROJECT_EDITOR_SCOPES: Scope[] = [ @@ -64,6 +66,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'project:list', 'project:read', 'folder:create', + 'folder:read', ]; export const PROJECT_VIEWER_SCOPES: Scope[] = [ @@ -73,4 +76,5 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [ 'project:read', 'workflow:list', 'workflow:read', + 'folder:read', ]; diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index b419566677..64784e4e01 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -4,6 +4,18 @@ import { Service } from '@n8n/di'; import { FolderRepository } from '@/databases/repositories/folder.repository'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; +export interface SimpleFolderNode { + id: string; + name: string; + children: SimpleFolderNode[]; +} + +interface FolderPathRow { + folder_id: string; + folder_name: string; + folder_parent_folder_id: string | null; +} + @Service() export class FolderService { constructor(private readonly folderRepository: FolderRepository) {} @@ -11,14 +23,7 @@ export class FolderService { async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) { let parentFolder = null; if (parentFolderId) { - try { - parentFolder = await this.folderRepository.findOneOrFailFolderInProject( - parentFolderId, - projectId, - ); - } catch { - throw new FolderNotFoundError(parentFolderId); - } + parentFolder = await this.getFolderInProject(parentFolderId, projectId); } const folderEntity = this.folderRepository.create({ @@ -31,4 +36,83 @@ export class FolderService { return folder; } + + async getFolderInProject(folderId: string, projectId: string) { + try { + return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId); + } catch { + throw new FolderNotFoundError(folderId); + } + } + + async getFolderTree(folderId: string, projectId: string): Promise { + await this.getFolderInProject(folderId, projectId); + + const baseQuery = this.folderRepository + .createQueryBuilder('folder') + .select('folder.id', 'id') + .addSelect('folder.parentFolderId', 'parentFolderId') + .where('folder.id = :folderId', { folderId }); + + const recursiveQuery = this.folderRepository + .createQueryBuilder('f') + .select('f.id', 'id') + .addSelect('f.parentFolderId', 'parentFolderId') + .innerJoin('folder_path', 'fp', 'f.id = fp.parentFolderId'); + + const mainQuery = this.folderRepository + .createQueryBuilder('folder') + .select('folder.id', 'folder_id') + .addSelect('folder.name', 'folder_name') + .addSelect('folder.parentFolderId', 'folder_parent_folder_id') + .addCommonTableExpression( + `${baseQuery.getQuery()} UNION ALL ${recursiveQuery.getQuery()}`, + 'folder_path', + { recursive: true }, + ) + .where((qb) => { + const subQuery = qb.subQuery().select('fp.id').from('folder_path', 'fp').getQuery(); + return `folder.id IN ${subQuery}`; + }) + .setParameters({ + folderId, + }); + + const result = await mainQuery.getRawMany(); + + return this.transformFolderPathToTree(result); + } + + private transformFolderPathToTree(flatPath: FolderPathRow[]): SimpleFolderNode[] { + if (!flatPath || flatPath.length === 0) { + return []; + } + + const folderMap = new Map(); + + // First pass: create all nodes + flatPath.forEach((folder) => { + folderMap.set(folder.folder_id, { + id: folder.folder_id, + name: folder.folder_name, + children: [], + }); + }); + + let rootNode: SimpleFolderNode | null = null; + + // Second pass: build the tree + flatPath.forEach((folder) => { + const currentNode = folderMap.get(folder.folder_id)!; + + if (folder.folder_parent_folder_id && folderMap.has(folder.folder_parent_folder_id)) { + const parentNode = folderMap.get(folder.folder_parent_folder_id)!; + parentNode.children = [currentNode]; + } else { + rootNode = currentNode; + } + }); + + return rootNode ? [rootNode] : []; + } } diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 04fc0831e8..5981750659 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -203,3 +203,79 @@ describe('POST /projects/:projectId/folders', () => { expect(folderInDb?.name).toBe(payload.name); }); }); + +describe('GET /projects/:projectId/folders/:folderId/tree', () => { + test('should not get folder tree when project does not exist', async () => { + await authOwnerAgent.get('/projects/non-existing-id/folders/some-folder-id/tree').expect(403); + }); + + test('should not get folder tree when folder does not exist', async () => { + const project = await createTeamProject('test project', owner); + + await authOwnerAgent + .get(`/projects/${project.id}/folders/non-existing-folder/tree`) + .expect(404); + }); + + test('should not get folder tree if user has no access to project', async () => { + const project = await createTeamProject('test project', owner); + const folder = await createFolder(project); + + await authMemberAgent.get(`/projects/${project.id}/folders/${folder.id}/tree`).expect(403); + }); + + test("should not allow getting folder tree from another user's personal project", async () => { + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const folder = await createFolder(ownerPersonalProject); + + await authMemberAgent + .get(`/projects/${ownerPersonalProject.id}/folders/${folder.id}/tree`) + .expect(403); + }); + + test('should get nested folder structure', async () => { + const project = await createTeamProject('test', owner); + const rootFolder = await createFolder(project, { name: 'Root' }); + + const childFolder1 = await createFolder(project, { + name: 'Child 1', + parentFolder: rootFolder, + }); + + await createFolder(project, { + name: 'Child 2', + parentFolder: rootFolder, + }); + + const grandchildFolder = await createFolder(project, { + name: 'Grandchild', + parentFolder: childFolder1, + }); + + const response = await authOwnerAgent + .get(`/projects/${project.id}/folders/${grandchildFolder.id}/tree`) + .expect(200); + + expect(response.body.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: rootFolder.id, + name: 'Root', + children: expect.arrayContaining([ + expect.objectContaining({ + id: childFolder1.id, + name: 'Child 1', + children: expect.arrayContaining([ + expect.objectContaining({ + id: grandchildFolder.id, + name: 'Grandchild', + children: [], + }), + ]), + }), + ]), + }), + ]), + ); + }); +});