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 { Z } from 'zod-class';
|
||||||
|
|
||||||
import { folderNameSchema } from '../../schemas/folder.schema';
|
import { folderNameSchema, folderId } from '../../schemas/folder.schema';
|
||||||
|
|
||||||
export class CreateFolderDto extends Z.class({
|
export class CreateFolderDto extends Z.class({
|
||||||
name: folderNameSchema,
|
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 { CreateFolderDto } from './folders/create-folder.dto';
|
||||||
export { UpdateFolderDto } from './folders/update-folder.dto';
|
export { UpdateFolderDto } from './folders/update-folder.dto';
|
||||||
|
export { DeleteFolderDto } from './folders/delete-folder.dto';
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const folderNameSchema = z.string().trim().min(1).max(128);
|
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,
|
variable: [...DEFAULT_OPERATIONS] as const,
|
||||||
workersView: ['manage'] as const,
|
workersView: ['manage'] as const,
|
||||||
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] 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;
|
} 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 { 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 { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
|
@ -26,7 +26,7 @@ export class ProjectController {
|
||||||
if (e instanceof FolderNotFoundError) {
|
if (e instanceof FolderNotFoundError) {
|
||||||
throw new NotFoundError(e.message);
|
throw new NotFoundError(e.message);
|
||||||
}
|
}
|
||||||
throw new InternalServerError();
|
throw new InternalServerError(undefined, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ export class ProjectController {
|
||||||
if (e instanceof FolderNotFoundError) {
|
if (e instanceof FolderNotFoundError) {
|
||||||
throw new NotFoundError(e.message);
|
throw new NotFoundError(e.message);
|
||||||
}
|
}
|
||||||
throw new InternalServerError();
|
throw new InternalServerError(undefined, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,26 @@ export class ProjectController {
|
||||||
if (e instanceof FolderNotFoundError) {
|
if (e instanceof FolderNotFoundError) {
|
||||||
throw new NotFoundError(e.message);
|
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()
|
@Column()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@ManyToOne(() => Folder, { nullable: true })
|
@ManyToOne(() => Folder, { nullable: true, onDelete: 'CASCADE' })
|
||||||
@JoinColumn({ name: 'parentFolderId' })
|
@JoinColumn({ name: 'parentFolderId' })
|
||||||
parentFolder: Folder | null;
|
parentFolder: Folder | null;
|
||||||
|
|
||||||
|
|
|
@ -100,7 +100,7 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl
|
||||||
|
|
||||||
@ManyToOne('Folder', 'workflows', {
|
@ManyToOne('Folder', 'workflows', {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
onDelete: 'SET NULL',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'parentFolderId' })
|
@JoinColumn({ name: 'parentFolderId' })
|
||||||
parentFolder: Folder | null;
|
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 { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-AddErrorColumnsToTestRuns';
|
||||||
import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable';
|
import { CreateFolderTable1738709609940 } from '../common/1738709609940-CreateFolderTable';
|
||||||
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
||||||
|
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
|
||||||
|
|
||||||
export const mysqlMigrations: Migration[] = [
|
export const mysqlMigrations: Migration[] = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -166,4 +167,5 @@ export const mysqlMigrations: Migration[] = [
|
||||||
CreateFolderTable1738709609940,
|
CreateFolderTable1738709609940,
|
||||||
FixTestDefinitionPrimaryKey1739873751194,
|
FixTestDefinitionPrimaryKey1739873751194,
|
||||||
CreateAnalyticsTables1739549398681,
|
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 { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
|
||||||
import { FixExecutionMetadataSequence1721377157740 } from './1721377157740-FixExecutionMetadataSequence';
|
import { FixExecutionMetadataSequence1721377157740 } from './1721377157740-FixExecutionMetadataSequence';
|
||||||
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
|
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
|
||||||
|
import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn';
|
||||||
import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities';
|
import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities';
|
||||||
import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections';
|
import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections';
|
||||||
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
|
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
|
||||||
|
@ -164,4 +165,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
AddErrorColumnsToTestRuns1737715421462,
|
AddErrorColumnsToTestRuns1737715421462,
|
||||||
CreateFolderTable1738709609940,
|
CreateFolderTable1738709609940,
|
||||||
CreateAnalyticsTables1739549398681,
|
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 { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition';
|
||||||
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
|
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
|
||||||
import { CreateFolderTable1738709609940 } from './1738709609940-CreateFolderTable';
|
import { CreateFolderTable1738709609940 } from './1738709609940-CreateFolderTable';
|
||||||
|
import { UpdateParentFolderIdColumn1740445074052 } from './1740445074052-UpdateParentFolderIdColumn';
|
||||||
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
|
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
|
||||||
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
|
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
|
||||||
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
|
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
|
||||||
|
@ -158,6 +159,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
AddErrorColumnsToTestRuns1737715421462,
|
AddErrorColumnsToTestRuns1737715421462,
|
||||||
CreateFolderTable1738709609940,
|
CreateFolderTable1738709609940,
|
||||||
CreateAnalyticsTables1739549398681,
|
CreateAnalyticsTables1739549398681,
|
||||||
|
UpdateParentFolderIdColumn1740445074052,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
|
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
import { DataSource, Repository } from '@n8n/typeorm';
|
import { DataSource, Repository } from '@n8n/typeorm';
|
||||||
|
import { PROJECT_ROOT } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { ListQuery } from '@/requests';
|
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');
|
query.andWhere('folder.parentFolderId IS NULL');
|
||||||
} else if (filter?.parentFolderId) {
|
} else if (filter?.parentFolderId) {
|
||||||
query.andWhere('folder.parentFolderId = :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,
|
FindOptionsSelect,
|
||||||
FindManyOptions,
|
FindManyOptions,
|
||||||
FindOptionsRelations,
|
FindOptionsRelations,
|
||||||
|
EntityManager,
|
||||||
} from '@n8n/typeorm';
|
} from '@n8n/typeorm';
|
||||||
|
import { PROJECT_ROOT } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { ListQuery } from '@/requests';
|
import type { ListQuery } from '@/requests';
|
||||||
import { isStringArray } from '@/utils';
|
import { isStringArray } from '@/utils';
|
||||||
|
@ -394,7 +396,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||||
filter: ListQuery.Options['filter'],
|
filter: ListQuery.Options['filter'],
|
||||||
): void {
|
): void {
|
||||||
if (filter?.parentFolderId === '0') {
|
if (filter?.parentFolderId === PROJECT_ROOT) {
|
||||||
qb.andWhere('workflow.parentFolderId IS NULL');
|
qb.andWhere('workflow.parentFolderId IS NULL');
|
||||||
} else if (filter?.parentFolderId) {
|
} else if (filter?.parentFolderId) {
|
||||||
qb.andWhere('workflow.parentFolderId = :parentFolderId', {
|
qb.andWhere('workflow.parentFolderId = :parentFolderId', {
|
||||||
|
@ -611,4 +613,16 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||||
async findByActiveState(activeState: boolean) {
|
async findByActiveState(activeState: boolean) {
|
||||||
return await this.findBy({ active: activeState });
|
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:create',
|
||||||
'folder:read',
|
'folder:read',
|
||||||
'folder:update',
|
'folder:update',
|
||||||
|
'folder:delete',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
|
export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
|
||||||
|
@ -51,6 +52,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
|
||||||
'folder:create',
|
'folder:create',
|
||||||
'folder:read',
|
'folder:read',
|
||||||
'folder:update',
|
'folder:update',
|
||||||
|
'folder:delete',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
||||||
|
@ -70,6 +72,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
||||||
'folder:create',
|
'folder:create',
|
||||||
'folder:read',
|
'folder:read',
|
||||||
'folder:update',
|
'folder:update',
|
||||||
|
'folder:delete',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROJECT_VIEWER_SCOPES: Scope[] = [
|
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';
|
import { Service } from '@n8n/di';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import type { EntityManager } from '@n8n/typeorm';
|
import type { EntityManager } from '@n8n/typeorm';
|
||||||
|
|
||||||
|
import { Folder } from '@/databases/entities/folder';
|
||||||
import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag-mapping.repository';
|
import { FolderTagMappingRepository } from '@/databases/repositories/folder-tag-mapping.repository';
|
||||||
import { FolderRepository } from '@/databases/repositories/folder.repository';
|
import { FolderRepository } from '@/databases/repositories/folder.repository';
|
||||||
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
||||||
|
|
||||||
export interface SimpleFolderNode {
|
export interface SimpleFolderNode {
|
||||||
|
@ -24,12 +26,13 @@ export class FolderService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly folderRepository: FolderRepository,
|
private readonly folderRepository: FolderRepository,
|
||||||
private readonly folderTagMappingRepository: FolderTagMappingRepository,
|
private readonly folderTagMappingRepository: FolderTagMappingRepository,
|
||||||
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) {
|
async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) {
|
||||||
let parentFolder = null;
|
let parentFolder = null;
|
||||||
if (parentFolderId) {
|
if (parentFolderId) {
|
||||||
parentFolder = await this.getFolderInProject(parentFolderId, projectId);
|
parentFolder = await this.findFolderInProjectOrFail(parentFolderId, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderEntity = this.folderRepository.create({
|
const folderEntity = this.folderRepository.create({
|
||||||
|
@ -44,7 +47,7 @@ export class FolderService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFolder(folderId: string, projectId: string, { name, tagIds }: UpdateFolderDto) {
|
async updateFolder(folderId: string, projectId: string, { name, tagIds }: UpdateFolderDto) {
|
||||||
await this.getFolderInProject(folderId, projectId);
|
await this.findFolderInProjectOrFail(folderId, projectId);
|
||||||
if (name) {
|
if (name) {
|
||||||
await this.folderRepository.update({ id: folderId }, { 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 {
|
try {
|
||||||
return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId, em);
|
return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId, em);
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -62,7 +65,7 @@ export class FolderService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFolderTree(folderId: string, projectId: string): Promise<SimpleFolderNode[]> {
|
async getFolderTree(folderId: string, projectId: string): Promise<SimpleFolderNode[]> {
|
||||||
await this.getFolderInProject(folderId, projectId);
|
await this.findFolderInProjectOrFail(folderId, projectId);
|
||||||
|
|
||||||
const escapedParentFolderId = this.folderRepository
|
const escapedParentFolderId = this.folderRepository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
|
@ -103,6 +106,24 @@ export class FolderService {
|
||||||
return this.transformFolderPathToTree(result);
|
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[] {
|
private transformFolderPathToTree(flatPath: FolderPathRow[]): SimpleFolderNode[] {
|
||||||
if (!flatPath || flatPath.length === 0) {
|
if (!flatPath || flatPath.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
@ -159,7 +159,7 @@ export class WorkflowsController {
|
||||||
|
|
||||||
if (parentFolderId) {
|
if (parentFolderId) {
|
||||||
try {
|
try {
|
||||||
const parentFolder = await this.folderService.getFolderInProject(
|
const parentFolder = await this.folderService.findFolderInProjectOrFail(
|
||||||
parentFolderId,
|
parentFolderId,
|
||||||
project.id,
|
project.id,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
|
|
|
@ -3,8 +3,10 @@ import { Container } from '@n8n/di';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { FolderRepository } from '@/databases/repositories/folder.repository';
|
import { FolderRepository } from '@/databases/repositories/folder.repository';
|
||||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||||
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { createFolder } from '@test-integration/db/folders';
|
import { createFolder } from '@test-integration/db/folders';
|
||||||
import { createTag } from '@test-integration/db/tags';
|
import { createTag } from '@test-integration/db/tags';
|
||||||
|
import { createWorkflow } from '@test-integration/db/workflows';
|
||||||
|
|
||||||
import { createTeamProject, linkUserToProject } from '../shared/db/projects';
|
import { createTeamProject, linkUserToProject } from '../shared/db/projects';
|
||||||
import { createOwner, createMember } from '../shared/db/users';
|
import { createOwner, createMember } from '../shared/db/users';
|
||||||
|
@ -23,12 +25,14 @@ const testServer = utils.setupTestServer({
|
||||||
|
|
||||||
let projectRepository: ProjectRepository;
|
let projectRepository: ProjectRepository;
|
||||||
let folderRepository: FolderRepository;
|
let folderRepository: FolderRepository;
|
||||||
|
let workflowRepository: WorkflowRepository;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await testDb.truncate(['Folder', 'SharedWorkflow', 'Tag', 'Project', 'ProjectRelation']);
|
await testDb.truncate(['Folder', 'SharedWorkflow', 'Tag', 'Project', 'ProjectRelation']);
|
||||||
|
|
||||||
projectRepository = Container.get(ProjectRepository);
|
projectRepository = Container.get(ProjectRepository);
|
||||||
folderRepository = Container.get(FolderRepository);
|
folderRepository = Container.get(FolderRepository);
|
||||||
|
workflowRepository = Container.get(WorkflowRepository);
|
||||||
|
|
||||||
owner = await createOwner();
|
owner = await createOwner();
|
||||||
member = await createMember();
|
member = await createMember();
|
||||||
|
@ -463,3 +467,205 @@ describe('PATCH /projects/:projectId/folders/:folderId', () => {
|
||||||
expect(folderWithTags?.tags[0].id).toBe(tag3.id);
|
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 FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE = 400;
|
||||||
|
|
||||||
export const FROM_AI_AUTO_GENERATED_MARKER = '/*n8n-auto-generated-fromAI-override*/';
|
export const FROM_AI_AUTO_GENERATED_MARKER = '/*n8n-auto-generated-fromAI-override*/';
|
||||||
|
|
||||||
|
export const PROJECT_ROOT = '0';
|
||||||
|
|
Loading…
Reference in a new issue