diff --git a/packages/backend/src/controllers/api/v1/flows/delete-flow.js b/packages/backend/src/controllers/api/v1/flows/delete-flow.js new file mode 100644 index 00000000..f4be3da1 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/delete-flow.js @@ -0,0 +1,10 @@ +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .clone() + .findById(request.params.flowId) + .throwIfNotFound(); + + await flow.delete(); + + response.status(204).end(); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/delete-flow.test.js b/packages/backend/src/controllers/api/v1/flows/delete-flow.test.js new file mode 100644 index 00000000..84103120 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/delete-flow.test.js @@ -0,0 +1,110 @@ +import { describe, it, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; + +describe('DELETE /api/v1/flows/:flowId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should remove the current user flow and return no content', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'delete', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .delete(`/api/v1/flows/${currentUserFlow.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should remove another user flow and return no content', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'delete', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .delete(`/api/v1/flows/${anotherUserFlow.id}`) + .set('Authorization', token) + .expect(204); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'delete', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .delete(`/api/v1/flows/${notExistingFlowUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'delete', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .delete('/api/v1/flows/invalidFlowUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index a603cbe4..67b1e305 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -25,6 +25,10 @@ const authorizationList = { action: 'create', subject: 'Flow', }, + 'DELETE /api/v1/flows/:flowId': { + action: 'delete', + subject: 'Flow', + }, 'GET /api/v1/steps/:stepId/connection': { action: 'read', subject: 'Flow', diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 3c8a2811..7806a9bb 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -3,6 +3,9 @@ import Base from './base.js'; import Step from './step.js'; import User from './user.js'; import Execution from './execution.js'; +import ExecutionStep from './execution-step.js'; +import globalVariable from '../helpers/global-variable.js'; +import logger from '../helpers/logger.js'; import Telemetry from '../helpers/telemetry/index.js'; class Flow extends Base { @@ -160,6 +163,39 @@ class Flow extends Base { return createdStep; } + async delete() { + const triggerStep = await this.getTriggerStep(); + const trigger = await triggerStep?.getTriggerCommand(); + + if (trigger?.type === 'webhook' && trigger.unregisterHook) { + const $ = await globalVariable({ + flow: this, + connection: await triggerStep.$relatedQuery('connection'), + app: await triggerStep.getApp(), + step: triggerStep, + }); + + try { + await trigger.unregisterHook($); + } catch (error) { + // suppress error as the remote resource might have been already deleted + logger.debug( + `Failed to unregister webhook for flow ${this.id}: ${error.message}` + ); + } + } + + const executionIds = ( + await this.$relatedQuery('executions').select('executions.id') + ).map((execution) => execution.id); + + await ExecutionStep.query().delete().whereIn('execution_id', executionIds); + + await this.$relatedQuery('executions').delete(); + await this.$relatedQuery('steps').delete(); + await this.$query().delete(); + } + async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js index e7d21e3b..9f11b30c 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -6,6 +6,7 @@ import getFlowAction from '../../../controllers/api/v1/flows/get-flow.js'; import updateFlowAction from '../../../controllers/api/v1/flows/update-flow.js'; import createFlowAction from '../../../controllers/api/v1/flows/create-flow.js'; import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; +import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js'; const router = Router(); @@ -13,6 +14,7 @@ router.get('/', authenticateUser, authorizeUser, getFlowsAction); router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction); router.post('/', authenticateUser, authorizeUser, createFlowAction); router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); + router.post( '/:flowId/steps', authenticateUser, @@ -20,4 +22,6 @@ router.post( createStepAction ); +router.delete('/:flowId', authenticateUser, authorizeUser, deleteFlowAction); + export default router;