Skip to content

Commit 06572ef

Browse files
authored
feat(core): Add endpoint GET /projects/:projectId/folders/:folderId/tree (no-changelog) (#13448)
1 parent b791677 commit 06572ef

File tree

4 files changed

+192
-9
lines changed

4 files changed

+192
-9
lines changed

packages/cli/src/controllers/folder.controller.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { CreateFolderDto } from '@n8n/api-types';
22
import { Response } from 'express';
33

4-
import { Post, RestController, ProjectScope, Body } from '@/decorators';
4+
import { Post, RestController, ProjectScope, Body, Get } from '@/decorators';
55
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
66
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
77
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -29,4 +29,23 @@ export class ProjectController {
2929
throw new InternalServerError();
3030
}
3131
}
32+
33+
@Get('/:folderId/tree')
34+
@ProjectScope('folder:read')
35+
async getFolderTree(
36+
req: AuthenticatedRequest<{ projectId: string; folderId: string }>,
37+
_res: Response,
38+
) {
39+
const { projectId, folderId } = req.params;
40+
41+
try {
42+
const tree = await this.folderService.getFolderTree(folderId, projectId);
43+
return tree;
44+
} catch (e) {
45+
if (e instanceof FolderNotFoundError) {
46+
throw new NotFoundError(e.message);
47+
}
48+
throw new InternalServerError();
49+
}
50+
}
3251
}

packages/cli/src/permissions.ee/project-roles.ts

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
2626
'project:update',
2727
'project:delete',
2828
'folder:create',
29+
'folder:read',
2930
];
3031

3132
export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
@@ -47,6 +48,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
4748
'project:list',
4849
'project:read',
4950
'folder:create',
51+
'folder:read',
5052
];
5153

