From a4a9d3fb5cd01879299616541ab865a3a4249c52 Mon Sep 17 00:00:00 2001 From: Stanislau Laniuk Date: Thu, 1 Feb 2024 11:05:34 +0200 Subject: [PATCH] feat: add profile/info route handling via nestjs --- client/src/api/api.ts | 585 +++++++++++++++++- client/src/services/user.ts | 8 +- nestjs/src/courses/courses.module.ts | 2 +- nestjs/src/courses/courses.service.ts | 4 + .../courses/interviews/interviews.service.ts | 135 +++- nestjs/src/disciplines/disciplines.service.ts | 4 + nestjs/src/profile/dto/index.ts | 2 + nestjs/src/profile/dto/permissions.dto.ts | 119 ++++ nestjs/src/profile/dto/profile-info.dto.ts | 389 ++++++++++++ nestjs/src/profile/dto/profile.dto.ts | 2 +- .../profile/dto/update-profile-info.dto.ts | 93 +++ nestjs/src/profile/dto/update-profile.dto.ts | 322 +--------- nestjs/src/profile/endorsement.service.ts | 4 +- nestjs/src/profile/mentor-info.service.ts | 88 +++ nestjs/src/profile/permissions.service.ts | 260 ++++++++ nestjs/src/profile/profile-info.service.ts | 175 ++++++ nestjs/src/profile/profile.controller.ts | 60 +- nestjs/src/profile/profile.module.ts | 52 +- nestjs/src/profile/profile.service.ts | 89 +-- nestjs/src/profile/public-feedback.service.ts | 45 ++ nestjs/src/profile/student-info.service.ts | 205 ++++++ nestjs/src/profile/user-info.service.ts | 139 +++++ nestjs/src/registry/registry.module.ts | 1 + nestjs/src/registry/registry.service.ts | 28 +- nestjs/src/spec.json | 308 +++++++-- 25 files changed, 2608 insertions(+), 511 deletions(-) create mode 100644 nestjs/src/profile/dto/permissions.dto.ts create mode 100644 nestjs/src/profile/dto/profile-info.dto.ts create mode 100644 nestjs/src/profile/dto/update-profile-info.dto.ts create mode 100644 nestjs/src/profile/mentor-info.service.ts create mode 100644 nestjs/src/profile/permissions.service.ts create mode 100644 nestjs/src/profile/profile-info.service.ts create mode 100644 nestjs/src/profile/public-feedback.service.ts create mode 100644 nestjs/src/profile/student-info.service.ts create mode 100644 nestjs/src/profile/user-info.service.ts diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 116257ddd9..f25f5edb8e 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -487,6 +487,91 @@ export interface CommentMentorRegistryDto { */ 'comment': string | null; } +/** + * + * @export + * @interface ConfigurableProfilePermissions + */ +export interface ConfigurableProfilePermissions { + /** + * + * @type {PublicVisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isProfileVisible'?: PublicVisibilitySettings; + /** + * + * @type {VisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isAboutVisible'?: VisibilitySettings; + /** + * + * @type {VisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isEducationVisible'?: VisibilitySettings; + /** + * + * @type {PartialStudentVisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isEnglishVisible'?: PartialStudentVisibilitySettings; + /** + * + * @type {ContactsVisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isEmailVisible'?: ContactsVisibilitySettings; + /** + * + * @type {ContactsVisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isTelegramVisible'?: ContactsVisibilitySettings; + /** + * + * @type {ContactsVisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isSkypeVisible'?: ContactsVisibilitySettings; + /** + * + * @type {ContactsVisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isPhoneVisible'?: ContactsVisibilitySettings; + /** + * + * @type {ContactsVisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isContactsNotesVisible'?: ContactsVisibilitySettings; + /** + * + * @type {VisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isLinkedInVisible'?: VisibilitySettings; + /** + * + * @type {VisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isPublicFeedbackVisible'?: VisibilitySettings; + /** + * + * @type {VisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isMentorStatsVisible'?: VisibilitySettings; + /** + * + * @type {PartialStudentVisibilitySettings} + * @memberof ConfigurableProfilePermissions + */ + 'isStudentStatsVisible'?: PartialStudentVisibilitySettings; +} /** * * @export @@ -561,6 +646,25 @@ export interface ContactsDto { */ 'discord'?: string | null; } +/** + * + * @export + * @interface ContactsVisibilitySettings + */ +export interface ContactsVisibilitySettings { + /** + * + * @type {boolean} + * @memberof ContactsVisibilitySettings + */ + 'all': boolean; + /** + * + * @type {boolean} + * @memberof ContactsVisibilitySettings + */ + 'student': boolean; +} /** * * @export @@ -2975,6 +3079,68 @@ export const FormDataDtoMilitaryServiceEnum = { export type FormDataDtoMilitaryServiceEnum = typeof FormDataDtoMilitaryServiceEnum[keyof typeof FormDataDtoMilitaryServiceEnum]; +/** + * + * @export + * @interface GeneralInfo + */ +export interface GeneralInfo { + /** + * + * @type {string} + * @memberof GeneralInfo + */ + 'name': string; + /** + * + * @type {string} + * @memberof GeneralInfo + */ + 'githubId': string; + /** + * + * @type {string} + * @memberof GeneralInfo + */ + 'aboutMyself'?: string | null; + /** + * + * @type {Location} + * @memberof GeneralInfo + */ + 'location': Location; + /** + * + * @type {Array} + * @memberof GeneralInfo + */ + 'educationHistory'?: Array | null; + /** + * + * @type {string} + * @memberof GeneralInfo + */ + 'englishLevel'?: string | null; +} +/** + * + * @export + * @interface GithubIdName + */ +export interface GithubIdName { + /** + * + * @type {string} + * @memberof GithubIdName + */ + 'name': string; + /** + * + * @type {string} + * @memberof GithubIdName + */ + 'githubId': string; +} /** * * @export @@ -3298,6 +3464,25 @@ export interface LeaveCourseRequestDto { */ 'comment'?: string; } +/** + * + * @export + * @interface Location + */ +export interface Location { + /** + * + * @type {string} + * @memberof Location + */ + 'cityName'?: string | null; + /** + * + * @type {string} + * @memberof Location + */ + 'countryName'?: string | null; +} /** * * @export @@ -3639,6 +3824,31 @@ export interface MentorRegistryDto { */ 'comment': string | null; } +/** + * + * @export + * @interface MentorStatsDto + */ +export interface MentorStatsDto { + /** + * + * @type {string} + * @memberof MentorStatsDto + */ + 'courseLocationName': string; + /** + * + * @type {string} + * @memberof MentorStatsDto + */ + 'courseName': string; + /** + * + * @type {Array} + * @memberof MentorStatsDto + */ + 'students'?: Array; +} /** * * @export @@ -3944,6 +4154,25 @@ export interface PaginationMetaDto { */ 'totalPages': number; } +/** + * + * @export + * @interface PartialStudentVisibilitySettings + */ +export interface PartialStudentVisibilitySettings { + /** + * + * @type {boolean} + * @memberof PartialStudentVisibilitySettings + */ + 'all': boolean; + /** + * + * @type {boolean} + * @memberof PartialStudentVisibilitySettings + */ + 'student': boolean; +} /** * * @export @@ -4154,13 +4383,74 @@ export interface ProfileCourseDto { /** * * @export - * @interface ProfileDto + * @interface ProfileInfoExtendedDto + */ +export interface ProfileInfoExtendedDto { + /** + * + * @type {ConfigurableProfilePermissions} + * @memberof ProfileInfoExtendedDto + */ + 'permissionsSettings': ConfigurableProfilePermissions; + /** + * + * @type {GeneralInfo} + * @memberof ProfileInfoExtendedDto + */ + 'generalInfo': GeneralInfo; + /** + * + * @type {ContactsDto} + * @memberof ProfileInfoExtendedDto + */ + 'contacts': ContactsDto; + /** + * + * @type {Discord} + * @memberof ProfileInfoExtendedDto + */ + 'discord'?: Discord | null; + /** + * + * @type {Array} + * @memberof ProfileInfoExtendedDto + */ + 'mentorStats'?: Array; + /** + * + * @type {Array} + * @memberof ProfileInfoExtendedDto + */ + 'studentStats'?: Array; + /** + * + * @type {Array} + * @memberof ProfileInfoExtendedDto + */ + 'publicFeedback'?: Array; + /** + * + * @type {Array} + * @memberof ProfileInfoExtendedDto + */ + 'stageInterviewFeedback'?: Array; + /** + * + * @type {string} + * @memberof ProfileInfoExtendedDto + */ + 'publicCvUrl'?: string | null; +} +/** + * + * @export + * @interface ProfileWithCvDto */ -export interface ProfileDto { +export interface ProfileWithCvDto { /** * * @type {string} - * @memberof ProfileDto + * @memberof ProfileWithCvDto */ 'publicCvUrl': string | null; } @@ -4195,6 +4485,50 @@ export interface PromptDto { */ 'temperature': number; } +/** + * + * @export + * @interface PublicFeedbackDto + */ +export interface PublicFeedbackDto { + /** + * + * @type {string} + * @memberof PublicFeedbackDto + */ + 'feedbackDate': string; + /** + * + * @type {string} + * @memberof PublicFeedbackDto + */ + 'badgeId': string; + /** + * + * @type {string} + * @memberof PublicFeedbackDto + */ + 'comment': string; + /** + * + * @type {GithubIdName} + * @memberof PublicFeedbackDto + */ + 'fromUser': GithubIdName; +} +/** + * + * @export + * @interface PublicVisibilitySettings + */ +export interface PublicVisibilitySettings { + /** + * + * @type {boolean} + * @memberof PublicVisibilitySettings + */ + 'all': boolean; +} /** * * @export @@ -4825,6 +5159,73 @@ export const SoftSkillEntryValueEnum = { export type SoftSkillEntryValueEnum = typeof SoftSkillEntryValueEnum[keyof typeof SoftSkillEntryValueEnum]; +/** + * + * @export + * @interface StageInterviewDetailedFeedbackDto + */ +export interface StageInterviewDetailedFeedbackDto { + /** + * + * @type {string} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'decision': string; + /** + * + * @type {boolean} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'isGoodCandidate': boolean; + /** + * + * @type {string} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'courseName': string; + /** + * + * @type {string} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'courseFullName': string; + /** + * + * @type {number} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'score': number; + /** + * + * @type {number} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'maxScore': number; + /** + * + * @type {string} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'date': string; + /** + * + * @type {number} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'version': number; + /** + * + * @type {GithubIdName} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'interviewer': GithubIdName; + /** + * + * @type {object} + * @memberof StageInterviewDetailedFeedbackDto + */ + 'feedback': object; +} /** * * @export @@ -5019,6 +5420,91 @@ export interface StudentId { */ 'id': number; } +/** + * + * @export + * @interface StudentStatsDto + */ +export interface StudentStatsDto { + /** + * + * @type {number} + * @memberof StudentStatsDto + */ + 'courseId': number; + /** + * + * @type {string} + * @memberof StudentStatsDto + */ + 'courseName': string; + /** + * + * @type {string} + * @memberof StudentStatsDto + */ + 'locationName': string; + /** + * + * @type {string} + * @memberof StudentStatsDto + */ + 'courseFullName': string; + /** + * + * @type {boolean} + * @memberof StudentStatsDto + */ + 'isExpelled': boolean; + /** + * + * @type {boolean} + * @memberof StudentStatsDto + */ + 'isSelfExpelled': boolean; + /** + * + * @type {string} + * @memberof StudentStatsDto + */ + 'expellingReason'?: string; + /** + * + * @type {string} + * @memberof StudentStatsDto + */ + 'certificateId': string; + /** + * + * @type {boolean} + * @memberof StudentStatsDto + */ + 'isCourseCompleted': boolean; + /** + * + * @type {number} + * @memberof StudentStatsDto + */ + 'totalScore': number; + /** + * + * @type {number} + * @memberof StudentStatsDto + */ + 'rank': number; + /** + * + * @type {GithubIdName} + * @memberof StudentStatsDto + */ + 'mentor': GithubIdName; + /** + * + * @type {Array} + * @memberof StudentStatsDto + */ + 'tasks': Array; +} /** * * @export @@ -6782,6 +7268,31 @@ export interface VisibilityDto { */ 'isHidden': boolean; } +/** + * + * @export + * @interface VisibilitySettings + */ +export interface VisibilitySettings { + /** + * + * @type {boolean} + * @memberof VisibilitySettings + */ + 'all': boolean; + /** + * + * @type {boolean} + * @memberof VisibilitySettings + */ + 'mentor': boolean; + /** + * + * @type {boolean} + * @memberof VisibilitySettings + */ + 'student': boolean; +} /** * ActivityApi - axios parameter creator @@ -13815,6 +14326,40 @@ export const ProfileApiAxiosParamCreator = function (configuration?: Configurati + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} [githubId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getProfileInfo: async (githubId?: string, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/profile/info`; + // 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 (githubId !== undefined) { + localVarQueryParameter['githubId'] = githubId; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -14006,10 +14551,20 @@ export const ProfileApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getProfile(username: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getProfile(username: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getProfile(username, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} [githubId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getProfileInfo(githubId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getProfileInfo(githubId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} username @@ -14093,9 +14648,18 @@ export const ProfileApiFactory = function (configuration?: Configuration, basePa * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getProfile(username: string, options?: any): AxiosPromise { + getProfile(username: string, options?: any): AxiosPromise { return localVarFp.getProfile(username, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} [githubId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getProfileInfo(githubId?: string, options?: any): AxiosPromise { + return localVarFp.getProfileInfo(githubId, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} username @@ -14186,6 +14750,17 @@ export class ProfileApi extends BaseAPI { return ProfileApiFp(this.configuration).getProfile(username, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} [githubId] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProfileApi + */ + public getProfileInfo(githubId?: string, options?: AxiosRequestConfig) { + return ProfileApiFp(this.configuration).getProfileInfo(githubId, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} username diff --git a/client/src/services/user.ts b/client/src/services/user.ts index ef2e89ccf9..bb6378be1d 100644 --- a/client/src/services/user.ts +++ b/client/src/services/user.ts @@ -1,7 +1,7 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import { getApiConfiguration, getServerAxiosProps } from 'utils/axios'; import { EnglishLevel } from 'common/models'; -import { ProfileApi, ProfileDto, UsersNotificationsApi, UpdateUserDtoLanguagesEnum } from 'api'; +import { ProfileApi, ProfileWithCvDto, UsersNotificationsApi, UpdateUserDtoLanguagesEnum } from 'api'; import discordIntegration from '../configs/discord-integration'; import type { ConfigurableProfilePermissions, @@ -101,6 +101,10 @@ export class UserService { } async getProfileInfo(githubId?: string) { + const { data } = await this.profileApi.getProfileInfo(githubId); + return data; + + // old implementation for regression verification purposes (to be deleted along with koa-related code in the final commit of PR) const response = await this.axios.get<{ data: ProfileInfo }>(`/api/profile/info`, { params: { githubId }, }); @@ -226,7 +230,7 @@ export type ProfileInfo = { publicFeedback?: PublicFeedback[]; stageInterviewFeedback?: StageInterviewDetailedFeedback[]; discord: Discord | null; -} & ProfileDto; +} & ProfileWithCvDto; export type ProfileMainCardData = { location: Location | null; diff --git a/nestjs/src/courses/courses.module.ts b/nestjs/src/courses/courses.module.ts index 7dcff84da5..67e390802b 100644 --- a/nestjs/src/courses/courses.module.ts +++ b/nestjs/src/courses/courses.module.ts @@ -146,6 +146,6 @@ import { CourseMentorsController, CourseMentorsService } from './course-mentors' TaskVerificationsService, CourseMentorsService, ], - exports: [CourseTasksService, CourseUsersService, CoursesService, StudentsService], + exports: [CourseTasksService, CourseUsersService, CoursesService, StudentsService, InterviewsService], }) export class CoursesModule {} diff --git a/nestjs/src/courses/courses.service.ts b/nestjs/src/courses/courses.service.ts index 5103e88ee0..27661fbb98 100644 --- a/nestjs/src/courses/courses.service.ts +++ b/nestjs/src/courses/courses.service.ts @@ -47,4 +47,8 @@ export class CoursesService { relations, }); } + + public async getByDisciplineIds(disciplinesIds: number[] = []) { + return this.repository.find({ where: { disciplineId: In(disciplinesIds) } }); + } } diff --git a/nestjs/src/courses/interviews/interviews.service.ts b/nestjs/src/courses/interviews/interviews.service.ts index 790797a0d8..e38d9c9a0d 100644 --- a/nestjs/src/courses/interviews/interviews.service.ts +++ b/nestjs/src/courses/interviews/interviews.service.ts @@ -1,14 +1,21 @@ import { In, Repository } from 'typeorm'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { StageInterviewFeedbackJson } from '@common/models'; -import { CourseTask } from '@entities/courseTask'; -import { StageInterview } from '@entities/stageInterview'; -import { TaskInterviewStudent } from '@entities/taskInterviewStudent'; import { UsersService } from 'src/users/users.service'; -import { StageInterviewStudent, Student } from '@entities/index'; -import { AvailableStudentDto } from './dto/available-student.dto'; +import { StageInterviewFeedbackJson, StageInterviewDetailedFeedback } from '@common/models'; +import { CourseTask } from '@entities/courseTask'; +import { + Course, + Mentor, + StageInterview, + StageInterviewFeedback, + StageInterviewStudent, + Student, + TaskInterviewStudent, + User, +} from '@entities/index'; import { TaskType } from '@entities/task'; +import { AvailableStudentDto } from './dto/available-student.dto'; @Injectable() export class InterviewsService { @@ -19,6 +26,8 @@ export class InterviewsService { readonly taskInterviewStudentRepository: Repository, @InjectRepository(Student) readonly studentRepository: Repository, + @InjectRepository(StageInterview) + readonly stageInterviewRepository: Repository, readonly userService: UsersService, ) {} @@ -184,4 +193,118 @@ export class InterviewsService { private isGoodCandidate(stageInterviews: StageInterview[]) { return stageInterviews.some(i => i.isCompleted && i.isGoodCandidate); } + + async getStageInterviewFeedback(githubId: string): Promise { + const data = await this.stageInterviewRepository + .createQueryBuilder('stageInterview') + .select('"stageInterview"."decision" AS "decision"') + .addSelect('"stageInterview"."isGoodCandidate" AS "isGoodCandidate"') + .addSelect('"stageInterview"."score" AS "interviewScore"') + .addSelect('"course"."name" AS "courseName"') + .addSelect('"course"."fullName" AS "courseFullName"') + .addSelect('"stageInterviewFeedback"."json" AS "interviewResultJson"') + .addSelect('"stageInterviewFeedback"."updatedDate" AS "interviewFeedbackDate"') + .addSelect('"stageInterviewFeedback"."version" AS "feedbackVersion"') + .addSelect('"userMentor"."firstName" AS "interviewerFirstName"') + .addSelect('"userMentor"."lastName" AS "interviewerLastName"') + .addSelect('"userMentor"."githubId" AS "interviewerGithubId"') + .addSelect('"courseTask"."maxScore" AS "maxScore"') + .leftJoin(Student, 'student', '"student"."id" = "stageInterview"."studentId"') + .leftJoin(User, 'user', '"user"."id" = "student"."userId"') + .leftJoin(Course, 'course', '"course"."id" = "stageInterview"."courseId"') + .leftJoin( + StageInterviewFeedback, + 'stageInterviewFeedback', + '"stageInterview"."id" = "stageInterviewFeedback"."stageInterviewId"', + ) + .leftJoin(CourseTask, 'courseTask', '"courseTask"."id" = "stageInterview"."courseTaskId"') + .leftJoin(Mentor, 'mentor', '"mentor"."id" = "stageInterview"."mentorId"') + .leftJoin(User, 'userMentor', '"userMentor"."id" = "mentor"."userId"') + .where('"user"."githubId" = :githubId', { githubId }) + .andWhere('"stageInterview"."isCompleted" = true') + .orderBy('"course"."updatedDate"', 'ASC') + .getRawMany(); + + return data + .map((feedbackData: FeedbackData) => { + const { + feedbackVersion, + decision, + interviewFeedbackDate, + interviewerFirstName, + courseFullName, + courseName, + interviewerLastName, + interviewerGithubId, + isGoodCandidate, + interviewScore, + interviewResultJson, + maxScore, + } = feedbackData; + const feedbackTemplate = JSON.parse(interviewResultJson) as any; + + const { score, feedback } = !feedbackVersion + ? InterviewsService.parseLegacyFeedback(feedbackTemplate) + : { + feedback: feedbackTemplate, + score: interviewScore ?? 0, + }; + + return { + version: feedbackVersion ?? 0, + date: interviewFeedbackDate, + decision, + isGoodCandidate, + courseName, + courseFullName, + feedback, + score, + interviewer: { + name: + this.userService.getFullName({ firstName: interviewerFirstName, lastName: interviewerLastName }) || + interviewerGithubId, + githubId: interviewerGithubId, + }, + maxScore, + }; + }) + .filter(Boolean); + } + + /** + * @deprecated - should be removed once Artsiom A. makes migration of the legacy feedback format + */ + private static parseLegacyFeedback(interviewResult: StageInterviewFeedbackJson) { + const { english, programmingTask, resume } = interviewResult; + const { rating, htmlCss, common, dataStructures } = InterviewsService.getInterviewRatings(interviewResult); + + return { + score: rating, + feedback: { + english: english.levelMentorOpinion ? english.levelMentorOpinion : english.levelStudentOpinion, + programmingTask, + comment: resume.comment, + skills: { + htmlCss, + common, + dataStructures, + }, + }, + }; + } } + +type FeedbackData = { + decision: string; + isGoodCandidate: boolean; + courseName: string; + courseFullName: string; + interviewResultJson: any; + interviewFeedbackDate: string; + interviewerFirstName: string; + interviewerLastName: string; + interviewerGithubId: string; + feedbackVersion: null | number; + interviewScore: null | number; + maxScore: number; +}; diff --git a/nestjs/src/disciplines/disciplines.service.ts b/nestjs/src/disciplines/disciplines.service.ts index af8a0c93be..54cf87ac3b 100644 --- a/nestjs/src/disciplines/disciplines.service.ts +++ b/nestjs/src/disciplines/disciplines.service.ts @@ -41,4 +41,8 @@ export class DisciplinesService { public async delete(id: number): Promise { await this.repository.softDelete(id); } + + public async getByNames(disciplineNames: string[]) { + return this.repository.find({ where: { name: In(disciplineNames) } }); + } } diff --git a/nestjs/src/profile/dto/index.ts b/nestjs/src/profile/dto/index.ts index 623e5587aa..2fcc9a54c8 100644 --- a/nestjs/src/profile/dto/index.ts +++ b/nestjs/src/profile/dto/index.ts @@ -1,3 +1,5 @@ export * from './profile-course.dto'; export * from './update-user.dto'; export * from './update-profile.dto'; +export * from './profile-info.dto'; +export * from './update-profile-info.dto'; diff --git a/nestjs/src/profile/dto/permissions.dto.ts b/nestjs/src/profile/dto/permissions.dto.ts new file mode 100644 index 0000000000..267a1dbcdb --- /dev/null +++ b/nestjs/src/profile/dto/permissions.dto.ts @@ -0,0 +1,119 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export interface Permissions { + isProfileVisible: boolean; + isAboutVisible: boolean; + isEducationVisible: boolean; + isEnglishVisible: boolean; + isEmailVisible: boolean; + isTelegramVisible: boolean; + isSkypeVisible: boolean; + isWhatsAppVisible: boolean; + isPhoneVisible: boolean; + isContactsNotesVisible: boolean; + isLinkedInVisible: boolean; + isPublicFeedbackVisible: boolean; + isMentorStatsVisible: boolean; + isStudentStatsVisible: boolean; + isStageInterviewFeedbackVisible: boolean; + isCoreJsFeedbackVisible: boolean; + isConsentsVisible: boolean; + isExpellingReasonVisible: boolean; +} + +class PublicVisibilitySettings { + @ApiProperty() + @IsBoolean() + all: boolean; +} + +class PartialStudentVisibilitySettings extends PublicVisibilitySettings { + @ApiProperty() + @IsBoolean() + student: boolean; +} + +class ContactsVisibilitySettings extends PublicVisibilitySettings { + @ApiProperty() + @IsBoolean() + student: boolean; +} + +class VisibilitySettings extends PublicVisibilitySettings { + @ApiProperty() + @IsBoolean() + mentor: boolean; + + @ApiProperty() + @IsBoolean() + student: boolean; +} + +export class ConfigurableProfilePermissions { + @ApiProperty({ required: false, type: PublicVisibilitySettings }) + @Type(() => PublicVisibilitySettings) + @IsOptional() + isProfileVisible?: PublicVisibilitySettings; + + @ApiProperty({ required: false, type: VisibilitySettings }) + @Type(() => VisibilitySettings) + @IsOptional() + isAboutVisible?: VisibilitySettings; + + @ApiProperty({ required: false, type: VisibilitySettings }) + @Type(() => VisibilitySettings) + @IsOptional() + isEducationVisible?: VisibilitySettings; + + @ApiProperty({ required: false, type: PartialStudentVisibilitySettings }) + @Type(() => PartialStudentVisibilitySettings) + @IsOptional() + isEnglishVisible?: PartialStudentVisibilitySettings; + + @ApiProperty({ required: false, type: ContactsVisibilitySettings }) + @Type(() => ContactsVisibilitySettings) + @IsOptional() + isEmailVisible?: ContactsVisibilitySettings; + + @ApiProperty({ required: false, type: ContactsVisibilitySettings }) + @Type(() => ContactsVisibilitySettings) + @IsOptional() + isTelegramVisible?: ContactsVisibilitySettings; + + @ApiProperty({ required: false, type: ContactsVisibilitySettings }) + @Type(() => ContactsVisibilitySettings) + @IsOptional() + isSkypeVisible?: ContactsVisibilitySettings; + + @ApiProperty({ required: false, type: ContactsVisibilitySettings }) + @Type(() => ContactsVisibilitySettings) + @IsOptional() + isPhoneVisible?: ContactsVisibilitySettings; + + @ApiProperty({ required: false, type: ContactsVisibilitySettings }) + @Type(() => ContactsVisibilitySettings) + @IsOptional() + isContactsNotesVisible?: ContactsVisibilitySettings; + + @ApiProperty({ required: false, type: VisibilitySettings }) + @Type(() => VisibilitySettings) + @IsOptional() + isLinkedInVisible?: VisibilitySettings; + + @ApiProperty({ required: false, type: VisibilitySettings }) + @Type(() => VisibilitySettings) + @IsOptional() + isPublicFeedbackVisible?: VisibilitySettings; + + @ApiProperty({ required: false, type: VisibilitySettings }) + @Type(() => VisibilitySettings) + @IsOptional() + isMentorStatsVisible?: VisibilitySettings; + + @ApiProperty({ required: false, type: PartialStudentVisibilitySettings }) + @Type(() => PartialStudentVisibilitySettings) + @IsOptional() + isStudentStatsVisible?: PartialStudentVisibilitySettings; +} diff --git a/nestjs/src/profile/dto/profile-info.dto.ts b/nestjs/src/profile/dto/profile-info.dto.ts new file mode 100644 index 0000000000..e997beb80f --- /dev/null +++ b/nestjs/src/profile/dto/profile-info.dto.ts @@ -0,0 +1,389 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator'; +import { ProfileWithCvDto } from './profile.dto'; +import { ConfigurableProfilePermissions } from './permissions.dto'; +import { + Contacts, + EnglishLevel, + MentorStats, + PublicFeedback, + StageInterviewDetailedFeedback, + Student, + StudentStats, + StudentTasksDetail, +} from '@common/models'; + +class GithubIdName { + @ApiProperty({ required: true, type: String }) + @IsString() + name: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + githubId: string; +} + +class Location { + @IsString() + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + cityName: string | null; + + @IsString() + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + countryName: string | null; +} + +export class Education { + @ApiProperty() + @IsString() + university: string; + + @ApiProperty() + @IsString() + faculty: string; + + @ApiProperty() + @IsNumber() + graduationYear: number; +} + +export class Discord { + @ApiProperty() + @IsNotEmpty() + id: string; + + @ApiProperty() + @IsNotEmpty() + username: string; + + @ApiProperty() + @IsNotEmpty() + discriminator: string; +} + +class GeneralInfo extends GithubIdName { + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + aboutMyself?: string | null; + + @ApiProperty({ type: Location }) + @Type(() => Location) + @ValidateNested() + location: Location; + + @ApiProperty({ required: false, nullable: true, type: [Education] }) + @IsOptional() + @Type(() => Education) + @IsArray() + educationHistory?: Education[] | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + englishLevel?: EnglishLevel | null; +} + +class ContactsDto implements Contacts { + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + phone: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + email: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + epamEmail: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + skype: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + whatsApp: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + telegram: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + notes: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + linkedIn: string | null; +} + +class StudentDto extends GithubIdName implements Student { + @ApiProperty({ required: true, type: Boolean }) + @IsBoolean() + isExpelled: boolean; + + @ApiProperty({ required: true, type: Number }) + @IsNumber() + totalScore: number; + + @ApiProperty({ required: false, type: String }) + @IsOptional() + @IsUrl() + repoUrl?: string; +} + +class MentorStatsDto implements MentorStats { + @ApiProperty({ required: true, type: String }) + @IsString() + courseLocationName: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + courseName: string; + + @ApiProperty({ required: false, type: [StudentDto] }) + @IsOptional() + @IsArray() + students?: StudentDto[]; +} + +class PublicFeedbackDto implements PublicFeedback { + @ApiProperty({ required: true, type: String }) + @IsString() + feedbackDate: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + badgeId: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + comment: string; + + @ApiProperty({ + required: true, + type: GithubIdName, + }) + fromUser: { + name: string; + githubId: string; + }; +} + +class InterviewFormAnswerDto { + @ApiProperty({ required: true, type: String }) + @IsString() + questionId: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + questionText: string; + + @ApiProperty({ required: false, type: Boolean }) + @IsOptional() + @IsBoolean() + answer?: boolean; +} + +export class StageInterviewDetailedFeedbackDto implements StageInterviewDetailedFeedback { + @ApiProperty({ required: true, type: String }) + @IsString() + decision: string; + + @ApiProperty({ required: true, type: Boolean }) + @IsBoolean() + isGoodCandidate: boolean; + + @ApiProperty({ required: true, type: String }) + @IsString() + courseName: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + courseFullName: string; + + @ApiProperty({ required: true, type: Number }) + @IsNumber() + score: number; + + @ApiProperty({ required: true, type: Number }) + @IsNumber() + maxScore: number; + + @ApiProperty({ required: true, type: String }) + @IsString() + date: string; + + @ApiProperty({ required: true, type: Number }) + @IsNumber() + version: number; + + @ApiProperty({ required: true, type: GithubIdName }) + interviewer: GithubIdName; + + @ApiProperty({ required: true }) + feedback: StageInterviewDetailedFeedback['feedback']; +} + +export class StudentTaskDetailDto implements StudentTasksDetail { + @ApiProperty({ required: true, type: Number }) + @IsNumber() + maxScore: number; + + @ApiProperty({ required: true, type: Number }) + @IsNumber() + scoreWeight: number; + + @ApiProperty({ required: true, type: String }) + @IsString() + name: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + descriptionUri: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + taskGithubPrUris: string; + + @ApiProperty({ required: true, type: Number }) + @IsNumber() + score: number; + + @ApiProperty({ required: true, type: String }) + @IsString() + comment: string; + + @ApiProperty({ required: false, type: String }) + @IsOptional() + @IsString() + interviewDate?: string; + + @ApiProperty({ required: false, type: GithubIdName }) + @IsOptional() + @ValidateNested() + interviewer?: GithubIdName; + + @ApiProperty({ required: false, type: InterviewFormAnswerDto }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + interviewFormAnswers?: InterviewFormAnswerDto[]; +} + +class StudentStatsDto implements StudentStats { + @ApiProperty({ required: true, type: Number }) + @IsNumber() + courseId: number; + + @ApiProperty({ required: true, type: String }) + @IsString() + courseName: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + locationName: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + courseFullName: string; + + @ApiProperty({ required: true, type: Boolean }) + @IsBoolean() + isExpelled: boolean; + + @ApiProperty({ required: true, type: Boolean }) + @IsBoolean() + isSelfExpelled: boolean; + + @ApiProperty({ required: false, type: String }) + @IsOptional() + @IsString() + expellingReason?: string; + + @ApiProperty({ required: true, type: String }) + @IsString() + certificateId: string | null; + + @ApiProperty({ required: true, type: Boolean }) + @IsBoolean() + isCourseCompleted: boolean; + + @ApiProperty({ required: true, type: Number }) + @IsNumber() + totalScore: number; + + @ApiProperty({ required: true, type: Number }) + @IsNumber() + rank: number | null; + + @ApiProperty({ required: true, type: GithubIdName }) + @ValidateNested() + mentor: GithubIdName; + + @ApiProperty({ required: true }) + @IsArray() + @ValidateNested({ each: true }) + tasks: StudentTaskDetailDto[]; +} + +export class ProfileInfoBaseDto { + @ApiProperty({ type: ConfigurableProfilePermissions }) + @ValidateNested() + @Type(() => ConfigurableProfilePermissions) + permissionsSettings: ConfigurableProfilePermissions; + + @ApiProperty({ type: GeneralInfo }) + @ValidateNested() + @Type(() => GeneralInfo) + generalInfo: GeneralInfo; + + @ApiProperty({ type: ContactsDto }) + @ValidateNested() + @Type(() => ContactsDto) + contacts: ContactsDto; + + @ApiProperty({ required: false, nullable: true, type: Discord }) + @Type(() => Discord) + @IsOptional() + discord: Discord | null; +} + +export class ProfileInfoExtendedDto extends ProfileInfoBaseDto implements ProfileWithCvDto { + @ApiProperty({ required: false, type: [MentorStatsDto] }) + @IsOptional() + @IsArray() + @ValidateNested() + mentorStats: MentorStatsDto[]; + + @ApiProperty({ required: false, type: [StudentStatsDto] }) + @IsOptional() + @ValidateNested() + studentStats: StudentStatsDto[]; + + @ApiProperty({ required: false, type: [PublicFeedbackDto] }) + @IsOptional() + @IsArray() + @ValidateNested() + publicFeedback: PublicFeedbackDto[]; + + @ApiProperty({ required: false, type: [StageInterviewDetailedFeedbackDto] }) + @IsOptional() + @ValidateNested() + stageInterviewFeedback: StageInterviewDetailedFeedbackDto[]; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + publicCvUrl: string | null; +} diff --git a/nestjs/src/profile/dto/profile.dto.ts b/nestjs/src/profile/dto/profile.dto.ts index 663ba7c5d2..613ecaa635 100644 --- a/nestjs/src/profile/dto/profile.dto.ts +++ b/nestjs/src/profile/dto/profile.dto.ts @@ -1,7 +1,7 @@ import { Resume } from '@entities/resume'; import { ApiProperty } from '@nestjs/swagger'; -export class ProfileDto { +export class ProfileWithCvDto { constructor(profile: { resume: Resume | null }) { this.publicCvUrl = profile.resume?.uuid ? `/cv/${profile.resume.uuid}` : null; } diff --git a/nestjs/src/profile/dto/update-profile-info.dto.ts b/nestjs/src/profile/dto/update-profile-info.dto.ts new file mode 100644 index 0000000000..b743e81918 --- /dev/null +++ b/nestjs/src/profile/dto/update-profile-info.dto.ts @@ -0,0 +1,93 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { Education, Discord } from './profile-info.dto'; +import { EnglishLevel } from '@common/models'; + +export class UpdateProfileInfoDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + githubId?: string; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + aboutMyself?: string | null; + + @IsString() + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + cityName?: string | null; + + @IsString() + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + countryName?: string | null; + + @ApiProperty({ required: false, nullable: true, type: [Education] }) + @IsOptional() + @IsArray() + educationHistory?: Education[]; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + englishLevel?: EnglishLevel | null; + + @ApiProperty({ required: false, type: [String] }) + @IsOptional() + @IsArray() + languages?: string[]; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + contactsPhone?: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + contactsEmail?: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + contactsEpamEmail?: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + contactsSkype?: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + contactsWhatsApp?: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + contactsTelegram?: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + contactsNotes?: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsOptional() + @IsString() + contactsLinkedIn?: string | null; + + @ApiProperty({ required: false, nullable: true, type: Discord }) + @ValidateNested() + @IsOptional() + @Type(() => Discord) + discord?: Discord | null; +} diff --git a/nestjs/src/profile/dto/update-profile.dto.ts b/nestjs/src/profile/dto/update-profile.dto.ts index 49cefd46c1..f69c17cb59 100644 --- a/nestjs/src/profile/dto/update-profile.dto.ts +++ b/nestjs/src/profile/dto/update-profile.dto.ts @@ -1,236 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { Contacts, EnglishLevel } from '@common/models'; - -class Location { - @IsString() - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - cityName: string | null; - - @IsString() - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - countryName: string | null; -} - -class PublicVisibilitySettings { - @ApiProperty() - @IsBoolean() - all: boolean; -} - -class PartialStudentVisibilitySettings extends PublicVisibilitySettings { - @ApiProperty() - @IsBoolean() - student: boolean; -} - -class ContactsVisibilitySettings extends PublicVisibilitySettings { - @ApiProperty() - @IsBoolean() - student: boolean; -} - -class VisibilitySettings extends PublicVisibilitySettings { - @ApiProperty() - @IsBoolean() - mentor: boolean; - - @ApiProperty() - @IsBoolean() - student: boolean; -} - -class ConfigurableProfilePermissions { - @ApiProperty({ required: false, type: PublicVisibilitySettings }) - @Type(() => PublicVisibilitySettings) - @IsOptional() - isProfileVisible?: PublicVisibilitySettings; - - @ApiProperty({ required: false, type: VisibilitySettings }) - @Type(() => VisibilitySettings) - @IsOptional() - isAboutVisible?: VisibilitySettings; - - @ApiProperty({ required: false, type: VisibilitySettings }) - @Type(() => VisibilitySettings) - @IsOptional() - isEducationVisible?: VisibilitySettings; - - @ApiProperty({ required: false, type: PartialStudentVisibilitySettings }) - @Type(() => PartialStudentVisibilitySettings) - @IsOptional() - isEnglishVisible?: PartialStudentVisibilitySettings; - - @ApiProperty({ required: false, type: ContactsVisibilitySettings }) - @Type(() => ContactsVisibilitySettings) - @IsOptional() - isEmailVisible?: ContactsVisibilitySettings; - - @ApiProperty({ required: false, type: ContactsVisibilitySettings }) - @Type(() => ContactsVisibilitySettings) - @IsOptional() - isTelegramVisible?: ContactsVisibilitySettings; - - @ApiProperty({ required: false, type: ContactsVisibilitySettings }) - @Type(() => ContactsVisibilitySettings) - @IsOptional() - isSkypeVisible?: ContactsVisibilitySettings; - - @ApiProperty({ required: false, type: ContactsVisibilitySettings }) - @Type(() => ContactsVisibilitySettings) - @IsOptional() - isPhoneVisible?: ContactsVisibilitySettings; - - @ApiProperty({ required: false, type: ContactsVisibilitySettings }) - @Type(() => ContactsVisibilitySettings) - @IsOptional() - isContactsNotesVisible?: ContactsVisibilitySettings; - - @ApiProperty({ required: false, type: VisibilitySettings }) - @Type(() => VisibilitySettings) - @IsOptional() - isLinkedInVisible?: VisibilitySettings; - - @ApiProperty({ required: false, type: VisibilitySettings }) - @Type(() => VisibilitySettings) - @IsOptional() - isPublicFeedbackVisible?: VisibilitySettings; - - @ApiProperty({ required: false, type: VisibilitySettings }) - @Type(() => VisibilitySettings) - @IsOptional() - isMentorStatsVisible?: VisibilitySettings; - - @ApiProperty({ required: false, type: PartialStudentVisibilitySettings }) - @Type(() => PartialStudentVisibilitySettings) - @IsOptional() - isStudentStatsVisible?: PartialStudentVisibilitySettings; -} - -class Education { - @ApiProperty() - @IsString() - university: string; - - @ApiProperty() - @IsString() - faculty: string; - - @ApiProperty() - @IsNumber() - graduationYear: number; -} - -class GeneralInfo { - @ApiProperty() - @IsString() - name: string; - - @ApiProperty() - @IsString() - githubId: string; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - aboutMyself?: string | null; - - @ApiProperty({ type: Location }) - @Type(() => Location) - @ValidateNested() - location: Location; - - @ApiProperty({ required: false, nullable: true, type: [Education] }) - @IsOptional() - @Type(() => Education) - @IsArray() - educationHistory?: Education[] | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - englishLevel?: EnglishLevel | null; -} - -export class ContactsDto implements Contacts { - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - phone: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - email: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - epamEmail: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - skype: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - whatsApp: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - telegram: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - notes: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - linkedIn: string | null; -} - -export class Discord { - @ApiProperty() - @IsNotEmpty() - id: string; - - @ApiProperty() - @IsNotEmpty() - username: string; - - @ApiProperty() - @IsNotEmpty() - discriminator: string; -} - -export class ProfileInfoDto { - @ApiProperty({ type: ConfigurableProfilePermissions }) - @ValidateNested() - @Type(() => ConfigurableProfilePermissions) - permissionsSettings: ConfigurableProfilePermissions; - - @ApiProperty({ type: GeneralInfo }) - @ValidateNested() - @Type(() => GeneralInfo) - generalInfo: GeneralInfo; - - @ApiProperty({ type: ContactsDto }) - @ValidateNested() - @Type(() => ContactsDto) - contacts: ContactsDto; - - @ApiProperty({ required: false, nullable: true, type: Discord }) - @Type(() => Discord) - @IsOptional() - discord: Discord | null; +import { IsBoolean } from 'class-validator'; +import { ProfileInfoBaseDto } from './profile-info.dto'; +export class UpdateProfileDto extends ProfileInfoBaseDto { @ApiProperty() @IsBoolean() isPermissionsSettingsChanged: boolean; @@ -239,91 +11,3 @@ export class ProfileInfoDto { @IsBoolean() isProfileSettingsChanged: boolean; } - -export class UpdateProfileInfoDto { - @ApiProperty({ required: false }) - @IsOptional() - @IsString() - name?: string; - - @ApiProperty({ required: false }) - @IsOptional() - @IsString() - githubId?: string; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - aboutMyself?: string | null; - - @IsString() - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - cityName?: string | null; - - @IsString() - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - countryName?: string | null; - - @ApiProperty({ required: false, nullable: true, type: [Education] }) - @IsOptional() - @IsArray() - educationHistory?: Education[]; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - englishLevel?: EnglishLevel | null; - - @ApiProperty({ required: false, type: [String] }) - @IsOptional() - @IsArray() - languages?: string[]; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - contactsPhone?: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - contactsEmail?: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - contactsEpamEmail?: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - contactsSkype?: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - contactsWhatsApp?: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - contactsTelegram?: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - contactsNotes?: string | null; - - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - @IsString() - contactsLinkedIn?: string | null; - - @ApiProperty({ required: false, nullable: true, type: Discord }) - @ValidateNested() - @IsOptional() - @Type(() => Discord) - discord?: Discord | null; -} diff --git a/nestjs/src/profile/endorsement.service.ts b/nestjs/src/profile/endorsement.service.ts index 1c190fd110..df278ff54e 100644 --- a/nestjs/src/profile/endorsement.service.ts +++ b/nestjs/src/profile/endorsement.service.ts @@ -55,7 +55,7 @@ export class EndorsementService { } } - async getEndorsmentData(githubId: string) { + async getEndorsementData(githubId: string) { const user = await this.userRepository.findOne({ where: { githubId } }); if (!user) { throw new NotFoundException(`User with githubId ${githubId} not found`); @@ -90,7 +90,7 @@ export class EndorsementService { async getEndorsementPrompt(githubId: string) { const [prompt, data] = await Promise.all([ this.promptRepository.findOne({ where: { type: 'endorsement' } }), - this.getEndorsmentData(githubId), + this.getEndorsementData(githubId), ]); if (!prompt?.text || data.mentors.length === 0) { diff --git a/nestjs/src/profile/mentor-info.service.ts b/nestjs/src/profile/mentor-info.service.ts new file mode 100644 index 0000000000..f0025b7e83 --- /dev/null +++ b/nestjs/src/profile/mentor-info.service.ts @@ -0,0 +1,88 @@ +import { toUpper, camelCase } from 'lodash'; +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { RegistryService } from 'src/registry/registry.service'; +import { UsersService } from 'src/users/users.service'; +import { MentorStats } from '@common/models'; +import { Course, Mentor, Student, User } from '@entities/index'; + +@Injectable() +export class MentorInfoService { + constructor( + @InjectRepository(Mentor) + private mentorRepository: Repository, + + private userService: UsersService, + private registryService: RegistryService, + ) {} + + async getRegistryCourses(githubId: string): Promise<{ courseId: number }[] | null> { + const [registeredCourseIds, registryCourseIds] = await Promise.all([ + this.getRegisteredMentorsCourseIds(githubId), + this.registryService.getMentorsFromRegistryCourseIds(githubId), + ]); + + const mentorsCourses = registeredCourseIds.concat(registryCourseIds); + + return mentorsCourses.length ? mentorsCourses : null; + } + + private async getRegisteredMentorsCourseIds(githubId: string) { + const result: { courseId: number }[] = await this.mentorRepository + .createQueryBuilder('mentor') + .select(['mentor.courseId']) + .leftJoin('mentor.user', 'user') + .where('user.githubId = :githubId', { githubId }) + .getMany(); + + return result.length ? result : []; + } + + async getStats(githubId: string): Promise { + const rawData = await this.mentorRepository + .createQueryBuilder('mentor') + .select('"course"."name" AS "courseName"') + .addSelect('"course"."alias" AS "courseAlias"') + .addSelect('"course"."id" AS "courseId"') + .addSelect('"course"."locationName" AS "courseLocationName"') + .addSelect('ARRAY_AGG ("userStudent"."githubId") AS "studentGithubIds"') + .addSelect('ARRAY_AGG ("userStudent"."firstName") AS "studentFirstNames"') + .addSelect('ARRAY_AGG ("userStudent"."lastName") AS "studentLastNames"') + .addSelect('ARRAY_AGG ("student"."isExpelled") AS "studentIsExpelledStatuses"') + .addSelect('ARRAY_AGG ("student"."totalScore") AS "studentTotalScores"') + .leftJoin(User, 'user', '"user"."id" = "mentor"."userId"') + .leftJoin(Course, 'course', '"course"."id" = "mentor"."courseId"') + .leftJoin(Student, 'student', '"student"."mentorId" = "mentor"."id"') + .leftJoin(User, 'userStudent', '"userStudent"."id" = "student"."userId"') + .where('"user"."githubId" = :githubId', { githubId }) + .groupBy('"course"."id"') + .orderBy('"course"."endDate"', 'DESC') + .getRawMany(); + return rawData.map( + ({ + courseName, + courseAlias, + courseLocationName, + studentGithubIds, + studentFirstNames, + studentLastNames, + studentIsExpelledStatuses, + studentTotalScores, + }: any) => { + const students = studentGithubIds[0] + ? studentGithubIds.map((githubId: string, idx: number) => ({ + githubId, + name: + this.userService.getFullName({ firstName: studentFirstNames[idx], lastName: studentLastNames[idx] }) || + githubId, + isExpelled: studentIsExpelledStatuses[idx], + totalScore: studentTotalScores[idx], + repoUrl: `https://github.com/rolling-scopes-school/${githubId}-${toUpper(camelCase(courseAlias))}`, + })) + : undefined; + return { courseLocationName, courseName, students }; + }, + ); + } +} diff --git a/nestjs/src/profile/permissions.service.ts b/nestjs/src/profile/permissions.service.ts new file mode 100644 index 0000000000..8b0e89ae68 --- /dev/null +++ b/nestjs/src/profile/permissions.service.ts @@ -0,0 +1,260 @@ +import { get, mergeWith, cloneDeep, mapValues } from 'lodash'; +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigurableProfilePermissions, Permissions } from './dto/permissions.dto'; +import { + User, + Student, + Mentor, + ProfilePermissions, + TaskChecker, + TaskInterviewResult, + StageInterview, + isManager, + IUserSession, + isSupervisor, +} from '@entities/index'; +import { defaultProfilePermissionsSettings } from '@entities/profilePermissions'; + +type RelationRole = 'student' | 'mentor' | 'coursementor' | 'coursesupervisor' | 'coursemanager' | 'all'; + +interface Relations { + student: string; + mentors: string[]; + interviewers: string[]; + stageInterviewers: string[]; + checkers: string[]; +} + +interface PermissionsSetup { + isProfileOwner: boolean; + isAdmin: boolean; + role?: RelationRole; + permissions?: ConfigurableProfilePermissions; +} + +type Permission = keyof Permissions; + +const defaultPermissions: Permissions = { + isProfileVisible: false, + isAboutVisible: false, + isEducationVisible: false, + isEnglishVisible: false, + isEmailVisible: false, + isTelegramVisible: false, + isSkypeVisible: false, + isWhatsAppVisible: false, + isPhoneVisible: false, + isContactsNotesVisible: false, + isLinkedInVisible: false, + isPublicFeedbackVisible: false, + isMentorStatsVisible: false, + isStudentStatsVisible: false, + isStageInterviewFeedbackVisible: false, + isCoreJsFeedbackVisible: false, + isConsentsVisible: false, + isExpellingReasonVisible: false, +}; + +const accessToContactsDefaultPermissions: Permission[] = [ + 'isEmailVisible', + 'isWhatsAppVisible', + 'isTelegramVisible', + 'isSkypeVisible', + 'isPhoneVisible', + 'isContactsNotesVisible', +]; +const accessToContactsPermissions: Permission[] = [ + 'isEmailVisible', + 'isTelegramVisible', + 'isSkypeVisible', + 'isPhoneVisible', + 'isWhatsAppVisible', + 'isContactsNotesVisible', + 'isEnglishVisible', +]; +const accessToFeedbacksPermissions: Permission[] = [ + 'isStageInterviewFeedbackVisible', + 'isStudentStatsVisible', + 'isCoreJsFeedbackVisible', + 'isProfileVisible', + 'isExpellingReasonVisible', +]; +const accessToProfilePermissions: Permission[] = ['isProfileVisible']; +const accessToOwnFeedbackPermissions: Permission[] = [ + 'isStageInterviewFeedbackVisible', + 'isCoreJsFeedbackVisible', + 'isExpellingReasonVisible', +]; + +const accessToContactsDefaultRoles: RelationRole[] = ['student']; +const accessToContactsRoles: RelationRole[] = ['mentor', 'coursemanager', 'coursesupervisor']; +const accessToFeedbacksRoles: RelationRole[] = ['mentor', 'coursementor', 'coursemanager']; +const accessToProfileRoles: RelationRole[] = ['student']; + +const getRolePermissionsChecker = (permissions: Permission[], roles: RelationRole[]) => { + return (permission: Permission, role?: RelationRole) => { + return !!role && roles.includes(role) && permissions.includes(permission); + }; +}; + +const isProfileAccessible = getRolePermissionsChecker(accessToProfilePermissions, accessToProfileRoles); +const areFeedbacksAccessible = getRolePermissionsChecker(accessToFeedbacksPermissions, accessToFeedbacksRoles); +const areContactsAccessible = getRolePermissionsChecker(accessToContactsPermissions, accessToContactsRoles); +const areContactsAccessibleByDefault = getRolePermissionsChecker( + accessToContactsDefaultPermissions, + accessToContactsDefaultRoles, +); + +@Injectable() +export class PermissionsService { + constructor( + @InjectRepository(Student) + private studentRepository: Repository, + @InjectRepository(ProfilePermissions) + private profilePermissionsRepository: Repository, + ) {} + + getAccessRights({ isAdmin, isProfileOwner, role, permissions }: PermissionsSetup): Permissions { + return mapValues(defaultPermissions, (_, permission: Permission) => { + if (isAdmin || role === 'coursemanager') { + return true; + } + if (role === 'coursesupervisor' && permission === 'isProfileVisible') { + return true; + } + if (areFeedbacksAccessible(permission, role)) { + return true; + } + if (areContactsAccessible(permission, role)) { + return true; + } + if (isProfileAccessible(permission, role)) { + return true; + } + // do not show own feedbacks + if (isProfileOwner && !accessToOwnFeedbackPermissions.includes(permission)) { + return true; + } + if (get(permissions, `${permission}.all`) || get(permissions, `${permission}.${role}`)) { + return true; + } + // show mentor contacts to students by default + if (get(permissions, `${permission}.student`) === undefined && areContactsAccessibleByDefault(permission, role)) { + return true; + } + return false; + }); + } + + getProfilePermissionsSettings(permissions: ConfigurableProfilePermissions) { + const newPermissions = cloneDeep(permissions); + + mergeWith(newPermissions, defaultProfilePermissionsSettings, (setting, defaultSetting) => + mapValues(defaultSetting, (value, key) => get(setting, key, value)), + ); + + return newPermissions; + } + + async getProfilePermissions(githubId: string): Promise { + const permissions = await this.profilePermissionsRepository + .createQueryBuilder('pp') + .select('"pp"."isProfileVisible" AS "isProfileVisible"') + .addSelect('"pp"."isAboutVisible" AS "isAboutVisible"') + .addSelect('"pp"."isEducationVisible" AS "isEducationVisible"') + .addSelect('"pp"."isEnglishVisible" AS "isEnglishVisible"') + .addSelect('"pp"."isEmailVisible" AS "isEmailVisible"') + .addSelect('"pp"."isTelegramVisible" AS "isTelegramVisible"') + .addSelect('"pp"."isSkypeVisible" AS "isSkypeVisible"') + .addSelect('"pp"."isPhoneVisible" AS "isPhoneVisible"') + .addSelect('"pp"."isContactsNotesVisible" AS "isContactsNotesVisible"') + .addSelect('"pp"."isLinkedInVisible" AS "isLinkedInVisible"') + .addSelect('"pp"."isPublicFeedbackVisible" AS "isPublicFeedbackVisible"') + .addSelect('"pp"."isMentorStatsVisible" AS "isMentorStatsVisible"') + .addSelect('"pp"."isStudentStatsVisible" AS "isStudentStatsVisible"') + .leftJoin(User, 'user', '"user"."id" = "pp"."userId"') + .where('"user"."githubId" = :githubId', { githubId }) + .getRawOne(); + + return permissions ?? {}; + } + + defineRole({ + relationsRoles, + studentCourses, + mentorRegistryCourses, + userSession, + userGithubId, + }: { + relationsRoles: Relations | null; + mentorRegistryCourses: { courseId: number }[] | null; + studentCourses: { courseId: number }[] | null; + userSession: IUserSession; + userGithubId: string; + }): RelationRole { + if (mentorRegistryCourses?.some(({ courseId }) => isManager(userSession, courseId))) { + return 'coursemanager'; + } else if (mentorRegistryCourses?.some(({ courseId }) => isSupervisor(userSession, courseId))) { + return 'coursesupervisor'; + } else if (studentCourses?.some(({ courseId }) => isManager(userSession, courseId))) { + return 'coursemanager'; + } else if (studentCourses?.some(({ courseId }) => isSupervisor(userSession, courseId))) { + return 'coursemanager'; + } else if (relationsRoles) { + const { student, mentors, interviewers, stageInterviewers, checkers } = relationsRoles; + + if (student === userGithubId) { + return 'student'; + } else if (new Set([...mentors, ...interviewers, ...stageInterviewers, ...checkers]).has(userGithubId)) { + return 'mentor'; + } + } else if (studentCourses?.some(({ courseId }) => !!userSession?.courses?.[courseId]?.mentorId)) { + return 'coursementor'; + } + + return 'all'; + } + + async getRelationsRoles(userGithubId: string, requestedGithubId: string): Promise { + return ( + (await this.studentRepository + .createQueryBuilder('student') + .select('"userStudent"."githubId" AS "student"') + .addSelect('ARRAY_AGG("userMentor"."githubId") as "mentors"') + .addSelect('ARRAY_AGG("userInterviewer"."githubId") as "interviewers"') + .addSelect('ARRAY_AGG("userStageInterviewer"."githubId") as "stageInterviewers"') + .addSelect('ARRAY_AGG("userChecker"."githubId") as "checkers"') + .leftJoin(User, 'userStudent', '"student"."userId" = "userStudent"."id"') + .leftJoin(Mentor, 'mentor', '"mentor"."id" = "student"."mentorId"') + .leftJoin(User, 'userMentor', '"mentor"."userId" = "userMentor"."id"') + .leftJoin(TaskChecker, 'taskChecker', '"student"."id" = "taskChecker"."studentId"') + .leftJoin(Mentor, 'mentorChecker', '"mentorChecker"."id" = "taskChecker"."mentorId"') + .leftJoin(User, 'userChecker', '"mentorChecker"."userId" = "userChecker"."id"') + .leftJoin(TaskInterviewResult, 'taskInterviewResult', '"student"."id" = "taskInterviewResult"."studentId"') + .leftJoin(Mentor, 'mentorInterviewer', '"mentorInterviewer"."id" = "taskInterviewResult"."mentorId"') + .leftJoin(User, 'userInterviewer', '"mentorInterviewer"."userId" = "userInterviewer"."id"') + .leftJoin(StageInterview, 'stageInterview', '"student"."id" = "stageInterview"."studentId"') + .leftJoin(Mentor, 'mentorStageInterviewer', '"mentorStageInterviewer"."id" = "stageInterview"."mentorId"') + .leftJoin(User, 'userStageInterviewer', '"mentorStageInterviewer"."userId" = "userStageInterviewer"."id"') + .where( + `"userStudent"."githubId" = :userGithubId AND + ("userMentor"."githubId" = :requestedGithubId OR + "userStageInterviewer"."githubId" = :requestedGithubId OR + "userInterviewer"."githubId" = :requestedGithubId OR + "userChecker"."githubId" = :requestedGithubId )`, + { userGithubId, requestedGithubId }, + ) + .orWhere( + `"userStudent"."githubId" = :requestedGithubId AND + ("userMentor"."githubId" = :userGithubId OR + "userStageInterviewer"."githubId" = :userGithubId OR + "userInterviewer"."githubId" = :userGithubId OR + "userChecker"."githubId" = :userGithubId)`, + ) + .groupBy('"userStudent"."githubId"') + .getRawOne()) || null + ); + } +} diff --git a/nestjs/src/profile/profile-info.service.ts b/nestjs/src/profile/profile-info.service.ts new file mode 100644 index 0000000000..bcb330b212 --- /dev/null +++ b/nestjs/src/profile/profile-info.service.ts @@ -0,0 +1,175 @@ +import { omitBy, isUndefined } from 'lodash'; +import { Repository } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { isEmail } from 'class-validator'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MentorInfoService } from './mentor-info.service'; +import { PermissionsService } from './permissions.service'; +import { PublicFeedbackService } from './public-feedback.service'; +import { StudentInfoService } from './student-info.service'; +import { UserInfoService } from './user-info.service'; +import { InterviewsService } from 'src/courses/interviews'; +import { ProfileService } from './profile.service'; +import { UpdateProfileInfoDto } from './dto'; +import { ConfigurableProfilePermissions } from './dto/permissions.dto'; +import { User, IUserSession } from '@entities/index'; + +@Injectable() +export class ProfileInfoService { + constructor( + @InjectRepository(User) + private userRepository: Repository, + + private interviewsService: InterviewsService, + private mentorInfoService: MentorInfoService, + private permissionsService: PermissionsService, + private publicFeedbackService: PublicFeedbackService, + private studentInfoService: StudentInfoService, + private userInfoService: UserInfoService, + private profileService: ProfileService, + ) {} + + public async updateProfileFlat(userId: number, profileInfo: UpdateProfileInfoDto) { + const { + name, + countryName, + cityName, + educationHistory, + discord, + englishLevel, + aboutMyself, + contactsTelegram, + contactsPhone, + contactsEmail, + contactsNotes, + contactsSkype, + contactsWhatsApp, + contactsLinkedIn, + contactsEpamEmail, + languages, + } = profileInfo; + + if (contactsEmail && !isEmail(contactsEmail)) { + throw new BadRequestException('Email is invalid.'); + } + if (contactsEpamEmail && !isEmail(contactsEpamEmail)) { + throw new BadRequestException('Epam email is invalid.'); + } + + const [firstName, lastName] = name?.split(' ') ?? []; + const user = await this.userRepository + .createQueryBuilder() + .update(User) + .set( + omitBy>( + { + firstName, + lastName: firstName ? lastName ?? '' : undefined, + countryName, + cityName, + educationHistory, + discord, + englishLevel, + aboutMyself, + contactsTelegram, + contactsPhone, + contactsEmail, + contactsNotes, + contactsSkype, + contactsWhatsApp, + contactsLinkedIn, + contactsEpamEmail, + languages, + }, + isUndefined, + ), + ) + .returning('*') + .where('id = :id', { id: userId }) + .execute(); + + await Promise.all([ + this.profileService.updateEmailChannel(userId, user), + this.profileService.updateDiscordChannel(userId, user), + ]); + } + + public async getProfileInfo(requestedGithubId: string, requestorGithubId: string, requestor: IUserSession) { + const isProfileOwner = requestedGithubId === requestorGithubId; + const requestedProfilePermissions = await this.permissionsService.getProfilePermissions(requestedGithubId); + const accessRights = isProfileOwner + ? this.permissionsService.getAccessRights({ isProfileOwner: true, isAdmin: requestor.isAdmin }) + : await this.getForeignAccessRights({ + requestedProfilePermissions, + requestedGithubId, + requestorGithubId, + requestor, + }); + const { + isProfileVisible, + isPublicFeedbackVisible, + isMentorStatsVisible, + isStudentStatsVisible, + isStageInterviewFeedbackVisible, + } = accessRights; + + if (!isProfileOwner && !isProfileVisible) { + throw new ForbiddenException(); + } + + const { generalInfo, contacts, discord } = await this.userInfoService.getUserInfo(requestedGithubId, accessRights); + const [permissionsSettings, mentorStats, studentStats, publicFeedback, stageInterviewFeedback] = await Promise.all([ + isProfileOwner ? this.permissionsService.getProfilePermissionsSettings(requestedProfilePermissions) : undefined, + isMentorStatsVisible ? this.mentorInfoService.getStats(requestedGithubId) : undefined, + isStudentStatsVisible ? this.studentInfoService.getStats(requestedGithubId, accessRights) : undefined, + isPublicFeedbackVisible ? this.publicFeedbackService.getFeedback(requestedGithubId) : undefined, + isStageInterviewFeedbackVisible ? this.interviewsService.getStageInterviewFeedback(requestedGithubId) : undefined, + ]); + + return { + permissionsSettings, + generalInfo, + contacts, + discord, + mentorStats, + publicFeedback, + stageInterviewFeedback, + studentStats, + }; + } + + private async getForeignAccessRights({ + requestedProfilePermissions, + requestedGithubId, + requestorGithubId, + requestor, + }: { + requestedProfilePermissions: ConfigurableProfilePermissions; + requestedGithubId: string; + requestorGithubId: string; + requestor: IUserSession; + }) { + const relationsRoles = await this.permissionsService.getRelationsRoles(requestorGithubId, requestedGithubId); + const [studentCourses, mentorRegistryCourses] = relationsRoles + ? [null, null] + : await Promise.all([ + this.studentInfoService.getCourses(requestedGithubId), + this.mentorInfoService.getRegistryCourses(requestedGithubId), + ]); + const requestorRole = this.permissionsService.defineRole({ + relationsRoles, + studentCourses, + mentorRegistryCourses, + userSession: requestor, + userGithubId: requestorGithubId, + }); + + return this.permissionsService.getAccessRights({ + isProfileOwner: false, + isAdmin: requestor.isAdmin, + permissions: requestedProfilePermissions, + role: requestorRole, + }); + } +} diff --git a/nestjs/src/profile/profile.controller.ts b/nestjs/src/profile/profile.controller.ts index 0c371c37d2..135bf0bf05 100644 --- a/nestjs/src/profile/profile.controller.ts +++ b/nestjs/src/profile/profile.controller.ts @@ -1,21 +1,36 @@ -import { Body, Controller, Delete, ForbiddenException, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; -import { ApiBody, ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + ForbiddenException, + NotFoundException, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBody, ApiOkResponse, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { DefaultGuard, RequiredRoles, Role, RoleGuard } from 'src/auth'; import { CoursesService } from 'src/courses/courses.service'; -import { CurrentRequest } from '../auth/auth.service'; -import { ProfileCourseDto, UpdateUserDto, UpdateProfileInfoDto } from './dto'; -import { ProfileDto } from './dto/profile.dto'; +import { CurrentRequest } from 'src/auth/auth.service'; +import { EndorsementService } from './endorsement.service'; import { ProfileService } from './profile.service'; +import { ProfileInfoService } from './profile-info.service'; +import { ProfileCourseDto, UpdateUserDto, UpdateProfileInfoDto, ProfileInfoExtendedDto } from './dto'; +import { ProfileWithCvDto } from './dto/profile.dto'; import { PersonalProfileDto } from './dto/personal-profile.dto'; -import { EndorsementService } from './endorsement.service'; -import { EndorsementDataDto, EndorsementDto } from './dto/endorsement.dto'; +import { EndorsementDto, EndorsementDataDto } from './dto/endorsement.dto'; @Controller('profile') @ApiTags('profile') export class ProfileController { constructor( private readonly profileService: ProfileService, - private readonly endormentService: EndorsementService, + private readonly profileInfoService: ProfileInfoService, + private readonly endorsementService: EndorsementService, private readonly coursesService: CoursesService, ) {} @@ -49,6 +64,22 @@ export class ProfileController { await this.profileService.updateUser(user.id, dto); } + @Get('/info') + @ApiQuery({ name: 'githubId', required: false }) + @ApiOperation({ operationId: 'getProfileInfo' }) + @ApiResponse({ type: ProfileInfoExtendedDto }) + @UseGuards(DefaultGuard) + public async getProfileInfo(@Req() req: CurrentRequest, @Query('githubId') githubId?: string) { + const { githubId: requestorGithubId } = req.user; + const requestedGithubId = githubId ?? requestorGithubId; + + if (!requestorGithubId && !requestedGithubId) { + throw new NotFoundException(`profile doesn't exist`); + } + + return this.profileInfoService.getProfileInfo(requestedGithubId, requestorGithubId, req.user); + } + @Patch('/info') @ApiOperation({ operationId: 'updateProfileInfoFlat' }) @ApiBody({ type: UpdateProfileInfoDto }) @@ -56,17 +87,16 @@ export class ProfileController { public async updateProfileFlatInfo(@Req() req: CurrentRequest, @Body() dto: UpdateProfileInfoDto) { const { user } = req; - await this.profileService.updateProfileFlat(user.id, dto); + await this.profileInfoService.updateProfileFlat(user.id, dto); } @Get(':username') @ApiOperation({ operationId: 'getProfile' }) - @ApiResponse({ type: ProfileDto }) - @UseGuards(DefaultGuard) - public async getProfileInfo(@Param('username') githubId: string) { + @ApiResponse({ type: ProfileWithCvDto }) + public async getProfile(@Param('username') githubId: string) { const profile = await this.profileService.getProfile(githubId); - return new ProfileDto(profile); + return new ProfileWithCvDto(profile); } @Get(':username/personal') @@ -86,7 +116,7 @@ export class ProfileController { @UseGuards(DefaultGuard, RoleGuard) @RequiredRoles([Role.Admin]) public async getEndorsement(@Param('username') githubId: string) { - const endorsement = await this.endormentService.getEndorsement(githubId); + const endorsement = await this.endorsementService.getEndorsement(githubId); return new EndorsementDto(endorsement); } @@ -95,7 +125,7 @@ export class ProfileController { @ApiResponse({ type: EndorsementDataDto }) @UseGuards(DefaultGuard) public async getEndorsementData(@Param('username') githubId: string) { - const data = await this.endormentService.getEndorsmentData(githubId); + const data = await this.endorsementService.getEndorsementData(githubId); return new EndorsementDataDto(data); } diff --git a/nestjs/src/profile/profile.module.ts b/nestjs/src/profile/profile.module.ts index 673bff5cf6..4c4899ef9f 100644 --- a/nestjs/src/profile/profile.module.ts +++ b/nestjs/src/profile/profile.module.ts @@ -1,17 +1,32 @@ import { Module } from '@nestjs/common'; -import { ProfileService } from './profile.service'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Course } from '@entities/course'; -import { ProfileController } from './profile.controller'; -import { CoursesModule } from '../courses/courses.module'; -import { NotificationUserConnection } from '@entities/notificationUserConnection'; -import { User } from '@entities/user'; -import { ProfilePermissions } from '@entities/profilePermissions'; -import { UsersNotificationsModule } from 'src/users-notifications/users-notifications.module'; -import { Resume } from '@entities/resume'; import { EndorsementService } from './endorsement.service'; -import { Certificate, Feedback, Mentor, Prompt, Student, TaskInterviewResult } from '@entities/index'; +import { PermissionsService } from './permissions.service'; +import { ProfileService } from './profile.service'; +import { ProfileInfoService } from './profile-info.service'; +import { UserInfoService } from './user-info.service'; +import { PublicFeedbackService } from './public-feedback.service'; +import { StudentInfoService } from './student-info.service'; +import { MentorInfoService } from './mentor-info.service'; +import { ProfileController } from './profile.controller'; import { ConfigModule } from 'src/config'; +import { CoursesModule } from 'src/courses/courses.module'; +import { RegistryModule } from 'src/registry/registry.module'; +import { UsersModule } from 'src/users/users.module'; +import { UsersNotificationsModule } from 'src/users-notifications/users-notifications.module'; +import { + Certificate, + Course, + Feedback, + Mentor, + NotificationUserConnection, + ProfilePermissions, + Prompt, + Resume, + Student, + TaskInterviewResult, + User, +} from '@entities/index'; @Module({ imports: [ @@ -28,12 +43,23 @@ import { ConfigModule } from 'src/config'; TaskInterviewResult, User, ]), - UsersNotificationsModule, - CoursesModule, ConfigModule, + CoursesModule, + RegistryModule, + UsersModule, + UsersNotificationsModule, ], controllers: [ProfileController], - providers: [ProfileService, EndorsementService], + providers: [ + EndorsementService, + MentorInfoService, + PermissionsService, + ProfileService, + ProfileInfoService, + PublicFeedbackService, + StudentInfoService, + UserInfoService, + ], exports: [ProfileService], }) export class ProfileModule {} diff --git a/nestjs/src/profile/profile.service.ts b/nestjs/src/profile/profile.service.ts index 600fdbd102..bf15f5f94f 100644 --- a/nestjs/src/profile/profile.service.ts +++ b/nestjs/src/profile/profile.service.ts @@ -1,20 +1,13 @@ -import { Course } from '@entities/course'; -import { NotificationUserConnection } from '@entities/notificationUserConnection'; -import { ProfilePermissions } from '@entities/profilePermissions'; -import { User } from '@entities/user'; +import { In, IsNull, Not, Repository, UpdateResult } from 'typeorm'; +import { nanoid } from 'nanoid'; +import { isEmail } from 'class-validator'; import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { AuthUser } from 'src/auth'; -import { In, IsNull, Not, Repository, UpdateResult } from 'typeorm'; -import { UserNotificationsService } from '../users-notifications'; -import { ProfileInfoDto, UpdateProfileInfoDto, UpdateUserDto } from './dto'; -import { isEmail } from 'class-validator'; -import { Resume } from '@entities/resume'; -import { Discord } from '../../../common/models'; -import { omitBy, isUndefined } from 'lodash'; -import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; -import { nanoid } from 'nanoid'; -import { Certificate } from '@entities/certificate'; +import { UserNotificationsService } from 'src/users-notifications'; +import { UpdateProfileDto, UpdateUserDto } from './dto'; +import { Discord } from '@common/models'; +import { Certificate, Course, NotificationUserConnection, ProfilePermissions, Resume, User } from '@entities/index'; @Injectable() export class ProfileService { @@ -89,7 +82,7 @@ export class ProfileService { .execute(); } - public async updateProfile(userId: number, profileInfo: ProfileInfoDto) { + public async updateProfile(userId: number, profileInfo: UpdateProfileDto) { const { isPermissionsSettingsChanged, isProfileSettingsChanged, @@ -150,68 +143,6 @@ export class ProfileService { } } - public async updateProfileFlat(userId: number, profileInfo: UpdateProfileInfoDto) { - const { - name, - countryName, - cityName, - educationHistory, - discord, - englishLevel, - aboutMyself, - contactsTelegram, - contactsPhone, - contactsEmail, - contactsNotes, - contactsSkype, - contactsWhatsApp, - contactsLinkedIn, - contactsEpamEmail, - languages, - } = profileInfo; - - if (contactsEmail && !isEmail(contactsEmail)) { - throw new BadRequestException('Email is invalid.'); - } - if (contactsEpamEmail && !isEmail(contactsEpamEmail)) { - throw new BadRequestException('Epam email is invalid.'); - } - - const [firstName, lastName] = name?.split(' ') ?? []; - const user = await this.userRepository - .createQueryBuilder() - .update(User) - .set( - omitBy>( - { - firstName, - lastName: firstName ? lastName ?? '' : undefined, - countryName, - cityName, - educationHistory, - discord, - englishLevel, - aboutMyself, - contactsTelegram, - contactsPhone, - contactsEmail, - contactsNotes, - contactsSkype, - contactsWhatsApp, - contactsLinkedIn, - contactsEpamEmail, - languages, - }, - isUndefined, - ), - ) - .returning('*') - .where('id = :id', { id: userId }) - .execute(); - - await Promise.all([this.updateEmailChannel(userId, user), this.updateDiscordChannel(userId, user)]); - } - public async getProfile(githubId: string) { const user = await this.userRepository.findOneOrFail({ where: { githubId } }); const resume = await this.resumeRepository.findOne({ @@ -225,7 +156,7 @@ export class ProfileService { return this.userRepository.findOneOrFail({ where: { githubId }, relations: ['students'] }); } - private async updateEmailChannel(userId: number, user: UpdateResult) { + public async updateEmailChannel(userId: number, user: UpdateResult) { const newEmail = user.raw[0]?.contactsEmail; const channelId = 'email'; @@ -256,7 +187,7 @@ export class ProfileService { } } - private async updateDiscordChannel(userId: number, user: UpdateResult) { + public async updateDiscordChannel(userId: number, user: UpdateResult) { const newDiscord: Discord = user.raw[0]?.discord; const channelId = 'discord'; if (!newDiscord) { diff --git a/nestjs/src/profile/public-feedback.service.ts b/nestjs/src/profile/public-feedback.service.ts new file mode 100644 index 0000000000..325304f139 --- /dev/null +++ b/nestjs/src/profile/public-feedback.service.ts @@ -0,0 +1,45 @@ +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UsersService } from 'src/users/users.service'; +import { PublicFeedback } from '@common/models'; +import { Feedback, User } from '@entities/index'; + +@Injectable() +export class PublicFeedbackService { + constructor( + @InjectRepository(Feedback) + private feedbackRepository: Repository, + + private userService: UsersService, + ) {} + + async getFeedback(githubId: string): Promise { + const records = await this.feedbackRepository + .createQueryBuilder('feedback') + .select('"feedback"."updatedDate" AS "feedbackDate"') + .addSelect('"feedback"."badgeId" AS "badgeId"') + .addSelect('"feedback"."comment" AS "comment"') + .addSelect('"fromUser"."firstName" AS "fromUserFirstName", "fromUser"."lastName" AS "fromUserLastName"') + .addSelect('"fromUser"."githubId" AS "fromUserGithubId"') + .leftJoin(User, 'user', '"user"."id" = "feedback"."toUserId"') + .leftJoin(User, 'fromUser', '"fromUser"."id" = "feedback"."fromUserId"') + .where('"user"."githubId" = :githubId', { githubId }) + .orderBy('"feedback"."updatedDate"', 'DESC') + .getRawMany(); + + return records.map( + ({ feedbackDate, badgeId, comment, fromUserFirstName, fromUserLastName, fromUserGithubId }: any) => ({ + feedbackDate, + badgeId, + comment, + fromUser: { + name: + this.userService.getFullName({ firstName: fromUserFirstName, lastName: fromUserLastName }) || + fromUserGithubId, + githubId: fromUserGithubId, + }, + }), + ); + } +} diff --git a/nestjs/src/profile/student-info.service.ts b/nestjs/src/profile/student-info.service.ts new file mode 100644 index 0000000000..bc5cfc7f35 --- /dev/null +++ b/nestjs/src/profile/student-info.service.ts @@ -0,0 +1,205 @@ +import { omit } from 'lodash'; +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UsersService } from 'src/users/users.service'; +import { Permissions } from './dto/permissions.dto'; +import { StudentStats } from '@common/models'; +import { + Certificate, + Course, + CourseTask, + Mentor, + StageInterview, + StageInterviewFeedback, + Student, + Task, + TaskInterviewResult, + TaskResult, + User, +} from '@entities/index'; + +@Injectable() +export class StudentInfoService { + constructor( + @InjectRepository(User) + private userRepository: Repository, + @InjectRepository(Student) + private studentRepository: Repository, + private userService: UsersService, + ) {} + + async getCourses(githubId: string): Promise<{ courseId: number }[] | null> { + const result = await this.userRepository + .createQueryBuilder('user') + .select('"student"."courseId" AS "courseId"') + .leftJoin(Student, 'student', '"student"."userId" = "user"."id"') + .where('"user"."githubId" = :githubId', { githubId }) + .getRawMany(); + + return result ?? null; + } + + async getStats(githubId: string, permissions: Permissions): Promise { + const { isCoreJsFeedbackVisible, isExpellingReasonVisible } = permissions; + + const query = await this.studentRepository + .createQueryBuilder('student') + .select('"course"."id" AS "courseId"') + .addSelect('"course"."name" AS "courseName"') + .addSelect('"course"."locationName" AS "locationName"') + .addSelect('"course"."fullName" AS "courseFullName"') + .addSelect('"course"."completed" AS "isCourseCompleted"') + .addSelect('"student"."isExpelled" AS "isExpelled"') + .addSelect('"student"."totalScore" AS "totalScore"') + .addSelect('"student"."rank" AS "rank"') + .addSelect('"userMentor"."firstName" AS "mentorFirstName"') + .addSelect('"userMentor"."lastName" AS "mentorLastName"') + .addSelect('"userMentor"."githubId" AS "mentorGithubId"') + .addSelect('"certificate"."publicId" AS "certificateId"') + .addSelect('ARRAY_AGG ("courseTask"."maxScore") AS "taskMaxScores"') + .addSelect('ARRAY_AGG ("courseTask"."scoreWeight") AS "taskScoreWeights"') + .addSelect('ARRAY_AGG ("courseTask"."studentEndDate") AS "taskEndDates"') + .addSelect('ARRAY_AGG ("task"."name") AS "taskNames"') + .addSelect('ARRAY_AGG ("task"."descriptionUrl") AS "taskDescriptionUris"') + .addSelect('ARRAY_AGG ("taskResult"."githubPrUrl") AS "taskGithubPrUris"').addSelect(`ARRAY_AGG (COALESCE( + "taskResult"."score", + "taskInterview"."score", + ("stageInterviewFeedback"."json"::json -> 'resume' ->> 'score')::int + )) AS "taskScores"`); + + query.addSelect('"student"."expellingReason" AS "expellingReason"'); + + if (isCoreJsFeedbackVisible) { + query + .addSelect('ARRAY_AGG (COALESCE("taskResult"."comment", "taskInterview"."comment")) AS "taskComments"') + .addSelect('ARRAY_AGG ("taskInterview"."formAnswers") AS "taskInterviewFormAnswers"') + .addSelect('ARRAY_AGG ("taskInterview"."createdDate") AS "taskInterviewDate"') + .addSelect('ARRAY_AGG ("interviewer"."githubId") AS "interviewerGithubId"') + .addSelect('ARRAY_AGG ("interviewer"."firstName") AS "interviewerFirstName"') + .addSelect('ARRAY_AGG ("interviewer"."lastName") AS "interviewerLastName"'); + } else { + query.addSelect('ARRAY_AGG ("taskResult"."comment") AS "taskComments"'); + } + + query + .leftJoin(User, 'user', '"user"."id" = "student"."userId"') + .leftJoin(Certificate, 'certificate', '"certificate"."studentId" = "student"."id"') + .leftJoin(Course, 'course', '"course"."id" = "student"."courseId"') + .leftJoin(Mentor, 'mentor', '"mentor"."id" = "student"."mentorId"') + .leftJoin(User, 'userMentor', '"userMentor"."id" = "mentor"."userId"') + .leftJoin(CourseTask, 'courseTask', '"courseTask"."courseId" = "student"."courseId"') + .leftJoin(Task, 'task', '"courseTask"."taskId" = "task"."id"') + .leftJoin( + TaskResult, + 'taskResult', + '"taskResult"."studentId" = "student"."id" AND "taskResult"."courseTaskId" = "courseTask"."id"', + ) + .leftJoin( + TaskInterviewResult, + 'taskInterview', + '"taskInterview"."studentId" = "student"."id" AND "taskInterview"."courseTaskId" = "courseTask"."id"', + ) + .leftJoin( + StageInterview, + 'stageInterview', + '"stageInterview"."studentId" = "student"."id" AND "stageInterview"."courseTaskId" = "courseTask"."id"', + ) + .leftJoin( + StageInterviewFeedback, + 'stageInterviewFeedback', + '"stageInterviewFeedback"."stageInterviewId" = "stageInterview"."id"', + ); + + if (isCoreJsFeedbackVisible) { + query + .leftJoin(Mentor, 'mentorInterviewer', '"mentorInterviewer"."id" = "taskInterview"."mentorId"') + .leftJoin(User, 'interviewer', '"interviewer"."id" = "mentorInterviewer"."userId"'); + } + + query + .where('"user"."githubId" = :githubId', { githubId }) + .andWhere('courseTask.disabled = :disabled', { disabled: false }) + .groupBy('"course"."id", "student"."id", "userMentor"."id", "certificate"."publicId"') + .orderBy('"course"."endDate"', 'DESC'); + + const rawStats = await query.getRawMany(); + + return rawStats.map( + ({ + courseId, + courseName, + locationName, + courseFullName, + isExpelled, + expellingReason, + isCourseCompleted, + totalScore, + mentorFirstName, + mentorLastName, + mentorGithubId, + taskMaxScores, + taskScoreWeights, + taskEndDates, + taskNames, + taskDescriptionUris, + taskGithubPrUris, + taskScores, + taskComments, + taskInterviewFormAnswers, + taskInterviewDate, + interviewerGithubId, + interviewerFirstName, + interviewerLastName, + certificateId, + rank, + }: any) => { + const tasksWithDates = taskMaxScores.map((maxScore: number, idx: number) => ({ + maxScore, + endDate: new Date(taskEndDates[idx]).getTime(), + scoreWeight: taskScoreWeights[idx], + name: taskNames[idx], + descriptionUri: taskDescriptionUris[idx], + githubPrUri: taskGithubPrUris[idx], + score: taskScores[idx], + comment: taskComments[idx], + interviewFormAnswers: (taskInterviewFormAnswers && taskInterviewFormAnswers[idx]) || undefined, + interviewDate: taskInterviewDate && taskInterviewDate[idx] ? String(taskInterviewDate[idx]) : undefined, + interviewer: + interviewerGithubId && interviewerGithubId[idx] + ? { + name: + this.userService.getFullName({ + firstName: interviewerFirstName[idx], + lastName: interviewerLastName[idx], + }) || interviewerGithubId[idx], + githubId: interviewerGithubId[idx], + } + : undefined, + })); + const orderedTasks = tasksWithDates + .sort((a: any, b: any) => a.endDate - b.endDate) + .map((task: any) => omit(task, 'endDate')); + return { + courseId, + courseName, + locationName, + courseFullName, + isExpelled, + expellingReason: isExpellingReasonVisible ? expellingReason : undefined, + isSelfExpelled: (expellingReason as string)?.startsWith('Self expelled from the course'), + isCourseCompleted, + totalScore, + tasks: orderedTasks, + certificateId, + rank, + mentor: { + githubId: mentorGithubId, + name: + this.userService.getFullName({ firstName: mentorFirstName, lastName: mentorLastName }) || mentorGithubId, + }, + }; + }, + ); + } +} diff --git a/nestjs/src/profile/user-info.service.ts b/nestjs/src/profile/user-info.service.ts new file mode 100644 index 0000000000..5eb75d41f5 --- /dev/null +++ b/nestjs/src/profile/user-info.service.ts @@ -0,0 +1,139 @@ +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UsersService } from 'src/users/users.service'; +import { Permissions } from './dto/permissions.dto'; +import { UserInfo } from '@common/models'; +import { User } from '@entities/index'; + +@Injectable() +export class UserInfoService { + constructor( + @InjectRepository(User) + private userRepository: Repository, + private userService: UsersService, + ) {} + + async getUserInfo(githubId: string, permissions: Permissions): Promise { + const { + isAboutVisible, + isEducationVisible, + isEnglishVisible, + isPhoneVisible, + isEmailVisible, + isTelegramVisible, + isSkypeVisible, + isContactsNotesVisible, + isLinkedInVisible, + isWhatsAppVisible, + } = permissions; + + const query = this.userRepository + .createQueryBuilder('user') + .select('"user"."firstName" AS "firstName", "user"."lastName" AS "lastName"') + .addSelect('"user"."githubId" AS "githubId"') + .addSelect('"user"."countryName" AS "countryName"') + .addSelect('"user"."cityName" AS "cityName"') + .addSelect('"user"."discord" AS "discord"') + .addSelect('"user"."languages" AS "languages"'); + + if (isEducationVisible) { + query.addSelect('"user"."educationHistory" AS "educationHistory"'); + } + + if (isEnglishVisible) { + query.addSelect('"user"."englishLevel" AS "englishLevel"'); + } + + if (isPhoneVisible) { + query.addSelect('"user"."contactsPhone" AS "contactsPhone"'); + } + + if (isEmailVisible) { + query + .addSelect('"user"."contactsEmail" AS "contactsEmail"') + .addSelect('"user"."contactsEpamEmail" AS "epamEmail"'); + } + + if (isTelegramVisible) { + query.addSelect('"user"."contactsTelegram" AS "contactsTelegram"'); + } + + if (isSkypeVisible) { + query.addSelect('"user"."contactsSkype" AS "contactsSkype"'); + } + + if (isWhatsAppVisible) { + query.addSelect('"user"."contactsWhatsApp" AS "contactsWhatsApp"'); + } + + if (isContactsNotesVisible) { + query.addSelect('"user"."contactsNotes" AS "contactsNotes"'); + } + + if (isLinkedInVisible) { + query.addSelect('"user"."contactsLinkedIn" AS "contactsLinkedIn"'); + } + + if (isAboutVisible) { + query.addSelect('"user"."aboutMyself" AS "aboutMyself"'); + } + + const rawUser = await query.where('"user"."githubId" = :githubId', { githubId }).getRawOne(); + + if (rawUser == null) { + throw new Error(`User with githubId ${githubId} not found`); + } + + const isContactsVisible = + isPhoneVisible || isEmailVisible || isTelegramVisible || isSkypeVisible || isContactsNotesVisible; + + const { + firstName, + lastName, + countryName, + cityName, + discord, + educationHistory = null, + englishLevel = null, + contactsPhone = null, + contactsEmail = null, + contactsTelegram = null, + contactsSkype = null, + contactsWhatsApp = null, + contactsNotes = null, + contactsLinkedIn = null, + aboutMyself = null, + epamEmail = null, + languages = [], + } = rawUser; + + return { + generalInfo: { + githubId, + location: { + countryName, + cityName, + }, + aboutMyself: isAboutVisible ? aboutMyself : undefined, + educationHistory: isEducationVisible ? educationHistory : undefined, + englishLevel: isEnglishVisible ? englishLevel : undefined, + name: this.userService.getFullName({ firstName, lastName }) || githubId, + languages, + }, + discord, + contacts: isContactsVisible + ? { + phone: contactsPhone, + email: contactsEmail, + epamEmail, + skype: contactsSkype, + telegram: contactsTelegram, + notes: contactsNotes, + linkedIn: contactsLinkedIn, + whatsApp: contactsWhatsApp, + } + : undefined, + }; + } +} diff --git a/nestjs/src/registry/registry.module.ts b/nestjs/src/registry/registry.module.ts index 0e2c36e663..aaf6180db3 100644 --- a/nestjs/src/registry/registry.module.ts +++ b/nestjs/src/registry/registry.module.ts @@ -18,5 +18,6 @@ import { DisciplinesModule } from 'src/disciplines'; ], controllers: [RegistryController], providers: [RegistryService], + exports: [RegistryService], }) export class RegistryModule {} diff --git a/nestjs/src/registry/registry.service.ts b/nestjs/src/registry/registry.service.ts index 1beb5d6b8a..9fbe8ac9fc 100644 --- a/nestjs/src/registry/registry.service.ts +++ b/nestjs/src/registry/registry.service.ts @@ -1,10 +1,12 @@ -import { User } from '@entities/user'; -import { MentorRegistry } from '@entities/mentorRegistry'; +import { uniqBy } from 'lodash'; +import { Repository } from 'typeorm'; import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { CoursesService } from 'src/courses/courses.service'; import { UsersService } from 'src/users/users.service'; -import { Repository } from 'typeorm'; +import { DisciplinesService } from 'src/disciplines/disciplines.service'; +import { User } from '@entities/user'; +import { MentorRegistry } from '@entities/mentorRegistry'; @Injectable() export class RegistryService { @@ -13,6 +15,7 @@ export class RegistryService { private mentorsRegistryRepository: Repository, private usersService: UsersService, private coursesService: CoursesService, + private disciplinesService: DisciplinesService, ) {} public async approveMentor(githubId: string, preselectedCourses: string[]): Promise { @@ -85,4 +88,23 @@ export class RegistryService { } await this.mentorsRegistryRepository.update({ userId: user.id }, { comment: comment ?? undefined }); } + + public async getMentorsFromRegistryCourseIds(githubId: string) { + const result = await this.mentorsRegistryRepository + .createQueryBuilder('mentorRegistry') + .select(['mentorRegistry.preferedCourses', 'mentorRegistry.technicalMentoring']) + .leftJoin('mentorRegistry.user', 'user') + .where('user.githubId = :githubId', { githubId }) + .andWhere('"mentorRegistry".canceled = false') + .getOne(); + + const disciplines = await this.disciplinesService.getByNames(result?.technicalMentoring ?? []); + const disciplinesIds = disciplines.map(({ id }) => id); + const coursesByDiscipline = (await this.coursesService.getByDisciplineIds(disciplinesIds)) ?? []; + const preferredCourses = result?.preferedCourses?.map(courseId => ({ courseId: Number(courseId) })) ?? []; + const courseIdsByDisciplines = coursesByDiscipline.map(({ id }) => ({ courseId: id })); + const courseIds = uniqBy(preferredCourses.concat(courseIdsByDisciplines), ({ courseId }) => courseId); + + return courseIds; + } } diff --git a/nestjs/src/spec.json b/nestjs/src/spec.json index 3af21e66c9..2d3f38795c 100644 --- a/nestjs/src/spec.json +++ b/nestjs/src/spec.json @@ -1613,6 +1613,18 @@ } }, "/profile/info": { + "get": { + "operationId": "getProfileInfo", + "summary": "", + "parameters": [{ "name": "githubId", "required": false, "in": "query", "schema": { "type": "string" } }], + "responses": { + "default": { + "description": "", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProfileInfoExtendedDto" } } } + } + }, + "tags": ["profile"] + }, "patch": { "operationId": "updateProfileInfoFlat", "summary": "", @@ -1633,7 +1645,7 @@ "responses": { "default": { "description": "", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProfileDto" } } } + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProfileWithCvDto" } } } } }, "tags": ["profile"] @@ -1688,6 +1700,57 @@ "tags": ["profile"] } }, + "/registry/mentor/{githubId}": { + "put": { + "operationId": "approveMentor", + "summary": "", + "parameters": [{ "name": "githubId", "required": true, "in": "path", "schema": { "type": "string" } }], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApproveMentorDto" } } } + }, + "responses": { "200": { "description": "" } }, + "tags": ["registry"] + }, + "delete": { + "operationId": "cancelMentorRegistry", + "summary": "", + "parameters": [{ "name": "githubId", "required": true, "in": "path", "schema": { "type": "string" } }], + "responses": { "200": { "description": "" } }, + "tags": ["registry"] + } + }, + "/registry/mentor/{githubId}/comment": { + "put": { + "operationId": "commentMentorRegistry", + "summary": "", + "parameters": [{ "name": "githubId", "required": true, "in": "path", "schema": { "type": "string" } }], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommentMentorRegistryDto" } } } + }, + "responses": { "200": { "description": "" } }, + "tags": ["registry"] + } + }, + "/registry/mentors": { + "get": { + "operationId": "getMentorRegistries", + "summary": "", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/MentorRegistryDto" } } + } + } + } + }, + "tags": ["registry"] + } + }, "/disciplines": { "post": { "operationId": "createDiscipline", @@ -1747,57 +1810,6 @@ "tags": ["disciplines"] } }, - "/registry/mentor/{githubId}": { - "put": { - "operationId": "approveMentor", - "summary": "", - "parameters": [{ "name": "githubId", "required": true, "in": "path", "schema": { "type": "string" } }], - "requestBody": { - "required": true, - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApproveMentorDto" } } } - }, - "responses": { "200": { "description": "" } }, - "tags": ["registry"] - }, - "delete": { - "operationId": "cancelMentorRegistry", - "summary": "", - "parameters": [{ "name": "githubId", "required": true, "in": "path", "schema": { "type": "string" } }], - "responses": { "200": { "description": "" } }, - "tags": ["registry"] - } - }, - "/registry/mentor/{githubId}/comment": { - "put": { - "operationId": "commentMentorRegistry", - "summary": "", - "parameters": [{ "name": "githubId", "required": true, "in": "path", "schema": { "type": "string" } }], - "requestBody": { - "required": true, - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommentMentorRegistryDto" } } } - }, - "responses": { "200": { "description": "" } }, - "tags": ["registry"] - } - }, - "/registry/mentors": { - "get": { - "operationId": "getMentorRegistries", - "summary": "", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { "type": "array", "items": { "$ref": "#/components/schemas/MentorRegistryDto" } } - } - } - } - }, - "tags": ["registry"] - } - }, "/certificate/{publicId}": { "get": { "operationId": "getCertificate", @@ -3880,6 +3892,55 @@ } } }, + "PublicVisibilitySettings": { + "type": "object", + "properties": { "all": { "type": "boolean" } }, + "required": ["all"] + }, + "VisibilitySettings": { + "type": "object", + "properties": { + "all": { "type": "boolean" }, + "mentor": { "type": "boolean" }, + "student": { "type": "boolean" } + }, + "required": ["all", "mentor", "student"] + }, + "PartialStudentVisibilitySettings": { + "type": "object", + "properties": { "all": { "type": "boolean" }, "student": { "type": "boolean" } }, + "required": ["all", "student"] + }, + "ContactsVisibilitySettings": { + "type": "object", + "properties": { "all": { "type": "boolean" }, "student": { "type": "boolean" } }, + "required": ["all", "student"] + }, + "ConfigurableProfilePermissions": { + "type": "object", + "properties": { + "isProfileVisible": { "$ref": "#/components/schemas/PublicVisibilitySettings" }, + "isAboutVisible": { "$ref": "#/components/schemas/VisibilitySettings" }, + "isEducationVisible": { "$ref": "#/components/schemas/VisibilitySettings" }, + "isEnglishVisible": { "$ref": "#/components/schemas/PartialStudentVisibilitySettings" }, + "isEmailVisible": { "$ref": "#/components/schemas/ContactsVisibilitySettings" }, + "isTelegramVisible": { "$ref": "#/components/schemas/ContactsVisibilitySettings" }, + "isSkypeVisible": { "$ref": "#/components/schemas/ContactsVisibilitySettings" }, + "isPhoneVisible": { "$ref": "#/components/schemas/ContactsVisibilitySettings" }, + "isContactsNotesVisible": { "$ref": "#/components/schemas/ContactsVisibilitySettings" }, + "isLinkedInVisible": { "$ref": "#/components/schemas/VisibilitySettings" }, + "isPublicFeedbackVisible": { "$ref": "#/components/schemas/VisibilitySettings" }, + "isMentorStatsVisible": { "$ref": "#/components/schemas/VisibilitySettings" }, + "isStudentStatsVisible": { "$ref": "#/components/schemas/PartialStudentVisibilitySettings" } + } + }, + "Location": { + "type": "object", + "properties": { + "cityName": { "type": "string", "nullable": true }, + "countryName": { "type": "string", "nullable": true } + } + }, "Education": { "type": "object", "properties": { @@ -3889,6 +3950,123 @@ }, "required": ["university", "faculty", "graduationYear"] }, + "GeneralInfo": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "githubId": { "type": "string" }, + "aboutMyself": { "type": "string", "nullable": true }, + "location": { "$ref": "#/components/schemas/Location" }, + "educationHistory": { + "nullable": true, + "type": "array", + "items": { "$ref": "#/components/schemas/Education" } + }, + "englishLevel": { "type": "string", "nullable": true } + }, + "required": ["name", "githubId", "location"] + }, + "MentorStatsDto": { + "type": "object", + "properties": { + "courseLocationName": { "type": "string" }, + "courseName": { "type": "string" }, + "students": { "type": "array", "items": { "$ref": "#/components/schemas/StudentDto" } } + }, + "required": ["courseLocationName", "courseName"] + }, + "GithubIdName": { + "type": "object", + "properties": { "name": { "type": "string" }, "githubId": { "type": "string" } }, + "required": ["name", "githubId"] + }, + "StudentStatsDto": { + "type": "object", + "properties": { + "courseId": { "type": "number" }, + "courseName": { "type": "string" }, + "locationName": { "type": "string" }, + "courseFullName": { "type": "string" }, + "isExpelled": { "type": "boolean" }, + "isSelfExpelled": { "type": "boolean" }, + "expellingReason": { "type": "string" }, + "certificateId": { "type": "string" }, + "isCourseCompleted": { "type": "boolean" }, + "totalScore": { "type": "number" }, + "rank": { "type": "number" }, + "mentor": { "$ref": "#/components/schemas/GithubIdName" }, + "tasks": { "type": "array", "items": { "type": "string" } } + }, + "required": [ + "courseId", + "courseName", + "locationName", + "courseFullName", + "isExpelled", + "isSelfExpelled", + "certificateId", + "isCourseCompleted", + "totalScore", + "rank", + "mentor", + "tasks" + ] + }, + "PublicFeedbackDto": { + "type": "object", + "properties": { + "feedbackDate": { "type": "string" }, + "badgeId": { "type": "string" }, + "comment": { "type": "string" }, + "fromUser": { "$ref": "#/components/schemas/GithubIdName" } + }, + "required": ["feedbackDate", "badgeId", "comment", "fromUser"] + }, + "StageInterviewDetailedFeedbackDto": { + "type": "object", + "properties": { + "decision": { "type": "string" }, + "isGoodCandidate": { "type": "boolean" }, + "courseName": { "type": "string" }, + "courseFullName": { "type": "string" }, + "score": { "type": "number" }, + "maxScore": { "type": "number" }, + "date": { "type": "string" }, + "version": { "type": "number" }, + "interviewer": { "$ref": "#/components/schemas/GithubIdName" }, + "feedback": { "type": "object" } + }, + "required": [ + "decision", + "isGoodCandidate", + "courseName", + "courseFullName", + "score", + "maxScore", + "date", + "version", + "interviewer", + "feedback" + ] + }, + "ProfileInfoExtendedDto": { + "type": "object", + "properties": { + "permissionsSettings": { "$ref": "#/components/schemas/ConfigurableProfilePermissions" }, + "generalInfo": { "$ref": "#/components/schemas/GeneralInfo" }, + "contacts": { "$ref": "#/components/schemas/ContactsDto" }, + "discord": { "nullable": true, "allOf": [{ "$ref": "#/components/schemas/Discord" }] }, + "mentorStats": { "type": "array", "items": { "$ref": "#/components/schemas/MentorStatsDto" } }, + "studentStats": { "type": "array", "items": { "$ref": "#/components/schemas/StudentStatsDto" } }, + "publicFeedback": { "type": "array", "items": { "$ref": "#/components/schemas/PublicFeedbackDto" } }, + "stageInterviewFeedback": { + "type": "array", + "items": { "$ref": "#/components/schemas/StageInterviewDetailedFeedbackDto" } + }, + "publicCvUrl": { "type": "string", "nullable": true } + }, + "required": ["permissionsSettings", "generalInfo", "contacts"] + }, "UpdateProfileInfoDto": { "type": "object", "properties": { @@ -3915,7 +4093,7 @@ "discord": { "nullable": true, "allOf": [{ "$ref": "#/components/schemas/Discord" }] } } }, - "ProfileDto": { + "ProfileWithCvDto": { "type": "object", "properties": { "publicCvUrl": { "type": "string", "nullable": true } }, "required": ["publicCvUrl"] @@ -3959,18 +4137,6 @@ }, "required": ["user", "courses", "studentsCount", "interviewsCount"] }, - "CreateDisciplineDto": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }, - "DisciplineDto": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "id": { "type": "number" }, - "createdDate": { "type": "string" }, - "updatedDate": { "type": "string" } - }, - "required": ["name", "id", "createdDate", "updatedDate"] - }, - "UpdateDisciplineDto": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }, "ApproveMentorDto": { "type": "object", "properties": { "preselectedCourses": { "type": "array", "items": { "type": "string" } } }, @@ -4024,6 +4190,18 @@ "comment" ] }, + "CreateDisciplineDto": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }, + "DisciplineDto": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "id": { "type": "number" }, + "createdDate": { "type": "string" }, + "updatedDate": { "type": "string" } + }, + "required": ["name", "id", "createdDate", "updatedDate"] + }, + "UpdateDisciplineDto": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }, "SaveCertificateDto": { "type": "object", "properties": {