feat(core): Add endpoint GET /projects/:projectId/folders/:folderId/tree (no-changelog) (#13448)

This commit is contained in:
Ricardo Espinoza 2025-02-24 13:26:22 -05:00 committed by GitHub
parent b791677ffa
commit 06572efad3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 192 additions and 9 deletions

View file

@ -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();
}
}
}

View file

@ -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',
];

View file

@ -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<SimpleFolderNode[]> {
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<FolderPathRow>();
return this.transformFolderPathToTree(result);
}
private transformFolderPathToTree(flatPath: FolderPathRow[]): SimpleFolderNode[] {
if (!flatPath || flatPath.length === 0) {
return [];
}
const folderMap = new Map<string, SimpleFolderNode>();
// 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] : [];
}
}

View file

@ -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: [],
}),
]),
}),
]),
}),
]),
);
});
});