diff --git a/packages/backend/src/controllers/api/v1/admin/templates/create-template.ee.js b/packages/backend/src/controllers/api/v1/admin/templates/create-template.ee.js new file mode 100644 index 00000000..404c9ca9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/templates/create-template.ee.js @@ -0,0 +1,20 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import Template from '../../../../../models/template.ee.js'; + +export default async (request, response) => { + const template = await Template.create(templateParams(request)); + + renderObject(response, template, { + serializer: 'AdminTemplate', + status: 201, + }); +}; + +const templateParams = (request) => { + const { name, flowId } = request.body; + + return { + name, + flowId, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/templates/create-template.ee.test.js b/packages/backend/src/controllers/api/v1/admin/templates/create-template.ee.test.js new file mode 100644 index 00000000..08a89d3c --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/templates/create-template.ee.test.js @@ -0,0 +1,135 @@ +import { vi, 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 Template from '../../../../../models/template.ee.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../../test/factories/step.js'; +import createTemplateMock from '../../../../../../test/mocks/rest/api/v1/admin/templates/create-template.ee.js'; +import * as license from '../../../../../helpers/license.ee.js'; + +describe('POST /api/v1/admin/templates', () => { + let currentUser, token, role; + + beforeEach(async () => { + vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); + + role = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: role.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return the created template', 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`, + }); + + 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, + }); + + const templatePayload = { + name: 'Sample Template Name', + flowId: currentUserFlow.id, + }; + + const response = await request(app) + .post('/api/v1/admin/templates') + .set('Authorization', token) + .send(templatePayload) + .expect(201); + + const refetchedTemplate = await Template.query().findById( + response.body.data.id + ); + + const expectedPayload = await createTemplateMock(refetchedTemplate); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid flow ID', async () => { + const invalidFlowId = Crypto.randomUUID(); + + await request(app) + .post('/api/v1/admin/templates') + .set('Authorization', token) + .send({ + name: 'Sample Template Name', + flowId: invalidFlowId, + }) + .expect(404); + }); + + it('should return unprocessable entity response for invalid name', 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`, + }); + + 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, + }); + + const templatePayload = { + name: '', + flowId: currentUserFlow.id, + }; + + const response = await request(app) + .post('/api/v1/admin/templates') + .set('Authorization', token) + .send(templatePayload) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + name: ['must NOT have fewer than 1 characters'], + }, + meta: { type: 'ModelValidation' }, + }); + }); +}); diff --git a/packages/backend/src/models/template.ee.js b/packages/backend/src/models/template.ee.js index 7137445f..26f87313 100644 --- a/packages/backend/src/models/template.ee.js +++ b/packages/backend/src/models/template.ee.js @@ -1,4 +1,5 @@ import Base from './base.js'; +import Flow from './flow.js'; class Template extends Base { static tableName = 'templates'; @@ -15,6 +16,13 @@ class Template extends Base { updatedAt: { type: 'string' }, }, }; + + static async create({ name, flowId }) { + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const flowData = await flow.export(); + + return this.query().insertAndFetch({ name, flowData }); + } } export default Template; diff --git a/packages/backend/src/models/template.ee.test.js b/packages/backend/src/models/template.ee.test.js index 560d8dc0..9ebb4daf 100644 --- a/packages/backend/src/models/template.ee.test.js +++ b/packages/backend/src/models/template.ee.test.js @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; +import Crypto from 'crypto'; import Template from './template.ee.js'; +import { createFlow } from '../../test/factories/flow'; +import { createStep } from '../../test/factories/step'; describe('Template model', () => { it('tableName should return correct name', () => { @@ -9,4 +12,68 @@ describe('Template model', () => { it('jsonSchema should have correct validations', () => { expect(Template.jsonSchema).toMatchSnapshot(); }); + + describe('create', () => { + it('should throw an error if the flow does not exist', async () => { + const nonExistentFlowId = Crypto.randomUUID(); + const templateName = 'Test Template'; + + await expect( + Template.create({ name: templateName, flowId: nonExistentFlowId }) + ).rejects.toThrowError('NotFoundError'); + }); + + it('should create template with the name', async () => { + const flow = await createFlow(); + const templateName = 'Test Template'; + + const template = await Template.create({ + name: templateName, + flowId: flow.id, + }); + + expect(template.name).toStrictEqual(templateName); + }); + + it('should create template with the flow data', async () => { + const flow = await createFlow(); + + const triggerStep = await createStep({ + flowId: flow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + }); + + await createStep({ + flowId: flow.id, + type: 'action', + appKey: 'ntfy', + key: 'sendMessage', + parameters: { + topic: 'Test notification', + message: `Message: {{step.${triggerStep.id}.body.message}} by {{step.${triggerStep.id}.body.sender}}`, + }, + }); + + const templateName = 'Test Template'; + const template = await Template.create({ + name: templateName, + flowId: flow.id, + }); + + const exportedFlowData = await flow.export(); + + expect(template.flowData).toMatchObject({ + name: exportedFlowData.name, + steps: template.flowData.steps.map((step) => ({ + appKey: step.appKey, + key: step.key, + name: step.name, + position: step.position, + type: step.type, + })), + }); + }); + }); }); diff --git a/packages/backend/src/routes/api/v1/admin/templates.ee.js b/packages/backend/src/routes/api/v1/admin/templates.ee.js new file mode 100644 index 00000000..34b5c137 --- /dev/null +++ b/packages/backend/src/routes/api/v1/admin/templates.ee.js @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { authenticateUser } from '../../../../helpers/authentication.js'; +import { authorizeAdmin } from '../../../../helpers/authorization.js'; +import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; + +import createTemplateAction from '../../../../controllers/api/v1/admin/templates/create-template.ee.js'; + +const router = Router(); + +router.post( + '/', + authenticateUser, + authorizeAdmin, + checkIsEnterprise, + createTemplateAction +); + +export default router; diff --git a/packages/backend/src/routes/index.js b/packages/backend/src/routes/index.js index d1841e42..958ab180 100644 --- a/packages/backend/src/routes/index.js +++ b/packages/backend/src/routes/index.js @@ -15,6 +15,7 @@ import samlAuthProvidersRouter from './api/v1/saml-auth-providers.ee.js'; import adminAppsRouter from './api/v1/admin/apps.ee.js'; import adminConfigRouter from './api/v1/admin/config.ee.js'; import adminSamlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.js'; +import adminTemplatesRouter from './api/v1/admin/templates.ee.js'; import rolesRouter from './api/v1/admin/roles.ee.js'; import permissionsRouter from './api/v1/admin/permissions.ee.js'; import adminUsersRouter from './api/v1/admin/users.ee.js'; @@ -42,6 +43,7 @@ router.use('/api/v1/admin/users', adminUsersRouter); router.use('/api/v1/admin/roles', rolesRouter); router.use('/api/v1/admin/permissions', permissionsRouter); router.use('/api/v1/admin/saml-auth-providers', adminSamlAuthProvidersRouter); +router.use('/api/v1/admin/templates', adminTemplatesRouter); router.use('/api/v1/installation/users', installationUsersRouter); router.use('/api/v1/folders', foldersRouter); diff --git a/packages/backend/test/mocks/rest/api/v1/admin/templates/create-template.ee.js b/packages/backend/test/mocks/rest/api/v1/admin/templates/create-template.ee.js new file mode 100644 index 00000000..2081a5b9 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/templates/create-template.ee.js @@ -0,0 +1,22 @@ +const createTemplateMock = async (template) => { + const data = { + id: template.id, + name: template.name, + createdAt: template.createdAt.getTime(), + updatedAt: template.updatedAt.getTime(), + flowData: template.flowData, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Template', + }, + }; +}; + +export default createTemplateMock;