diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js index 1c648e64..add5ae12 100644 --- a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js @@ -42,7 +42,7 @@ describe('POST /api/v1/flows/:flowId/export', () => { key: 'text', name: 'Text', parameters: { - input: `hello {{step.${triggerStep.id}.query.sample}} deneme`, + input: `hello {{step.${triggerStep.id}.query.sample}} world`, transform: 'capitalize', }, position: 2, @@ -99,7 +99,7 @@ describe('POST /api/v1/flows/:flowId/export', () => { key: 'text', name: 'Text', parameters: { - input: `hello {{step.${triggerStep.id}.query.sample}} deneme`, + input: `hello {{step.${triggerStep.id}.query.sample}} world`, transform: 'capitalize', }, position: 2, diff --git a/packages/backend/src/controllers/api/v1/flows/import-flow.js b/packages/backend/src/controllers/api/v1/flows/import-flow.js new file mode 100644 index 00000000..c64d0f9e --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/import-flow.js @@ -0,0 +1,29 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import importFlow from '../../../../helpers/import-flow.js'; + +export default async function importFlowController(request, response) { + const flow = await importFlow( + request.currentUser, + flowParams(request), + response + ); + + return renderObject(response, flow, { status: 201 }); +} + +const flowParams = (request) => { + return { + id: request.body.id, + name: request.body.name, + steps: request.body.steps.map((step) => ({ + id: step.id, + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: step.parameters, + position: step.position, + webhookPath: step.webhookPath, + })), + }; +}; diff --git a/packages/backend/src/controllers/api/v1/flows/import-flow.test.js b/packages/backend/src/controllers/api/v1/flows/import-flow.test.js new file mode 100644 index 00000000..2915c485 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/import-flow.test.js @@ -0,0 +1,355 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +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 importFlowMock from '../../../../../test/mocks/rest/api/v1/flows/import-flow.js'; + +describe('POST /api/v1/flows/import', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should import the flow data', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const expectedPayload = await importFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toMatchObject(expectedPayload); + }); + + it('should have correct parameters of the steps', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newTriggerParameters = response.body.data.steps[0].parameters; + const newActionParameters = response.body.data.steps[1].parameters; + const newTriggerStepId = response.body.data.steps[0].id; + + expect(newTriggerParameters).toMatchObject({ + workSynchronously: true, + }); + + expect(newActionParameters).toMatchObject({ + input: `hello {{step.${newTriggerStepId}.query.sample}} world`, + transform: 'capitalize', + }); + }); + + it('should have the new flow id in the new webhook url', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newWebhookUrl = response.body.data.steps[0].webhookUrl; + + expect(newWebhookUrl).toContain(`/webhooks/flows/${response.body.data.id}`); + }); + + it('should have the first step id in the input parameter of the second step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} world`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [ + { + id: triggerStep.id, + key: triggerStep.key, + name: triggerStep.name, + appKey: triggerStep.appKey, + type: triggerStep.type, + parameters: triggerStep.parameters, + position: triggerStep.position, + webhookPath: triggerStep.webhookPath, + }, + { + id: actionStep.id, + key: actionStep.key, + name: actionStep.name, + appKey: actionStep.appKey, + type: actionStep.type, + parameters: actionStep.parameters, + position: actionStep.position, + }, + ], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(201); + + const newTriggerStepId = response.body.data.steps[0].id; + const newActionStepInputParameter = + response.body.data.steps[1].parameters.input; + + expect(newActionStepInputParameter).toContain( + `{{step.${newTriggerStepId}.query.sample}}` + ); + }); + + it('should throw an error in case there is no trigger step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const importFlowData = { + id: currentUserFlow.id, + name: currentUserFlow.name, + steps: [], + }; + + const response = await request(app) + .post('/api/v1/flows/import') + .set('Authorization', token) + .send(importFlowData) + .expect(422); + + expect(response.body.errors.steps).toStrictEqual([ + 'The first step must be a trigger!', + ]); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index 13718283..e9f6fb0e 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -117,6 +117,10 @@ const authorizationList = { action: 'update', subject: 'Flow', }, + 'POST /api/v1/flows/import': { + action: 'create', + subject: 'Flow', + }, 'POST /api/v1/flows/:flowId/steps': { action: 'update', subject: 'Flow', diff --git a/packages/backend/src/helpers/import-flow.js b/packages/backend/src/helpers/import-flow.js new file mode 100644 index 00000000..fe60957b --- /dev/null +++ b/packages/backend/src/helpers/import-flow.js @@ -0,0 +1,75 @@ +import Crypto from 'crypto'; +import Step from '../models/step.js'; +import { renderObjectionError } from './renderer.js'; + +const importFlow = async (user, flowData, response) => { + const steps = flowData.steps || []; + + // Validation: the first step must be a trigger + if (!steps.length || steps[0].type !== 'trigger') { + return renderObjectionError(response, { + statusCode: 422, + type: 'ValidationError', + data: { + steps: [{ message: 'The first step must be a trigger!' }], + }, + }); + } + + const newFlowId = Crypto.randomUUID(); + + const newFlow = await user.$relatedQuery('flows').insertAndFetch({ + id: newFlowId, + name: flowData.name, + active: false, + }); + + const stepIdMap = {}; + + // Generate new step IDs and insert steps without parameters + for (const step of steps) { + const newStepId = Crypto.randomUUID(); + stepIdMap[step.id] = newStepId; + + await Step.query().insert({ + id: newStepId, + flowId: newFlowId, + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: {}, + position: step.position, + webhookPath: step.webhookPath?.replace(flowData.id, newFlowId), + }); + } + + // Update steps with correct parameters + for (const step of steps) { + const newStepId = stepIdMap[step.id]; + + await Step.query().patchAndFetchById(newStepId, { + parameters: updateParameters(step.parameters, stepIdMap), + }); + } + + return await newFlow.$query().withGraphFetched('steps'); +}; + +const updateParameters = (parameters, stepIdMap) => { + if (!parameters) return parameters; + + const stringifiedParameters = JSON.stringify(parameters); + let updatedParameters = stringifiedParameters; + + Object.entries(stepIdMap).forEach(([oldStepId, newStepId]) => { + updatedParameters = updatedParameters.replace( + `{{step.${oldStepId}.`, + `{{step.${newStepId}.` + ); + }); + + return JSON.parse(updatedParameters); +}; + +export default importFlow; diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js index 10b19e74..3b7b5159 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -10,6 +10,7 @@ import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js'; import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js'; import exportFlowAction from '../../../controllers/api/v1/flows/export-flow.js'; +import importFlowAction from '../../../controllers/api/v1/flows/import-flow.js'; const router = Router(); @@ -25,6 +26,8 @@ router.post( exportFlowAction ); +router.post('/import', authenticateUser, authorizeUser, importFlowAction); + router.patch( '/:flowId/status', authenticateUser, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js new file mode 100644 index 00000000..abceb584 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/import-flow.js @@ -0,0 +1,35 @@ +import { expect } from 'vitest'; + +const importFlowMock = async (flow, steps = []) => { + const data = { + name: flow.name, + status: flow.active ? 'published' : 'draft', + active: flow.active, + }; + + if (steps.length) { + data.steps = steps.map((step) => ({ + appKey: step.appKey, + iconUrl: step.iconUrl, + key: step.key, + name: step.name, + parameters: expect.any(Object), + position: step.position, + status: 'incomplete', + type: step.type, + })); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default importFlowMock;