diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow-folder.js b/packages/backend/src/controllers/api/v1/flows/update-flow-folder.js new file mode 100644 index 00000000..2decc114 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow-folder.js @@ -0,0 +1,14 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let flow = await request.currentUser + .$relatedQuery('flows') + .findOne({ + id: request.params.flowId, + }) + .throwIfNotFound(); + + flow = await flow.updateFolder(request.body.folderId); + + renderObject(response, flow); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/update-flow-folder.test.js b/packages/backend/src/controllers/api/v1/flows/update-flow-folder.test.js new file mode 100644 index 00000000..0abdef5d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/update-flow-folder.test.js @@ -0,0 +1,176 @@ +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 { createFolder } from '../../../../../test/factories/folder.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import updateFlowFolderMock from '../../../../../test/mocks/rest/api/v1/flows/update-flow-folder.js'; + +describe('PATCH /api/v1/flows/:flowId/folder', () => { + 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: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const folder = await createFolder({ + name: 'test', + userId: currentUser.id, + }); + + const response = await request(app) + .patch(`/api/v1/flows/${currentUserFlow.id}/folder`) + .set('Authorization', token) + .send({ folderId: folder.id }) + .expect(200); + + const refetchedFlow = await currentUser + .$relatedQuery('flows') + .findById(response.body.data.id); + + const expectedPayload = await updateFlowFolderMock(refetchedFlow, folder); + + expect(response.body).toStrictEqual(expectedPayload); + expect(response.body.data.folder.name).toStrictEqual('test'); + }); + + it('should return not found response for other user flows', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .patch(`/api/v1/flows/${anotherUserFlow.id}/folder`) + .set('Authorization', token) + .send({ folderId: 12345 }) + .expect(404); + }); + + it('should return not found response for other user folders', async () => { + const flow = await createFlow({ userId: currentUser.id }); + const anotherUser = await createUser(); + const anotherUserFolder = await createFolder({ userId: anotherUser.id }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .patch(`/api/v1/flows/${flow.id}/folder`) + .set('Authorization', token) + .send({ folderId: anotherUserFolder.id }) + .expect(404); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/flows/${notExistingFlowUUID}/folder`) + .set('Authorization', token) + .send({ folderId: 12345 }) + .expect(404); + }); + + it('should return not found response for not existing folder UUID', async () => { + const flow = await createFlow({ userId: currentUser.id }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFolderUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/flows/${flow.id}/folder`) + .set('Authorization', token) + .send({ folderId: notExistingFolderUUID }) + .expect(404); + }); + + it('should return bad request response for invalid flow UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + await request(app) + .patch('/api/v1/flows/invalidFlowUUID/folder') + .set('Authorization', token) + .expect(400); + }); + + it('should return bad request response for invalid folder UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + }); + + const flow = await createFlow({ userId: currentUser.id }); + + await request(app) + .patch(`/api/v1/flows/${flow.id}/folder`) + .set('Authorization', token) + .send({ folderId: 'invalidFolderUUID' }) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index 7a11a850..38ec539e 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -149,6 +149,10 @@ const authorizationList = { action: 'read', subject: 'Flow', }, + 'PATCH /api/v1/flows/:flowId/folder': { + action: 'update', + subject: 'Flow', + }, }; export const authorizeUser = async (request, response, next) => { diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 29d3c135..750208a4 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -2,6 +2,7 @@ import { ValidationError } from 'objection'; import Base from './base.js'; import Step from './step.js'; import User from './user.js'; +import Folder from './folder.js'; import Execution from './execution.js'; import ExecutionStep from './execution-step.js'; import globalVariable from '../helpers/global-variable.js'; @@ -88,6 +89,14 @@ class Flow extends Base { to: 'users.id', }, }, + folder: { + relation: Base.HasOneRelation, + modelClass: Folder, + join: { + from: 'flows.folder_id', + to: 'folders.id', + }, + }, }); static async populateStatusProperty(flows) { @@ -338,6 +347,23 @@ class Flow extends Base { return allowedToRunFlows ? false : true; } + async updateFolder(folderId) { + const user = await this.$relatedQuery('user'); + + const folder = await user + .$relatedQuery('folders') + .findOne({ + id: folderId, + }) + .throwIfNotFound(); + + await this.$query().patch({ + folderId: folder.id, + }); + + return this.$query().withGraphFetched('folder'); + } + async updateStatus(newActiveValue) { if (this.active === newActiveValue) { return this; diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js index cbaae474..dc9049b8 100644 --- a/packages/backend/src/models/flow.test.js +++ b/packages/backend/src/models/flow.test.js @@ -3,10 +3,13 @@ import Flow from './flow.js'; import User from './user.js'; import Base from './base.js'; import Step from './step.js'; +import Folder from './folder.js'; import Execution from './execution.js'; import Telemetry from '../helpers/telemetry/index.js'; import * as globalVariableModule from '../helpers/global-variable.js'; import { createFlow } from '../../test/factories/flow.js'; +import { createUser } from '../../test/factories/user.js'; +import { createFolder } from '../../test/factories/folder.js'; import { createStep } from '../../test/factories/step.js'; import { createExecution } from '../../test/factories/execution.js'; import { createExecutionStep } from '../../test/factories/execution-step.js'; @@ -69,6 +72,14 @@ describe('Flow model', () => { to: 'users.id', }, }, + folder: { + relation: Base.HasOneRelation, + modelClass: Folder, + join: { + from: 'flows.folder_id', + to: 'folders.id', + }, + }, }; expect(relationMappings).toStrictEqual(expectedRelations); @@ -475,6 +486,27 @@ describe('Flow model', () => { }); }); + describe('updateFolder', () => { + it('should throw an error if the folder does not exist', async () => { + const user = await createUser(); + const flow = await createFlow({ userId: user.id }); + const nonExistentFolderId = 'non-existent-folder-id'; + + await expect(flow.updateFolder(nonExistentFolderId)).rejects.toThrow(); + }); + + it('should return the flow with the updated folder', async () => { + const user = await createUser(); + const flow = await createFlow({ userId: user.id }); + const folder = await createFolder({ userId: user.id }); + + const updatedFlow = await flow.updateFolder(folder.id); + + expect(updatedFlow.folder.id).toBe(folder.id); + expect(updatedFlow.folder.name).toBe(folder.name); + }); + }); + describe('throwIfHavingIncompleteSteps', () => { it('should throw validation error with incomplete steps', async () => { const flow = await createFlow(); diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js index 3b7b5159..259bd0fb 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -5,6 +5,7 @@ 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 updateFlowFolderAction from '../../../controllers/api/v1/flows/update-flow-folder.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'; @@ -19,6 +20,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.patch( + '/:flowId/folder', + authenticateUser, + authorizeUser, + updateFlowFolderAction +); + router.post( '/:flowId/export', authenticateUser, @@ -28,13 +43,6 @@ router.post( router.post('/import', authenticateUser, authorizeUser, importFlowAction); -router.patch( - '/:flowId/status', - authenticateUser, - authorizeUser, - updateFlowStatusAction -); - router.post( '/:flowId/steps', authenticateUser, diff --git a/packages/backend/src/serializers/flow.js b/packages/backend/src/serializers/flow.js index 04adc112..28475644 100644 --- a/packages/backend/src/serializers/flow.js +++ b/packages/backend/src/serializers/flow.js @@ -1,4 +1,5 @@ import stepSerializer from './step.js'; +import folderSerilializer from './folder.js'; const flowSerializer = (flow) => { let flowData = { @@ -14,6 +15,10 @@ const flowSerializer = (flow) => { flowData.steps = flow.steps.map((step) => stepSerializer(step)); } + if (flow.folder) { + flowData.folder = folderSerilializer(flow.folder); + } + return flowData; }; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/update-flow-folder.js b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-folder.js new file mode 100644 index 00000000..bace9dc4 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-folder.js @@ -0,0 +1,29 @@ +const updateFlowFolderMock = async (flow, folder) => { + const data = { + active: flow.active, + id: flow.id, + name: flow.name, + status: flow.active ? 'published' : 'draft', + createdAt: flow.createdAt.getTime(), + updatedAt: flow.updatedAt.getTime(), + folder: { + id: folder.id, + name: folder.name, + createdAt: folder.createdAt.getTime(), + updatedAt: folder.updatedAt.getTime(), + }, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Flow', + }, + }; +}; + +export default updateFlowFolderMock;