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 205baa0b99..c1b86661c1 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 @@ -12,6 +12,7 @@ const VALID_SELECT_FIELDS = [ 'parentFolder', 'workflowCount', 'subFolderCount', + 'path', ] as const; const VALID_SORT_OPTIONS = [ diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index 1007757ef9..f30709b804 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -272,8 +272,12 @@ export const enum StatisticsNames { export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google'; export type FolderWithWorkflowAndSubFolderCount = Folder & { - workflowCount: boolean; - subFolderCount: number; + workflowCount?: boolean; + subFolderCount?: number; +}; + +export type FolderWithWorkflowAndSubFolderCountAndPath = FolderWithWorkflowAndSubFolderCount & { + path?: string[]; }; export type TestRunFinalResult = 'success' | 'error' | 'warning'; diff --git a/packages/@n8n/db/src/repositories/folder.repository.ts b/packages/@n8n/db/src/repositories/folder.repository.ts index 1b75244056..59a29648b6 100644 --- a/packages/@n8n/db/src/repositories/folder.repository.ts +++ b/packages/@n8n/db/src/repositories/folder.repository.ts @@ -4,29 +4,28 @@ import { DataSource, Repository } from '@n8n/typeorm'; import { PROJECT_ROOT } from 'n8n-workflow'; import { Folder, FolderTagMapping, TagEntity } from '../entities'; -import type { FolderWithWorkflowAndSubFolderCount, ListQuery } from '../entities/types-db'; +import type { FolderWithWorkflowAndSubFolderCountAndPath, ListQuery } from '../entities/types-db'; @Service() -export class FolderRepository extends Repository { +export class FolderRepository extends Repository { constructor(dataSource: DataSource) { super(Folder, dataSource.manager); } - async getManyAndCount( - options: ListQuery.Options = {}, - ): Promise<[FolderWithWorkflowAndSubFolderCount[], number]> { + async getManyAndCount(options: ListQuery.Options = {}) { const query = this.getManyQuery(options); - return await query.getManyAndCount(); + return (await query.getManyAndCount()) as unknown as [ + FolderWithWorkflowAndSubFolderCountAndPath[], + number, + ]; } - async getMany(options: ListQuery.Options = {}): Promise { + async getMany(options: ListQuery.Options = {}) { const query = this.getManyQuery(options); - return await query.getMany(); + return (await query.getMany()) as unknown as FolderWithWorkflowAndSubFolderCountAndPath[]; } - getManyQuery( - options: ListQuery.Options = {}, - ): SelectQueryBuilder { + getManyQuery(options: ListQuery.Options = {}): SelectQueryBuilder { const query = this.createQueryBuilder('folder'); this.applySelections(query, options.select, options.filter); @@ -38,7 +37,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, select?: ListQuery.Options['select'], filter?: ListQuery.Options['filter'], ): void { @@ -50,7 +49,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, filter?: ListQuery.Options['filter'], ): void { if (typeof filter?.isArchived === 'boolean') { @@ -65,7 +64,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, filter?: ListQuery.Options['filter'], ): void { this.applyWorkflowCountSelect(query, filter); @@ -84,7 +83,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, select?: ListQuery.Options['select'], filter?: ListQuery.Options['filter'], ): void { @@ -103,7 +102,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, selections: string[], select?: ListQuery.Options['select'], filter?: ListQuery.Options['filter'], @@ -147,7 +146,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, filter?: ListQuery.Options['filter'], ): void { if (!filter) return; @@ -164,7 +163,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, filter: ListQuery.Options['filter'], ): void { if (filter?.folderIds && Array.isArray(filter.folderIds)) { @@ -196,10 +195,7 @@ export class FolderRepository extends Repository, - tags?: string[], - ): void { + private applyTagsFilter(query: SelectQueryBuilder, tags?: string[]): void { if (!Array.isArray(tags) || tags.length === 0) return; const subQuery = this.createTagsSubQuery(query, tags); @@ -211,7 +207,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, tags: string[], ): SelectQueryBuilder { return query @@ -226,10 +222,7 @@ export class FolderRepository extends Repository, - sortBy?: string, - ): void { + private applySorting(query: SelectQueryBuilder, sortBy?: string): void { if (!sortBy) { query.orderBy('folder.updatedAt', 'DESC'); return; @@ -245,7 +238,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, field: string, direction: 'DESC' | 'ASC', ): void { @@ -258,10 +251,7 @@ export class FolderRepository extends Repository, - options: ListQuery.Options, - ): void { + private applyPagination(query: SelectQueryBuilder, options: ListQuery.Options): void { if (options?.take) { query.skip(options.skip ?? 0).take(options.take); } @@ -320,7 +310,7 @@ export class FolderRepository extends Repository, + query: SelectQueryBuilder, excludeFolderIdAndDescendants: string, ): void { // Exclude the specific folder by ID @@ -395,4 +385,55 @@ export class FolderRepository extends Repository(); return result.map((row) => row.id); } + + async getFolderPathsToRoot(folderIds: string[]): Promise> { + if (!folderIds.length) { + return new Map(); + } + + // Base query: select all root folders + const baseQuery = this.createQueryBuilder('folder') + .select([ + 'folder.id as id', + 'folder.name as name', + 'folder.parentFolderId as parentFolderId', + 'CONCAT(folder.name) as path', + '1 as level', + ]) + .where('folder.parentFolderId IS NULL'); + + const recursiveQuery = this.createQueryBuilder('child') + .select([ + 'child.id as id', + 'child.name as name', + 'child.parentFolderId as parentFolderId', + "CONCAT(parent.path, '/', child.name) as path", + 'parent.level + 1 as level', + ]) + .innerJoin('folder_paths', 'parent', 'child.parentFolderId = parent.id'); + + const mainQuery = this.createQueryBuilder() + .addCommonTableExpression( + `${baseQuery.getQuery()} UNION ALL ${recursiveQuery.getQuery()}`, + 'folder_paths', + { recursive: true }, + ) + .select('fp.id as folder_id, fp.path as folder_path') + .from('folder_paths', 'fp') + .where('fp.id IN (:...folderIds)', { folderIds }); + + const results = await mainQuery.getRawMany<{ + folder_id: string; + folder_path: string; + }>(); + + const pathMap = new Map(); + + for (const row of results) { + const pathNames = row.folder_path.split('/'); + pathMap.set(row.folder_id, pathNames); + } + + return pathMap; + } } diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 11227cb4ec..ebaf12f826 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -13,7 +13,6 @@ import type { import { PROJECT_ROOT } from 'n8n-workflow'; import { FolderRepository } from './folder.repository'; -import type { Folder } from '../entities'; import { WebhookEntity, TagEntity, WorkflowEntity, WorkflowTagMapping } from '../entities'; import type { ListQueryDb, @@ -348,7 +347,7 @@ export class WorkflowRepository extends Repository { baseData: WorkflowFolderUnionRow[], extraData: { workflows: ListQueryDb.Workflow.WithSharing[] | ListQueryDb.Workflow.Plain[]; - folders: Folder[]; + folders: FolderWithWorkflowAndSubFolderCount[]; }, ): WorkflowFolderUnionFull[] { const workflowsMap = new Map(extraData.workflows.map((workflow) => [workflow.id, workflow])); diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index be409148bc..f5be9e79fc 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -1,5 +1,9 @@ import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types'; -import type { User } from '@n8n/db'; +import type { + FolderWithWorkflowAndSubFolderCount, + FolderWithWorkflowAndSubFolderCountAndPath, + User, +} from '@n8n/db'; import { Folder, FolderTagMappingRepository, FolderRepository, WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import @@ -281,6 +285,27 @@ export class FolderService { async getManyAndCount(projectId: string, options: ListQuery.Options) { options.filter = { ...options.filter, projectId, isArchived: false }; - return await this.folderRepository.getManyAndCount(options); + // eslint-disable-next-line prefer-const + let [folders, count] = await this.folderRepository.getManyAndCount(options); + if (options.select?.path) { + folders = await this.enrichFoldersWithPaths(folders); + } + return [folders, count]; + } + + private async enrichFoldersWithPaths( + folders: FolderWithWorkflowAndSubFolderCount[], + ): Promise { + const folderIds = folders.map((folder) => folder.id); + + const folderPaths = await this.folderRepository.getFolderPathsToRoot(folderIds); + + return folders.map( + (folder) => + ({ + ...folder, + path: folderPaths.get(folder.id), + }) as FolderWithWorkflowAndSubFolderCountAndPath, + ); } } diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index b8a70cdd7b..5b913266f3 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -1275,6 +1275,42 @@ describe('GET /projects/:projectId/folders', () => { expect(response.body.data[0].parentFolder).toBeUndefined(); }); + test('should select path field when requested', async () => { + const folder1 = await createFolder(ownerProject, { name: 'Test Folder' }); + const folder2 = await createFolder(ownerProject, { + name: 'Test Folder 2', + parentFolder: folder1, + }); + const folder3 = await createFolder(ownerProject, { + name: 'Test Folder 3', + parentFolder: folder2, + }); + + const response = await authOwnerAgent + .get( + `/projects/${ownerProject.id}/folders?select=["id","path", "name"]&sortBy=updatedAt:desc`, + ) + .expect(200); + + expect(response.body.data[0]).toEqual({ + id: expect.any(String), + name: 'Test Folder 3', + path: [folder1.name, folder2.name, folder3.name], + }); + + expect(response.body.data[1]).toEqual({ + id: expect.any(String), + name: 'Test Folder 2', + path: [folder1.name, folder2.name], + }); + + expect(response.body.data[2]).toEqual({ + id: expect.any(String), + name: 'Test Folder', + path: [folder1.name], + }); + }); + test('should combine multiple query parameters correctly', async () => { const tag = await createTag({ name: 'important' }); const parentFolder = await createFolder(ownerProject, { name: 'Parent' });