feat(core): Change workflow deletions to soft deletes (#14894)

Adds soft‑deletion support for workflows through a new boolean column `isArchived`.

When a workflow is archived we now set `isArchived` flag to true and the workflows
stays in the database and is omitted from the default workflow listing query.

Archived workflows can be viewed in read-only mode, but they cannot be activated.

Archived workflows are still available by ID and can be invoked as sub-executions,
so existing Execute Workflow nodes continue to work. Execution engine doesn't
care about isArchived flag.

Users can restore workflows via Unarchive action at the UI.
This commit is contained in:
Jaakko Husso
2025-05-06 17:48:24 +03:00
committed by GitHub
parent 32b72011e6
commit 3a13139f78
64 changed files with 1616 additions and 124 deletions

View File

@@ -2328,9 +2328,197 @@ describe('POST /workflows/:workflowId/run', () => {
});
});
describe('DELETE /workflows/:workflowId', () => {
test('deletes a workflow owned by the user', async () => {
describe('POST /workflows/:workflowId/archive', () => {
test('should archive workflow', async () => {
const workflow = await createWorkflow({}, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(200);
const {
data: { isArchived, versionId },
} = response.body;
expect(isArchived).toBe(true);
expect(versionId).not.toBe(workflow.versionId);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(true);
});
test('should deactivate active workflow on archive', async () => {
const workflow = await createWorkflow({ active: true }, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(200);
const {
data: { isArchived, versionId, active },
} = response.body;
expect(isArchived).toBe(true);
expect(active).toBe(false);
expect(versionId).not.toBe(workflow.versionId);
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(true);
});
test('should not archive workflow that is already archived', async () => {
const workflow = await createWorkflow({ isArchived: true }, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(400);
expect(response.body.message).toBe('Workflow is already archived.');
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(true);
});
test('should not archive missing workflow', async () => {
const response = await authOwnerAgent.post('/workflows/404/archive').send().expect(403);
expect(response.body.message).toBe(
'Could not archive the workflow - workflow was not found in your projects',
);
});
test('should not archive a workflow that is not owned by the user', async () => {
const workflow = await createWorkflow({ isArchived: false }, member);
await testServer
.authAgentFor(anotherMember)
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(403);
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowsInDb).not.toBeNull();
expect(workflowsInDb!.isArchived).toBe(false);
expect(sharedWorkflowsInDb).toHaveLength(1);
});
test("should allow the owner to archive workflows they don't own", async () => {
const workflow = await createWorkflow({ isArchived: false }, member);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(200);
const {
data: { isArchived, versionId },
} = response.body;
expect(isArchived).toBe(true);
expect(versionId).not.toBe(workflow.versionId);
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowsInDb).not.toBeNull();
expect(workflowsInDb!.isArchived).toBe(true);
expect(sharedWorkflowsInDb).toHaveLength(1);
});
});
describe('POST /workflows/:workflowId/unarchive', () => {
test('should unarchive workflow', async () => {
const workflow = await createWorkflow({ isArchived: true }, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/unarchive`)
.send()
.expect(200);
const {
data: { isArchived, versionId },
} = response.body;
expect(isArchived).toBe(false);
expect(versionId).not.toBe(workflow.versionId);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(false);
});
test('should not unarchive workflow that is already not archived', async () => {
const workflow = await createWorkflow({ isArchived: false }, owner);
await authOwnerAgent.post(`/workflows/${workflow.id}/unarchive`).send().expect(400);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(false);
});
test('should not unarchive missing workflow', async () => {
const response = await authOwnerAgent.post('/workflows/404/unarchive').send().expect(403);
expect(response.body.message).toBe(
'Could not unarchive the workflow - workflow was not found in your projects',
);
});
test('should not unarchive a workflow that is not owned by the user', async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
await testServer
.authAgentFor(anotherMember)
.post(`/workflows/${workflow.id}/unarchive`)
.send()
.expect(403);
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowsInDb).not.toBeNull();
expect(workflowsInDb!.isArchived).toBe(true);
expect(sharedWorkflowsInDb).toHaveLength(1);
});
test("should allow the owner to unarchive workflows they don't own", async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/unarchive`)
.send()
.expect(200);
const {
data: { isArchived, versionId },
} = response.body;
expect(isArchived).toBe(false);
expect(versionId).not.toBe(workflow.versionId);
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowsInDb).not.toBeNull();
expect(workflowsInDb!.isArchived).toBe(false);
expect(sharedWorkflowsInDb).toHaveLength(1);
});
});
describe('DELETE /workflows/:workflowId', () => {
test('deletes an archived workflow owned by the user', async () => {
const workflow = await createWorkflow({ isArchived: true }, owner);
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);
@@ -2343,8 +2531,15 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(0);
});
test('deletes a workflow owned by the user, even if the user is just a member', async () => {
const workflow = await createWorkflow({}, member);
test('should not delete missing workflow', async () => {
const response = await authOwnerAgent.delete('/workflows/404').send().expect(403);
expect(response.body.message).toBe(
'Could not delete the workflow - workflow was not found in your projects',
);
});
test('deletes an archived workflow owned by the user, even if the user is just a member', async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
await testServer.authAgentFor(member).delete(`/workflows/${workflow.id}`).send().expect(200);
@@ -2357,8 +2552,23 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(0);
});
test('does not delete a workflow that is not owned by the user', async () => {
const workflow = await createWorkflow({}, member);
test('does not delete a workflow that is not archived', async () => {
const workflow = await createWorkflow({}, owner);
const response = await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(400);
expect(response.body.message).toBe('Workflow must be archived before it can be deleted.');
const workflowInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowInDb).not.toBeNull();
expect(sharedWorkflowsInDb).toHaveLength(1);
});
test('does not delete an archived workflow that is not owned by the user', async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
await testServer
.authAgentFor(anotherMember)
@@ -2375,8 +2585,8 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(1);
});
test("allows the owner to delete workflows they don't own", async () => {
const workflow = await createWorkflow({}, member);
test("allows the owner to delete archived workflows they don't own", async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);