mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Add endpoint GET /projects/:projectId/folders/:folderId/tree
(no-changelog) (#13448)
This commit is contained in:
parent
b791677ffa
commit
06572efad3
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
|
|
@ -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] : [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: [],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue