From 1790ef0ee61e7ee1521f504b9f561cd06ce751ce Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Tue, 17 Sep 2024 16:07:12 +0300 Subject: [PATCH] feat: Implement update flow status rest API endpoint --- .../api/v1/flows/update-flow-status.js | 14 ++ .../api/v1/flows/update-flow-status.test.js | 213 ++++++++++++++++++ .../backend/src/graphql/mutation-resolvers.js | 2 +- packages/backend/src/helpers/authorization.js | 8 +- packages/backend/src/models/flow.js | 70 ++++++ packages/backend/src/routes/api/v1/flows.js | 9 + .../rest/api/v1/flows/update-flow-status.js | 37 +++ 7 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/controllers/api/v1/flows/update-flow-status.js create mode 100644 packages/backend/src/controllers/api/v1/flows/update-flow-status.test.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow-status.js b/packages/backend/src/controllers/api/v1/flows/update-flow-status.js new file mode 100644 index 00000000..4bc4fd9a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow-status.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let flow = await request.currentUser.authorizedFlows + .clone() + .findOne({ + id: request.params.flowId, + }) + .throwIfNotFound(); + + flow = await flow.updateStatus(request.body.active); + + renderObject(response, flow); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow-status.test.js b/packages/backend/src/controllers/api/v1/flows/update-flow-status.test.js new file mode 100644 index 00000000..c36f0110 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow-status.test.js @@ -0,0 +1,213 @@ +import { describe, it, expect, 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 { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import updateFlowStatusMock from '../../../../../test/mocks/rest/api/v1/flows/update-flow-status.js'; + +describe('PATCH /api/v1/flows/:flowId/status', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return updated flow data of current user', async () => { + const currentUserFlow = await createFlow({ + userId: currentUser.id, + active: false, + }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'ntfy', + key: 'sendMessage', + parameters: { + topic: 'Test notification', + message: `Message: {{step.${triggerStep.id}.body.message}} by {{step.${triggerStep.id}.body.sender}}`, + }, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .patch(`/api/v1/flows/${currentUserFlow.id}/status`) + .set('Authorization', token) + .send({ active: true }) + .expect(200); + + const refetchedFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const refetchedFlowSteps = await refetchedFlow + .$relatedQuery('steps') + .orderBy('position', 'asc'); + + const expectedPayload = await updateFlowStatusMock( + refetchedFlow, + refetchedFlowSteps + ); + + expect(response.body).toStrictEqual(expectedPayload); + expect(response.body.data.status).toStrictEqual('published'); + }); + + it('should return updated flow data of another user', async () => { + const anotherUser = await createUser(); + + const anotherUserFlow = await createFlow({ + userId: anotherUser.id, + active: false, + }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + appKey: 'ntfy', + key: 'sendMessage', + parameters: { + topic: 'Test notification', + message: `Message: {{step.${triggerStep.id}.body.message}} by {{step.${triggerStep.id}.body.sender}}`, + }, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .patch(`/api/v1/flows/${anotherUserFlow.id}/status`) + .set('Authorization', token) + .send({ active: true }) + .expect(200); + + const refetchedFlow = await anotherUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const refetchedFlowSteps = await refetchedFlow + .$relatedQuery('steps') + .orderBy('position', 'asc'); + + const expectedPayload = await updateFlowStatusMock( + refetchedFlow, + refetchedFlowSteps + ); + + expect(response.body).toStrictEqual(expectedPayload); + expect(response.body.data.status).toStrictEqual('published'); + }); + + 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: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/flows/${notExistingFlowUUID}/status`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .patch(`/api/v1/flows/${anotherUserFlow.id}/status`) + .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: 'publish', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .patch('/api/v1/flows/invalidFlowUUID/status') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/graphql/mutation-resolvers.js b/packages/backend/src/graphql/mutation-resolvers.js index 23db05be..ac6f70f7 100644 --- a/packages/backend/src/graphql/mutation-resolvers.js +++ b/packages/backend/src/graphql/mutation-resolvers.js @@ -1,4 +1,3 @@ -import updateFlowStatus from './mutations/update-flow-status.js'; import updateStep from './mutations/update-step.js'; // Converted mutations @@ -15,6 +14,7 @@ import deleteFlow from './mutations/delete-flow.js'; import resetConnection from './mutations/reset-connection.js'; import updateConnection from './mutations/update-connection.js'; import createUser from './mutations/create-user.ee.js'; +import updateFlowStatus from './mutations/update-flow-status.js'; const mutationResolvers = { createConnection, diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index 94e6bf08..b59cbcc0 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', }, + 'PATCH /api/v1/flows/:flowId': { + action: 'update', + subject: 'Flow', + }, 'DELETE /api/v1/flows/:flowId': { action: 'delete', subject: 'Flow', @@ -97,8 +101,8 @@ const authorizationList = { action: 'create', subject: 'Connection', }, - 'PATCH /api/v1/flows/:flowId': { - action: 'update', + 'PATCH /api/v1/flows/:flowId/status': { + action: 'publish', subject: 'Flow', }, 'POST /api/v1/flows/:flowId/duplicate': { diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 1df12d52..2b2d4d10 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -7,6 +7,14 @@ 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'; +import flowQueue from '../queues/flow.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; + +const JOB_NAME = 'flow'; +const EVERY_15_MINUTES_CRON = '*/15 * * * *'; class Flow extends Base { static tableName = 'flows'; @@ -277,6 +285,68 @@ class Flow extends Base { return duplicatedFlowWithSteps; } + async updateStatus(newActiveValue) { + if (this.active === newActiveValue) { + return this; + } + + const triggerStep = await this.getTriggerStep(); + + if (triggerStep.status === 'incomplete') { + throw this.IncompleteStepsError; + } + + const trigger = await triggerStep.getTriggerCommand(); + const interval = trigger.getInterval?.(triggerStep.parameters); + const repeatOptions = { + pattern: interval || EVERY_15_MINUTES_CRON, + }; + + if (trigger.type === 'webhook') { + const $ = await globalVariable({ + flow: this, + connection: await triggerStep.$relatedQuery('connection'), + app: await triggerStep.getApp(), + step: triggerStep, + testRun: false, + }); + + if (newActiveValue && trigger.registerHook) { + await trigger.registerHook($); + } else if (!newActiveValue && trigger.unregisterHook) { + await trigger.unregisterHook($); + } + } else { + if (newActiveValue) { + await this.$query().patchAndFetch({ + publishedAt: new Date().toISOString(), + }); + + const jobName = `${JOB_NAME}-${this.id}`; + + await flowQueue.add( + jobName, + { flowId: this.id }, + { + repeat: repeatOptions, + jobId: this.id, + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + } + ); + } else { + const repeatableJobs = await flowQueue.getRepeatableJobs(); + const job = repeatableJobs.find((job) => job.id === this.id); + + await flowQueue.removeRepeatableByKey(job.key); + } + } + + return await this.$query().withGraphFetched('steps').patchAndFetch({ + active: newActiveValue, + }); + } + 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 83d2c021..8b507b82 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -4,6 +4,7 @@ import { authorizeUser } from '../../../helpers/authorization.js'; import getFlowsAction from '../../../controllers/api/v1/flows/get-flows.js'; import getFlowAction from '../../../controllers/api/v1/flows/get-flow.js'; import updateFlowAction from '../../../controllers/api/v1/flows/update-flow.js'; +import updateFlowStatusAction from '../../../controllers/api/v1/flows/update-flow-status.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'; @@ -16,12 +17,20 @@ router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction); router.post('/', authenticateUser, authorizeUser, createFlowAction); router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); +router.patch( + '/:flowId/status', + authenticateUser, + authorizeUser, + updateFlowStatusAction +); + router.post( '/:flowId/steps', authenticateUser, authorizeUser, createStepAction ); + router.post( '/:flowId/duplicate', authenticateUser, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js new file mode 100644 index 00000000..f303f295 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js @@ -0,0 +1,37 @@ +const updateFlowStatusMock = async (flow, steps = []) => { + const data = { + active: flow.active, + id: flow.id, + name: flow.name, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + }; + + if (steps.length) { + data.steps = steps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + id: step.id, + key: step.key, + parameters: step.parameters, + position: step.position, + status: step.status, + type: step.type, + webhookUrl: step.webhookUrl, + })); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default updateFlowStatusMock;