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..924b4f10 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/duplicate-flow.test.js @@ -0,0 +1,204 @@ +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', + }); + + 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 + ); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedDuplicateFlow.userId).toStrictEqual(currentUser.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', + }); + + 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 + ); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedDuplicateFlow.userId).toStrictEqual(currentUser.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/graphql/mutation-resolvers.js b/packages/backend/src/graphql/mutation-resolvers.js index 76811fd8..23db05be 100644 --- a/packages/backend/src/graphql/mutation-resolvers.js +++ b/packages/backend/src/graphql/mutation-resolvers.js @@ -1,4 +1,3 @@ -import duplicateFlow from './mutations/duplicate-flow.js'; import updateFlowStatus from './mutations/update-flow-status.js'; import updateStep from './mutations/update-step.js'; @@ -24,7 +23,6 @@ const mutationResolvers = { deleteCurrentUser, deleteFlow, deleteStep, - duplicateFlow, executeFlow, generateAuthUrl, resetConnection, diff --git a/packages/backend/src/graphql/mutations/duplicate-flow.js b/packages/backend/src/graphql/mutations/duplicate-flow.js deleted file mode 100644 index 4b5c67f0..00000000 --- a/packages/backend/src/graphql/mutations/duplicate-flow.js +++ /dev/null @@ -1,78 +0,0 @@ -function 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.replace(partialOldVariable, partialNewVariable); - } - - return newValue; -} - -function 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 duplicateFlow = async (_parent, params, context) => { - context.currentUser.can('create', 'Flow'); - - const flow = await context.currentUser - .authorizedFlows - .withGraphJoined('[steps]') - .orderBy('steps.position', 'asc') - .findOne({ 'flows.id': params.input.id }) - .throwIfNotFound(); - - const duplicatedFlow = await context.currentUser - .$relatedQuery('flows') - .insert({ - name: `Copy of ${flow.name}`, - active: false, - }); - - const newStepIds = {}; - for (const step of flow.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; - } - - return duplicatedFlow; -}; - -export default duplicateFlow; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 5b4520b5..c960eb42 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -8,7 +8,6 @@ type Mutation { deleteCurrentUser: Boolean deleteFlow(input: DeleteFlowInput): Boolean deleteStep(input: DeleteStepInput): Step - duplicateFlow(input: DuplicateFlowInput): Flow executeFlow(input: ExecuteFlowInput): executeFlowType generateAuthUrl(input: GenerateAuthUrlInput): AuthLink resetConnection(input: ResetConnectionInput): Connection @@ -259,10 +258,6 @@ input DeleteFlowInput { id: String! } -input DuplicateFlowInput { - id: String! -} - input UpdateStepInput { id: String previousStepId: String 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..67684191 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js @@ -0,0 +1,37 @@ +const duplicateFlowMock = 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 duplicateFlowMock; diff --git a/packages/web/src/components/FlowContextMenu/index.jsx b/packages/web/src/components/FlowContextMenu/index.jsx index bba8f10b..c8413508 100644 --- a/packages/web/src/components/FlowContextMenu/index.jsx +++ b/packages/web/src/components/FlowContextMenu/index.jsx @@ -11,8 +11,8 @@ import { Link } from 'react-router-dom'; import Can from 'components/Can'; import * as URLS from 'config/urls'; import { DELETE_FLOW } from 'graphql/mutations/delete-flow'; -import { DUPLICATE_FLOW } from 'graphql/mutations/duplicate-flow'; import useFormatMessage from 'hooks/useFormatMessage'; +import useDuplicateFlow from 'hooks/useDuplicateFlow'; function ContextMenu(props) { const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow, appKey } = @@ -20,13 +20,11 @@ function ContextMenu(props) { const enqueueSnackbar = useEnqueueSnackbar(); const formatMessage = useFormatMessage(); const queryClient = useQueryClient(); - const [duplicateFlow] = useMutation(DUPLICATE_FLOW); + const { mutateAsync: duplicateFlow } = useDuplicateFlow(flowId); const [deleteFlow] = useMutation(DELETE_FLOW); const onFlowDuplicate = React.useCallback(async () => { - await duplicateFlow({ - variables: { input: { id: flowId } }, - }); + await duplicateFlow(); if (appKey) { await queryClient.invalidateQueries({ @@ -43,7 +41,15 @@ function ContextMenu(props) { onDuplicateFlow?.(); onClose(); - }, [flowId, onClose, duplicateFlow, queryClient, onDuplicateFlow]); + }, [ + appKey, + enqueueSnackbar, + onClose, + duplicateFlow, + queryClient, + onDuplicateFlow, + formatMessage, + ]); const onFlowDelete = React.useCallback(async () => { await deleteFlow({ diff --git a/packages/web/src/graphql/mutations/duplicate-flow.js b/packages/web/src/graphql/mutations/duplicate-flow.js deleted file mode 100644 index 6e359ff5..00000000 --- a/packages/web/src/graphql/mutations/duplicate-flow.js +++ /dev/null @@ -1,27 +0,0 @@ -import { gql } from '@apollo/client'; -export const DUPLICATE_FLOW = gql` - mutation DuplicateFlow($input: DuplicateFlowInput) { - duplicateFlow(input: $input) { - id - name - active - status - steps { - id - type - key - appKey - iconUrl - webhookUrl - status - position - connection { - id - verified - createdAt - } - parameters - } - } - } -`; diff --git a/packages/web/src/hooks/useDuplicateFlow.js b/packages/web/src/hooks/useDuplicateFlow.js new file mode 100644 index 00000000..c8df605f --- /dev/null +++ b/packages/web/src/hooks/useDuplicateFlow.js @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import api from 'helpers/api'; + +export default function useDuplicateFlow(flowId) { + const queryClient = useQueryClient(); + + const query = useMutation({ + mutationFn: async () => { + const { data } = await api.post(`/v1/flows/${flowId}/duplicate`); + + return data; + }, + + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['flows'], + }); + }, + }); + + return query; +}