Skip to content

Commit 403f08b

Browse files
authored
feat(core): Archive workflows when removing folders without transfer (#15057)
1 parent 14f5937 commit 403f08b

File tree

6 files changed

+95
-16
lines changed

6 files changed

+95
-16
lines changed

packages/cli/src/controllers/folder.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export class ProjectController {
104104
const { projectId, folderId } = req.params;
105105

106106
try {
107-
await this.folderService.deleteFolder(folderId, projectId, payload);
107+
await this.folderService.deleteFolder(req.user, folderId, projectId, payload);
108108
} catch (e) {
109109
if (e instanceof FolderNotFoundError) {
110110
throw new NotFoundError(e.message);

packages/cli/src/databases/repositories/workflow.repository.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,22 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
297297
return [enrichedWorkflowsAndFolders, count] as const;
298298
}
299299

300+
async getAllWorkflowIdsInHierarchy(folderId: string, projectId: string): Promise<string[]> {
301+
const subFolderIds = await this.folderRepository.getAllFolderIdsInHierarchy(
302+
folderId,
303+
projectId,
304+
);
305+
306+
const query = this.createQueryBuilder('workflow');
307+
308+
this.applySelect(query, { id: true });
309+
this.applyParentFolderFilter(query, { parentFolderIds: [folderId, ...subFolderIds] });
310+
311+
const workflowIds = (await query.getMany()).map((workflow) => workflow.id);
312+
313+
return workflowIds;
314+
}
315+
300316
private getFolderIds(workflowsAndFolders: WorkflowFolderUnionRow[]) {
301317
return workflowsAndFolders.filter((item) => item.resource === 'folder').map((item) => item.id);
302318
}
@@ -669,4 +685,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
669685
},
670686
);
671687
}
688+
689+
async moveToFolder(workflowIds: string[], toFolderId: string) {
690+
await this.update(
691+
{ id: In(workflowIds) },
692+
{ parentFolder: toFolderId === PROJECT_ROOT ? null : { id: toFolderId } },
693+
);
694+
}
672695
}

packages/cli/src/services/folder.service.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CreateFolderDto, DeleteFolderDto, UpdateFolderDto } from '@n8n/api-types';
2-
import { Folder, FolderTagMappingRepository, FolderRepository } from '@n8n/db';
2+
import { Folder, FolderTagMappingRepository, FolderRepository, type User } from '@n8n/db';
33
import { Service } from '@n8n/di';
44
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
55
import type { EntityManager } from '@n8n/typeorm';
@@ -8,6 +8,7 @@ import { UserError, PROJECT_ROOT } from 'n8n-workflow';
88
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
99
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
1010
import type { ListQuery } from '@/requests';
11+
import { WorkflowService } from '@/workflows/workflow.service';
1112

