From 461e39a74e54f3238a35e198288e0b03069fac6d Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 25 Feb 2025 15:29:35 -0500 Subject: [PATCH] feat(core): Update POST `/workflows` to link folder (no-changelog) (#13449) --- .../repositories/folder.repository.ts | 11 +- .../shared-workflow.repository.ts | 3 +- packages/cli/src/services/folder.service.ts | 12 +- .../cli/src/workflows/workflow.request.ts | 1 + .../cli/src/workflows/workflows.controller.ts | 17 ++- .../workflows/workflows.controller.test.ts | 116 +++++++++++++++++- 6 files changed, 148 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/databases/repositories/folder.repository.ts b/packages/cli/src/databases/repositories/folder.repository.ts index 536dea8291..e730048d4c 100644 --- a/packages/cli/src/databases/repositories/folder.repository.ts +++ b/packages/cli/src/databases/repositories/folder.repository.ts @@ -1,5 +1,5 @@ import { Service } from '@n8n/di'; -import type { SelectQueryBuilder } from '@n8n/typeorm'; +import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm'; import type { ListQuery } from '@/requests'; @@ -227,8 +227,13 @@ export class FolderRepository extends Repository { } } - async findOneOrFailFolderInProject(folderId: string, projectId: string): Promise { - return await this.manager.findOneOrFail(Folder, { + async findOneOrFailFolderInProject( + folderId: string, + projectId: string, + em?: EntityManager, + ): Promise { + const manager = em ?? this.manager; + return await manager.findOneOrFail(Folder, { where: { id: folderId, homeProject: { diff --git a/packages/cli/src/databases/repositories/shared-workflow.repository.ts b/packages/cli/src/databases/repositories/shared-workflow.repository.ts index f0a574fa0c..6573355841 100644 --- a/packages/cli/src/databases/repositories/shared-workflow.repository.ts +++ b/packages/cli/src/databases/repositories/shared-workflow.repository.ts @@ -112,7 +112,7 @@ export class SharedWorkflowRepository extends Repository { workflowId: string, user: User, scopes: Scope[], - { includeTags = false, em = this.manager } = {}, + { includeTags = false, includeParentFolder = false, em = this.manager } = {}, ) { let where: FindOptionsWhere = { workflowId }; @@ -138,6 +138,7 @@ export class SharedWorkflowRepository extends Repository { workflow: { shared: { project: { projectRelations: { user: true } } }, tags: includeTags, + parentFolder: includeParentFolder, }, }, }); diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index 64784e4e01..54ecbe04d9 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -1,5 +1,7 @@ import type { CreateFolderDto } from '@n8n/api-types'; import { Service } from '@n8n/di'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import type { EntityManager } from '@n8n/typeorm'; import { FolderRepository } from '@/databases/repositories/folder.repository'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; @@ -37,9 +39,9 @@ export class FolderService { return folder; } - async getFolderInProject(folderId: string, projectId: string) { + async getFolderInProject(folderId: string, projectId: string, em?: EntityManager) { try { - return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId); + return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId, em); } catch { throw new FolderNotFoundError(folderId); } @@ -48,6 +50,10 @@ export class FolderService { async getFolderTree(folderId: string, projectId: string): Promise { await this.getFolderInProject(folderId, projectId); + const escapedParentFolderId = this.folderRepository + .createQueryBuilder() + .escape('parentFolderId'); + const baseQuery = this.folderRepository .createQueryBuilder('folder') .select('folder.id', 'id') @@ -58,7 +64,7 @@ export class FolderService { .createQueryBuilder('f') .select('f.id', 'id') .addSelect('f.parentFolderId', 'parentFolderId') - .innerJoin('folder_path', 'fp', 'f.id = fp.parentFolderId'); + .innerJoin('folder_path', 'fp', `f.id = fp.${escapedParentFolderId}`); const mainQuery = this.folderRepository .createQueryBuilder('folder') diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index b0c28e86ea..848d84a61c 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -22,6 +22,7 @@ export declare namespace WorkflowRequest { hash: string; meta: Record; projectId: string; + parentFolderId?: string; }>; type ManualRunPayload = { diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index b4b0136e4e..cf3eee51d1 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -46,6 +46,7 @@ import { License } from '@/license'; import { listQueryMiddleware } from '@/middlewares'; import { AuthenticatedRequest } from '@/requests'; import * as ResponseHelper from '@/response-helper'; +import { FolderService } from '@/services/folder.service'; import { NamingService } from '@/services/naming.service'; import { ProjectService } from '@/services/project.service.ee'; import { TagService } from '@/services/tag.service'; @@ -82,6 +83,7 @@ export class WorkflowsController { private readonly projectRelationRepository: ProjectRelationRepository, private readonly eventService: EventService, private readonly globalConfig: GlobalConfig, + private readonly folderService: FolderService, ) {} @Post('/') @@ -133,7 +135,7 @@ export class WorkflowsController { const savedWorkflow = await Db.transaction(async (transactionManager) => { const workflow = await transactionManager.save(newWorkflow); - const { projectId } = req.body; + const { projectId, parentFolderId } = req.body; project = projectId === undefined ? await this.projectRepository.getPersonalProjectForUser(req.user.id, transactionManager) @@ -155,6 +157,17 @@ export class WorkflowsController { throw new ApplicationError('No personal project found'); } + if (parentFolderId) { + try { + const parentFolder = await this.folderService.getFolderInProject( + parentFolderId, + project.id, + transactionManager, + ); + await transactionManager.update(WorkflowEntity, { id: workflow.id }, { parentFolder }); + } catch {} + } + const newSharedWorkflow = this.sharedWorkflowRepository.create({ role: 'workflow:owner', projectId: project.id, @@ -167,7 +180,7 @@ export class WorkflowsController { workflow.id, req.user, ['workflow:read'], - { em: transactionManager, includeTags: true }, + { em: transactionManager, includeTags: true, includeParentFolder: true }, ); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 0f8406b895..a9f948d21f 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -58,13 +58,14 @@ let projectService: ProjectService; beforeEach(async () => { await testDb.truncate([ - 'Workflow', 'SharedWorkflow', - 'Tag', 'WorkflowHistory', - 'Project', 'ProjectRelation', 'Folder', + 'Workflow', + 'Tag', + 'Project', + 'User', ]); projectRepository = Container.get(ProjectRepository); projectService = Container.get(ProjectService); @@ -378,6 +379,115 @@ describe('POST /workflows', () => { message: "You don't have the permissions to save the workflow in this project.", }); }); + + test('create link workflow with folder if one is provided', async () => { + // + // ARRANGE + // + const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const folder = await createFolder(personalProject, { name: 'Folder 1' }); + + const workflow = makeWorkflow(); + + // + // ACT + // + const response = await authOwnerAgent + .post('/workflows') + .send({ ...workflow, parentFolderId: folder.id }); + + // + // ASSERT + // + + expect(response.body.data).toMatchObject({ + active: false, + id: expect.any(String), + name: workflow.name, + sharedWithProjects: [], + usedCredentials: [], + homeProject: { + id: personalProject.id, + name: personalProject.name, + type: personalProject.type, + }, + parentFolder: { + id: folder.id, + name: folder.name, + }, + }); + expect(response.body.data.shared).toBeUndefined(); + }); + + test('create workflow without parent folder if no folder is provided', async () => { + // + // ARRANGE + // + const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const workflow = makeWorkflow(); + + // + // ACT + // + const response = await authOwnerAgent + .post('/workflows') + .send({ ...workflow }) + .expect(200); + + // + // ASSERT + // + + expect(response.body.data).toMatchObject({ + active: false, + id: expect.any(String), + name: workflow.name, + sharedWithProjects: [], + usedCredentials: [], + homeProject: { + id: personalProject.id, + name: personalProject.name, + type: personalProject.type, + }, + parentFolder: null, + }); + expect(response.body.data.shared).toBeUndefined(); + }); + + test('create workflow without parent is provided folder does not exist in the project', async () => { + // + // ARRANGE + // + const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const workflow = makeWorkflow(); + + // + // ACT + // + const response = await authOwnerAgent + .post('/workflows') + .send({ ...workflow, parentFolderId: 'non-existing-folder-id' }) + .expect(200); + + // + // ASSERT + // + + expect(response.body.data).toMatchObject({ + active: false, + id: expect.any(String), + name: workflow.name, + sharedWithProjects: [], + usedCredentials: [], + homeProject: { + id: personalProject.id, + name: personalProject.name, + type: personalProject.type, + }, + parentFolder: null, + }); + expect(response.body.data.shared).toBeUndefined(); + }); }); describe('GET /workflows/:workflowId', () => {