From feda30b6c669e742ec4e230fbde6fb747afcc644 Mon Sep 17 00:00:00 2001 From: apalchys Date: Wed, 11 Dec 2024 13:35:59 +0100 Subject: [PATCH] feat: extend user search with information about being student or mentor --- client/src/api/api.ts | 202 ++++++++++++++++++ client/src/pages/admin/users.tsx | 46 ++-- .../courses/interviews/interviews.service.ts | 5 +- nestjs/src/spec.json | 54 +++++ nestjs/src/users/dto/index.ts | 1 + nestjs/src/users/dto/user-search.dto.ts | 83 +++++++ nestjs/src/users/users.controller.ts | 21 ++ nestjs/src/users/users.module.ts | 2 + nestjs/src/users/users.service.ts | 30 ++- 9 files changed, 425 insertions(+), 19 deletions(-) create mode 100644 nestjs/src/users/dto/index.ts create mode 100644 nestjs/src/users/dto/user-search.dto.ts create mode 100644 nestjs/src/users/users.controller.ts diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 4c10d74242..fdb112bb3b 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -1042,6 +1042,25 @@ export interface CourseMentorsStatsDto { */ 'epamMentorsCount': number; } +/** + * + * @export + * @interface CourseRecord + */ +export interface CourseRecord { + /** + * + * @type {string} + * @memberof CourseRecord + */ + 'courseName': string; + /** + * + * @type {number} + * @memberof CourseRecord + */ + 'id': number; +} /** * * @export @@ -7374,6 +7393,85 @@ export interface UserNotificationsDto { */ 'connections': object; } +/** + * + * @export + * @interface UserSearchDto + */ +export interface UserSearchDto { + /** + * + * @type {number} + * @memberof UserSearchDto + */ + 'id': number; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'githubId': string; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'cityName': string | null; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'countryName': string | null; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'contactsEmail': string | null; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'contactsEpamEmail': string | null; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'primaryEmail': string | null; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'contactsDiscord': string | null; + /** + * + * @type {string} + * @memberof UserSearchDto + */ + 'contactsTelegram': string | null; + /** + * + * @type {Array} + * @memberof UserSearchDto + */ + 'mentors': Array | null; + /** + * + * @type {Array} + * @memberof UserSearchDto + */ + 'students': Array | null; +} /** * * @export @@ -19605,6 +19703,110 @@ export class UserGroupApi extends BaseAPI { } +/** + * UsersApi - axios parameter creator + * @export + */ +export const UsersApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} query + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchUsers: async (query: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'query' is not null or undefined + assertParamExists('searchUsers', 'query', query) + const localVarPath = `/users/search`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (query !== undefined) { + localVarQueryParameter['query'] = query; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * UsersApi - functional programming interface + * @export + */ +export const UsersApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = UsersApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} query + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async searchUsers(query: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchUsers(query, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * UsersApi - factory interface + * @export + */ +export const UsersApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = UsersApiFp(configuration) + return { + /** + * + * @param {string} query + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchUsers(query: string, options?: any): AxiosPromise> { + return localVarFp.searchUsers(query, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * UsersApi - object-oriented interface + * @export + * @class UsersApi + * @extends {BaseAPI} + */ +export class UsersApi extends BaseAPI { + /** + * + * @param {string} query + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof UsersApi + */ + public searchUsers(query: string, options?: AxiosRequestConfig) { + return UsersApiFp(this.configuration).searchUsers(query, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * UsersNotificationsApi - axios parameter creator * @export diff --git a/client/src/pages/admin/users.tsx b/client/src/pages/admin/users.tsx index c600608d33..c48cfc9d48 100644 --- a/client/src/pages/admin/users.tsx +++ b/client/src/pages/admin/users.tsx @@ -1,24 +1,25 @@ -import { useState, useMemo } from 'react'; +import { useState } from 'react'; import { Button, Col, Input, List, Row, Layout, Form } from 'antd'; import { GithubAvatar } from 'components/GithubAvatar'; -import { UserService, UserFull } from 'services/user'; import { AdminPageLayout } from 'components/PageLayout'; import { CourseRole } from 'services/models'; import { ActiveCourseProvider, SessionProvider, useActiveCourseContext } from 'modules/Course/contexts'; +import { UsersApi, UserSearchDto } from 'api'; const { Content } = Layout; +const userApi = new UsersApi(); + function Page() { const { courses } = useActiveCourseContext(); - const [users, setUsers] = useState(null as any[] | null); - const userService = useMemo(() => new UserService(), []); + const [users, setUsers] = useState(null as UserSearchDto[] | null); const handleSearch = async (values: any) => { if (!values.searchText) { return; } - const users = await userService.extendedUserSearch(values.searchText); - setUsers(users); + const users = await userApi.searchUsers(values.searchText); + setUsers(users.data); }; return ( @@ -48,20 +49,22 @@ function Page() { rowKey="id" locale={{ emptyText: 'No results' }} dataSource={users} - renderItem={(user: UserFull) => ( + renderItem={user => ( } title={{user.githubId}} description={ -
-
{user.name}
-
{`Primary email: ${user.primaryEmail || ''}`}
-
{`EPAM email: ${user.contactsEpamEmail || ''}`}
-
{`Skype: ${user.contactsSkype || ''}`}
-
{`Telegram: ${user.contactsTelegram || ''}`}
-
{`Discord: ${user.discord || ''}`}
-
+ <> + + + + + + + courseName)} /> + courseName)} /> + } />
@@ -76,6 +79,19 @@ function Page() { ); } +function UserField({ label, value }: { label?: string; value: string | string[] | null | undefined }) { + const valueStr = Array.isArray(value) ? value.join(', ') : value; + if (!valueStr) { + return null; + } + return ( +
+ {label ? {label}: : null} + {valueStr} +
+ ); +} + export default function () { return ( diff --git a/nestjs/src/courses/interviews/interviews.service.ts b/nestjs/src/courses/interviews/interviews.service.ts index a606002a5b..0e84314b87 100644 --- a/nestjs/src/courses/interviews/interviews.service.ts +++ b/nestjs/src/courses/interviews/interviews.service.ts @@ -19,7 +19,6 @@ export class InterviewsService { readonly taskInterviewStudentRepository: Repository, @InjectRepository(Student) readonly studentRepository: Repository, - readonly userService: UsersService, ) {} public getAll( @@ -84,7 +83,7 @@ export class InterviewsService { return records.map(record => ({ id: record.student.id, - name: this.userService.getFullName(record.student.user), + name: UsersService.getFullName(record.student.user), githubId: record.student.user.githubId, cityName: record.student.user.cityName, countryName: record.student.user.countryName, @@ -143,7 +142,7 @@ export class InterviewsService { id, totalScore, githubId: user.githubId, - name: this.userService.getFullName(student.user), + name: UsersService.getFullName(student.user), cityName: user.cityName, countryName: user.countryName, isGoodCandidate: this.isGoodCandidate(stageInterviews), diff --git a/nestjs/src/spec.json b/nestjs/src/spec.json index fc5eb7da7b..0a6f022cd0 100644 --- a/nestjs/src/spec.json +++ b/nestjs/src/spec.json @@ -49,6 +49,24 @@ "tags": ["activity"] } }, + "/users/search": { + "get": { + "operationId": "searchUsers", + "summary": "", + "parameters": [{ "name": "query", "required": true, "in": "query", "schema": { "type": "string" } }], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/UserSearchDto" } } + } + } + } + }, + "tags": ["users"] + } + }, "/alerts": { "post": { "operationId": "createAlert", @@ -2683,6 +2701,42 @@ "properties": { "sender": { "$ref": "#/components/schemas/SenderDto" } }, "required": ["sender"] }, + "CourseRecord": { + "type": "object", + "properties": { "courseName": { "type": "string" }, "id": { "type": "number" } }, + "required": ["courseName", "id"] + }, + "UserSearchDto": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "githubId": { "type": "string" }, + "name": { "type": "string" }, + "cityName": { "type": "string", "nullable": true }, + "countryName": { "type": "string", "nullable": true }, + "contactsEmail": { "type": "string", "nullable": true }, + "contactsEpamEmail": { "type": "string", "nullable": true }, + "primaryEmail": { "type": "string", "nullable": true }, + "contactsDiscord": { "type": "string", "nullable": true }, + "contactsTelegram": { "type": "string", "nullable": true }, + "mentors": { "nullable": true, "type": "array", "items": { "$ref": "#/components/schemas/CourseRecord" } }, + "students": { "nullable": true, "type": "array", "items": { "$ref": "#/components/schemas/CourseRecord" } } + }, + "required": [ + "id", + "githubId", + "name", + "cityName", + "countryName", + "contactsEmail", + "contactsEpamEmail", + "primaryEmail", + "contactsDiscord", + "contactsTelegram", + "mentors", + "students" + ] + }, "CreateAlertDto": { "type": "object", "properties": { diff --git a/nestjs/src/users/dto/index.ts b/nestjs/src/users/dto/index.ts new file mode 100644 index 0000000000..92ded88c20 --- /dev/null +++ b/nestjs/src/users/dto/index.ts @@ -0,0 +1 @@ +export { UserSearchDto } from './user-search.dto'; diff --git a/nestjs/src/users/dto/user-search.dto.ts b/nestjs/src/users/dto/user-search.dto.ts new file mode 100644 index 0000000000..686fc8e8b8 --- /dev/null +++ b/nestjs/src/users/dto/user-search.dto.ts @@ -0,0 +1,83 @@ +import { User } from '@entities/user'; +import { ApiProperty } from '@nestjs/swagger'; +import { UsersService } from '../users.service'; + +export class CourseRecord { + constructor(obj: { courseName: string; id: number }) { + this.id = obj.id; + this.courseName = obj.courseName; + } + + @ApiProperty({ type: String }) + courseName: string; + + @ApiProperty({ type: Number }) + id: number; +} + +export class UserSearchDto { + constructor(user: User, isAdmin?: boolean) { + this.id = user.id; + this.name = UsersService.getFullName(user); + this.githubId = user.githubId; + + this.primaryEmail = isAdmin ? (user.primaryEmail ?? null) : null; + this.contactsEmail = isAdmin ? user.contactsEmail : null; + this.contactsEpamEmail = isAdmin ? user.contactsEpamEmail : null; + this.contactsDiscord = isAdmin ? (user.discord?.username ?? null) : null; + this.contactsTelegram = isAdmin ? (user.contactsTelegram ?? null) : null; + + this.cityName = isAdmin ? user.cityName : null; + this.countryName = isAdmin ? user.countryName : null; + + this.mentors = + user.mentors?.map(mentor => ({ + id: mentor.id, + courseName: mentor.course?.name, + })) ?? []; + + this.students = + user.students + ?.filter(student => student.certificate != null) + .map(student => ({ + id: student.id, + courseName: student.course?.name, + })) ?? []; + } + + @ApiProperty() + public id: number; + + @ApiProperty() + public githubId: string; + + @ApiProperty() + public name: string; + + @ApiProperty({ nullable: true, type: String }) + public cityName: string | null; + + @ApiProperty({ nullable: true, type: String }) + public countryName: string | null; + + @ApiProperty({ nullable: true, type: String }) + public contactsEmail: string | null; + + @ApiProperty({ nullable: true, type: String }) + public contactsEpamEmail: string | null; + + @ApiProperty({ nullable: true, type: String }) + public primaryEmail: string | null; + + @ApiProperty({ nullable: true, type: String }) + public contactsDiscord: string | null; + + @ApiProperty({ nullable: true, type: String }) + public contactsTelegram: string | null; + + @ApiProperty({ nullable: true, type: [CourseRecord] }) + public mentors: CourseRecord[]; + + @ApiProperty({ nullable: true, type: [CourseRecord] }) + public students: CourseRecord[]; +} diff --git a/nestjs/src/users/users.controller.ts b/nestjs/src/users/users.controller.ts new file mode 100644 index 0000000000..10fd42d4c3 --- /dev/null +++ b/nestjs/src/users/users.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CourseRole, CurrentRequest, DefaultGuard, RequiredRoles, Role, RoleGuard } from '../auth'; +import { UserSearchDto } from './dto'; +import { UsersService } from './users.service'; + +@Controller('users') +@ApiTags('users') +@UseGuards(DefaultGuard, RoleGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get('/search') + @ApiOperation({ operationId: 'searchUsers' }) + @RequiredRoles([Role.Admin, CourseRole.Manager]) + @ApiOkResponse({ type: [UserSearchDto] }) + public async searchUsers(@Req() req: CurrentRequest, @Query('query') query?: string) { + const users = await this.usersService.searchUsers(query); + return users.map(user => new UserSearchDto(user, req.user.isAdmin || req.user.isHirer)); + } +} diff --git a/nestjs/src/users/users.module.ts b/nestjs/src/users/users.module.ts index a94ea1f24d..ab3cbcca6e 100644 --- a/nestjs/src/users/users.module.ts +++ b/nestjs/src/users/users.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '@entities/user'; +import { UsersController } from './users.controller'; @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UsersService], + controllers: [UsersController], exports: [UsersService], }) export class UsersModule {} diff --git a/nestjs/src/users/users.service.ts b/nestjs/src/users/users.service.ts index fd36131126..603ae41ea0 100644 --- a/nestjs/src/users/users.service.ts +++ b/nestjs/src/users/users.service.ts @@ -47,7 +47,7 @@ export class UsersService { }); } - public getFullName({ firstName, lastName }: { firstName: string; lastName: string }) { + public static getFullName({ firstName, lastName }: { firstName: string; lastName: string }) { const result = []; if (firstName) { result.push(firstName.trim()); @@ -58,6 +58,34 @@ export class UsersService { return result.join(' '); } + public async searchUsers(query?: string) { + if (!query) { + return []; + } + + const search = `${query.trim()}%`; + + // Search by full name, githubId, discord username + const userIds = await this.userRepository + .createQueryBuilder() + .where(`CONCAT("firstName", ' ', "lastName") ILIKE :search`, { search }) + .orWhere('"githubId" ILIKE :search', { search }) + .orWhere(`CAST(discord AS jsonb)->>'username' ILIKE :search`, { search }) + .select(['id']) + .limit(20) + .getRawMany(); + + if (userIds.length === 0) { + return []; + } + + // Get full user data by ids + return this.userRepository.find({ + where: { id: In(userIds.map(({ id }) => id)) }, + relations: ['mentors', 'students', 'mentors.course', 'students.course', 'students.certificate'], + }); + } + public static getPrimaryUserFields(modelName = 'user') { return [ `${modelName}.id`,