1213
export interface SimpleFolderNode {
1314
id: string;
@@ -27,6 +28,7 @@ export class FolderService {
2728
private readonly folderRepository: FolderRepository,
2829
private readonly folderTagMappingRepository: FolderTagMappingRepository,
2930
private readonly workflowRepository: WorkflowRepository,
31+
private readonly workflowService: WorkflowService,
3032
) {}
3133

3234
async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) {
@@ -124,10 +126,35 @@ export class FolderService {
124126
return this.transformFolderPathToTree(result);
125127
}
126128

127-
async deleteFolder(folderId: string, projectId: string, { transferToFolderId }: DeleteFolderDto) {
129+
/**
130+
* Moves all workflows in a folder to the root of the project and archives them,
131+
* flattening the folder structure.
132+
*
133+
* If any workflows were active this will also deactivate those workflows.
134+
*/
135+
async flattenAndArchive(user: User, folderId: string, projectId: string): Promise<void> {
136+
const workflowIds = await this.workflowRepository.getAllWorkflowIdsInHierarchy(
137+
folderId,
138+
projectId,
139+
);
140+
141+
for (const workflowId of workflowIds) {
142+
await this.workflowService.archive(user, workflowId, true);
143+
}
144+
145+
await this.workflowRepository.moveToFolder(workflowIds, PROJECT_ROOT);
146+
}
147+
148+
async deleteFolder(
149+
user: User,
150+
folderId: string,
151+
projectId: string,
152+
{ transferToFolderId }: DeleteFolderDto,
153+
) {
128154
await this.findFolderInProjectOrFail(folderId, projectId);
129155

130156
if (!transferToFolderId) {
157+
await this.flattenAndArchive(user, folderId, projectId);
131158
await this.folderRepository.delete({ id: folderId });
132159
return;
133160
}

packages/cli/src/workflows/workflow.service.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { User, WorkflowEntity, ListQueryDb } from '@n8n/db';
33
import {
44
SharedWorkflow,
55
ExecutionRepository,
6+
FolderRepository,
67
WorkflowTagMappingRepository,
78
SharedWorkflowRepository,
89
} from '@n8n/db';
@@ -23,14 +24,14 @@ import { ActiveWorkflowManager } from '@/active-workflow-manager';
2324
import config from '@/config';
2425
import type { WorkflowFolderUnionFull } from '@/databases/repositories/workflow.repository';
2526
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
27+
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
2628
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
2729
import { NotFoundError } from '@/errors/response-errors/not-found.error';
2830
import { EventService } from '@/events/event.service';
2931
import { ExternalHooks } from '@/external-hooks';
3032
import { validateEntity } from '@/generic-helpers';
3133
import type { ListQuery } from '@/requests';
3234
import { hasSharing } from '@/requests';
33-
import { FolderService } from '@/services/folder.service';
3435
import { OwnershipService } from '@/services/ownership.service';
3536
import { ProjectService } from '@/services/project.service.ee';
3637
import { RoleService } from '@/services/role.service';
@@ -60,7 +61,7 @@ export class WorkflowService {
6061
private readonly executionRepository: ExecutionRepository,
6162
private readonly eventService: EventService,
6263
private readonly globalConfig: GlobalConfig,
63-
private readonly folderService: FolderService,
64+
private readonly folderRepository: FolderRepository,
6465
private readonly workflowFinderService: WorkflowFinderService,
6566
) {}
6667

@@ -301,7 +302,14 @@ export class WorkflowService {
301302
if (parentFolderId) {
302303
const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id);
303304
if (parentFolderId !== PROJECT_ROOT) {
304-
await this.folderService.findFolderInProjectOrFail(parentFolderId, project?.id ?? '');
305+
try {
306+
await this.folderRepository.findOneOrFailFolderInProject(
307+
parentFolderId,
308+
project?.id ?? '',
309+
);
310+
} catch (e) {
311+
throw new FolderNotFoundError(parentFolderId);
312+
}
305313
}
306314
updatePayload.parentFolder = parentFolderId === PROJECT_ROOT ? null : { id: parentFolderId };
307315
}
@@ -417,7 +425,11 @@ export class WorkflowService {
417425
return workflow;
418426
}
419427

420-
async archive(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
428+
async archive(
429+
user: User,
430+
workflowId: string,
431+
skipArchived: boolean = false,
432+
): Promise<WorkflowEntity | undefined> {
421433
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
422434
'workflow:delete',
423435
]);
@@ -427,6 +439,10 @@ export class WorkflowService {
427439
}
428440

429441
if (workflow.isArchived) {
442+
if (skipArchived) {
443+
return workflow;
444+
}
445+
430446
throw new BadRequestError('Workflow is already archived.');
431447
}
432448

packages/cli/test/integration/folder/folder.controller.test.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -757,7 +757,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
757757
expect(folderInDb).toBeNull();
758758
});
759759

760-
test('should delete folder, all child folders, and contained workflows when no transfer folder is specified', async () => {
760+
test('should delete folder, all child folders, and archive and move contained workflows to project root when no transfer folder is specified', async () => {
761761
const project = await createTeamProject('test', owner);
762762
const rootFolder = await createFolder(project, { name: 'Root' });
763763
const childFolder = await createFolder(project, {
@@ -766,9 +766,8 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
766766
});
767767

768768
// Create workflows in the folders
769-
const workflow1 = await createWorkflow({ parentFolder: rootFolder }, owner);
770-
771-
const workflow2 = await createWorkflow({ parentFolder: childFolder }, owner);
769+
const workflow1 = await createWorkflow({ parentFolder: rootFolder, active: false }, owner);
770+
const workflow2 = await createWorkflow({ parentFolder: childFolder, active: true }, owner);
772771

773772
await authOwnerAgent.delete(`/projects/${project.id}/folders/${rootFolder.id}`);
774773

@@ -780,10 +779,24 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => {
780779
expect(childFolderInDb).toBeNull();
781780

782781
// Check workflows
783-
const workflow1InDb = await workflowRepository.findOneBy({ id: workflow1.id });
784-
const workflow2InDb = await workflowRepository.findOneBy({ id: workflow2.id });
785-
expect(workflow1InDb).toBeNull();
786-
expect(workflow2InDb).toBeNull();
782+
783+
const workflow1InDb = await workflowRepository.findOne({
784+
where: { id: workflow1.id },
785+
relations: ['parentFolder'],
786+
});
787+
expect(workflow1InDb).not.toBeNull();
788+
expect(workflow1InDb?.isArchived).toBe(true);
789+
expect(workflow1InDb?.parentFolder).toBe(null);
790+
expect(workflow1InDb?.active).toBe(false);
791+
792+
const workflow2InDb = await workflowRepository.findOne({
793+
where: { id: workflow2.id },
794+
relations: ['parentFolder'],
795+
});
796+
expect(workflow2InDb).not.toBeNull();
797+
expect(workflow2InDb?.isArchived).toBe(true);
798+
expect(workflow2InDb?.parentFolder).toBe(null);
799+
expect(workflow2InDb?.active).toBe(false);
787800
});
788801

789802
test('should transfer folder contents when transferToFolderId is specified', async () => {

packages/frontend/editor-ui/src/plugins/i18n/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -949,7 +949,7 @@
949949
"folder.count": "the {count} folder | the {count} folders",
950950
"workflow.count": "the {count} workflow | the {count} workflows",
951951
"folder.and.workflow.separator": "and",
952-
"folders.delete.action": "Delete all workflows and subfolders",
952+
"folders.delete.action": "Archive all workflows and delete subfolders",
953953
"folders.delete.error.message": "Problem while deleting folder",
954954
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
955955
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",

0 commit comments

Comments
 (0)