From 422ddb25898b3565150fb61284323fb9888c3c24 Mon Sep 17 00:00:00 2001 From: Valery <57412523+valerydluski@users.noreply.github.com> Date: Sat, 7 Dec 2024 20:33:09 +0100 Subject: [PATCH 1/6] feat: add column search functionality for task name in admin tasks table (#2558) --- client/src/pages/course/admin/tasks.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/pages/course/admin/tasks.tsx b/client/src/pages/course/admin/tasks.tsx index 1879b94fa2..1681d557bd 100644 --- a/client/src/pages/course/admin/tasks.tsx +++ b/client/src/pages/course/admin/tasks.tsx @@ -5,7 +5,13 @@ import { ColumnsType } from 'antd/lib/table'; import { CoursesTasksApi, CourseTaskDto } from 'api'; import { GithubUserLink } from 'components/GithubUserLink'; import { AdminPageLayout } from 'components/PageLayout'; -import { crossCheckDateRenderer, crossCheckStatusRenderer, dateRenderer, stringSorter } from 'components/Table'; +import { + crossCheckDateRenderer, + crossCheckStatusRenderer, + dateRenderer, + getColumnSearchProps, + stringSorter, +} from 'components/Table'; import { ActiveCourseProvider, SessionProvider, useActiveCourseContext } from 'modules/Course/contexts'; import { CourseTaskModal } from 'modules/CourseManagement/components/CourseTaskModal'; import { useCallback, useMemo, useState } from 'react'; @@ -203,6 +209,7 @@ function getColumns(getDropdownMenu: (record: CourseTaskDto) => any): ColumnsTyp title: 'Name', dataIndex: 'name', fixed: 'left', + ...getColumnSearchProps('name'), }, { title: 'Scores Count', dataIndex: 'resultsCount' }, { From 5d2cb8b35fbc59d57701777ce5551c10899028c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:37:17 +0200 Subject: [PATCH 2/6] chore(deps): bump cross-spawn from 7.0.3 to 7.0.6 (#2551) Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6. - [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md) - [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6) --- updated-dependencies: - dependency-name: cross-spawn dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7c9eec267..cab93c455b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9521,9 +9521,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -31045,9 +31045,9 @@ } }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", From df1500d52880045753f9711fe69cf2e602802a6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 16:01:51 +0200 Subject: [PATCH 3/6] chore(deps): bump nanoid from 3.3.6 to 3.3.8 in /tools/sloths (#2560) Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.6 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tools/sloths/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/sloths/package-lock.json b/tools/sloths/package-lock.json index fd078d99c8..7b06f77b77 100644 --- a/tools/sloths/package-lock.json +++ b/tools/sloths/package-lock.json @@ -3950,9 +3950,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -8713,9 +8713,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" }, "natural-compare": { "version": "1.4.0", From 789ed845ccfe8334e6c44c6bde2028d8522c8cb7 Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 11 Dec 2024 15:24:35 +0100 Subject: [PATCH 4/6] fix: update telegram banner message (#2561) --- .../Notifications/components/Consents.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/client/src/modules/Notifications/components/Consents.tsx b/client/src/modules/Notifications/components/Consents.tsx index e5d3904ec8..4e92601525 100644 --- a/client/src/modules/Notifications/components/Consents.tsx +++ b/client/src/modules/Notifications/components/Consents.tsx @@ -40,17 +40,21 @@ export function Consents({ return !hasContacts ? ( - {!hasTelegram && ( - - Note: To enable telegram notifications please open the @rsschool_bot and + + Telegram notifications are sent from @rsschool_bot + + ) : ( + <> + Note: To enable Telegram notifications please open the @rsschool_bot and click the Start button to set it up - - } - type="info" - /> - )} + + ) + } + type="info" + /> {!hasDiscord && ( Date: Wed, 11 Dec 2024 15:24:46 +0100 Subject: [PATCH 5/6] feat: extending user search with information about being mentor and student (#2559) * chore: update prettier and use podman compose * feat: extend user search with information about being student or mentor --- client/src/api/api.ts | 202 ++++++++++++++++++ client/src/components/UserSearch.tsx | 2 +- .../CrossCheck/components/CriteriaForm.tsx | 6 +- .../components/RepositoryCard.tsx | 2 +- client/src/pages/admin/auto-test-task.tsx | 2 +- client/src/pages/admin/users.tsx | 46 ++-- .../courses/interviews/interviews.service.ts | 5 +- nestjs/src/gratitudes/gratitudes.service.ts | 2 +- nestjs/src/profile/profile.service.ts | 2 +- 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 ++- package-lock.json | 14 +- package.json | 6 +- server/src/models/session.ts | 2 +- setup/docker-compose.yml | 1 - 19 files changed, 445 insertions(+), 38 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/components/UserSearch.tsx b/client/src/components/UserSearch.tsx index 0a29fee3e2..da6cb38540 100644 --- a/client/src/components/UserSearch.tsx +++ b/client/src/components/UserSearch.tsx @@ -50,7 +50,7 @@ export function UserSearch(props: UserProps) { suffixIcon={defaultValues ? Boolean(defaultValues.length) : false} filterOption={false} onSearch={handleSearch} - placeholder={defaultValues?.length ?? 0 > 0 ? 'Select...' : 'Search...'} + placeholder={(defaultValues?.length ?? 0 > 0) ? 'Select...' : 'Search...'} notFoundContent={null} > {data.map(person => { diff --git a/client/src/modules/CrossCheck/components/CriteriaForm.tsx b/client/src/modules/CrossCheck/components/CriteriaForm.tsx index 6b879c30f4..b013ac52cb 100644 --- a/client/src/modules/CrossCheck/components/CriteriaForm.tsx +++ b/client/src/modules/CrossCheck/components/CriteriaForm.tsx @@ -52,7 +52,9 @@ export function CriteriaForm({ authorId, comments, reviewComments, criteria, onC .map(d => ({ criteriaId: d.criteriaId, percentage: - criteriaId === d.criteriaId ? percentage : value?.find(v => v.criteriaId === d.criteriaId)?.percentage ?? 0, + criteriaId === d.criteriaId + ? percentage + : (value?.find(v => v.criteriaId === d.criteriaId)?.percentage ?? 0), })); onChange?.(newReview, reviewComments); }, @@ -69,7 +71,7 @@ export function CriteriaForm({ authorId, comments, reviewComments, criteria, onC authorId: authorId, timestamp: comment?.timestamp ?? Date.now(), criteriaId: d.criteriaId, - text: criteriaId === d.criteriaId ? text : comment?.text ?? '', + text: criteriaId === d.criteriaId ? text : (comment?.text ?? ''), }; }) .filter(c => c.text); diff --git a/client/src/modules/StudentDashboard/components/RepositoryCard.tsx b/client/src/modules/StudentDashboard/components/RepositoryCard.tsx index d3cd9b2b7e..b601fb2947 100644 --- a/client/src/modules/StudentDashboard/components/RepositoryCard.tsx +++ b/client/src/modules/StudentDashboard/components/RepositoryCard.tsx @@ -10,7 +10,7 @@ type Props = { updateUrl: () => void; }; -const getGithubRepoName = (url: string | null | undefined) => (url ? url.split('/').pop() ?? '' : ''); +const getGithubRepoName = (url: string | null | undefined) => (url ? (url.split('/').pop() ?? '') : ''); export function RepositoryCard(props: Props) { const { Text, Paragraph } = Typography; diff --git a/client/src/pages/admin/auto-test-task.tsx b/client/src/pages/admin/auto-test-task.tsx index 2ddc090f56..5989199b3e 100644 --- a/client/src/pages/admin/auto-test-task.tsx +++ b/client/src/pages/admin/auto-test-task.tsx @@ -18,7 +18,7 @@ function Page() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [selectedTask, setSelectedTask] = useState(); - const taskId = router?.query ? router.query?.taskId ?? null : null; + const taskId = router?.query ? (router.query?.taskId ?? null) : null; const { courses } = useActiveCourseContext(); useAsync(async () => { 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/gratitudes/gratitudes.service.ts b/nestjs/src/gratitudes/gratitudes.service.ts index 455638fb71..8fd9cdaedf 100644 --- a/nestjs/src/gratitudes/gratitudes.service.ts +++ b/nestjs/src/gratitudes/gratitudes.service.ts @@ -45,7 +45,7 @@ export class GratitudesService { return gratitudeBadge; } - const userCourseRoles = courses ? courses[courseId]?.roles ?? [] : []; + const userCourseRoles = courses ? (courses[courseId]?.roles ?? []) : []; return gratitudeBadge.filter((badge: GratitudeBadge) => { const allowed = badge.roles?.some(role => userCourseRoles.includes(role)) ?? true; return allowed; diff --git a/nestjs/src/profile/profile.service.ts b/nestjs/src/profile/profile.service.ts index 600fdbd102..1499ec5497 100644 --- a/nestjs/src/profile/profile.service.ts +++ b/nestjs/src/profile/profile.service.ts @@ -185,7 +185,7 @@ export class ProfileService { omitBy>( { firstName, - lastName: firstName ? lastName ?? '' : undefined, + lastName: firstName ? (lastName ?? '') : undefined, countryName, cityName, educationHistory, 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`, diff --git a/package-lock.json b/package-lock.json index cab93c455b..56c39b1eb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@typescript-eslint/parser": "6.12.0", "eslint": "8.54.0", "jest": "29.7.0", - "prettier": "3.2.5", + "prettier": "3.4.2", "turbo": "1.10.16" } }, @@ -18095,9 +18095,9 @@ } }, "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -37323,9 +37323,9 @@ "dev": true }, "prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "dev": true }, "prettier-linter-helpers": { diff --git a/package.json b/package.json index eb47a98a51..7bacc1d981 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "db:restore": "podman exec -i db psql -U rs_master -d rs_school < ./setup/backup-local.sql", "db:dump": "PGPASSWORD=12345678 pg_dump -h localhost --username rs_master rs_school --file ./setup/backup-local.sql", "db:dump:win": "pg_dump -h localhost --username rs_master rs_school > ./setup/backup-local.sql", - "db:up": "podman-compose -f ./setup/docker-compose.yml up -d", - "db:down": "podman-compose -f ./setup/docker-compose.yml down" + "db:up": "podman compose -f ./setup/docker-compose.yml up -d", + "db:down": "podman compose -f ./setup/docker-compose.yml down" }, "devDependencies": { "@total-typescript/ts-reset": "0.5.1", @@ -28,7 +28,7 @@ "@typescript-eslint/parser": "6.12.0", "eslint": "8.54.0", "jest": "29.7.0", - "prettier": "3.2.5", + "prettier": "3.4.2", "turbo": "1.10.16" }, "packageManager": "npm@10.7.0" diff --git a/server/src/models/session.ts b/server/src/models/session.ts index dbdd03b3ae..d47f1f732f 100644 --- a/server/src/models/session.ts +++ b/server/src/models/session.ts @@ -23,7 +23,7 @@ export enum CourseRole { } function hasRole(user?: IUserSession, courseId?: number, role?: CourseRole) { - return courseId && role ? user?.courses?.[courseId]?.roles.includes(role) ?? false : false; + return courseId && role ? (user?.courses?.[courseId]?.roles.includes(role) ?? false) : false; } function hasRoleInAny(user?: IUserSession, role?: CourseRole) { diff --git a/setup/docker-compose.yml b/setup/docker-compose.yml index d191178579..1a14d2e568 100644 --- a/setup/docker-compose.yml +++ b/setup/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: postgres: image: postgres:15.5 From 6bf5a960074173718d3e56c44415c0bf7b9010e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 22 Dec 2024 11:03:57 +0100 Subject: [PATCH 6/6] chore(deps): bump nanoid from 3.3.6 to 3.3.8 (#2564) Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.6 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- nestjs/package.json | 2 +- package-lock.json | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/nestjs/package.json b/nestjs/package.json index 4ab3b35d4c..489b28136b 100644 --- a/nestjs/package.json +++ b/nestjs/package.json @@ -54,7 +54,7 @@ "json2csv": "5.0.3", "jsonwebtoken": "9.0.2", "lodash": "4.17.21", - "nanoid": "3.3.6", + "nanoid": "3.3.8", "nestjs-pino": "3.5.0", "openai": "4.19.0", "passport": "0.6.0", diff --git a/package-lock.json b/package-lock.json index 56c39b1eb1..d67b6ccb9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,7 +112,7 @@ "json2csv": "5.0.3", "jsonwebtoken": "9.0.2", "lodash": "4.17.21", - "nanoid": "3.3.6", + "nanoid": "3.3.8", "nestjs-pino": "3.5.0", "openai": "4.19.0", "passport": "0.6.0", @@ -16593,9 +16593,9 @@ "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -36161,9 +36161,9 @@ "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" }, "nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" }, "natural-compare": { "version": "1.4.0", @@ -36234,7 +36234,7 @@ "json2csv": "5.0.3", "jsonwebtoken": "9.0.2", "lodash": "4.17.21", - "nanoid": "3.3.6", + "nanoid": "3.3.8", "nestjs-pino": "3.5.0", "openai": "4.19.0", "passport": "0.6.0",