5254
export const PROJECT_EDITOR_SCOPES: Scope[] = [
@@ -64,6 +66,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [
6466
'project:list',
6567
'project:read',
6668
'folder:create',
69+
'folder:read',
6770
];
6871

6972
export const PROJECT_VIEWER_SCOPES: Scope[] = [
@@ -73,4 +76,5 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [
7376
'project:read',
7477
'workflow:list',
7578
'workflow:read',
79+
'folder:read',
7680
];

packages/cli/src/services/folder.service.ts

+92-8
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,26 @@ import { Service } from '@n8n/di';
44
import { FolderRepository } from '@/databases/repositories/folder.repository';
55
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
66

7+
export interface SimpleFolderNode {
8+
id: string;
9+
name: string;
10+
children: SimpleFolderNode[];
11+
}
12+
13+
interface FolderPathRow {
14+
folder_id: string;
15+
folder_name: string;
16+
folder_parent_folder_id: string | null;
17+
}
18+
719
@Service()
820
export class FolderService {
921
constructor(private readonly folderRepository: FolderRepository) {}
1022

1123
async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) {
1224
let parentFolder = null;
1325
if (parentFolderId) {
14-
try {
15-
parentFolder = await this.folderRepository.findOneOrFailFolderInProject(
16-
parentFolderId,
17-
projectId,
18-
);
19-
} catch {
20-
throw new FolderNotFoundError(parentFolderId);
21-
}
26+
parentFolder = await this.getFolderInProject(parentFolderId, projectId);
2227
}
2328

2429
const folderEntity = this.folderRepository.create({
@@ -31,4 +36,83 @@ export class FolderService {
3136

3237
return folder;
3338
}
39+
40+
async getFolderInProject(folderId: string, projectId: string) {
41+
try {
42+
return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId);
43+
} catch {
44+
throw new FolderNotFoundError(folderId);
45+
}
46+
}
47+
48+
async getFolderTree(folderId: string, projectId: string): Promise<SimpleFolderNode[]> {
49+
await this.getFolderInProject(folderId, projectId);
50+
51+
const baseQuery = this.folderRepository
52+
.createQueryBuilder('folder')
53+
.select('folder.id', 'id')
54+
.addSelect('folder.parentFolderId', 'parentFolderId')
55+
.where('folder.id = :folderId', { folderId });
56+
57+
const recursiveQuery = this.folderRepository
58+
.createQueryBuilder('f')
59+
.select('f.id', 'id')
60+
.addSelect('f.parentFolderId', 'parentFolderId')
61+
.innerJoin('folder_path', 'fp', 'f.id = fp.parentFolderId');
62+
63+
const mainQuery = this.folderRepository
64+
.createQueryBuilder('folder')
65+
.select('folder.id', 'folder_id')
66+
.addSelect('folder.name', 'folder_name')
67+
.addSelect('folder.parentFolderId', 'folder_parent_folder_id')
68+
.addCommonTableExpression(
69+
`${baseQuery.getQuery()} UNION ALL ${recursiveQuery.getQuery()}`,
70+
'folder_path',
71+
{ recursive: true },
72+
)
73+
.where((qb) => {
74+
const subQuery = qb.subQuery().select('fp.id').from('folder_path', 'fp').getQuery();
75+
return `folder.id IN ${subQuery}`;
76+
})
77+
.setParameters({
78+
folderId,
79+
});
80+
81+
const result = await mainQuery.getRawMany<FolderPathRow>();
82+
83+
return this.transformFolderPathToTree(result);
84+
}
85+
86+
private transformFolderPathToTree(flatPath: FolderPathRow[]): SimpleFolderNode[] {
87+
if (!flatPath || flatPath.length === 0) {
88+
return [];
89+
}
90+
91+
const folderMap = new Map<string, SimpleFolderNode>();
92+
93+
// First pass: create all nodes
94+
flatPath.forEach((folder) => {
95+
folderMap.set(folder.folder_id, {
96+
id: folder.folder_id,
97+
name: folder.folder_name,
98+
children: [],
99+
});
100+
});
101+
102+
let rootNode: SimpleFolderNode | null = null;
103+
104+
// Second pass: build the tree
105+
flatPath.forEach((folder) => {
106+
const currentNode = folderMap.get(folder.folder_id)!;
107+
108+
if (folder.folder_parent_folder_id && folderMap.has(folder.folder_parent_folder_id)) {
109+
const parentNode = folderMap.get(folder.folder_parent_folder_id)!;
110+
parentNode.children = [currentNode];
111+
} else {
112+
rootNode = currentNode;
113+
}
114+
});
115+
116+
return rootNode ? [rootNode] : [];
117+
}
34118
}

packages/cli/test/integration/folder/folder.controller.test.ts

+76
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,79 @@ describe('POST /projects/:projectId/folders', () => {
203203
expect(folderInDb?.name).toBe(payload.name);
204204
});
205205
});
206+
207+
describe('GET /projects/:projectId/folders/:folderId/tree', () => {
208+
test('should not get folder tree when project does not exist', async () => {
209+
await authOwnerAgent.get('/projects/non-existing-id/folders/some-folder-id/tree').expect(403);
210+
});
211+
212+
test('should not get folder tree when folder does not exist', async () => {
213+
const project = await createTeamProject('test project', owner);
214+
215+
await authOwnerAgent
216+
.get(`/projects/${project.id}/folders/non-existing-folder/tree`)
217+
.expect(404);
218+
});
219+
220+
test('should not get folder tree if user has no access to project', async () => {
221+
const project = await createTeamProject('test project', owner);
222+
const folder = await createFolder(project);
223+
224+
await authMemberAgent.get(`/projects/${project.id}/folders/${folder.id}/tree`).expect(403);
225+
});
226+
227+
test("should not allow getting folder tree from another user's personal project", async () => {
228+
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
229+
const folder = await createFolder(ownerPersonalProject);
230+
231+
await authMemberAgent
232+
.get(`/projects/${ownerPersonalProject.id}/folders/${folder.id}/tree`)
233+
.expect(403);
234+
});
235+
236+
test('should get nested folder structure', async () => {
237+
const project = await createTeamProject('test', owner);
238+
const rootFolder = await createFolder(project, { name: 'Root' });
239+
240+
const childFolder1 = await createFolder(project, {
241+
name: 'Child 1',
242+
parentFolder: rootFolder,
243+
});
244+
245+
await createFolder(project, {
246+
name: 'Child 2',
247+
parentFolder: rootFolder,
248+
});
249+
250+
const grandchildFolder = await createFolder(project, {
251+
name: 'Grandchild',
252+
parentFolder: childFolder1,
253+
});
254+
255+
const response = await authOwnerAgent
256+
.get(`/projects/${project.id}/folders/${grandchildFolder.id}/tree`)
257+
.expect(200);
258+
259+
expect(response.body.data).toEqual(
260+
expect.arrayContaining([
261+
expect.objectContaining({
262+
id: rootFolder.id,
263+
name: 'Root',
264+
children: expect.arrayContaining([
265+
expect.objectContaining({
266+
id: childFolder1.id,
267+
name: 'Child 1',
268+
children: expect.arrayContaining([
269+
expect.objectContaining({
270+
id: grandchildFolder.id,
271+
name: 'Grandchild',
272+
children: [],
273+
}),
274+
]),
275+
}),
276+
]),
277+
}),
278+
]),
279+
);
280+
});
281+
});

0 commit comments

Comments
 (0)