diff --git a/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts index 098292045a..a673284f51 100644 --- a/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts @@ -1,9 +1,8 @@ -import { z } from 'zod'; import { Z } from 'zod-class'; -import { folderNameSchema } from '../../schemas/folder.schema'; +import { folderNameSchema, folderId } from '../../schemas/folder.schema'; export class CreateFolderDto extends Z.class({ name: folderNameSchema, - parentFolderId: z.string().optional(), + parentFolderId: folderId.optional(), }) {} diff --git a/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts new file mode 100644 index 0000000000..c03659ad18 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts @@ -0,0 +1,7 @@ +import { Z } from 'zod-class'; + +import { folderId } from '../../schemas/folder.schema'; + +export class DeleteFolderDto extends Z.class({ + transferToFolderId: folderId.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index a4467d65c6..f2dd481ee2 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -57,3 +57,4 @@ export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto'; export { CreateFolderDto } from './folders/create-folder.dto'; export { UpdateFolderDto } from './folders/update-folder.dto'; +export { DeleteFolderDto } from './folders/delete-folder.dto'; diff --git a/packages/@n8n/api-types/src/schemas/folder.schema.ts b/packages/@n8n/api-types/src/schemas/folder.schema.ts index 40b59d4faa..4544a1b58c 100644 --- a/packages/@n8n/api-types/src/schemas/folder.schema.ts +++ b/packages/@n8n/api-types/src/schemas/folder.schema.ts @@ -1,3 +1,4 @@ import { z } from 'zod'; export const folderNameSchema = z.string().trim().min(1).max(128); +export const folderId = z.string().max(36); diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 19b39df8fc..f1fbfbf0f4 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -22,5 +22,5 @@ export const RESOURCES = { variable: [...DEFAULT_OPERATIONS] as const, workersView: ['manage'] as const, workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, - folder: ['create', 'read', 'update'] as const, + folder: ['create', 'read', 'update', 'delete'] as const, } as const; diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts index 17fb4ddb7e..774369768d 100644 --- a/packages/cli/src/controllers/folder.controller.ts +++ b/packages/cli/src/controllers/folder.controller.ts @@ -1,7 +1,7 @@ -import { CreateFolderDto, UpdateFolderDto } from '@n8n/api-types'; +import { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types'; import { Response } from 'express'; -import { Post, RestController, ProjectScope, Body, Get, Patch } from '@/decorators'; +import { Post, RestController, ProjectScope, Body, Get, Patch, Delete } 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'; @@ -26,7 +26,7 @@ export class ProjectController { if (e instanceof FolderNotFoundError) { throw new NotFoundError(e.message); } - throw new InternalServerError(); + throw new InternalServerError(undefined, e); } } @@ -45,7 +45,7 @@ export class ProjectController { if (e instanceof FolderNotFoundError) { throw new NotFoundError(e.message); } - throw new InternalServerError(); + throw new InternalServerError(undefined, e); } } @@ -64,7 +64,26 @@ export class ProjectController { if (e instanceof FolderNotFoundError) { throw new NotFoundError(e.message); } - throw new InternalServerError(); + throw new InternalServerError(undefined, e); + } + } + + @Delete('/:folderId') + @ProjectScope('folder:delete') + async deleteFolder( + req: AuthenticatedRequest<{ projectId: string; folderId: string }>, + _res: Response, + @Body payload: DeleteFolderDto, + ) { + const { projectId, folderId } = req.params; + + try { + await this.folderService.deleteFolder(folderId, projectId, payload); + } catch (e) { + if (e instanceof FolderNotFoundError) { + throw new NotFoundError(e.message); + } + throw new InternalServerError(undefined, e); } } } diff --git a/packages/cli/src/databases/entities/folder.ts b/packages/cli/src/databases/entities/folder.ts index 4e4eb94539..78e07a237e 100644 --- a/packages/cli/src/databases/entities/folder.ts +++ b/packages/cli/src/databases/entities/folder.ts @@ -22,7 +22,7 @@ export class Folder extends WithTimestampsAndStringId { @Column() name: string; - @ManyToOne(() => Folder, { nullable: true }) + @ManyToOne(() => Folder, { nullable: true, onDelete: 'CASCADE' }) @JoinColumn({ name: 'parentFolderId' }) parentFolder: Folder | null; diff --git a/packages/cli/src/databases/entities/workflow-entity.ts b/packages/cli/src/databases/entities/workflow-entity.ts index 177fa4771c..d008159175 100644 --- a/packages/cli/src/databases/entities/workflow-entity.ts +++ b/packages/cli/src/databases/entities/workflow-entity.ts @@ -100,7 +100,7 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl @ManyToOne('Folder', 'workflows', { nullable: true, - onDelete: 'SET NULL', + onDelete: 'CASCADE', }) @JoinColumn({ name: 'parentFolderId' }) parentFolder: Folder | null; diff --git a/packages/cli/src/databases/migrations/mysqldb/1740445074052-UpdateParentFolderIdColumn.ts b/packages/cli/src/databases/migrations/mysqldb/1740445074052-UpdateParentFolderIdColumn.ts new file mode 100644 index 0000000000..31c5cf86f3 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1740445074052-UpdateParentFolderIdColumn.ts @@ -0,0 +1,14 @@ +import type { BaseMigration, MigrationContext } from '@/databases/types'; + +export class UpdateParentFolderIdColumn1740445074052 implements BaseMigration { + async up({ escape, queryRunner }: MigrationContext) { + const workflowTableName = escape.tableName('workflow_entity'); + const folderTableName = escape.tableName('folder'); + const parentFolderIdColumn = escape.columnName('parentFolderId'); + const idColumn = escape.columnName('id'); + + await queryRunner.query( + `ALTER TABLE ${workflowTableName} ADD CONSTRAINT fk_workflow_parent_folder FOREIGN KEY (${parentFolderIdColumn}) REFERENCES ${folderTableName}(${idColumn}) ON DELETE CASCADE`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 5e727487dd..01cda1ef2e 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -82,6 +82,7 @@ import { CreateTestCaseExecutionTable1736947513045 } from '../common/17369475130 import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns'; import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable'; import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; +import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -166,4 +167,5 @@ export const mysqlMigrations: Migration[] = [ CreateFolderTable1738709609940, FixTestDefinitionPrimaryKey1739873751194, CreateAnalyticsTables1739549398681, + UpdateParentFolderIdColumn1740445074052, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1740445074052-UpdateParentFolderIdColumn.ts b/packages/cli/src/databases/migrations/postgresdb/1740445074052-UpdateParentFolderIdColumn.ts new file mode 100644 index 0000000000..30088a3229 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1740445074052-UpdateParentFolderIdColumn.ts @@ -0,0 +1,36 @@ +import { UnexpectedError } from 'n8n-workflow'; + +import type { BaseMigration, MigrationContext } from '@/databases/types'; + +export class UpdateParentFolderIdColumn1740445074052 implements BaseMigration { + async up({ + escape, + queryRunner, + schemaBuilder: { dropForeignKey }, + tablePrefix, + }: MigrationContext) { + const workflowTableName = escape.tableName('workflow_entity'); + const folderTableName = escape.tableName('folder'); + const parentFolderIdColumn = escape.columnName('parentFolderId'); + const idColumn = escape.columnName('id'); + + const workflowTable = await queryRunner.getTable(`${tablePrefix}workflow_entity`); + + if (!workflowTable) throw new UnexpectedError('Table workflow_entity not found'); + + const foreignKey = workflowTable.foreignKeys.find( + (fk) => + fk.columnNames.includes('parentFolderId') && + fk.referencedTableName === `${tablePrefix}folder`, + ); + + if (!foreignKey) + throw new UnexpectedError('Foreign key in column parentFolderId was not found'); + + await dropForeignKey('workflow_entity', 'parentFolderId', ['folder', 'id'], foreignKey.name); + + await queryRunner.query( + `ALTER TABLE ${workflowTableName} ADD CONSTRAINT fk_workflow_parent_folder FOREIGN KEY (${parentFolderIdColumn}) REFERENCES ${folderTableName}(${idColumn}) ON DELETE CASCADE`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 0d5d7d72bd..03d1368072 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -44,6 +44,7 @@ import { MigrateToTimestampTz1694091729095 } from './1694091729095-MigrateToTime import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; import { FixExecutionMetadataSequence1721377157740 } from './1721377157740-FixExecutionMetadataSequence'; import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString'; +import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn'; import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities'; import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; @@ -164,4 +165,5 @@ export const postgresMigrations: Migration[] = [ AddErrorColumnsToTestRuns1737715421462, CreateFolderTable1738709609940, CreateAnalyticsTables1739549398681, + UpdateParentFolderIdColumn1740445074052, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1740445074052-UpdateParentFolderIdColumn.ts b/packages/cli/src/databases/migrations/sqlite/1740445074052-UpdateParentFolderIdColumn.ts new file mode 100644 index 0000000000..634e6cac8a --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1740445074052-UpdateParentFolderIdColumn.ts @@ -0,0 +1,61 @@ +import type { MigrationContext } from '@/databases/types'; + +import type { UpdateParentFolderIdColumn1740445074052 as BaseMigration } from './1740445074052-UpdateParentFolderIdColumn'; + +export class UpdateParentFolderIdColumn1740445074052 implements BaseMigration { + transaction = false as const; + + async up({ + queryRunner, + copyTable, + schemaBuilder: { createTable, column }, + tablePrefix, + }: MigrationContext) { + await createTable('temp_workflow_entity') + .withColumns( + column('id').varchar(36).primary.notNull, + column('name').varchar(128).notNull, + column('active').bool.notNull, + column('nodes').json, + column('connections').json, + column('settings').json, + column('staticData').json, + column('pinData').json, + column('versionId').varchar(36), + column('triggerCount').int.default(0), + column('meta').json, + column('parentFolderId').varchar(36).default(null), + ) + .withForeignKey('parentFolderId', { + tableName: 'folder', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withIndexOn(['name'], false).withTimestamps; + + const columns = [ + 'id', + 'name', + 'active', + 'nodes', + 'connections', + 'settings', + 'staticData', + 'pinData', + 'versionId', + 'triggerCount', + 'meta', + 'parentFolderId', + 'createdAt', + 'updatedAt', + ]; + + await copyTable(`${tablePrefix}workflow_entity`, 'temp_workflow_entity', columns); + + await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity";`); + + await queryRunner.query( + `ALTER TABLE "temp_workflow_entity" RENAME TO "${tablePrefix}workflow_entity";`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 73e1bc180c..c175d214c7 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -43,6 +43,7 @@ import { AddProjectIcons1729607673469 } from './1729607673469-AddProjectIcons'; import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition'; import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString'; import { CreateFolderTable1738709609940 } from './1738709609940-CreateFolderTable'; +import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; @@ -158,6 +159,7 @@ const sqliteMigrations: Migration[] = [ AddErrorColumnsToTestRuns1737715421462, CreateFolderTable1738709609940, CreateAnalyticsTables1739549398681, + UpdateParentFolderIdColumn1740445074052, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/folder.repository.ts b/packages/cli/src/databases/repositories/folder.repository.ts index bc210ac4d8..d0e3cb0c63 100644 --- a/packages/cli/src/databases/repositories/folder.repository.ts +++ b/packages/cli/src/databases/repositories/folder.repository.ts @@ -1,6 +1,7 @@ import { Service } from '@n8n/di'; import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm'; +import { PROJECT_ROOT } from 'n8n-workflow'; import type { ListQuery } from '@/requests'; @@ -152,7 +153,7 @@ export class FolderRepository extends Repository { }); } - if (filter?.parentFolderId === '0') { + if (filter?.parentFolderId === PROJECT_ROOT) { query.andWhere('folder.parentFolderId IS NULL'); } else if (filter?.parentFolderId) { query.andWhere('folder.parentFolderId = :parentFolderId', { @@ -242,4 +243,23 @@ export class FolderRepository extends Repository { }, }); } + + async moveAllToFolder( + fromFolderId: string, + toFolderId: string, + tx: EntityManager, + ): Promise { + await tx.update( + Folder, + { parentFolder: { id: fromFolderId } }, + { + parentFolder: + toFolderId === PROJECT_ROOT + ? null + : { + id: toFolderId, + }, + }, + ); + } } diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index e046ce21ba..b865aab28b 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -8,7 +8,9 @@ import type { FindOptionsSelect, FindManyOptions, FindOptionsRelations, + EntityManager, } from '@n8n/typeorm'; +import { PROJECT_ROOT } from 'n8n-workflow'; import type { ListQuery } from '@/requests'; import { isStringArray } from '@/utils'; @@ -394,7 +396,7 @@ export class WorkflowRepository extends Repository { qb: SelectQueryBuilder, filter: ListQuery.Options['filter'], ): void { - if (filter?.parentFolderId === '0') { + if (filter?.parentFolderId === PROJECT_ROOT) { qb.andWhere('workflow.parentFolderId IS NULL'); } else if (filter?.parentFolderId) { qb.andWhere('workflow.parentFolderId = :parentFolderId', { @@ -611,4 +613,16 @@ export class WorkflowRepository extends Repository { async findByActiveState(activeState: boolean) { return await this.findBy({ active: activeState }); } + + async moveAllToFolder(fromFolderId: string, toFolderId: string, tx: EntityManager) { + await tx.update( + WorkflowEntity, + { parentFolder: { id: fromFolderId } }, + { + parentFolder: { + id: toFolderId, + }, + }, + ); + } } diff --git a/packages/cli/src/permissions.ee/project-roles.ts b/packages/cli/src/permissions.ee/project-roles.ts index cd5f285e2f..f642d4b252 100644 --- a/packages/cli/src/permissions.ee/project-roles.ts +++ b/packages/cli/src/permissions.ee/project-roles.ts @@ -28,6 +28,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'folder:create', 'folder:read', 'folder:update', + 'folder:delete', ]; export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ @@ -51,6 +52,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'folder:create', 'folder:read', 'folder:update', + 'folder:delete', ]; export const PROJECT_EDITOR_SCOPES: Scope[] = [ @@ -70,6 +72,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'folder:create', 'folder:read', 'folder:update', + 'folder:delete', ]; export const PROJECT_VIEWER_SCOPES: Scope[] = [ diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts index f3caacdd92..2a7512f9bc 100644 --- a/packages/cli/src/services/folder.service.ts +++ b/packages/cli/src/services/folder.service.ts @@ -1,10 +1,12 @@ -import type { CreateFolderDto, UpdateFolderDto } from '@n8n/api-types'; +import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } 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 { Folder } from '@/databases/entities/folder'; import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag-mapping.repository'; import { FolderRepository } from '@/databases/repositories/folder.repository'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; export interface SimpleFolderNode { @@ -24,12 +26,13 @@ export class FolderService { constructor( private readonly folderRepository: FolderRepository, private readonly folderTagMappingRepository: FolderTagMappingRepository, + private readonly workflowRepository: WorkflowRepository, ) {} async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) { let parentFolder = null; if (parentFolderId) { - parentFolder = await this.getFolderInProject(parentFolderId, projectId); + parentFolder = await this.findFolderInProjectOrFail(parentFolderId, projectId); } const folderEntity = this.folderRepository.create({ @@ -44,7 +47,7 @@ export class FolderService { } async updateFolder(folderId: string, projectId: string, { name, tagIds }: UpdateFolderDto) { - await this.getFolderInProject(folderId, projectId); + await this.findFolderInProjectOrFail(folderId, projectId); if (name) { await this.folderRepository.update({ id: folderId }, { name }); } @@ -53,7 +56,7 @@ export class FolderService { } } - async getFolderInProject(folderId: string, projectId: string, em?: EntityManager) { + async findFolderInProjectOrFail(folderId: string, projectId: string, em?: EntityManager) { try { return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId, em); } catch { @@ -62,7 +65,7 @@ export class FolderService { } async getFolderTree(folderId: string, projectId: string): Promise { - await this.getFolderInProject(folderId, projectId); + await this.findFolderInProjectOrFail(folderId, projectId); const escapedParentFolderId = this.folderRepository .createQueryBuilder() @@ -103,6 +106,24 @@ export class FolderService { return this.transformFolderPathToTree(result); } + async deleteFolder(folderId: string, projectId: string, { transferToFolderId }: DeleteFolderDto) { + await this.findFolderInProjectOrFail(folderId, projectId); + + if (!transferToFolderId) { + await this.folderRepository.delete({ id: folderId }); + return; + } + + await this.findFolderInProjectOrFail(transferToFolderId, projectId); + + return await this.folderRepository.manager.transaction(async (tx) => { + await this.folderRepository.moveAllToFolder(folderId, transferToFolderId, tx); + await this.workflowRepository.moveAllToFolder(folderId, transferToFolderId, tx); + await tx.delete(Folder, { id: folderId }); + return; + }); + } + private transformFolderPathToTree(flatPath: FolderPathRow[]): SimpleFolderNode[] { if (!flatPath || flatPath.length === 0) { return []; diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 79cdef3409..0744f3c5c4 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -159,7 +159,7 @@ export class WorkflowsController { if (parentFolderId) { try { - const parentFolder = await this.folderService.getFolderInProject( + const parentFolder = await this.folderService.findFolderInProjectOrFail( parentFolderId, project.id, transactionManager, diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 8f0454ac38..9935f4ec2d 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -3,8 +3,10 @@ import { Container } from '@n8n/di'; import type { User } from '@/databases/entities/user'; import { FolderRepository } from '@/databases/repositories/folder.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { createFolder } from '@test-integration/db/folders'; import { createTag } from '@test-integration/db/tags'; +import { createWorkflow } from '@test-integration/db/workflows'; import { createTeamProject, linkUserToProject } from '../shared/db/projects'; import { createOwner, createMember } from '../shared/db/users'; @@ -23,12 +25,14 @@ const testServer = utils.setupTestServer({ let projectRepository: ProjectRepository; let folderRepository: FolderRepository; +let workflowRepository: WorkflowRepository; beforeEach(async () => { await testDb.truncate(['Folder', 'SharedWorkflow', 'Tag', 'Project', 'ProjectRelation']); projectRepository = Container.get(ProjectRepository); folderRepository = Container.get(FolderRepository); + workflowRepository = Container.get(WorkflowRepository); owner = await createOwner(); member = await createMember(); @@ -463,3 +467,205 @@ describe('PATCH /projects/:projectId/folders/:folderId', () => { expect(folderWithTags?.tags[0].id).toBe(tag3.id); }); }); + +describe('DELETE /projects/:projectId/folders/:folderId', () => { + test('should not delete folder when project does not exist', async () => { + await authOwnerAgent + .delete('/projects/non-existing-id/folders/some-folder-id') + .send({}) + .expect(403); + }); + + test('should not delete folder when folder does not exist', async () => { + const project = await createTeamProject('test project', owner); + + await authOwnerAgent + .delete(`/projects/${project.id}/folders/non-existing-folder`) + .send({}) + .expect(404); + }); + + test('should not delete folder if user has project:viewer role in team project', async () => { + const project = await createTeamProject(undefined, owner); + const folder = await createFolder(project); + await linkUserToProject(member, project, 'project:viewer'); + + await authMemberAgent + .delete(`/projects/${project.id}/folders/${folder.id}`) + .send({}) + .expect(403); + + const folderInDb = await folderRepository.findOneBy({ id: folder.id }); + expect(folderInDb).toBeDefined(); + }); + + test("should not allow deleting folder in another user's personal project", async () => { + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const folder = await createFolder(ownerPersonalProject); + + await authMemberAgent + .delete(`/projects/${ownerPersonalProject.id}/folders/${folder.id}`) + .send({}) + .expect(403); + + const folderInDb = await folderRepository.findOneBy({ id: folder.id }); + expect(folderInDb).toBeDefined(); + }); + + test('should delete folder if user has project:editor role in team project', async () => { + const project = await createTeamProject(undefined, owner); + const folder = await createFolder(project); + await linkUserToProject(member, project, 'project:editor'); + + await authMemberAgent + .delete(`/projects/${project.id}/folders/${folder.id}`) + .send({}) + .expect(200); + + const folderInDb = await folderRepository.findOneBy({ id: folder.id }); + expect(folderInDb).toBeNull(); + }); + + test('should delete folder if user has project:admin role in team project', async () => { + const project = await createTeamProject(undefined, owner); + const folder = await createFolder(project); + + await authOwnerAgent + .delete(`/projects/${project.id}/folders/${folder.id}`) + .send({}) + .expect(200); + + const folderInDb = await folderRepository.findOneBy({ id: folder.id }); + expect(folderInDb).toBeNull(); + }); + + test('should delete folder in personal project', async () => { + const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const folder = await createFolder(personalProject); + + await authOwnerAgent + .delete(`/projects/${personalProject.id}/folders/${folder.id}`) + .send({}) + .expect(200); + + const folderInDb = await folderRepository.findOneBy({ id: folder.id }); + expect(folderInDb).toBeNull(); + }); + + test('should delete folder, all child folders, and contained workflows when no transfer folder is specified', async () => { + const project = await createTeamProject('test', owner); + const rootFolder = await createFolder(project, { name: 'Root' }); + const childFolder = await createFolder(project, { + name: 'Child', + parentFolder: rootFolder, + }); + + // Create workflows in the folders + const workflow1 = await createWorkflow({ parentFolder: rootFolder }, owner); + + const workflow2 = await createWorkflow({ parentFolder: childFolder }, owner); + + await authOwnerAgent.delete(`/projects/${project.id}/folders/${rootFolder.id}`); + + // Check folders + const rootFolderInDb = await folderRepository.findOneBy({ id: rootFolder.id }); + const childFolderInDb = await folderRepository.findOneBy({ id: childFolder.id }); + + expect(rootFolderInDb).toBeNull(); + expect(childFolderInDb).toBeNull(); + + // Check workflows + const workflow1InDb = await workflowRepository.findOneBy({ id: workflow1.id }); + const workflow2InDb = await workflowRepository.findOneBy({ id: workflow2.id }); + expect(workflow1InDb).toBeNull(); + expect(workflow2InDb).toBeNull(); + }); + + test('should transfer folder contents when transferToFolderId is specified', async () => { + const project = await createTeamProject('test', owner); + const sourceFolder = await createFolder(project, { name: 'Source' }); + const targetFolder = await createFolder(project, { name: 'Target' }); + const childFolder = await createFolder(project, { + name: 'Child', + parentFolder: sourceFolder, + }); + + const workflow1 = await createWorkflow({ parentFolder: sourceFolder }, owner); + + const workflow2 = await createWorkflow({ parentFolder: childFolder }, owner); + + const payload = { + transferToFolderId: targetFolder.id, + }; + + await authOwnerAgent + .delete(`/projects/${project.id}/folders/${sourceFolder.id}`) + .send(payload) + .expect(200); + + const sourceFolderInDb = await folderRepository.findOne({ + where: { id: sourceFolder.id }, + relations: ['parentFolder'], + }); + const childFolderInDb = await folderRepository.findOne({ + where: { id: childFolder.id }, + relations: ['parentFolder'], + }); + + // Check folders + expect(sourceFolderInDb).toBeNull(); + expect(childFolderInDb).toBeDefined(); + expect(childFolderInDb?.parentFolder?.id).toBe(targetFolder.id); + + // Check workflows + const workflow1InDb = await workflowRepository.findOne({ + where: { id: workflow1.id }, + relations: ['parentFolder'], + }); + expect(workflow1InDb).toBeDefined(); + expect(workflow1InDb?.parentFolder?.id).toBe(targetFolder.id); + + const workflow2InDb = await workflowRepository.findOne({ + where: { id: workflow2.id }, + relations: ['parentFolder'], + }); + expect(workflow2InDb).toBeDefined(); + expect(workflow2InDb?.parentFolder?.id).toBe(childFolder.id); + }); + + test('should not transfer folder contents when transfer folder does not exist', async () => { + const project = await createTeamProject('test', owner); + const folder = await createFolder(project); + + const payload = { + transferToFolderId: 'non-existing-folder', + }; + + await authOwnerAgent + .delete(`/projects/${project.id}/folders/${folder.id}`) + .send(payload) + .expect(404); + + const folderInDb = await folderRepository.findOneBy({ id: folder.id }); + expect(folderInDb).toBeDefined(); + }); + + test('should not transfer folder contents when transfer folder is in another project', async () => { + const project1 = await createTeamProject('Project 1', owner); + const project2 = await createTeamProject('Project 2', owner); + const sourceFolder = await createFolder(project1); + const targetFolder = await createFolder(project2); + + const payload = { + transferToFolderId: targetFolder.id, + }; + + await authOwnerAgent + .delete(`/projects/${project1.id}/folders/${sourceFolder.id}`) + .send(payload) + .expect(404); + + const folderInDb = await folderRepository.findOneBy({ id: sourceFolder.id }); + expect(folderInDb).toBeDefined(); + }); +}); diff --git a/packages/workflow/src/Constants.ts b/packages/workflow/src/Constants.ts index 59e3c47d47..0dec1b5838 100644 --- a/packages/workflow/src/Constants.ts +++ b/packages/workflow/src/Constants.ts @@ -101,3 +101,5 @@ export const FREE_AI_CREDITS_ERROR_TYPE = 'free_ai_credits_request_error'; export const FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE = 400; export const FROM_AI_AUTO_GENERATED_MARKER = '/*n8n-auto-generated-fromAI-override*/'; + +export const PROJECT_ROOT = '0';