mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Add endpoint DELETE /projects/:projectId/folders/:folderId
(no-changelog) (#13516)
This commit is contained in:
parent
6c266acced
commit
0c266b3060
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { Z } from 'zod-class';
|
||||
|
||||
import { folderId } from '../../schemas/folder.schema';
|
||||
|
||||
export class DeleteFolderDto extends Z.class({
|
||||
transferToFolderId: folderId.optional(),
|
||||
}) {}
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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";`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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<FolderWithWorkflowCount> {
|
|||
});
|
||||
}
|
||||
|
||||
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<FolderWithWorkflowCount> {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
async moveAllToFolder(
|
||||
fromFolderId: string,
|
||||
toFolderId: string,
|
||||
tx: EntityManager,
|
||||
): Promise<void> {
|
||||
await tx.update(
|
||||
Folder,
|
||||
{ parentFolder: { id: fromFolderId } },
|
||||
{
|
||||
parentFolder:
|
||||
toFolderId === PROJECT_ROOT
|
||||
? null
|
||||
: {
|
||||
id: toFolderId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<WorkflowEntity> {
|
|||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
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<WorkflowEntity> {
|
|||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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[] = [
|
||||
|
|
|
@ -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<SimpleFolderNode[]> {
|
||||
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 [];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue