diff --git a/packages/backend/src/controllers/api/v1/flows/duplicate-flow.js b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.js new file mode 100644 index 00000000..a4c7d582 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findById(request.params.flowId) + .throwIfNotFound(); + + const duplicatedFlow = await flow.duplicateFor(request.currentUser); + + renderObject(response, duplicatedFlow, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/duplicate-flow.test.js b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.test.js new file mode 100644 index 00000000..fa8a4c47 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.test.js @@ -0,0 +1,330 @@ +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 duplicateFlowMock from '../../../../../test/mocks/rest/api/v1/flows/duplicate-flow.js'; + +describe('POST /api/v1/flows/:flowId/duplicate', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return duplicated flow data of current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + const actionStep = 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: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/flows/${currentUserFlow.id}/duplicate`) + .set('Authorization', token) + .expect(201); + + const refetchedDuplicateFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const refetchedDuplicateFlowSteps = await refetchedDuplicateFlow + .$relatedQuery('steps') + .orderBy('position', 'asc'); + + const expectedPayload = await duplicateFlowMock( + refetchedDuplicateFlow, + refetchedDuplicateFlowSteps + ); + + const refetchedDuplicateFlowTriggerStep = refetchedDuplicateFlowSteps[0]; + const refetchedDuplicateFlowActionStep = refetchedDuplicateFlowSteps[1]; + + expect(response.body).toStrictEqual(expectedPayload); + + expect(refetchedDuplicateFlow.userId).toStrictEqual(currentUser.id); + + expect(refetchedDuplicateFlowSteps.length).toStrictEqual(2); + + expect(refetchedDuplicateFlowTriggerStep.appKey).toStrictEqual( + triggerStep.appKey + ); + + expect(refetchedDuplicateFlowTriggerStep.key).toStrictEqual( + triggerStep.key + ); + + expect(refetchedDuplicateFlowTriggerStep.connectionId).toStrictEqual( + triggerStep.connectionId + ); + + expect(refetchedDuplicateFlowTriggerStep.position).toStrictEqual( + triggerStep.position + ); + + expect(refetchedDuplicateFlowTriggerStep.parameters).toStrictEqual( + triggerStep.parameters + ); + + expect(refetchedDuplicateFlowTriggerStep.type).toStrictEqual( + triggerStep.type + ); + + expect(refetchedDuplicateFlowActionStep.appKey).toStrictEqual( + actionStep.appKey + ); + + expect(refetchedDuplicateFlowActionStep.key).toStrictEqual(actionStep.key); + + expect(refetchedDuplicateFlowActionStep.connectionId).toStrictEqual( + actionStep.connectionId + ); + + expect(refetchedDuplicateFlowActionStep.position).toStrictEqual( + actionStep.position + ); + + expect(refetchedDuplicateFlowActionStep.parameters.topic).toStrictEqual( + actionStep.parameters.topic + ); + + expect(refetchedDuplicateFlowActionStep.parameters.message).toStrictEqual( + actionStep.parameters.message.replaceAll( + `{{step.${triggerStep.id}.`, + `{{step.${refetchedDuplicateFlowTriggerStep.id}.` + ) + ); + + expect(refetchedDuplicateFlowActionStep.type).toStrictEqual( + actionStep.type + ); + + expect(refetchedDuplicateFlowTriggerStep.webhookPath).toStrictEqual( + `/webhooks/flows/${refetchedDuplicateFlow.id}` + ); + }); + + it('should return duplicated flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + const actionStep = 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: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/duplicate`) + .set('Authorization', token) + .expect(201); + + const refetchedDuplicateFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const refetchedDuplicateFlowSteps = await refetchedDuplicateFlow + .$relatedQuery('steps') + .orderBy('position', 'asc'); + + const expectedPayload = await duplicateFlowMock( + refetchedDuplicateFlow, + refetchedDuplicateFlowSteps + ); + + const refetchedDuplicateFlowTriggerStep = refetchedDuplicateFlowSteps[0]; + const refetchedDuplicateFlowActionStep = refetchedDuplicateFlowSteps[1]; + + expect(response.body).toStrictEqual(expectedPayload); + + expect(refetchedDuplicateFlow.userId).toStrictEqual(currentUser.id); + + expect(refetchedDuplicateFlowSteps.length).toStrictEqual(2); + + expect(refetchedDuplicateFlowTriggerStep.appKey).toStrictEqual( + triggerStep.appKey + ); + + expect(refetchedDuplicateFlowTriggerStep.key).toStrictEqual( + triggerStep.key + ); + + expect(refetchedDuplicateFlowTriggerStep.connectionId).toStrictEqual( + triggerStep.connectionId + ); + + expect(refetchedDuplicateFlowTriggerStep.position).toStrictEqual( + triggerStep.position + ); + + expect(refetchedDuplicateFlowTriggerStep.parameters).toStrictEqual( + triggerStep.parameters + ); + + expect(refetchedDuplicateFlowTriggerStep.type).toStrictEqual( + triggerStep.type + ); + + expect(refetchedDuplicateFlowActionStep.appKey).toStrictEqual( + actionStep.appKey + ); + + expect(refetchedDuplicateFlowActionStep.key).toStrictEqual(actionStep.key); + + expect(refetchedDuplicateFlowActionStep.connectionId).toStrictEqual( + actionStep.connectionId + ); + + expect(refetchedDuplicateFlowActionStep.position).toStrictEqual( + actionStep.position + ); + + expect(refetchedDuplicateFlowActionStep.parameters.topic).toStrictEqual( + actionStep.parameters.topic + ); + + expect(refetchedDuplicateFlowActionStep.parameters.message).toStrictEqual( + actionStep.parameters.message.replaceAll( + `{{step.${triggerStep.id}.`, + `{{step.${refetchedDuplicateFlowTriggerStep.id}.` + ) + ); + + expect(refetchedDuplicateFlowActionStep.type).toStrictEqual( + actionStep.type + ); + + expect(refetchedDuplicateFlowTriggerStep.webhookPath).toStrictEqual( + `/webhooks/flows/${refetchedDuplicateFlow.id}` + ); + }); + + 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: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/duplicate`) + .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: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/duplicate`) + .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: 'create', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/duplicate') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index 95cd3f0c..94e6bf08 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -101,6 +101,10 @@ const authorizationList = { action: 'update', subject: 'Flow', }, + 'POST /api/v1/flows/:flowId/duplicate': { + action: 'create', + subject: 'Flow', + }, 'POST /api/v1/flows/:flowId/steps': { action: 'update', subject: 'Flow', diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 7806a9bb..1df12d52 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -196,6 +196,87 @@ class Flow extends Base { await this.$query().delete(); } + async duplicateFor(user) { + const steps = await this.$relatedQuery('steps').orderBy( + 'steps.position', + 'asc' + ); + + const duplicatedFlow = await user.$relatedQuery('flows').insertAndFetch({ + name: `Copy of ${this.name}`, + active: false, + }); + + const updateStepId = (value, newStepIds) => { + let newValue = value; + + const stepIdEntries = Object.entries(newStepIds); + for (const stepIdEntry of stepIdEntries) { + const [oldStepId, newStepId] = stepIdEntry; + + const partialOldVariable = `{{step.${oldStepId}.`; + const partialNewVariable = `{{step.${newStepId}.`; + + newValue = newValue.replaceAll(partialOldVariable, partialNewVariable); + } + + return newValue; + }; + + const updateStepVariables = (parameters, newStepIds) => { + const entries = Object.entries(parameters); + + return entries.reduce((result, [key, value]) => { + if (typeof value === 'string') { + return { + ...result, + [key]: updateStepId(value, newStepIds), + }; + } + + if (Array.isArray(value)) { + return { + ...result, + [key]: value.map((item) => updateStepVariables(item, newStepIds)), + }; + } + + return { + ...result, + [key]: value, + }; + }, {}); + }; + + const newStepIds = {}; + for (const step of steps) { + const duplicatedStep = await duplicatedFlow + .$relatedQuery('steps') + .insert({ + key: step.key, + appKey: step.appKey, + type: step.type, + connectionId: step.connectionId, + position: step.position, + parameters: updateStepVariables(step.parameters, newStepIds), + }); + + if (duplicatedStep.isTrigger) { + await duplicatedStep.updateWebhookUrl(); + } + + newStepIds[step.id] = duplicatedStep.id; + } + + const duplicatedFlowWithSteps = duplicatedFlow + .$query() + .withGraphJoined({ steps: true }) + .orderBy('steps.position', 'asc') + .throwIfNotFound(); + + return duplicatedFlowWithSteps; + } + 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 9f11b30c..83d2c021 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -7,6 +7,7 @@ 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'; +import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js'; const router = Router(); @@ -21,6 +22,12 @@ router.post( authorizeUser, createStepAction ); +router.post( + '/:flowId/duplicate', + authenticateUser, + authorizeUser, + duplicateFlowAction +); router.delete('/:flowId', authenticateUser, authorizeUser, deleteFlowAction); diff --git a/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js new file mode 100644 index 00000000..db1e4a47 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js @@ -0,0 +1,37 @@ +const getFlowMock = 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 getFlowMock;