mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(core): Archive workflows when removing folders without transfer (#15057)
This commit is contained in:
@@ -104,7 +104,7 @@ export class ProjectController {
|
|||||||
const { projectId, folderId } = req.params;
|
const { projectId, folderId } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.folderService.deleteFolder(folderId, projectId, payload);
|
await this.folderService.deleteFolder(req.user, folderId, projectId, payload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof FolderNotFoundError) {
|
if (e instanceof FolderNotFoundError) {
|
||||||
throw new NotFoundError(e.message);
|
throw new NotFoundError(e.message);
|
||||||
|
|||||||
@@ -297,6 +297,22 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||||||
return [enrichedWorkflowsAndFolders, count] as const;
|
return [enrichedWorkflowsAndFolders, count] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllWorkflowIdsInHierarchy(folderId: string, projectId: string): Promise<string[]> {
|
||||||
|
const subFolderIds = await this.folderRepository.getAllFolderIdsInHierarchy(
|
||||||
|
folderId,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = this.createQueryBuilder('workflow');
|
||||||
|
|
||||||
|
this.applySelect(query, { id: true });
|
||||||
|
this.applyParentFolderFilter(query, { parentFolderIds: [folderId, ...subFolderIds] });
|
||||||
|
|
||||||
|
const workflowIds = (await query.getMany()).map((workflow) => workflow.id);
|
||||||
|
|
||||||
|
return workflowIds;
|
||||||
|
}
|
||||||
|
|
||||||
private getFolderIds(workflowsAndFolders: WorkflowFolderUnionRow[]) {
|
private getFolderIds(workflowsAndFolders: WorkflowFolderUnionRow[]) {
|
||||||
return workflowsAndFolders.filter((item) => item.resource === 'folder').map((item) => item.id);
|
return workflowsAndFolders.filter((item) => item.resource === 'folder').map((item) => item.id);
|
||||||
}
|
}
|
||||||
@@ -669,4 +685,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async moveToFolder(workflowIds: string[], toFolderId: string) {
|
||||||
|
await this.update(
|
||||||
|
{ id: In(workflowIds) },
|
||||||
|
{ parentFolder: toFolderId === PROJECT_ROOT ? null : { id: toFolderId } },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types';
|
import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types';
|
||||||
import { Folder, FolderTagMappingRepository, FolderRepository } from '@n8n/db';
|
import { Folder, FolderTagMappingRepository, FolderRepository, type User } from '@n8n/db';
|
||||||
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';
|
||||||
@@ -8,6 +8,7 @@ import { UserError, PROJECT_ROOT } from 'n8n-workflow';
|
|||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
||||||
import type { ListQuery } from '@/requests';
|
import type { ListQuery } from '@/requests';
|
||||||
|
import { WorkflowService } from '@/workflows/workflow.service';
|
||||||
|
|
||||||
export interface SimpleFolderNode {
|
export interface SimpleFolderNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +28,7 @@ export class FolderService {
|
|||||||
private readonly folderRepository: FolderRepository,
|
private readonly folderRepository: FolderRepository,
|
||||||
private readonly folderTagMappingRepository: FolderTagMappingRepository,
|
private readonly folderTagMappingRepository: FolderTagMappingRepository,
|
||||||
private readonly workflowRepository: WorkflowRepository,
|
private readonly workflowRepository: WorkflowRepository,
|
||||||
|
private readonly workflowService: WorkflowService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) {
|
async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) {
|
||||||
@@ -124,10 +126,35 @@ export class FolderService {
|
|||||||
return this.transformFolderPathToTree(result);
|
return this.transformFolderPathToTree(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFolder(folderId: string, projectId: string, { transferToFolderId }: DeleteFolderDto) {
|
/**
|
||||||
|
* Moves all workflows in a folder to the root of the project and archives them,
|
||||||
|
* flattening the folder structure.
|
||||||
|
*
|
||||||
|
* If any workflows were active this will also deactivate those workflows.
|
||||||
|
*/
|
||||||
|
async flattenAndArchive(user: User, folderId: string, projectId: string): Promise<void> {
|
||||||
|
const workflowIds = await this.workflowRepository.getAllWorkflowIdsInHierarchy(
|
||||||
|
folderId,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const workflowId of workflowIds) {
|
||||||
|
await this.workflowService.archive(user, workflowId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.workflowRepository.moveToFolder(workflowIds, PROJECT_ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFolder(
|
||||||
|
user: User,
|
||||||
|
folderId: string,
|
||||||
|
projectId: string,
|
||||||
|
{ transferToFolderId }: DeleteFolderDto,
|
||||||
|
) {
|
||||||
await this.findFolderInProjectOrFail(folderId, projectId);
|
await this.findFolderInProjectOrFail(folderId, projectId);
|
||||||
|
|
||||||
if (!transferToFolderId) {
|
if (!transferToFolderId) {
|
||||||
|
await this.flattenAndArchive(user, folderId, projectId);
|
||||||
await this.folderRepository.delete({ id: folderId });
|
await this.folderRepository.delete({ id: folderId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { User, WorkflowEntity, ListQueryDb } from '@n8n/db';
|
|||||||
import {
|
import {
|
||||||
SharedWorkflow,
|
SharedWorkflow,
|
||||||
ExecutionRepository,
|
ExecutionRepository,
|
||||||
|
FolderRepository,
|
||||||
WorkflowTagMappingRepository,
|
WorkflowTagMappingRepository,
|
||||||
SharedWorkflowRepository,
|
SharedWorkflowRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
@@ -23,6 +24,7 @@ import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
|||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { WorkflowFolderUnionFull } from '@/databases/repositories/workflow.repository';
|
import type { WorkflowFolderUnionFull } from '@/databases/repositories/workflow.repository';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
|
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
@@ -30,7 +32,6 @@ import { ExternalHooks } from '@/external-hooks';
|
|||||||
import { validateEntity } from '@/generic-helpers';
|
import { validateEntity } from '@/generic-helpers';
|
||||||
import type { ListQuery } from '@/requests';
|
import type { ListQuery } from '@/requests';
|
||||||
import { hasSharing } from '@/requests';
|
import { hasSharing } from '@/requests';
|
||||||
import { FolderService } from '@/services/folder.service';
|
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
import { ProjectService } from '@/services/project.service.ee';
|
import { ProjectService } from '@/services/project.service.ee';
|
||||||
import { RoleService } from '@/services/role.service';
|
import { RoleService } from '@/services/role.service';
|
||||||
@@ -60,7 +61,7 @@ export class WorkflowService {
|
|||||||
private readonly executionRepository: ExecutionRepository,
|
private readonly executionRepository: ExecutionRepository,
|
||||||
private readonly eventService: EventService,
|
private readonly eventService: EventService,
|
||||||
private readonly globalConfig: GlobalConfig,
|
private readonly globalConfig: GlobalConfig,
|
||||||
private readonly folderService: FolderService,
|
private readonly folderRepository: FolderRepository,
|
||||||
private readonly workflowFinderService: WorkflowFinderService,
|
private readonly workflowFinderService: WorkflowFinderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -301,7 +302,14 @@ export class WorkflowService {
|
|||||||
if (parentFolderId) {
|
if (parentFolderId) {
|
||||||
const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id);
|
const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id);
|
||||||
if (parentFolderId !== PROJECT_ROOT) {
|
if (parentFolderId !== PROJECT_ROOT) {
|
||||||
await this.folderService.findFolderInProjectOrFail(parentFolderId, project?.id ?? '');
|
try {
|
||||||
|
await this.folderRepository.findOneOrFailFolderInProject(
|
||||||
|
parentFolderId,
|
||||||
|
project?.id ?? '',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw new FolderNotFoundError(parentFolderId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updatePayload.parentFolder = parentFolderId === PROJECT_ROOT ? null : { id: parentFolderId };
|
updatePayload.parentFolder = parentFolderId === PROJECT_ROOT ? null : { id: parentFolderId };
|
||||||
}
|
}
|
||||||
@@ -417,7 +425,11 @@ export class WorkflowService {
|
|||||||
return workflow;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
async archive(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
|
async archive(
|
||||||
|
user: User,
|
||||||
|
workflowId: string,
|
||||||
|
skipArchived: boolean = false,
|
||||||
|
): Promise<WorkflowEntity | undefined> {
|
||||||
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
|
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||||
'workflow:delete',
|
'workflow:delete',
|
||||||
]);
|
]);
|
||||||
@@ -427,6 +439,10 @@ export class WorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (workflow.isArchived) {
|
if (workflow.isArchived) {
|
||||||
|
if (skipArchived) {
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
throw new BadRequestError('Workflow is already archived.');
|
throw new BadRequestError('Workflow is already archived.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
|
|||||||
expect(folderInDb).toBeNull();
|
expect(folderInDb).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should delete folder, all child folders, and contained workflows when no transfer folder is specified', async () => {
|
test('should delete folder, all child folders, and archive and move contained workflows to project root when no transfer folder is specified', async () => {
|
||||||
const project = await createTeamProject('test', owner);
|
const project = await createTeamProject('test', owner);
|
||||||
const rootFolder = await createFolder(project, { name: 'Root' });
|
const rootFolder = await createFolder(project, { name: 'Root' });
|
||||||
const childFolder = await createFolder(project, {
|
const childFolder = await createFolder(project, {
|
||||||
@@ -766,9 +766,8 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create workflows in the folders
|
// Create workflows in the folders
|
||||||
const workflow1 = await createWorkflow({ parentFolder: rootFolder }, owner);
|
const workflow1 = await createWorkflow({ parentFolder: rootFolder, active: false }, owner);
|
||||||
|
const workflow2 = await createWorkflow({ parentFolder: childFolder, active: true }, owner);
|
||||||
const workflow2 = await createWorkflow({ parentFolder: childFolder }, owner);
|
|
||||||
|
|
||||||
await authOwnerAgent.delete(`/projects/${project.id}/folders/${rootFolder.id}`);
|
await authOwnerAgent.delete(`/projects/${project.id}/folders/${rootFolder.id}`);
|
||||||
|
|
||||||
@@ -780,10 +779,24 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
|
|||||||
expect(childFolderInDb).toBeNull();
|
expect(childFolderInDb).toBeNull();
|
||||||
|
|
||||||
// Check workflows
|
// Check workflows
|
||||||
const workflow1InDb = await workflowRepository.findOneBy({ id: workflow1.id });
|
|
||||||
const workflow2InDb = await workflowRepository.findOneBy({ id: workflow2.id });
|
const workflow1InDb = await workflowRepository.findOne({
|
||||||
expect(workflow1InDb).toBeNull();
|
where: { id: workflow1.id },
|
||||||
expect(workflow2InDb).toBeNull();
|
relations: ['parentFolder'],
|
||||||
|
});
|
||||||
|
expect(workflow1InDb).not.toBeNull();
|
||||||
|
expect(workflow1InDb?.isArchived).toBe(true);
|
||||||
|
expect(workflow1InDb?.parentFolder).toBe(null);
|
||||||
|
expect(workflow1InDb?.active).toBe(false);
|
||||||
|
|
||||||
|
const workflow2InDb = await workflowRepository.findOne({
|
||||||
|
where: { id: workflow2.id },
|
||||||
|
relations: ['parentFolder'],
|
||||||
|
});
|
||||||
|
expect(workflow2InDb).not.toBeNull();
|
||||||
|
expect(workflow2InDb?.isArchived).toBe(true);
|
||||||
|
expect(workflow2InDb?.parentFolder).toBe(null);
|
||||||
|
expect(workflow2InDb?.active).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should transfer folder contents when transferToFolderId is specified', async () => {
|
test('should transfer folder contents when transferToFolderId is specified', async () => {
|
||||||
|
|||||||
@@ -949,7 +949,7 @@
|
|||||||
"folder.count": "the {count} folder | the {count} folders",
|
"folder.count": "the {count} folder | the {count} folders",
|
||||||
"workflow.count": "the {count} workflow | the {count} workflows",
|
"workflow.count": "the {count} workflow | the {count} workflows",
|
||||||
"folder.and.workflow.separator": "and",
|
"folder.and.workflow.separator": "and",
|
||||||
"folders.delete.action": "Delete all workflows and subfolders",
|
"folders.delete.action": "Archive all workflows and delete subfolders",
|
||||||
"folders.delete.error.message": "Problem while deleting folder",
|
"folders.delete.error.message": "Problem while deleting folder",
|
||||||
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
|
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
|
||||||
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",
|
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",
|
||||||
|
|||||||
Reference in New Issue
Block a user