From 3e3e48110d57267d57af60902d89329090e4de29 Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Sun, 7 Apr 2024 03:45:33 +0200 Subject: [PATCH] feat: Implement users get apps API endpoint --- .../src/controllers/api/v1/users/get-apps.js | 7 + .../controllers/api/v1/users/get-apps.test.js | 210 ++++++++++++++++++ packages/backend/src/helpers/authorization.js | 4 + packages/backend/src/models/user.js | 51 +++++ packages/backend/src/routes/api/v1/users.js | 10 + packages/backend/src/serializers/app.js | 16 +- .../test/mocks/rest/api/v1/users/get-apps.js | 55 +++++ 7 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/controllers/api/v1/users/get-apps.js create mode 100644 packages/backend/src/controllers/api/v1/users/get-apps.test.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/get-apps.js diff --git a/packages/backend/src/controllers/api/v1/users/get-apps.js b/packages/backend/src/controllers/api/v1/users/get-apps.js new file mode 100644 index 00000000..801fbc71 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-apps.js @@ -0,0 +1,7 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const apps = await request.currentUser.getApps(request.query.name); + + renderObject(response, apps, { serializer: 'App' }); +}; diff --git a/packages/backend/src/controllers/api/v1/users/get-apps.test.js b/packages/backend/src/controllers/api/v1/users/get-apps.test.js new file mode 100644 index 00000000..175a0f48 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/users/get-apps.test.js @@ -0,0 +1,210 @@ +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'; +import { createRole } from '../../../../../test/factories/role'; +import { createUser } from '../../../../../test/factories/user'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import getAppsMock from '../../../../../test/mocks/rest/api/v1/users/get-apps.js'; + +describe('GET /api/v1/users/:userId/apps', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUserRole = await createRole(); + currentUser = await createUser({ roleId: currentUserRole.id }); + + token = createAuthTokenByUserId(currentUser.id); + }); + + it('should return all apps of the current user', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const flowOne = await createFlow({ userId: currentUser.id }); + + await createStep({ + flowId: flowOne.id, + appKey: 'webhook', + }); + + const flowOneActionStepConnection = await createConnection({ + userId: currentUser.id, + key: 'deepl', + draft: false, + }); + + await createStep({ + connectionId: flowOneActionStepConnection.id, + flowId: flowOne.id, + appKey: 'deepl', + }); + + const flowTwo = await createFlow({ userId: currentUser.id }); + + const flowTwoTriggerStepConnection = await createConnection({ + userId: currentUser.id, + key: 'github', + draft: false, + }); + + await createStep({ + connectionId: flowTwoTriggerStepConnection.id, + flowId: flowTwo.id, + appKey: 'github', + }); + + await createStep({ + flowId: flowTwo.id, + appKey: 'slack', + }); + + const response = await request(app) + .get(`/api/v1/users/${currentUser.id}/apps`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppsMock(); + expect(response.body).toEqual(expectedPayload); + }); + + it('should return all apps of the another user', async () => { + const anotherUser = await createUser(); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: [], + }); + + const flowOne = await createFlow({ userId: anotherUser.id }); + + await createStep({ + flowId: flowOne.id, + appKey: 'webhook', + }); + + const flowOneActionStepConnection = await createConnection({ + userId: anotherUser.id, + key: 'deepl', + draft: false, + }); + + await createStep({ + connectionId: flowOneActionStepConnection.id, + flowId: flowOne.id, + appKey: 'deepl', + }); + + const flowTwo = await createFlow({ userId: anotherUser.id }); + + const flowTwoTriggerStepConnection = await createConnection({ + userId: anotherUser.id, + key: 'github', + draft: false, + }); + + await createStep({ + connectionId: flowTwoTriggerStepConnection.id, + flowId: flowTwo.id, + appKey: 'github', + }); + + await createStep({ + flowId: flowTwo.id, + appKey: 'slack', + }); + + const response = await request(app) + .get(`/api/v1/users/${currentUser.id}/apps`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = getAppsMock(); + expect(response.body).toEqual(expectedPayload); + }); + + it('should return specified app of the current user', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const flowOne = await createFlow({ userId: currentUser.id }); + + await createStep({ + flowId: flowOne.id, + appKey: 'webhook', + }); + + const flowOneActionStepConnection = await createConnection({ + userId: currentUser.id, + key: 'deepl', + draft: false, + }); + + await createStep({ + connectionId: flowOneActionStepConnection.id, + flowId: flowOne.id, + appKey: 'deepl', + }); + + const flowTwo = await createFlow({ userId: currentUser.id }); + + const flowTwoTriggerStepConnection = await createConnection({ + userId: currentUser.id, + key: 'github', + draft: false, + }); + + await createStep({ + connectionId: flowTwoTriggerStepConnection.id, + flowId: flowTwo.id, + appKey: 'github', + }); + + await createStep({ + flowId: flowTwo.id, + appKey: 'slack', + }); + + const response = await request(app) + .get(`/api/v1/users/${currentUser.id}/apps?name=deepl`) + .set('Authorization', token) + .expect(200); + + expect(response.body.data.length).toEqual(1); + expect(response.body.data[0].key).toEqual('deepl'); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index ee393014..5a3e8a25 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -7,6 +7,10 @@ const authorizationList = { action: 'read', subject: 'User', }, + 'GET /api/v1/users/:userId/apps': { + action: 'read', + subject: 'Connection', + }, 'GET /api/v1/flows/:flowId': { action: 'read', subject: 'Flow', diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index 29ed5741..9452e8b0 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -7,6 +7,7 @@ import { hasValidLicense } from '../helpers/license.ee.js'; import userAbility from '../helpers/user-ability.js'; import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js'; import Base from './base.js'; +import App from './app.js'; import Connection from './connection.js'; import Execution from './execution.js'; import Flow from './flow.js'; @@ -313,6 +314,56 @@ class User extends Base { return invoices; } + async getApps(name) { + const connections = await this.authorizedConnections + .clone() + .select('connections.key') + .where({ draft: false }) + .count('connections.id as count') + .groupBy('connections.key'); + + const flows = await this.authorizedFlows + .clone() + .withGraphJoined('steps') + .orderBy('created_at', 'desc'); + + const duplicatedUsedApps = flows + .map((flow) => flow.steps.map((step) => step.appKey)) + .flat() + .filter(Boolean); + + const connectionKeys = connections.map((connection) => connection.key); + const usedApps = [...new Set([...duplicatedUsedApps, ...connectionKeys])]; + + let apps = await App.findAll(name); + + apps = apps + .filter((app) => { + return usedApps.includes(app.key); + }) + .map((app) => { + const connection = connections.find( + (connection) => connection.key === app.key + ); + + app.connectionCount = connection?.count || 0; + app.flowCount = 0; + + flows.forEach((flow) => { + const usedFlow = flow.steps.find((step) => step.appKey === app.key); + + if (usedFlow) { + app.flowCount += 1; + } + }); + + return app; + }) + .sort((appA, appB) => appA.name.localeCompare(appB.name)); + + return apps; + } + async $beforeInsert(queryContext) { await super.$beforeInsert(queryContext); diff --git a/packages/backend/src/routes/api/v1/users.js b/packages/backend/src/routes/api/v1/users.js index ab1d1d7f..2755c3b6 100644 --- a/packages/backend/src/routes/api/v1/users.js +++ b/packages/backend/src/routes/api/v1/users.js @@ -1,9 +1,11 @@ import { Router } from 'express'; import asyncHandler from 'express-async-handler'; import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; import checkIsCloud from '../../../helpers/check-is-cloud.js'; import getCurrentUserAction from '../../../controllers/api/v1/users/get-current-user.js'; import getUserTrialAction from '../../../controllers/api/v1/users/get-user-trial.ee.js'; +import getAppsAction from '../../../controllers/api/v1/users/get-apps.js'; import getInvoicesAction from '../../../controllers/api/v1/users/get-invoices.ee.js'; import getSubscriptionAction from '../../../controllers/api/v1/users/get-subscription.ee.js'; import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-and-usage.ee.js'; @@ -11,6 +13,14 @@ import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-an const router = Router(); router.get('/me', authenticateUser, asyncHandler(getCurrentUserAction)); + +router.get( + '/:userId/apps', + authenticateUser, + authorizeUser, + asyncHandler(getAppsAction) +); + router.get( '/invoices', authenticateUser, diff --git a/packages/backend/src/serializers/app.js b/packages/backend/src/serializers/app.js index 4b3a6462..57e0306d 100644 --- a/packages/backend/src/serializers/app.js +++ b/packages/backend/src/serializers/app.js @@ -1,12 +1,22 @@ const appSerializer = (app) => { - return { - name: app.name, + let appData = { key: app.key, + name: app.name, iconUrl: app.iconUrl, + primaryColor: app.primaryColor, authDocUrl: app.authDocUrl, supportsConnections: app.supportsConnections, - primaryColor: app.primaryColor, }; + + if (app.connectionCount) { + appData.connectionCount = app.connectionCount; + } + + if (app.flowCount) { + appData.flowCount = app.flowCount; + } + + return appData; }; export default appSerializer; diff --git a/packages/backend/test/mocks/rest/api/v1/users/get-apps.js b/packages/backend/test/mocks/rest/api/v1/users/get-apps.js new file mode 100644 index 00000000..79ac0e8a --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/get-apps.js @@ -0,0 +1,55 @@ +const getAppsMock = () => { + const appsData = [ + { + authDocUrl: 'https://automatisch.io/docs/apps/deepl/connection', + connectionCount: 1, + flowCount: 1, + iconUrl: 'http://localhost:3000/apps/deepl/assets/favicon.svg', + key: 'deepl', + name: 'DeepL', + primaryColor: '0d2d45', + supportsConnections: true, + }, + { + authDocUrl: 'https://automatisch.io/docs/apps/github/connection', + connectionCount: 1, + flowCount: 1, + iconUrl: 'http://localhost:3000/apps/github/assets/favicon.svg', + key: 'github', + name: 'GitHub', + primaryColor: '000000', + supportsConnections: true, + }, + { + authDocUrl: 'https://automatisch.io/docs/apps/slack/connection', + flowCount: 1, + iconUrl: 'http://localhost:3000/apps/slack/assets/favicon.svg', + key: 'slack', + name: 'Slack', + primaryColor: '4a154b', + supportsConnections: true, + }, + { + authDocUrl: 'https://automatisch.io/docs/apps/webhook/connection', + flowCount: 1, + iconUrl: 'http://localhost:3000/apps/webhook/assets/favicon.svg', + key: 'webhook', + name: 'Webhook', + primaryColor: '0059F7', + supportsConnections: false, + }, + ]; + + return { + data: appsData, + meta: { + count: appsData.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default getAppsMock;