From ec7e256140c5d8db9bd618862541d63d99565a3c Mon Sep 17 00:00:00 2001 From: sjaanus Date: Thu, 29 Sep 2022 15:27:54 +0200 Subject: [PATCH] Backend for profile page (#2114) * First version of profile * Fix tests * Fix typings * Replace where to andwhere to be more clear --- src/lib/db/project-store.ts | 26 ++++++++- src/lib/openapi/index.ts | 2 + src/lib/openapi/spec/profile-schema.test.ts | 18 +++++++ src/lib/openapi/spec/profile-schema.ts | 35 ++++++++++++ src/lib/routes/admin-api/user/user.ts | 49 +++++++++++++++++ src/lib/services/project-service.ts | 4 ++ src/lib/types/stores/project-store.ts | 1 + .../__snapshots__/openapi.e2e.test.ts.snap | 53 +++++++++++++++++++ src/test/fixtures/fake-project-store.ts | 5 ++ 9 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/lib/openapi/spec/profile-schema.test.ts create mode 100644 src/lib/openapi/spec/profile-schema.ts diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts index 4811d1bf5d..f7d39b1f74 100644 --- a/src/lib/db/project-store.ts +++ b/src/lib/db/project-store.ts @@ -288,7 +288,31 @@ class ProjectStore implements IProjectStore { return members; } - async getMembersCountByProject(projectId?: string): Promise { + async getProjectsByUser(userId: number): Promise { + const members = await this.db.from((db) => { + db.select('project') + .from('role_user') + .leftJoin('roles', 'role_user.role_id', 'roles.id') + .where('type', 'root') + .andWhere('name', 'Editor') + .andWhere('user_id', userId) + .union((queryBuilder) => { + queryBuilder + .select('project') + .from('group_role') + .leftJoin( + 'group_user', + 'group_user.group_id', + 'group_role.group_id', + ) + .where('user_id', userId); + }) + .as('query'); + }); + return members; + } + + async getMembersCountByProject(projectId: string): Promise { const members = await this.db .from((db) => { db.select('user_id') diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index f888bf0ab5..4966494f24 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -120,6 +120,7 @@ import { publicSignupTokenSchema } from './spec/public-signup-token-schema'; import { publicSignupTokensSchema } from './spec/public-signup-tokens-schema'; import { publicSignupTokenUpdateSchema } from './spec/public-signup-token-update-schema'; import apiVersion from '../util/version'; +import { profileSchema } from './spec/profile-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -196,6 +197,7 @@ export const schemas = { publicSignupTokenUpdateSchema, publicSignupTokensSchema, publicSignupTokenSchema, + profileSchema, proxyClientSchema, proxyFeaturesSchema, proxyFeatureSchema, diff --git a/src/lib/openapi/spec/profile-schema.test.ts b/src/lib/openapi/spec/profile-schema.test.ts new file mode 100644 index 0000000000..f55c300ae3 --- /dev/null +++ b/src/lib/openapi/spec/profile-schema.test.ts @@ -0,0 +1,18 @@ +import { validateSchema } from '../validate'; +import { ProfileSchema } from './profile-schema'; +import { RoleName } from '../../types/model'; + +test('profileSchema', () => { + const data: ProfileSchema = { + rootRole: 'Editor' as RoleName, + projects: ['default', 'secretproject'], + features: [ + { name: 'firstFeature', project: 'default' }, + { name: 'secondFeature', project: 'secretproject' }, + ], + }; + + expect( + validateSchema('#/components/schemas/profileSchema', data), + ).toBeUndefined(); +}); diff --git a/src/lib/openapi/spec/profile-schema.ts b/src/lib/openapi/spec/profile-schema.ts new file mode 100644 index 0000000000..556e416df0 --- /dev/null +++ b/src/lib/openapi/spec/profile-schema.ts @@ -0,0 +1,35 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { featureSchema } from './feature-schema'; +import { RoleName } from '../../types/model'; + +export const profileSchema = { + $id: '#/components/schemas/profileSchema', + type: 'object', + additionalProperties: false, + required: ['rootRole', 'projects', 'features'], + properties: { + rootRole: { + type: 'string', + enum: Object.values(RoleName), + }, + projects: { + type: 'array', + items: { + type: 'string', + }, + }, + features: { + type: 'array', + items: { + $ref: '#/components/schemas/featureSchema', + }, + }, + }, + components: { + schemas: { + featureSchema, + }, + }, +} as const; + +export type ProfileSchema = FromSchema; diff --git a/src/lib/routes/admin-api/user/user.ts b/src/lib/routes/admin-api/user/user.ts index 34dd70492b..aecbb447e7 100644 --- a/src/lib/routes/admin-api/user/user.ts +++ b/src/lib/routes/admin-api/user/user.ts @@ -16,6 +16,12 @@ import { serializeDates } from '../../../types/serialize-dates'; import { IUserPermission } from '../../../types/stores/access-store'; import { PasswordSchema } from '../../../openapi/spec/password-schema'; import { emptyResponse } from '../../../openapi/util/standard-responses'; +import { + profileSchema, + ProfileSchema, +} from '../../../openapi/spec/profile-schema'; +import ProjectService from '../../../services/project-service'; +import { RoleName } from '../../../types/model'; class UserController extends Controller { private accessService: AccessService; @@ -28,6 +34,8 @@ class UserController extends Controller { private openApiService: OpenApiService; + private projectService: ProjectService; + constructor( config: IUnleashConfig, { @@ -36,6 +44,7 @@ class UserController extends Controller { userFeedbackService, userSplashService, openApiService, + projectService, }: Pick< IUnleashServices, | 'accessService' @@ -43,6 +52,7 @@ class UserController extends Controller { | 'userFeedbackService' | 'userSplashService' | 'openApiService' + | 'projectService' >, ) { super(config); @@ -51,6 +61,7 @@ class UserController extends Controller { this.userFeedbackService = userFeedbackService; this.userSplashService = userSplashService; this.openApiService = openApiService; + this.projectService = projectService; this.route({ method: 'get', @@ -66,6 +77,20 @@ class UserController extends Controller { ], }); + this.route({ + method: 'get', + path: '/profile', + handler: this.getProfile, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Users'], + operationId: 'getProfile', + responses: { 200: createResponseSchema('profileSchema') }, + }), + ], + }); + this.route({ method: 'post', path: '/change-password', @@ -114,6 +139,30 @@ class UserController extends Controller { ); } + async getProfile( + req: IAuthRequest, + res: Response, + ): Promise { + const { user } = req; + + const projects = await this.projectService.getProjectsByUser(user.id); + + const roles = await this.accessService.getUserRootRoles(user.id); + + const responseData: ProfileSchema = { + projects, + rootRole: roles[0].name as RoleName, + features: [], + }; + + this.openApiService.respondWithValidation( + 200, + res, + profileSchema.$id, + responseData, + ); + } + async changeMyPassword( req: IAuthRequest, res: Response, diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 5c5d2732ee..a13bf74a8d 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -574,6 +574,10 @@ export default class ProjectService { return this.store.getMembersCountByProject(projectId); } + async getProjectsByUser(userId: number): Promise { + return this.store.getProjectsByUser(userId); + } + async getProjectOverview( projectId: string, archived: boolean = false, diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts index 8cce795f6c..cdacb37337 100644 --- a/src/lib/types/stores/project-store.ts +++ b/src/lib/types/stores/project-store.ts @@ -36,6 +36,7 @@ export interface IProjectStore extends Store { deleteEnvironmentForProject(id: string, environment: string): Promise; getEnvironmentsForProject(id: string): Promise; getMembersCountByProject(projectId: string): Promise; + getProjectsByUser(userId: number): Promise; getMembersCount(): Promise; getProjectsWithCounts(query?: IProjectQuery): Promise; count(): Promise; diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 570e740479..1d5b5c6aa4 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -2128,6 +2128,39 @@ exports[`should serve the OpenAPI spec 1`] = ` ], "type": "object", }, + "profileSchema": { + "additionalProperties": false, + "properties": { + "features": { + "items": { + "$ref": "#/components/schemas/featureSchema", + }, + "type": "array", + }, + "projects": { + "items": { + "type": "string", + }, + "type": "array", + }, + "rootRole": { + "enum": [ + "Admin", + "Editor", + "Viewer", + "Owner", + "Member", + ], + "type": "string", + }, + }, + "required": [ + "rootRole", + "projects", + "features", + ], + "type": "object", + }, "projectEnvironmentSchema": { "additionalProperties": false, "properties": { @@ -7013,6 +7046,26 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/user/profile": { + "get": { + "operationId": "getProfile", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/profileSchema", + }, + }, + }, + "description": "profileSchema", + }, + }, + "tags": [ + "Users", + ], + }, + }, "/api/admin/user/tokens": { "get": { "operationId": "getPats", diff --git a/src/test/fixtures/fake-project-store.ts b/src/test/fixtures/fake-project-store.ts index 0b9bbabb4f..7cf6635377 100644 --- a/src/test/fixtures/fake-project-store.ts +++ b/src/test/fixtures/fake-project-store.ts @@ -127,4 +127,9 @@ export default class FakeProjectStore implements IProjectStore { getMembersCount(): Promise { throw new Error('Method not implemented.'); } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getProjectsByUser(userId: number): Promise { + throw new Error('Method not implemented.'); + } }