diff --git a/packages/cli/src/databases/entities/folder.ts b/packages/cli/src/databases/entities/folder.ts index 6ed45857f5..c1af39ae66 100644 --- a/packages/cli/src/databases/entities/folder.ts +++ b/packages/cli/src/databases/entities/folder.ts @@ -13,6 +13,10 @@ import { Project } from './project'; import { TagEntity } from './tag-entity'; import { type WorkflowEntity } from './workflow-entity'; +export type FolderWithWorkflowsCount = Folder & { + workflowsCount: boolean; +}; + @Entity() export class Folder extends WithTimestampsAndStringId { @Column() @@ -24,7 +28,7 @@ export class Folder extends WithTimestampsAndStringId { @ManyToOne(() => Project) @JoinColumn({ name: 'projectId' }) - project: Project; + homeProject: Project; @OneToMany('WorkflowEntity', 'parentFolder') workflows: WorkflowEntity[]; diff --git a/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts index 256ba5e6df..770d7559d6 100644 --- a/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/folder.repository.test.ts @@ -4,7 +4,6 @@ import { DateTime } from 'luxon'; import type { Folder } from '@/databases/entities/folder'; import type { Project } from '@/databases/entities/project'; import type { User } from '@/databases/entities/user'; -import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { createFolder } from '@test-integration/db/folders'; import { getPersonalProject } from '@test-integration/db/projects'; import { createTag } from '@test-integration/db/tags'; @@ -46,7 +45,7 @@ describe('FolderRepository', () => { await Promise.all([folder1, folder2]); - const [folders, count] = await folderRepository.getMany(); + const [folders, count] = await folderRepository.getManyAndCount(); expect(count).toBe(2); expect(folders).toHaveLength(2); @@ -60,7 +59,7 @@ describe('FolderRepository', () => { createdAt: expect.any(Date), updatedAt: expect.any(Date), parentFolder: null, - project: { + homeProject: { id: expect.any(String), name: expect.any(String), type: expect.any(String), @@ -70,6 +69,23 @@ describe('FolderRepository', () => { }); }); }); + + it('should filter folders by IDs', async () => { + const anotherUser = await createMember(); + const anotherProject = await getPersonalProject(anotherUser); + + const folder1 = await createFolder(project, { name: 'folder1' }); + await createFolder(anotherProject, { name: 'folder2' }); + + const [folders, count] = await folderRepository.getManyAndCount({ + filter: { folderIds: [folder1.id] }, + }); + + expect(count).toBe(1); + expect(folders).toHaveLength(1); + expect(folders[0].name).toBe('folder1'); + }); + it('should filter folders by project ID', async () => { const anotherUser = await createMember(); const anotherProject = await getPersonalProject(anotherUser); @@ -79,14 +95,14 @@ describe('FolderRepository', () => { await Promise.all([folder1, folder2]); - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ filter: { projectId: project.id }, }); expect(count).toBe(1); expect(folders).toHaveLength(1); expect(folders[0].name).toBe('folder1'); - expect(folders[0].project.id).toBe(project.id); + expect(folders[0].homeProject.id).toBe(project.id); }); it('should filter folders by name case-insensitively', async () => { @@ -96,7 +112,7 @@ describe('FolderRepository', () => { await Promise.all([folder1, folder2, folder3]); - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ filter: { name: 'test' }, }); @@ -106,6 +122,8 @@ describe('FolderRepository', () => { }); it('should filter folders by parent folder ID', async () => { + let folders: Folder[]; + let count: number; const parentFolder = await createFolder(project, { name: 'Parent' }); await createFolder(project, { name: 'Child 1', @@ -117,7 +135,7 @@ describe('FolderRepository', () => { }); await createFolder(project, { name: 'Unrelated' }); - const [folders, count] = await folderRepository.getMany({ + [folders, count] = await folderRepository.getManyAndCount({ filter: { parentFolderId: parentFolder.id }, }); @@ -127,6 +145,17 @@ describe('FolderRepository', () => { folders.forEach((folder) => { expect(folder.parentFolder?.id).toBe(parentFolder.id); }); + + [folders, count] = await folderRepository.getManyAndCount({ + filter: { parentFolderId: '0' }, + }); + + expect(count).toBe(2); + expect(folders).toHaveLength(2); + expect(folders.map((f) => f.name).sort()).toEqual(['Parent', 'Unrelated']); + folders.forEach((folder) => { + expect(folder.parentFolder).toBe(null); + }); }); it('should filter folders by a single tag', async () => { @@ -143,7 +172,7 @@ describe('FolderRepository', () => { tags: [tag2], }); - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ filter: { tags: ['important'] }, }); @@ -171,7 +200,7 @@ describe('FolderRepository', () => { tags: [tag3], }); - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ filter: { tags: ['important', 'active'] }, }); @@ -199,7 +228,7 @@ describe('FolderRepository', () => { tags: [tag2], }); - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ filter: { name: 'test', parentFolderId: parentFolder.id, @@ -217,7 +246,6 @@ describe('FolderRepository', () => { describe('select', () => { let testFolder: Folder; - let workflowWithTestFolder: WorkflowEntity; beforeEach(async () => { const parentFolder = await createFolder(project, { name: 'Parent Folder' }); @@ -227,11 +255,11 @@ describe('FolderRepository', () => { parentFolder, tags: [tag], }); - workflowWithTestFolder = await createWorkflow({ parentFolder: testFolder }); + await createWorkflow({ parentFolder: testFolder }); }); it('should select only id and name when specified', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ select: { id: true, name: true, @@ -251,7 +279,7 @@ describe('FolderRepository', () => { }); it('should return id, name and tags when specified', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ select: { id: true, name: true, @@ -276,7 +304,7 @@ describe('FolderRepository', () => { }); it('should return id, name and project when specified', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ select: { id: true, name: true, @@ -286,8 +314,8 @@ describe('FolderRepository', () => { expect(folders).toHaveLength(2); folders.forEach((folder) => { - expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'project']); - expect(folder.project).toEqual({ + expect(Object.keys(folder).sort()).toEqual(['homeProject', 'id', 'name']); + expect(folder.homeProject).toEqual({ id: expect.any(String), name: expect.any(String), type: expect.any(String), @@ -297,7 +325,7 @@ describe('FolderRepository', () => { }); it('should return id, name and parentFolder when specified', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ select: { id: true, name: true, @@ -320,29 +348,26 @@ describe('FolderRepository', () => { }); }); - it('should return id, name and workflows when specified', async () => { - const [folders] = await folderRepository.getMany({ + it('should return id, name and workflowsCount when specified', async () => { + const [folders] = await folderRepository.getManyAndCount({ select: { id: true, name: true, - workflows: true, + workflowsCount: true, }, }); expect(folders).toHaveLength(2); folders.forEach((folder) => { - expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'workflows']); + expect(Object.keys(folder).sort()).toEqual(['id', 'name', 'workflowsCount']); expect(folder.id).toBeDefined(); expect(folder.name).toBeDefined(); - expect(Array.isArray(folder.workflows)).toBeTruthy(); + expect(folder.workflowsCount).toBeDefined(); }); - - expect(folders[0].workflows).toHaveLength(1); - expect(folders[0].workflows[0].id).toBe(workflowWithTestFolder.id); }); it('should return timestamps when specified', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ select: { id: true, createdAt: true, @@ -359,7 +384,7 @@ describe('FolderRepository', () => { }); it('should return all properties when no select is specified', async () => { - const [folders] = await folderRepository.getMany(); + const [folders] = await folderRepository.getManyAndCount(); expect(folders).toHaveLength(2); folders.forEach((folder) => { @@ -368,13 +393,13 @@ describe('FolderRepository', () => { name: expect.any(String), createdAt: expect.any(Date), updatedAt: expect.any(Date), - project: { + homeProject: { id: expect.any(String), name: expect.any(String), type: expect.any(String), icon: null, }, - workflows: expect.any(Array), + workflowsCount: expect.any(Number), tags: expect.any(Array), }); }); @@ -408,7 +433,7 @@ describe('FolderRepository', () => { }); it('should limit results when take is specified', async () => { - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ take: 3, }); @@ -417,7 +442,7 @@ describe('FolderRepository', () => { }); it('should skip results when skip is specified', async () => { - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ skip: 2, take: 5, }); @@ -428,7 +453,7 @@ describe('FolderRepository', () => { }); it('should handle skip and take together', async () => { - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ skip: 1, take: 2, }); @@ -439,7 +464,7 @@ describe('FolderRepository', () => { }); it('should handle take larger than remaining items', async () => { - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ skip: 3, take: 10, }); @@ -450,7 +475,7 @@ describe('FolderRepository', () => { }); it('should handle zero take by returning all results', async () => { - const [folders, count] = await folderRepository.getMany({ + const [folders, count] = await folderRepository.getManyAndCount({ take: 0, }); @@ -495,7 +520,7 @@ describe('FolderRepository', () => { }); it('should sort by default (updatedAt:desc)', async () => { - const [folders] = await folderRepository.getMany(); + const [folders] = await folderRepository.getManyAndCount(); expect(folders.map((f) => f.name)).toEqual([ 'C Folder', @@ -506,7 +531,7 @@ describe('FolderRepository', () => { }); it('should sort by name:asc', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ sortBy: 'name:asc', }); @@ -519,7 +544,7 @@ describe('FolderRepository', () => { }); it('should sort by name:desc', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ sortBy: 'name:desc', }); @@ -532,7 +557,7 @@ describe('FolderRepository', () => { }); it('should sort by createdAt:asc', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ sortBy: 'createdAt:asc', }); @@ -545,7 +570,7 @@ describe('FolderRepository', () => { }); it('should sort by createdAt:desc', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ sortBy: 'createdAt:desc', }); @@ -558,7 +583,7 @@ describe('FolderRepository', () => { }); it('should sort by updatedAt:asc', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ sortBy: 'updatedAt:asc', }); @@ -571,7 +596,7 @@ describe('FolderRepository', () => { }); it('should sort by updatedAt:desc', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ sortBy: 'updatedAt:desc', }); @@ -584,7 +609,7 @@ describe('FolderRepository', () => { }); it('should default to asc if order not specified', async () => { - const [folders] = await folderRepository.getMany({ + const [folders] = await folderRepository.getManyAndCount({ sortBy: 'name', }); diff --git a/packages/cli/src/databases/repositories/folder.repository.ts b/packages/cli/src/databases/repositories/folder.repository.ts index ae8c5d4d6f..e331bb8463 100644 --- a/packages/cli/src/databases/repositories/folder.repository.ts +++ b/packages/cli/src/databases/repositories/folder.repository.ts @@ -4,17 +4,30 @@ import { DataSource, Repository } from '@n8n/typeorm'; import type { ListQuery } from '@/requests'; +import type { FolderWithWorkflowsCount } from '../entities/folder'; import { Folder } from '../entities/folder'; import { FolderTagMapping } from '../entities/folder-tag-mapping'; import { TagEntity } from '../entities/tag-entity'; @Service() -export class FolderRepository extends Repository { +export class FolderRepository extends Repository { constructor(dataSource: DataSource) { super(Folder, dataSource.manager); } - async getMany(options: ListQuery.Options = {}): Promise<[Folder[], number]> { + async getManyAndCount( + options: ListQuery.Options = {}, + ): Promise<[FolderWithWorkflowsCount[], number]> { + const query = this.getManyQuery(options); + return await query.getManyAndCount(); + } + + async getMany(options: ListQuery.Options = {}): Promise { + const query = this.getManyQuery(options); + return await query.getMany(); + } + + getManyQuery(options: ListQuery.Options = {}): SelectQueryBuilder { const query = this.createQueryBuilder('folder'); this.applySelections(query, options.select); @@ -22,11 +35,11 @@ export class FolderRepository extends Repository { this.applySorting(query, options.sortBy); this.applyPagination(query, options); - return await query.getManyAndCount(); + return query; } private applySelections( - query: SelectQueryBuilder, + query: SelectQueryBuilder, select?: Record, ): void { if (select) { @@ -36,23 +49,22 @@ export class FolderRepository extends Repository { } } - private applyDefaultSelect(query: SelectQueryBuilder): void { + private applyDefaultSelect(query: SelectQueryBuilder): void { query - .leftJoinAndSelect('folder.project', 'project') + .leftJoinAndSelect('folder.homeProject', 'homeProject') .leftJoinAndSelect('folder.parentFolder', 'parentFolder') .leftJoinAndSelect('folder.tags', 'tags') - .leftJoinAndSelect('folder.workflows', 'workflows') + .loadRelationCountAndMap('folder.workflowsCount', 'folder.workflows') .select([ 'folder', - ...this.getProjectFields('project'), + ...this.getProjectFields('homeProject'), ...this.getTagFields(), ...this.getParentFolderFields('parentFolder'), - 'workflows.id', ]); } private applyCustomSelect( - query: SelectQueryBuilder, + query: SelectQueryBuilder, select?: Record, ): void { const selections = ['folder.id']; @@ -70,13 +82,13 @@ export class FolderRepository extends Repository { } private addRelationFields( - query: SelectQueryBuilder, + query: SelectQueryBuilder, selections: string[], select?: Record, ): void { if (select?.project) { - query.leftJoin('folder.project', 'project'); - selections.push(...this.getProjectFields('project')); + query.leftJoin('folder.homeProject', 'homeProject'); + selections.push(...this.getProjectFields('homeProject')); } if (select?.tags) { @@ -89,9 +101,8 @@ export class FolderRepository extends Repository { selections.push(...this.getParentFolderFields('parentFolder')); } - if (select?.workflows) { - query.leftJoinAndSelect('folder.workflows', 'workflows'); - selections.push('workflows.id'); + if (select?.workflowsCount) { + query.loadRelationCountAndMap('folder.workflowsCount', 'folder.workflows'); } } @@ -108,7 +119,7 @@ export class FolderRepository extends Repository { } private applyFilters( - query: SelectQueryBuilder, + query: SelectQueryBuilder, filter?: ListQuery.Options['filter'], ): void { if (!filter) return; @@ -118,9 +129,19 @@ export class FolderRepository extends Repository { } private applyBasicFilters( - query: SelectQueryBuilder, + query: SelectQueryBuilder, filter: ListQuery.Options['filter'], ): void { + if (filter?.folderIds && Array.isArray(filter.folderIds)) { + query.andWhere('folder.id IN (:...folderIds)', { + /* + * If folderIds is empty, add a dummy value to prevent an error + * when using the IN operator with an empty array. + */ + folderIds: !filter?.folderIds.length ? [''] : filter?.folderIds, + }); + } + if (filter?.projectId) { query.andWhere('folder.projectId = :projectId', { projectId: filter.projectId }); } @@ -131,14 +152,19 @@ export class FolderRepository extends Repository { }); } - if (filter?.parentFolderId) { + if (filter?.parentFolderId === '0') { + query.andWhere('folder.parentFolderId IS NULL'); + } else if (filter?.parentFolderId) { query.andWhere('folder.parentFolderId = :parentFolderId', { parentFolderId: filter.parentFolderId, }); } } - private applyTagsFilter(query: SelectQueryBuilder, tags?: string[]): void { + private applyTagsFilter( + query: SelectQueryBuilder, + tags?: string[], + ): void { if (!Array.isArray(tags) || tags.length === 0) return; const subQuery = this.createTagsSubQuery(query, tags); @@ -150,7 +176,7 @@ export class FolderRepository extends Repository { } private createTagsSubQuery( - query: SelectQueryBuilder, + query: SelectQueryBuilder, tags: string[], ): SelectQueryBuilder { return query @@ -165,7 +191,7 @@ export class FolderRepository extends Repository { }); } - private applySorting(query: SelectQueryBuilder, sortBy?: string): void { + private applySorting(query: SelectQueryBuilder, sortBy?: string): void { if (!sortBy) { query.orderBy('folder.updatedAt', 'DESC'); return; @@ -181,7 +207,7 @@ export class FolderRepository extends Repository { } private applySortingByField( - query: SelectQueryBuilder, + query: SelectQueryBuilder, field: string, direction: 'DESC' | 'ASC', ): void { @@ -192,7 +218,10 @@ export class FolderRepository extends Repository { } } - private applyPagination(query: SelectQueryBuilder, options: ListQuery.Options): void { + private applyPagination( + query: SelectQueryBuilder, + options: ListQuery.Options, + ): void { if (options?.take) { query.skip(options.skip ?? 0).take(options.take); } diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 8e750ba2f5..8b229840c8 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -13,16 +13,38 @@ import type { import type { ListQuery } from '@/requests'; import { isStringArray } from '@/utils'; +import { FolderRepository } from './folder.repository'; +import type { Folder, FolderWithWorkflowsCount } from '../entities/folder'; import { TagEntity } from '../entities/tag-entity'; import { WebhookEntity } from '../entities/webhook-entity'; import { WorkflowEntity } from '../entities/workflow-entity'; import { WorkflowTagMapping } from '../entities/workflow-tag-mapping'; +type ResourceType = 'folder' | 'workflow'; + +type WorkflowFolderUnionRow = { + id: string; + name: string; + name_lower?: string; + resource: ResourceType; + createdAt: Date; + updatedAt: Date; +}; + +export type WorkflowFolderUnionFull = ( + | ListQuery.Workflow.Plain + | ListQuery.Workflow.WithSharing + | FolderWithWorkflowsCount +) & { + resource: ResourceType; +}; + @Service() export class WorkflowRepository extends Repository { constructor( dataSource: DataSource, private readonly globalConfig: GlobalConfig, + private readonly folderRepository: FolderRepository, ) { super(WorkflowEntity, dataSource.manager); } @@ -99,20 +121,218 @@ export class WorkflowRepository extends Repository { .execute(); } - async getMany(sharedWorkflowIds: string[], options: ListQuery.Options = {}) { + private buildBaseUnionQuery(workflowIds: string[], options: ListQuery.Options = {}) { + const subQueryParameters: ListQuery.Options = { + select: { + createdAt: true, + updatedAt: true, + id: true, + name: true, + }, + filter: options.filter, + }; + + const columnNames = [...Object.keys(subQueryParameters.select ?? {}), 'resource']; + + const [sortByColumn, sortByDirection] = this.parseSortingParams( + options.sortBy ?? 'updatedAt:asc', + ); + + const foldersQuery = this.folderRepository + .getManyQuery(subQueryParameters) + .addSelect("'folder'", 'resource'); + + const workflowsQuery = this.getManyQuery(workflowIds, subQueryParameters).addSelect( + "'workflow'", + 'resource', + ); + + const qb = this.manager.createQueryBuilder(); + + return { + baseQuery: qb + .createQueryBuilder() + .addCommonTableExpression(foldersQuery, 'FOLDERS_QUERY', { columnNames }) + .addCommonTableExpression(workflowsQuery, 'WORKFLOWS_QUERY', { columnNames }) + .addCommonTableExpression( + `SELECT * FROM ${qb.escape('FOLDERS_QUERY')} UNION ALL SELECT * FROM ${qb.escape('WORKFLOWS_QUERY')}`, + 'RESULT_QUERY', + ), + sortByColumn, + sortByDirection, + }; + } + + async getWorkflowsAndFoldersUnion(workflowIds: string[], options: ListQuery.Options = {}) { + const { baseQuery, sortByColumn, sortByDirection } = this.buildBaseUnionQuery( + workflowIds, + options, + ); + + const query = this.buildUnionQuery(baseQuery, { + sortByColumn, + sortByDirection, + pagination: { + take: options.take, + skip: options.skip ?? 0, + }, + }); + + const workflowsAndFolders = await query.getRawMany(); + return this.removeNameLowerFromResults(workflowsAndFolders); + } + + private buildUnionQuery( + baseQuery: SelectQueryBuilder, + options: { + sortByColumn: string; + sortByDirection: 'ASC' | 'DESC'; + pagination: { + take?: number; + skip: number; + }; + }, + ) { + const query = baseQuery + .select(`${baseQuery.escape('RESULT')}.*`) + .from('RESULT_QUERY', 'RESULT'); + + this.applySortingToUnionQuery(query, baseQuery, options); + this.applyPaginationToUnionQuery(query, options.pagination); + + return query; + } + + private applySortingToUnionQuery( + query: SelectQueryBuilder, + baseQuery: SelectQueryBuilder, + options: { sortByColumn: string; sortByDirection: 'ASC' | 'DESC' }, + ) { + const { sortByColumn, sortByDirection } = options; + + const resultTableEscaped = baseQuery.escape('RESULT'); + const nameColumnEscaped = baseQuery.escape('name'); + const resourceColumnEscaped = baseQuery.escape('resource'); + const sortByColumnEscaped = baseQuery.escape(sortByColumn); + + // Guarantee folders show up first + query.orderBy(`${resultTableEscaped}.${resourceColumnEscaped}`, 'ASC'); + + if (sortByColumn === 'name') { + query + .addSelect(`LOWER(${resultTableEscaped}.${nameColumnEscaped})`, 'name_lower') + .addOrderBy('name_lower', sortByDirection); + } else { + query.addOrderBy(`${resultTableEscaped}.${sortByColumnEscaped}`, sortByDirection); + } + } + + private applyPaginationToUnionQuery( + query: SelectQueryBuilder, + pagination: { take?: number; skip: number }, + ) { + if (pagination.take) { + query.take(pagination.take); + } + query.skip(pagination.skip); + } + + private removeNameLowerFromResults(results: WorkflowFolderUnionRow[]) { + return results.map(({ name_lower, ...rest }) => rest); + } + + async getWorkflowsAndFoldersCount(workflowIds: string[], options: ListQuery.Options = {}) { + const { skip, take, ...baseQueryParameters } = options; + + const { baseQuery } = this.buildBaseUnionQuery(workflowIds, baseQueryParameters); + + const response = await baseQuery + .select(`COUNT(DISTINCT ${baseQuery.escape('RESULT')}.${baseQuery.escape('id')})`, 'count') + .from('RESULT_QUERY', 'RESULT') + .select('COUNT(*)', 'count') + .getRawOne<{ count: number | string }>(); + + return Number(response?.count) || 0; + } + + async getWorkflowsAndFoldersWithCount(workflowIds: string[], options: ListQuery.Options = {}) { + const [workflowsAndFolders, count] = await Promise.all([ + this.getWorkflowsAndFoldersUnion(workflowIds, options), + this.getWorkflowsAndFoldersCount(workflowIds, options), + ]); + + const { workflows, folders } = await this.fetchExtraData(workflowsAndFolders); + + const enrichedWorkflowsAndFolders = this.enrichDataWithExtras(workflowsAndFolders, { + workflows, + folders, + }); + + return [enrichedWorkflowsAndFolders, count] as const; + } + + private getFolderIds(workflowsAndFolders: WorkflowFolderUnionRow[]) { + return workflowsAndFolders.filter((item) => item.resource === 'folder').map((item) => item.id); + } + + private getWorkflowsIds(workflowsAndFolders: WorkflowFolderUnionRow[]) { + return workflowsAndFolders + .filter((item) => item.resource === 'workflow') + .map((item) => item.id); + } + + private async fetchExtraData(workflowsAndFolders: WorkflowFolderUnionRow[]) { + const workflowIds = this.getWorkflowsIds(workflowsAndFolders); + const folderIds = this.getFolderIds(workflowsAndFolders); + + const [workflows, folders] = await Promise.all([ + this.getMany(workflowIds), + this.folderRepository.getMany({ filter: { folderIds } }), + ]); + + return { workflows, folders }; + } + + private enrichDataWithExtras( + baseData: WorkflowFolderUnionRow[], + extraData: { + workflows: ListQuery.Workflow.WithSharing[] | ListQuery.Workflow.Plain[]; + folders: Folder[]; + }, + ): WorkflowFolderUnionFull[] { + const workflowsMap = new Map(extraData.workflows.map((workflow) => [workflow.id, workflow])); + const foldersMap = new Map(extraData.folders.map((folder) => [folder.id, folder])); + + return baseData.map((item) => { + const lookupMap = item.resource === 'folder' ? foldersMap : workflowsMap; + const extraItem = lookupMap.get(item.id); + + return extraItem ? { ...item, ...extraItem } : item; + }); + } + + async getMany(workflowIds: string[], options: ListQuery.Options = {}) { + if (workflowIds.length === 0) { + return []; + } + + const query = this.getManyQuery(workflowIds, options); + + const workflows = (await query.getMany()) as + | ListQuery.Workflow.Plain[] + | ListQuery.Workflow.WithSharing[]; + + return workflows; + } + + async getManyAndCount(sharedWorkflowIds: string[], options: ListQuery.Options = {}) { if (sharedWorkflowIds.length === 0) { return { workflows: [], count: 0 }; } - const qb = this.createBaseQuery(sharedWorkflowIds); + const query = this.getManyQuery(sharedWorkflowIds, options); - this.applyFilters(qb, options.filter); - this.applySelect(qb, options.select); - this.applyRelations(qb, options.select); - this.applySorting(qb, options.sortBy); - this.applyPagination(qb, options); - - const [workflows, count] = (await qb.getManyAndCount()) as [ + const [workflows, count] = (await query.getManyAndCount()) as [ ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], number, ]; @@ -120,9 +340,25 @@ export class WorkflowRepository extends Repository { return { workflows, count }; } - private createBaseQuery(sharedWorkflowIds: string[]): SelectQueryBuilder { - return this.createQueryBuilder('workflow').where('workflow.id IN (:...sharedWorkflowIds)', { - sharedWorkflowIds, + getManyQuery(workflowIds: string[], options: ListQuery.Options = {}) { + const qb = this.createBaseQuery(workflowIds); + + this.applyFilters(qb, options.filter); + this.applySelect(qb, options.select); + this.applyRelations(qb, options.select); + this.applySorting(qb, options.sortBy); + this.applyPagination(qb, options); + + return qb; + } + + private createBaseQuery(workflowIds: string[]): SelectQueryBuilder { + return this.createQueryBuilder('workflow').where('workflow.id IN (:...workflowIds)', { + /* + * If workflowIds is empty, add a dummy value to prevent an error + * when using the IN operator with an empty array. + */ + workflowIds: !workflowIds.length ? [''] : workflowIds, }); } @@ -130,12 +366,11 @@ export class WorkflowRepository extends Repository { qb: SelectQueryBuilder, filter?: ListQuery.Options['filter'], ): void { - if (!filter) return; - this.applyNameFilter(qb, filter); this.applyActiveFilter(qb, filter); this.applyTagsFilter(qb, filter); this.applyProjectFilter(qb, filter); + this.applyParentFolderFilter(qb, filter); } private applyNameFilter( @@ -149,6 +384,19 @@ export class WorkflowRepository extends Repository { } } + private applyParentFolderFilter( + qb: SelectQueryBuilder, + filter: ListQuery.Options['filter'], + ): void { + if (filter?.parentFolderId === '0') { + qb.andWhere('workflow.parentFolderId IS NULL'); + } else if (filter?.parentFolderId) { + qb.andWhere('workflow.parentFolderId = :parentFolderId', { + parentFolderId: filter.parentFolderId, + }); + } + } + private applyActiveFilter( qb: SelectQueryBuilder, filter: ListQuery.Options['filter'], @@ -219,12 +467,11 @@ export class WorkflowRepository extends Repository { qb: SelectQueryBuilder, select?: Record, ): void { - // Always start with workflow.id - qb.select(['workflow.id']); - if (!select) { - // Default select fields when no select option provided - qb.addSelect([ + // Instead of selecting id first and then adding more fields, + // select all fields at once + qb.select([ + 'workflow.id', 'workflow.name', 'workflow.active', 'workflow.createdAt', @@ -234,17 +481,23 @@ export class WorkflowRepository extends Repository { return; } + // For custom select, still start with ID but don't add it again + const fieldsToSelect = ['workflow.id']; + // Handle special fields separately const regularFields = Object.entries(select).filter( - ([field]) => !['ownedBy', 'tags'].includes(field), + ([field]) => !['ownedBy', 'tags', 'parentFolder'].includes(field), ); // Add regular fields regularFields.forEach(([field, include]) => { - if (include) { - qb.addSelect(`workflow.${field}`); + if (include && field !== 'id') { + // Skip id since we already added it + fieldsToSelect.push(`workflow.${field}`); } }); + + qb.select(fieldsToSelect); } private applyRelations( @@ -255,6 +508,11 @@ export class WorkflowRepository extends Repository { const isDefaultSelect = select === undefined; const areTagsRequested = isDefaultSelect || select?.tags; const isOwnedByIncluded = isDefaultSelect || select?.ownedBy; + const isParentFolderIncluded = isDefaultSelect || select?.parentFolder; + + if (isParentFolderIncluded) { + qb.leftJoinAndSelect('workflow.parentFolder', 'parentFolder'); + } if (areTagsEnabled && areTagsRequested) { this.applyTagsRelation(qb); @@ -273,7 +531,7 @@ export class WorkflowRepository extends Repository { private applySorting(qb: SelectQueryBuilder, sortBy?: string): void { if (!sortBy) { - this.applyDefaultSorting(qb); + qb.orderBy('workflow.updatedAt', 'ASC'); return; } @@ -286,10 +544,6 @@ export class WorkflowRepository extends Repository { return [column, order.toUpperCase() as 'ASC' | 'DESC']; } - private applyDefaultSorting(qb: SelectQueryBuilder): void { - qb.orderBy('workflow.updatedAt', 'ASC'); - } - private applySortingByColumn( qb: SelectQueryBuilder, column: string, diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts index 28e4106c8f..ca9d27a6dc 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts @@ -25,6 +25,11 @@ export class WorkflowFilter extends BaseFilter { @Expose() projectId?: string; + @IsString() + @IsOptional() + @Expose() + parentFolderId?: string; + static async fromString(rawFilter: string) { return await this.toFilter(rawFilter, WorkflowFilter); } diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts index 0a90560462..4ce2971f07 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts @@ -11,6 +11,7 @@ export class WorkflowSelect extends BaseSelect { 'updatedAt', 'versionId', 'ownedBy', // non-entity field + 'parentFolder', ]); } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index a8e80ac7ab..412a2370c1 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -149,7 +149,12 @@ export declare namespace CredentialRequest { type Get = AuthenticatedRequest<{ credentialId: string }, {}, {}, Record>; - type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & { + type GetMany = AuthenticatedRequest< + {}, + {}, + {}, + ListQuery.Params & { includeScopes?: string; includeFolders?: string } + > & { listQueryOptions: ListQuery.Options; }; diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 5c2e7cab48..b0c28e86ea 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -40,7 +40,12 @@ export declare namespace WorkflowRequest { type Get = AuthenticatedRequest<{ workflowId: string }>; - type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & { + type GetMany = AuthenticatedRequest< + {}, + {}, + {}, + ListQuery.Params & { includeScopes?: string; includeFolders?: string } + > & { listQueryOptions: ListQuery.Options; }; diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 7101cf2e41..55d02621df 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -19,6 +19,7 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-tag-mapping.repository'; +import type { WorkflowFolderUnionFull } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -58,13 +59,33 @@ export class WorkflowService { private readonly globalConfig: GlobalConfig, ) {} - async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) { + async getMany( + user: User, + options?: ListQuery.Options, + includeScopes?: boolean, + includeFolders?: boolean, + ) { + let count; + let workflows; + let workflowsAndFolders: WorkflowFolderUnionFull[] = []; + const sharedWorkflowIds = await this.workflowSharingService.getSharedWorkflowIds(user, { scopes: ['workflow:read'], }); - // eslint-disable-next-line prefer-const - let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options); + if (includeFolders) { + [workflowsAndFolders, count] = await this.workflowRepository.getWorkflowsAndFoldersWithCount( + sharedWorkflowIds, + options, + ); + + workflows = workflowsAndFolders.filter((wf) => wf.resource === 'workflow'); + } else { + ({ workflows, count } = await this.workflowRepository.getManyAndCount( + sharedWorkflowIds, + options, + )); + } /* Since we're filtering using project ID as part of the relation, @@ -83,6 +104,10 @@ export class WorkflowService { this.cleanupSharedField(workflows); + if (includeFolders) { + workflows = this.mergeProcessedWorkflows(workflowsAndFolders, workflows); + } + return { workflows, count, @@ -137,6 +162,17 @@ export class WorkflowService { }); } + private mergeProcessedWorkflows( + workflowsAndFolders: WorkflowFolderUnionFull[], + processedWorkflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], + ) { + const workflowMap = new Map(processedWorkflows.map((workflow) => [workflow.id, workflow])); + + return workflowsAndFolders.map((item) => + item.resource === 'workflow' ? (workflowMap.get(item.id) ?? item) : item, + ); + } + // eslint-disable-next-line complexity async update( user: User, diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index e89bbd3822..b4b0136e4e 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -212,6 +212,7 @@ export class WorkflowsController { req.user, req.listQueryOptions, !!req.query.includeScopes, + !!req.query.includeFolders, ); res.json({ count, data }); diff --git a/packages/cli/test/integration/shared/db/folders.ts b/packages/cli/test/integration/shared/db/folders.ts index 1858c81f91..1887a4524a 100644 --- a/packages/cli/test/integration/shared/db/folders.ts +++ b/packages/cli/test/integration/shared/db/folders.ts @@ -20,7 +20,7 @@ export const createFolder = async ( const folder = await folderRepository.save( folderRepository.create({ name: options.name ?? randomName(), - project, + homeProject: project, parentFolder: options.parentFolder ?? null, tags: options.tags ?? [], updatedAt: options.updatedAt ?? new Date(), diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 0556372197..0f8406b895 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -9,11 +9,13 @@ import type { User } from '@/databases/entities/user'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; +import type { WorkflowFolderUnionFull } from '@/databases/repositories/workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { License } from '@/license'; import type { ListQuery } from '@/requests'; import { ProjectService } from '@/services/project.service.ee'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; +import { createFolder } from '@test-integration/db/folders'; import { mockInstance } from '../../shared/mocking'; import { saveCredential } from '../shared/db/credentials'; @@ -62,6 +64,7 @@ beforeEach(async () => { 'WorkflowHistory', 'Project', 'ProjectRelation', + 'Folder', ]); projectRepository = Container.get(ProjectRepository); projectService = Container.get(ProjectService); @@ -723,6 +726,33 @@ describe('GET /workflows', () => { expect(response2.body.data).toHaveLength(0); }); + test('should filter workflows by parentFolderId', async () => { + const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); + + const folder1 = await createFolder(pp, { name: 'Folder 1' }); + + const workflow1 = await createWorkflow({ name: 'First', parentFolder: folder1 }, owner); + + const workflow2 = await createWorkflow({ name: 'Second' }, owner); + + const response1 = await authOwnerAgent + .get('/workflows') + .query(`filter={ "parentFolderId": "${folder1.id}" }`) + .expect(200); + + expect(response1.body.data).toHaveLength(1); + expect(response1.body.data[0].id).toBe(workflow1.id); + + // if not provided, looks for workflows without a parentFolder + const response2 = await authOwnerAgent + .get('/workflows') + .query('filter={ "parentFolderId": "0" }'); + expect(200); + + expect(response2.body.data).toHaveLength(1); + expect(response2.body.data[0].id).toBe(workflow2.id); + }); + test('should return homeProject when filtering workflows by projectId', async () => { const workflow = await createWorkflow({ name: 'First' }, owner); await shareWorkflowWithUsers(workflow, [member]); @@ -893,6 +923,40 @@ describe('GET /workflows', () => { ]), }); }); + + test('should select workflow field: parentFolder', async () => { + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + owner.id, + ); + const folder = await createFolder(ownerPersonalProject, { name: 'Folder 1' }); + + await createWorkflow({ parentFolder: folder }, owner); + await createWorkflow({}, owner); + + const response = await authOwnerAgent + .get('/workflows') + .query('select=["parentFolder"]') + .expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + { + id: any(String), + parentFolder: { + id: folder.id, + name: folder.name, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + }, + { + id: any(String), + parentFolder: null, + }, + ]), + }); + }); }); describe('sortBy', () => { @@ -1056,6 +1120,647 @@ describe('GET /workflows', () => { }); }); +describe('GET /workflows?includeFolders=true', () => { + test('should return zero workflows and folders if none exist', async () => { + const response = await authOwnerAgent.get('/workflows').query({ includeFolders: true }); + + expect(response.body).toEqual({ count: 0, data: [] }); + }); + + test('should return workflows and folders', async () => { + const credential = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + + const nodes: INode[] = [ + { + id: uuid(), + name: 'Action Network', + type: 'n8n-nodes-base.actionNetwork', + parameters: {}, + typeVersion: 1, + position: [0, 0], + credentials: { + actionNetworkApi: { + id: credential.id, + name: credential.name, + }, + }, + }, + ]; + + const tag = await createTag({ name: 'A' }); + + await createWorkflow({ name: 'First', nodes, tags: [tag] }, owner); + await createWorkflow({ name: 'Second' }, owner); + await createFolder(ownerPersonalProject, { name: 'Folder' }); + + const response = await authOwnerAgent.get('/workflows').query({ includeFolders: true }); + expect(200); + + expect(response.body).toEqual({ + count: 3, + data: arrayContaining([ + objectContaining({ + resource: 'workflow', + id: any(String), + name: 'First', + active: any(Boolean), + createdAt: any(String), + updatedAt: any(String), + tags: [{ id: any(String), name: 'A' }], + versionId: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + icon: null, + type: ownerPersonalProject.type, + }, + sharedWithProjects: [], + }), + objectContaining({ + id: any(String), + name: 'Second', + active: any(Boolean), + createdAt: any(String), + updatedAt: any(String), + tags: [], + versionId: any(String), + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + icon: null, + type: ownerPersonalProject.type, + }, + sharedWithProjects: [], + }), + objectContaining({ + resource: 'folder', + id: any(String), + name: 'Folder', + createdAt: any(String), + updatedAt: any(String), + tags: [], + homeProject: { + id: ownerPersonalProject.id, + name: owner.createPersonalProjectName(), + icon: null, + type: ownerPersonalProject.type, + }, + parentFolder: null, + workflowsCount: 0, + }), + ]), + }); + + const found = response.body.data.find( + (w: ListQuery.Workflow.WithOwnership) => w.name === 'First', + ); + + expect(found.nodes).toBeUndefined(); + expect(found.sharedWithProjects).toHaveLength(0); + expect(found.usedCredentials).toBeUndefined(); + }); + + test('should return workflows with scopes and folders when ?includeScopes=true', async () => { + const [member1, member2] = await createManyUsers(2, { + role: 'global:member', + }); + + const teamProject = await createTeamProject(undefined, member1); + await linkUserToProject(member2, teamProject, 'project:editor'); + + const credential = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + + const nodes: INode[] = [ + { + id: uuid(), + name: 'Action Network', + type: 'n8n-nodes-base.actionNetwork', + parameters: {}, + typeVersion: 1, + position: [0, 0], + credentials: { + actionNetworkApi: { + id: credential.id, + name: credential.name, + }, + }, + }, + ]; + + const tag = await createTag({ name: 'A' }); + + const [savedWorkflow1, savedWorkflow2, savedFolder1] = await Promise.all([ + createWorkflow({ name: 'First', nodes, tags: [tag] }, teamProject), + createWorkflow({ name: 'Second' }, member2), + createFolder(teamProject, { name: 'Folder' }), + ]); + + await shareWorkflowWithProjects(savedWorkflow2, [{ project: teamProject }]); + + { + const response = await testServer + .authAgentFor(member1) + .get('/workflows?includeScopes=true&includeFolders=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((wf) => wf.id === savedWorkflow1.id)!; + const wf2 = workflows.find((wf) => wf.id === savedWorkflow2.id)!; + const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual( + [ + 'workflow:delete', + 'workflow:execute', + 'workflow:move', + 'workflow:read', + 'workflow:update', + ].sort(), + ); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual(['workflow:read', 'workflow:update', 'workflow:execute'].sort()); + + expect(f1.id).toBe(savedFolder1.id); + } + + { + const response = await testServer + .authAgentFor(member2) + .get('/workflows?includeScopes=true&includeFolders=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!; + const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!; + const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual([ + 'workflow:delete', + 'workflow:execute', + 'workflow:read', + 'workflow:update', + ]); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual( + [ + 'workflow:delete', + 'workflow:execute', + 'workflow:move', + 'workflow:read', + 'workflow:share', + 'workflow:update', + ].sort(), + ); + + expect(f1.id).toBe(savedFolder1.id); + } + + { + const response = await testServer + .authAgentFor(owner) + .get('/workflows?includeScopes=true&includeFolders=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + const workflows = response.body.data as Array; + const wf1 = workflows.find((w) => w.id === savedWorkflow1.id)!; + const wf2 = workflows.find((w) => w.id === savedWorkflow2.id)!; + const f1 = workflows.find((wf) => wf.id === savedFolder1.id)!; + + // Team workflow + expect(wf1.id).toBe(savedWorkflow1.id); + expect(wf1.scopes).toEqual( + [ + 'workflow:create', + 'workflow:delete', + 'workflow:execute', + 'workflow:list', + 'workflow:move', + 'workflow:read', + 'workflow:share', + 'workflow:update', + ].sort(), + ); + + // Shared workflow + expect(wf2.id).toBe(savedWorkflow2.id); + expect(wf2.scopes).toEqual( + [ + 'workflow:create', + 'workflow:delete', + 'workflow:execute', + 'workflow:list', + 'workflow:move', + 'workflow:read', + 'workflow:share', + 'workflow:update', + ].sort(), + ); + + expect(f1.id).toBe(savedFolder1.id); + } + }); + + describe('filter', () => { + test('should filter workflows and folders by field: name', async () => { + const workflow1 = await createWorkflow({ name: 'First' }, owner); + await createWorkflow({ name: 'Second' }, owner); + + const ownerProject = await getPersonalProject(owner); + + const folder1 = await createFolder(ownerProject, { name: 'First' }); + const response = await authOwnerAgent + .get('/workflows') + .query('filter={"name":"First"}&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 2, + data: [ + objectContaining({ id: folder1.id, name: 'First' }), + objectContaining({ id: workflow1.id, name: 'First' }), + ], + }); + }); + + test('should filter workflows and folders by field: active', async () => { + const workflow1 = await createWorkflow({ active: true }, owner); + await createWorkflow({ active: false }, owner); + + const response = await authOwnerAgent + .get('/workflows') + .query('filter={ "active": true }&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 1, + data: [objectContaining({ id: workflow1.id, active: true })], + }); + }); + + test('should filter workflows and folders by field: tags (AND operator)', async () => { + const baseDate = DateTime.now(); + + const workflow1 = await createWorkflow( + { name: 'First', updatedAt: baseDate.toJSDate() }, + owner, + ); + const workflow2 = await createWorkflow( + { name: 'Second', updatedAt: baseDate.toJSDate() }, + owner, + ); + + const ownerProject = await getPersonalProject(owner); + + const tagA = await createTag( + { + name: 'A', + }, + workflow1, + ); + const tagB = await createTag( + { + name: 'B', + }, + workflow1, + ); + + await createTag({ name: 'C' }, workflow2); + + await createFolder(ownerProject, { + name: 'First Folder', + tags: [tagA, tagB], + updatedAt: baseDate.plus({ minutes: 2 }).toJSDate(), + }); + + const response = await authOwnerAgent + .get('/workflows') + .query('filter={ "tags": ["A", "B"] }&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 2, + data: [ + objectContaining({ + name: 'First Folder', + tags: expect.arrayContaining([ + { id: any(String), name: 'A' }, + { id: any(String), name: 'B' }, + ]), + }), + objectContaining({ + name: 'First', + tags: expect.arrayContaining([ + { id: any(String), name: 'A' }, + { id: any(String), name: 'B' }, + ]), + }), + ], + }); + }); + + test('should filter workflows by projectId', async () => { + const workflow = await createWorkflow({ name: 'First' }, owner); + const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id); + + const folder = await createFolder(pp, { + name: 'First Folder', + }); + + const response1 = await authOwnerAgent + .get('/workflows') + .query(`filter={ "projectId": "${pp.id}" }&includeFolders=true`) + .expect(200); + + expect(response1.body.data).toHaveLength(2); + expect(response1.body.data[0].id).toBe(folder.id); + expect(response1.body.data[1].id).toBe(workflow.id); + + const response2 = await authOwnerAgent + .get('/workflows') + .query('filter={ "projectId": "Non-Existing Project ID" }&includeFolders=true') + .expect(200); + + expect(response2.body.data).toHaveLength(0); + }); + + test('should return homeProject when filtering workflows and folders by projectId', async () => { + const workflow = await createWorkflow({ name: 'First' }, owner); + await shareWorkflowWithUsers(workflow, [member]); + const pp = await getPersonalProject(member); + const folder = await createFolder(pp, { + name: 'First Folder', + }); + + const response = await authMemberAgent + .get('/workflows') + .query(`filter={ "projectId": "${pp.id}" }&includeFolders=true`) + .expect(200); + + expect(response.body.data).toHaveLength(2); + expect(response.body.data[0].id).toBe(folder.id); + expect(response.body.data[0].homeProject).not.toBeNull(); + expect(response.body.data[1].id).toBe(workflow.id); + expect(response.body.data[1].homeProject).not.toBeNull(); + }); + }); + + describe('sortBy', () => { + test('should fail when trying to sort by non sortable column', async () => { + await authOwnerAgent + .get('/workflows') + .query('sortBy=nonSortableColumn:asc&?includeFolders=true') + .expect(500); + }); + + test('should sort by createdAt column', async () => { + await createWorkflow({ name: 'First' }, owner); + await createWorkflow({ name: 'Second' }, owner); + const pp = await getPersonalProject(owner); + await createFolder(pp, { + name: 'First Folder', + }); + + await createFolder(pp, { + name: 'Z Folder', + }); + + let response = await authOwnerAgent + .get('/workflows') + .query('sortBy=createdAt:asc&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 4, + data: arrayContaining([ + expect.objectContaining({ name: 'First Folder' }), + expect.objectContaining({ name: 'Z Folder' }), + expect.objectContaining({ name: 'First' }), + expect.objectContaining({ name: 'Second' }), + ]), + }); + + response = await authOwnerAgent + .get('/workflows') + .query('sortBy=createdAt:asc&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 4, + data: arrayContaining([ + expect.objectContaining({ name: 'Z Folder' }), + expect.objectContaining({ name: 'First Folder' }), + expect.objectContaining({ name: 'Second' }), + expect.objectContaining({ name: 'First' }), + ]), + }); + }); + + test('should sort by name column', async () => { + await createWorkflow({ name: 'a' }, owner); + await createWorkflow({ name: 'b' }, owner); + await createWorkflow({ name: 'My workflow' }, owner); + const pp = await getPersonalProject(owner); + await createFolder(pp, { + name: 'a Folder', + }); + + await createFolder(pp, { + name: 'Z Folder', + }); + + let response; + + response = await authOwnerAgent + .get('/workflows') + .query('sortBy=name:asc&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 5, + data: [ + expect.objectContaining({ name: 'a Folder' }), + expect.objectContaining({ name: 'Z Folder' }), + expect.objectContaining({ name: 'a' }), + expect.objectContaining({ name: 'b' }), + expect.objectContaining({ name: 'My workflow' }), + ], + }); + + response = await authOwnerAgent + .get('/workflows') + .query('sortBy=name:desc&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 5, + data: [ + expect.objectContaining({ name: 'Z Folder' }), + expect.objectContaining({ name: 'a Folder' }), + expect.objectContaining({ name: 'My workflow' }), + expect.objectContaining({ name: 'b' }), + expect.objectContaining({ name: 'a' }), + ], + }); + }); + + test('should sort by updatedAt column', async () => { + const baseDate = DateTime.now(); + + const pp = await getPersonalProject(owner); + await createFolder(pp, { + name: 'Folder', + }); + await createFolder(pp, { + name: 'Z Folder', + }); + await createWorkflow( + { name: 'Second', updatedAt: baseDate.plus({ minutes: 1 }).toJSDate() }, + owner, + ); + await createWorkflow( + { name: 'First', updatedAt: baseDate.plus({ minutes: 2 }).toJSDate() }, + owner, + ); + + let response; + + response = await authOwnerAgent + .get('/workflows') + .query('sortBy=updatedAt:asc&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 4, + data: arrayContaining([ + expect.objectContaining({ name: 'Folder' }), + expect.objectContaining({ name: 'Z Folder' }), + expect.objectContaining({ name: 'Second' }), + expect.objectContaining({ name: 'First' }), + ]), + }); + + response = await authOwnerAgent + .get('/workflows') + .query('sortBy=updatedAt:desc&includeFolders=true') + .expect(200); + + expect(response.body).toEqual({ + count: 4, + data: arrayContaining([ + expect.objectContaining({ name: 'Z Folder' }), + expect.objectContaining({ name: 'Folder' }), + expect.objectContaining({ name: 'First' }), + expect.objectContaining({ name: 'Second' }), + ]), + }); + }); + }); + + describe('pagination', () => { + beforeEach(async () => { + const pp = await getPersonalProject(owner); + await createWorkflow({ name: 'Workflow 1' }, owner); + await createWorkflow({ name: 'Workflow 2' }, owner); + await createWorkflow({ name: 'Workflow 3' }, owner); + await createWorkflow({ name: 'Workflow 4' }, owner); + await createWorkflow({ name: 'Workflow 5' }, owner); + await createFolder(pp, { + name: 'Folder 1', + }); + }); + + test('should fail when skip is provided without take', async () => { + await authOwnerAgent.get('/workflows?includeFolders=true').query('skip=2').expect(500); + }); + + test('should handle skip with take parameter', async () => { + const response = await authOwnerAgent + .get('/workflows') + .query('skip=2&take=4&includeFolders=true'); + + expect(response.body.data).toHaveLength(4); + expect(response.body.count).toBe(6); + expect(response.body.data[0].name).toBe('Workflow 2'); + expect(response.body.data[1].name).toBe('Workflow 3'); + expect(response.body.data[2].name).toBe('Workflow 4'); + expect(response.body.data[3].name).toBe('Workflow 5'); + }); + + test('should handle pagination with sorting', async () => { + const response = await authOwnerAgent + .get('/workflows') + .query('skip=1&take=2&sortBy=name:desc&includeFolders=true'); + + expect(response.body.data).toHaveLength(2); + expect(response.body.count).toBe(6); + expect(response.body.data[0].name).toBe('Workflow 5'); + expect(response.body.data[1].name).toBe('Workflow 4'); + }); + + test('should handle pagination with filtering', async () => { + const pp = await getPersonalProject(owner); + await createWorkflow({ name: 'Special Workflow 1' }, owner); + await createWorkflow({ name: 'Special Workflow 2' }, owner); + await createWorkflow({ name: 'Special Workflow 3' }, owner); + await createFolder(pp, { + name: 'Special Folder 1', + }); + + const response = await authOwnerAgent + .get('/workflows') + .query('take=2&skip=1') + .query('filter={"name":"Special"}&includeFolders=true') + .expect(200); + + expect(response.body.data).toHaveLength(2); + expect(response.body.count).toBe(4); + expect(response.body.data[0].name).toBe('Special Workflow 1'); + expect(response.body.data[1].name).toBe('Special Workflow 2'); + }); + + test('should return empty array when pagination exceeds total count', async () => { + const response = await authOwnerAgent + .get('/workflows') + .query('take=2&skip=10&includeFolders=true') + .expect(200); + + expect(response.body.data).toHaveLength(0); + expect(response.body.count).toBe(6); + }); + + test('should return all results when no pagination parameters are provided', async () => { + const response = await authOwnerAgent + .get('/workflows') + .query('includeFolders=true') + .expect(200); + + expect(response.body.data).toHaveLength(6); + expect(response.body.count).toBe(6); + }); + }); +}); + describe('PATCH /workflows/:workflowId', () => { test('should create workflow history version when licensed', async () => { license.enable('feat:workflowHistory');