From 436df258edf6eecd8946e7a08e52436f27932782 Mon Sep 17 00:00:00 2001 From: Stanislau Laniuk Date: Thu, 8 Feb 2024 21:31:02 +0200 Subject: [PATCH 1/4] 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 3bef252c59..537abd5346 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 @@ -3013,6 +3117,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 @@ -3336,6 +3502,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 @@ -3677,6 +3862,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 @@ -3982,6 +4192,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 @@ -4192,13 +4421,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; } @@ -4233,6 +4523,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 @@ -4863,6 +5197,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 @@ -5057,6 +5458,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 @@ -6807,6 +7293,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 @@ -13966,6 +14477,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}; @@ -14157,10 +14702,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 @@ -14244,9 +14799,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 @@ -14337,6 +14901,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 6cc4f56e87..b98af6a62a 100644 --- a/nestjs/src/spec.json +++ b/nestjs/src/spec.json @@ -1641,6 +1641,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": "", @@ -1661,7 +1673,7 @@ "responses": { "default": { "description": "", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProfileDto" } } } + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProfileWithCvDto" } } } } }, "tags": ["profile"] @@ -1716,6 +1728,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", @@ -1775,57 +1838,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", @@ -3917,6 +3929,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": { @@ -3926,6 +3987,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": { @@ -3952,7 +4130,7 @@ "discord": { "nullable": true, "allOf": [{ "$ref": "#/components/schemas/Discord" }] } } }, - "ProfileDto": { + "ProfileWithCvDto": { "type": "object", "properties": { "publicCvUrl": { "type": "string", "nullable": true } }, "required": ["publicCvUrl"] @@ -3996,18 +4174,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" } } }, @@ -4061,6 +4227,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": { From b8e900a9fcbc5b801c233b4810f3e855b50f5218 Mon Sep 17 00:00:00 2001 From: Stanislau Laniuk Date: Wed, 7 Feb 2024 15:58:35 +0200 Subject: [PATCH 2/4] fix: adjust types for profile-info DTOs --- client/src/api/api.ts | 248 ++++++++++++++++--- client/src/pages/profile/index.tsx | 12 +- client/src/services/user.ts | 8 +- common/models/profile.ts | 4 +- nestjs/src/profile/dto/profile-info.dto.ts | 154 ++++++++---- nestjs/src/profile/dto/update-profile.dto.ts | 33 ++- nestjs/src/profile/profile.controller.ts | 1 + nestjs/src/spec.json | 78 +++++- 8 files changed, 434 insertions(+), 104 deletions(-) diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 537abd5346..0e9437cccc 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -646,6 +646,61 @@ export interface ContactsDto { */ 'discord'?: string | null; } +/** + * + * @export + * @interface ContactsInfoDto + */ +export interface ContactsInfoDto { + /** + * + * @type {string} + * @memberof ContactsInfoDto + */ + 'phone': string | null; + /** + * + * @type {string} + * @memberof ContactsInfoDto + */ + 'email': string | null; + /** + * + * @type {string} + * @memberof ContactsInfoDto + */ + 'epamEmail': string | null; + /** + * + * @type {string} + * @memberof ContactsInfoDto + */ + 'skype': string | null; + /** + * + * @type {string} + * @memberof ContactsInfoDto + */ + 'whatsApp': string | null; + /** + * + * @type {string} + * @memberof ContactsInfoDto + */ + 'telegram': string | null; + /** + * + * @type {string} + * @memberof ContactsInfoDto + */ + 'notes': string | null; + /** + * + * @type {string} + * @memberof ContactsInfoDto + */ + 'linkedIn': string | null; +} /** * * @export @@ -3120,45 +3175,51 @@ export type FormDataDtoMilitaryServiceEnum = typeof FormDataDtoMilitaryServiceEn /** * * @export - * @interface GeneralInfo + * @interface GeneralInfoDto */ -export interface GeneralInfo { +export interface GeneralInfoDto { /** * * @type {string} - * @memberof GeneralInfo + * @memberof GeneralInfoDto */ 'name': string; /** * * @type {string} - * @memberof GeneralInfo + * @memberof GeneralInfoDto */ 'githubId': string; /** * * @type {string} - * @memberof GeneralInfo + * @memberof GeneralInfoDto */ 'aboutMyself'?: string | null; - /** - * - * @type {Location} - * @memberof GeneralInfo - */ - 'location': Location; /** * * @type {Array} - * @memberof GeneralInfo + * @memberof GeneralInfoDto */ 'educationHistory'?: Array | null; /** * * @type {string} - * @memberof GeneralInfo + * @memberof GeneralInfoDto */ 'englishLevel'?: string | null; + /** + * + * @type {LocationInfoDto} + * @memberof GeneralInfoDto + */ + 'location': LocationInfoDto; + /** + * + * @type {Array} + * @memberof GeneralInfoDto + */ + 'languages': Array; } /** * @@ -3476,6 +3537,31 @@ export interface InterviewFeedbackDto { */ 'maxScore': number; } +/** + * + * @export + * @interface InterviewFormAnswerDto + */ +export interface InterviewFormAnswerDto { + /** + * + * @type {string} + * @memberof InterviewFormAnswerDto + */ + 'questionId': string; + /** + * + * @type {string} + * @memberof InterviewFormAnswerDto + */ + 'questionText': string; + /** + * + * @type {boolean} + * @memberof InterviewFormAnswerDto + */ + 'answer'?: boolean; +} /** * * @export @@ -3505,21 +3591,21 @@ export interface LeaveCourseRequestDto { /** * * @export - * @interface Location + * @interface LocationInfoDto */ -export interface Location { +export interface LocationInfoDto { /** * * @type {string} - * @memberof Location + * @memberof LocationInfoDto */ - 'cityName'?: string | null; + 'cityName': string; /** * * @type {string} - * @memberof Location + * @memberof LocationInfoDto */ - 'countryName'?: string | null; + 'countryName': string; } /** * @@ -3882,10 +3968,10 @@ export interface MentorStatsDto { 'courseName': string; /** * - * @type {Array} + * @type {Array} * @memberof MentorStatsDto */ - 'students'?: Array; + 'students'?: Array; } /** * @@ -4432,22 +4518,22 @@ export interface ProfileInfoExtendedDto { 'permissionsSettings': ConfigurableProfilePermissions; /** * - * @type {GeneralInfo} + * @type {ContactsInfoDto} * @memberof ProfileInfoExtendedDto */ - 'generalInfo': GeneralInfo; + 'contacts': ContactsInfoDto; /** * - * @type {ContactsDto} + * @type {Discord} * @memberof ProfileInfoExtendedDto */ - 'contacts': ContactsDto; + 'discord': Discord | null; /** * - * @type {Discord} + * @type {GeneralInfoDto} * @memberof ProfileInfoExtendedDto */ - 'discord'?: Discord | null; + 'generalInfo': GeneralInfoDto; /** * * @type {Array} @@ -5458,6 +5544,43 @@ export interface StudentId { */ 'id': number; } +/** + * + * @export + * @interface StudentInfoDto + */ +export interface StudentInfoDto { + /** + * + * @type {string} + * @memberof StudentInfoDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof StudentInfoDto + */ + 'githubId': string; + /** + * + * @type {boolean} + * @memberof StudentInfoDto + */ + 'isExpelled': boolean; + /** + * + * @type {number} + * @memberof StudentInfoDto + */ + 'totalScore': number; + /** + * + * @type {string} + * @memberof StudentInfoDto + */ + 'repoUrl'?: string; +} /** * * @export @@ -5538,10 +5661,77 @@ export interface StudentStatsDto { 'mentor': GithubIdName; /** * - * @type {Array} + * @type {Array} * @memberof StudentStatsDto */ - 'tasks': Array; + 'tasks': Array; +} +/** + * + * @export + * @interface StudentTaskDetailDto + */ +export interface StudentTaskDetailDto { + /** + * + * @type {number} + * @memberof StudentTaskDetailDto + */ + 'maxScore': number; + /** + * + * @type {number} + * @memberof StudentTaskDetailDto + */ + 'scoreWeight': number; + /** + * + * @type {string} + * @memberof StudentTaskDetailDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof StudentTaskDetailDto + */ + 'descriptionUri': string; + /** + * + * @type {string} + * @memberof StudentTaskDetailDto + */ + 'taskGithubPrUris': string; + /** + * + * @type {number} + * @memberof StudentTaskDetailDto + */ + 'score': number; + /** + * + * @type {string} + * @memberof StudentTaskDetailDto + */ + 'comment': string; + /** + * + * @type {string} + * @memberof StudentTaskDetailDto + */ + 'interviewDate'?: string; + /** + * + * @type {GithubIdName} + * @memberof StudentTaskDetailDto + */ + 'interviewer'?: GithubIdName; + /** + * + * @type {Array} + * @memberof StudentTaskDetailDto + */ + 'interviewFormAnswers'?: Array; } /** * diff --git a/client/src/pages/profile/index.tsx b/client/src/pages/profile/index.tsx index 54b5495940..113816bf87 100644 --- a/client/src/pages/profile/index.tsx +++ b/client/src/pages/profile/index.tsx @@ -51,25 +51,25 @@ const ProfilePage = () => { const fetchData = async () => { try { const githubId = router.query ? (router.query.githubId as string) : undefined; - const [profile, connections, { data }] = await Promise.all([ + const [profileInfo, connections, { data }] = await Promise.all([ userService.getProfileInfo(githubId?.toLowerCase()), notificationsService.getUserConnections().catch(() => ({})), profileApi.getProfile(githubId?.toLowerCase() ?? session.githubId), ]); - const updateProfile = { - ...profile, + const profileInfoExtendedWithCv = { + ...profileInfo, ...data, }; let isProfileOwner = false; - if (profile?.generalInfo) { + if (profileInfo?.generalInfo) { const userId = session.githubId; - const profileId = profile.generalInfo.githubId; + const profileId = profileInfo.generalInfo.githubId; isProfileOwner = checkIsProfileOwner(userId, profileId); } - setProfile(updateProfile); + setProfile(profileInfoExtendedWithCv); setIsProfileOwner(isProfileOwner); setConnections(connections as Connections); } catch (e) { diff --git a/client/src/services/user.ts b/client/src/services/user.ts index bb6378be1d..985b86865c 100644 --- a/client/src/services/user.ts +++ b/client/src/services/user.ts @@ -105,10 +105,10 @@ export class UserService { 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 }, - }); - return response.data.data; + // const response = await this.axios.get<{ data: ProfileInfo }>(`/api/profile/info`, { + // params: { githubId }, + // }); + // return response.data.data; } async sendEmailConfirmationLink() { diff --git a/common/models/profile.ts b/common/models/profile.ts index d077dd8d0e..1847fe6c41 100644 --- a/common/models/profile.ts +++ b/common/models/profile.ts @@ -44,7 +44,8 @@ export interface GeneralInfo { aboutMyself?: string | null; location: Location; educationHistory?: any | null; - englishLevel?: EnglishLevel | null; + // TODO: String type is too abstract for englishLevel. + englishLevel?: EnglishLevel | null | string; languages: string[]; } @@ -144,6 +145,7 @@ export interface StageInterviewDetailedFeedback { // This type have to updated to refer to `InterviewFeedbackStepData`, when profile is migrated to nestjs feedback: | LegacyFeedback + | object | { steps: Record< string, diff --git a/nestjs/src/profile/dto/profile-info.dto.ts b/nestjs/src/profile/dto/profile-info.dto.ts index e997beb80f..2586832a32 100644 --- a/nestjs/src/profile/dto/profile-info.dto.ts +++ b/nestjs/src/profile/dto/profile-info.dto.ts @@ -6,6 +6,8 @@ import { ConfigurableProfilePermissions } from './permissions.dto'; import { Contacts, EnglishLevel, + GeneralInfo, + LegacyFeedback, MentorStats, PublicFeedback, StageInterviewDetailedFeedback, @@ -24,16 +26,14 @@ class GithubIdName { githubId: string; } -class Location { +class LocationInfoDto { + @ApiProperty({ required: true, type: String }) @IsString() - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - cityName: string | null; + cityName: string; + @ApiProperty({ required: true, type: String }) @IsString() - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() - countryName: string | null; + countryName: string; } export class Education { @@ -64,71 +64,128 @@ export class Discord { discriminator: string; } -class GeneralInfo extends GithubIdName { +class ProgrammingTaskDto { + @ApiProperty({ required: true, type: String }) + task: string; + + @ApiProperty({ required: true, type: Number }) + codeWritingLevel: number; + + @ApiProperty({ required: true, type: Number }) + resolved: number; + + @ApiProperty({ required: true, type: String }) + comment: string; +} + +class SkillsDto { + @ApiProperty({ required: true, type: Number }) + htmlCss: number; + + @ApiProperty({ required: true, type: Number }) + common: number; + + @ApiProperty({ required: true, type: Number }) + dataStructures: number; +} + +class StepsDto { + @ApiProperty({ required: true }) + steps: Record< + string, + { + isCompleted: boolean; + values?: Record; + } + >; +} + +class PartialFeedbackInfoDto { + @ApiProperty({ required: false, type: StepsDto }) + @IsOptional() + steps?: StepsDto; +} + +class LegacyFeedbackInfoDto implements LegacyFeedback { + @ApiProperty({ required: false, type: String }) + @IsOptional() + english?: EnglishLevel; + + @ApiProperty({ required: true, type: String }) + comment: string; + + @ApiProperty({ required: true, type: ProgrammingTaskDto }) + programmingTask: ProgrammingTaskDto; + + @ApiProperty({ required: true, type: SkillsDto }) + skills: SkillsDto; +} + +export class GeneralInfoBase 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; + // TODO: englishLevel should be of type enum, not String type. + // Currently generator produces enums with the same keys (A1 for both a1/a1+, etc) which is compile blocker for TS. + // Keywords: typescript-axios openapi enumPropertyNamingReplaceSpecialChar. @ApiProperty({ required: false, nullable: true, type: String }) @IsOptional() @IsString() englishLevel?: EnglishLevel | null; } -class ContactsDto implements Contacts { - @ApiProperty({ required: false, nullable: true, type: String }) +class GeneralInfoDto extends GeneralInfoBase implements GeneralInfo { + @ApiProperty({ required: true, type: LocationInfoDto }) + location: LocationInfoDto; + + @ApiProperty({ required: true, type: [String] }) @IsOptional() + @IsArray() + languages: string[]; +} + +class ContactsInfoDto implements Contacts { + @ApiProperty({ required: true, nullable: true, type: String }) @IsString() phone: string | null; - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() + @ApiProperty({ required: true, nullable: true, type: String }) @IsString() email: string | null; - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() + @ApiProperty({ required: true, nullable: true, type: String }) @IsString() epamEmail: string | null; - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() + @ApiProperty({ required: true, nullable: true, type: String }) @IsString() skype: string | null; - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() + @ApiProperty({ required: true, nullable: true, type: String }) @IsString() whatsApp: string | null; - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() + @ApiProperty({ required: true, nullable: true, type: String }) @IsString() telegram: string | null; - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() + @ApiProperty({ required: true, nullable: true, type: String }) @IsString() notes: string | null; - @ApiProperty({ required: false, nullable: true, type: String }) - @IsOptional() + @ApiProperty({ required: true, nullable: true, type: String }) @IsString() linkedIn: string | null; } -class StudentDto extends GithubIdName implements Student { +class StudentInfoDto extends GithubIdName implements Student { @ApiProperty({ required: true, type: Boolean }) @IsBoolean() isExpelled: boolean; @@ -152,10 +209,10 @@ class MentorStatsDto implements MentorStats { @IsString() courseName: string; - @ApiProperty({ required: false, type: [StudentDto] }) + @ApiProperty({ required: false, type: [StudentInfoDto] }) @IsOptional() @IsArray() - students?: StudentDto[]; + students?: StudentInfoDto[]; } class PublicFeedbackDto implements PublicFeedback { @@ -232,8 +289,8 @@ export class StageInterviewDetailedFeedbackDto implements StageInterviewDetailed @ApiProperty({ required: true, type: GithubIdName }) interviewer: GithubIdName; - @ApiProperty({ required: true }) - feedback: StageInterviewDetailedFeedback['feedback']; + @ApiProperty({ required: true, type: Object }) + feedback: LegacyFeedbackInfoDto | PartialFeedbackInfoDto; } export class StudentTaskDetailDto implements StudentTasksDetail { @@ -275,7 +332,7 @@ export class StudentTaskDetailDto implements StudentTasksDetail { @ValidateNested() interviewer?: GithubIdName; - @ApiProperty({ required: false, type: InterviewFormAnswerDto }) + @ApiProperty({ required: false, type: [InterviewFormAnswerDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @@ -332,35 +389,36 @@ class StudentStatsDto implements StudentStats { @ValidateNested() mentor: GithubIdName; - @ApiProperty({ required: true }) + @ApiProperty({ required: true, type: [StudentTaskDetailDto] }) @IsArray() @ValidateNested({ each: true }) tasks: StudentTaskDetailDto[]; } -export class ProfileInfoBaseDto { +export class ProfileInfoBase { @ApiProperty({ type: ConfigurableProfilePermissions }) @ValidateNested() @Type(() => ConfigurableProfilePermissions) permissionsSettings: ConfigurableProfilePermissions; - @ApiProperty({ type: GeneralInfo }) - @ValidateNested() - @Type(() => GeneralInfo) - generalInfo: GeneralInfo; - - @ApiProperty({ type: ContactsDto }) + @ApiProperty({ type: ContactsInfoDto }) @ValidateNested() - @Type(() => ContactsDto) - contacts: ContactsDto; + @Type(() => ContactsInfoDto) + contacts: ContactsInfoDto; - @ApiProperty({ required: false, nullable: true, type: Discord }) + @ApiProperty({ required: true, nullable: true, type: Discord }) @Type(() => Discord) - @IsOptional() discord: Discord | null; } -export class ProfileInfoExtendedDto extends ProfileInfoBaseDto implements ProfileWithCvDto { +export class ProfileInfoDto extends ProfileInfoBase { + @ApiProperty({ type: GeneralInfoDto }) + @ValidateNested() + @Type(() => GeneralInfoDto) + generalInfo: GeneralInfoDto; +} + +export class ProfileInfoExtendedDto extends ProfileInfoDto implements ProfileWithCvDto { @ApiProperty({ required: false, type: [MentorStatsDto] }) @IsOptional() @IsArray() diff --git a/nestjs/src/profile/dto/update-profile.dto.ts b/nestjs/src/profile/dto/update-profile.dto.ts index f69c17cb59..8269eb1f4f 100644 --- a/nestjs/src/profile/dto/update-profile.dto.ts +++ b/nestjs/src/profile/dto/update-profile.dto.ts @@ -1,8 +1,35 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean } from 'class-validator'; -import { ProfileInfoBaseDto } from './profile-info.dto'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsString, IsOptional, IsArray, ValidateNested } from 'class-validator'; +import { GeneralInfoBase, ProfileInfoBase } from './profile-info.dto'; + +class UpdateLocationInfoDto { + @ApiProperty({ required: false, nullable: true, type: String }) + @IsString() + @IsOptional() + cityName: string | null; + + @ApiProperty({ required: false, nullable: true, type: String }) + @IsString() + @IsOptional() + countryName: string | null; +} +class UpdateGeneralInfoDto extends GeneralInfoBase { + @ApiProperty({ type: UpdateLocationInfoDto }) + location: UpdateLocationInfoDto; + + @ApiProperty({ required: false, type: [String] }) + @IsOptional() + @IsArray() + languages?: string[]; +} + +export class UpdateProfileDto extends ProfileInfoBase { + @ApiProperty({ type: UpdateGeneralInfoDto }) + @ValidateNested() + @Type(() => UpdateGeneralInfoDto) + generalInfo: UpdateGeneralInfoDto; -export class UpdateProfileDto extends ProfileInfoBaseDto { @ApiProperty() @IsBoolean() isPermissionsSettingsChanged: boolean; diff --git a/nestjs/src/profile/profile.controller.ts b/nestjs/src/profile/profile.controller.ts index 135bf0bf05..f4434753b7 100644 --- a/nestjs/src/profile/profile.controller.ts +++ b/nestjs/src/profile/profile.controller.ts @@ -93,6 +93,7 @@ export class ProfileController { @Get(':username') @ApiOperation({ operationId: 'getProfile' }) @ApiResponse({ type: ProfileWithCvDto }) + @UseGuards(DefaultGuard) public async getProfile(@Param('username') githubId: string) { const profile = await this.profileService.getProfile(githubId); diff --git a/nestjs/src/spec.json b/nestjs/src/spec.json index b98af6a62a..6c4e12a443 100644 --- a/nestjs/src/spec.json +++ b/nestjs/src/spec.json @@ -3971,12 +3971,19 @@ "isStudentStatsVisible": { "$ref": "#/components/schemas/PartialStudentVisibilitySettings" } } }, - "Location": { + "ContactsInfoDto": { "type": "object", "properties": { - "cityName": { "type": "string", "nullable": true }, - "countryName": { "type": "string", "nullable": true } - } + "phone": { "type": "string", "nullable": true }, + "email": { "type": "string", "nullable": true }, + "epamEmail": { "type": "string", "nullable": true }, + "skype": { "type": "string", "nullable": true }, + "whatsApp": { "type": "string", "nullable": true }, + "telegram": { "type": "string", "nullable": true }, + "notes": { "type": "string", "nullable": true }, + "linkedIn": { "type": "string", "nullable": true } + }, + "required": ["phone", "email", "epamEmail", "skype", "whatsApp", "telegram", "notes", "linkedIn"] }, "Education": { "type": "object", @@ -3987,28 +3994,45 @@ }, "required": ["university", "faculty", "graduationYear"] }, - "GeneralInfo": { + "LocationInfoDto": { + "type": "object", + "properties": { "cityName": { "type": "string" }, "countryName": { "type": "string" } }, + "required": ["cityName", "countryName"] + }, + "GeneralInfoDto": { "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 } + "englishLevel": { "type": "string", "nullable": true }, + "location": { "$ref": "#/components/schemas/LocationInfoDto" }, + "languages": { "type": "array", "items": { "type": "string" } } }, - "required": ["name", "githubId", "location"] + "required": ["name", "githubId", "location", "languages"] + }, + "StudentInfoDto": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "githubId": { "type": "string" }, + "isExpelled": { "type": "boolean" }, + "totalScore": { "type": "number" }, + "repoUrl": { "type": "string" } + }, + "required": ["name", "githubId", "isExpelled", "totalScore"] }, "MentorStatsDto": { "type": "object", "properties": { "courseLocationName": { "type": "string" }, "courseName": { "type": "string" }, - "students": { "type": "array", "items": { "$ref": "#/components/schemas/StudentDto" } } + "students": { "type": "array", "items": { "$ref": "#/components/schemas/StudentInfoDto" } } }, "required": ["courseLocationName", "courseName"] }, @@ -4017,6 +4041,34 @@ "properties": { "name": { "type": "string" }, "githubId": { "type": "string" } }, "required": ["name", "githubId"] }, + "InterviewFormAnswerDto": { + "type": "object", + "properties": { + "questionId": { "type": "string" }, + "questionText": { "type": "string" }, + "answer": { "type": "boolean" } + }, + "required": ["questionId", "questionText"] + }, + "StudentTaskDetailDto": { + "type": "object", + "properties": { + "maxScore": { "type": "number" }, + "scoreWeight": { "type": "number" }, + "name": { "type": "string" }, + "descriptionUri": { "type": "string" }, + "taskGithubPrUris": { "type": "string" }, + "score": { "type": "number" }, + "comment": { "type": "string" }, + "interviewDate": { "type": "string" }, + "interviewer": { "$ref": "#/components/schemas/GithubIdName" }, + "interviewFormAnswers": { + "type": "array", + "items": { "$ref": "#/components/schemas/InterviewFormAnswerDto" } + } + }, + "required": ["maxScore", "scoreWeight", "name", "descriptionUri", "taskGithubPrUris", "score", "comment"] + }, "StudentStatsDto": { "type": "object", "properties": { @@ -4032,7 +4084,7 @@ "totalScore": { "type": "number" }, "rank": { "type": "number" }, "mentor": { "$ref": "#/components/schemas/GithubIdName" }, - "tasks": { "type": "array", "items": { "type": "string" } } + "tasks": { "type": "array", "items": { "$ref": "#/components/schemas/StudentTaskDetailDto" } } }, "required": [ "courseId", @@ -4090,9 +4142,9 @@ "type": "object", "properties": { "permissionsSettings": { "$ref": "#/components/schemas/ConfigurableProfilePermissions" }, - "generalInfo": { "$ref": "#/components/schemas/GeneralInfo" }, - "contacts": { "$ref": "#/components/schemas/ContactsDto" }, + "contacts": { "$ref": "#/components/schemas/ContactsInfoDto" }, "discord": { "nullable": true, "allOf": [{ "$ref": "#/components/schemas/Discord" }] }, + "generalInfo": { "$ref": "#/components/schemas/GeneralInfoDto" }, "mentorStats": { "type": "array", "items": { "$ref": "#/components/schemas/MentorStatsDto" } }, "studentStats": { "type": "array", "items": { "$ref": "#/components/schemas/StudentStatsDto" } }, "publicFeedback": { "type": "array", "items": { "$ref": "#/components/schemas/PublicFeedbackDto" } }, @@ -4102,7 +4154,7 @@ }, "publicCvUrl": { "type": "string", "nullable": true } }, - "required": ["permissionsSettings", "generalInfo", "contacts"] + "required": ["permissionsSettings", "contacts", "discord", "generalInfo"] }, "UpdateProfileInfoDto": { "type": "object", From 8018e56ab9c4957cd5f5be3cbd26d6ce703af52d Mon Sep 17 00:00:00 2001 From: Stanislau Laniuk Date: Thu, 8 Feb 2024 21:14:38 +0200 Subject: [PATCH 3/4] test: transfer tests for permission service --- .../src/profile/permissions.service.spec.ts | 462 ++++++++++++++++++ nestjs/src/profile/permissions.service.ts | 2 +- 2 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 nestjs/src/profile/permissions.service.spec.ts diff --git a/nestjs/src/profile/permissions.service.spec.ts b/nestjs/src/profile/permissions.service.spec.ts new file mode 100644 index 0000000000..d113ce923a --- /dev/null +++ b/nestjs/src/profile/permissions.service.spec.ts @@ -0,0 +1,462 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { PermissionsService, Relations } from './permissions.service'; +import { CourseRole, IUserSession, ProfilePermissions, Student } from '@entities/index'; + +describe('PermissionsService', () => { + let service: PermissionsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PermissionsService, + { provide: getRepositoryToken(Student), useValue: {} }, + { provide: getRepositoryToken(ProfilePermissions), useValue: {} }, + ], + }).compile(); + + service = module.get(PermissionsService); + }); + + describe('getAccessRights', () => { + it(`should return object with all falsy values if no permissions are provided + and requestor with unknown role is neither an admin nor a profile owner`, () => { + expect( + service.getAccessRights({ + isProfileOwner: false, + isAdmin: false, + }), + ).toEqual({ + isProfileVisible: false, + isAboutVisible: false, + isEducationVisible: false, + isEnglishVisible: false, + isEmailVisible: false, + isTelegramVisible: false, + isWhatsAppVisible: false, + isSkypeVisible: false, + isPhoneVisible: false, + isContactsNotesVisible: false, + isLinkedInVisible: false, + isPublicFeedbackVisible: false, + isMentorStatsVisible: false, + isStudentStatsVisible: false, + isStageInterviewFeedbackVisible: false, + isCoreJsFeedbackVisible: false, + isConsentsVisible: false, + isExpellingReasonVisible: false, + }); + }); + + describe('role and permissions are provided', () => { + describe('requestor is a profile owner', () => { + it('should return object with values relevant to role "all"', () => { + expect( + service.getAccessRights({ + isProfileOwner: false, + isAdmin: false, + role: 'all', + permissions: { + isProfileVisible: { all: true }, + isAboutVisible: { all: true, mentor: true, student: true }, + isEducationVisible: { all: true, mentor: true, student: true }, + isEnglishVisible: { all: false, student: false }, + isEmailVisible: { all: true, student: true }, + isTelegramVisible: { all: false, student: false }, + isSkypeVisible: { all: true, student: true }, + isPhoneVisible: { all: false, student: false }, + isContactsNotesVisible: { all: true, student: true }, + isLinkedInVisible: { all: false, mentor: false, student: false }, + isPublicFeedbackVisible: { all: true, mentor: true, student: true }, + isMentorStatsVisible: { all: true, mentor: true, student: true }, + isStudentStatsVisible: { all: true, student: true }, + }, + }), + ).toEqual({ + isProfileVisible: true, + isAboutVisible: true, + isEducationVisible: true, + isEnglishVisible: false, + isEmailVisible: true, + isTelegramVisible: false, + isWhatsAppVisible: false, + isSkypeVisible: true, + isPhoneVisible: false, + isContactsNotesVisible: true, + isLinkedInVisible: false, + isPublicFeedbackVisible: true, + isMentorStatsVisible: true, + isStudentStatsVisible: true, + isStageInterviewFeedbackVisible: false, + isCoreJsFeedbackVisible: false, + isConsentsVisible: false, + isExpellingReasonVisible: false, + }); + }); + + it('should return object with values relevant to role "mentor"', () => { + expect( + service.getAccessRights({ + isProfileOwner: false, + isAdmin: false, + role: 'mentor', + permissions: { + isProfileVisible: { all: true }, + isAboutVisible: { all: false, mentor: true, student: false }, + isEducationVisible: { all: false, mentor: false, student: true }, + isEnglishVisible: { all: false, student: false }, + isEmailVisible: { all: false, student: true }, + isTelegramVisible: { all: false, student: false }, + isSkypeVisible: { all: false, student: true }, + isPhoneVisible: { all: false, student: false }, + isContactsNotesVisible: { all: true, student: true }, + isLinkedInVisible: { all: false, mentor: false, student: false }, + isPublicFeedbackVisible: { all: false, mentor: true, student: true }, + isMentorStatsVisible: { all: false, mentor: true, student: true }, + isStudentStatsVisible: { all: false, student: true }, + }, + }), + ).toEqual({ + isProfileVisible: true, + isAboutVisible: true, + isEducationVisible: false, + isEnglishVisible: true, + isEmailVisible: true, + isTelegramVisible: true, + isWhatsAppVisible: true, + isSkypeVisible: true, + isPhoneVisible: true, + isContactsNotesVisible: true, + isLinkedInVisible: false, + isPublicFeedbackVisible: true, + isMentorStatsVisible: true, + isStudentStatsVisible: true, + isStageInterviewFeedbackVisible: true, + isCoreJsFeedbackVisible: true, + isConsentsVisible: false, + isExpellingReasonVisible: true, + }); + }); + + it('should return object with values relevant to role "student"', () => { + expect( + service.getAccessRights({ + isProfileOwner: false, + isAdmin: false, + role: 'student', + permissions: { + isProfileVisible: { all: true }, + isAboutVisible: { all: false, mentor: true, student: true }, + isEducationVisible: { all: false, mentor: false, student: false }, + isEnglishVisible: { all: false, student: false }, + isEmailVisible: { all: false, student: false }, + isTelegramVisible: { all: false, student: true }, + isSkypeVisible: { all: false, student: true }, + isPhoneVisible: { all: false, student: false }, + isContactsNotesVisible: { all: true, student: true }, + isLinkedInVisible: { all: false, mentor: false, student: false }, + isPublicFeedbackVisible: { all: false, mentor: true, student: true }, + isMentorStatsVisible: { all: false, mentor: true, student: true }, + isStudentStatsVisible: { all: false, student: true }, + }, + }), + ).toEqual({ + isProfileVisible: true, + isAboutVisible: true, + isEducationVisible: false, + isEnglishVisible: false, + isEmailVisible: false, + isTelegramVisible: true, + isWhatsAppVisible: true, + isSkypeVisible: true, + isPhoneVisible: false, + isContactsNotesVisible: true, + isLinkedInVisible: false, + isPublicFeedbackVisible: true, + isMentorStatsVisible: true, + isStudentStatsVisible: true, + isStageInterviewFeedbackVisible: false, + isCoreJsFeedbackVisible: false, + isConsentsVisible: false, + isExpellingReasonVisible: false, + }); + }); + + it('should return object with values relevant to role "coursemanager"', () => { + expect( + service.getAccessRights({ + isProfileOwner: false, + isAdmin: false, + role: 'coursemanager', + permissions: { + isProfileVisible: { all: true }, + isAboutVisible: { all: false, mentor: true, student: true }, + isEducationVisible: { all: false, mentor: false, student: false }, + isEnglishVisible: { all: false, student: false }, + isEmailVisible: { all: false, student: false }, + isTelegramVisible: { all: false, student: true }, + isSkypeVisible: { all: false, student: true }, + isPhoneVisible: { all: false, student: false }, + isContactsNotesVisible: { all: true, student: true }, + isLinkedInVisible: { all: false, mentor: false, student: false }, + isPublicFeedbackVisible: { all: false, mentor: true, student: true }, + isMentorStatsVisible: { all: false, mentor: true, student: true }, + isStudentStatsVisible: { all: false, student: true }, + }, + }), + ).toEqual({ + isProfileVisible: true, + isAboutVisible: true, + isEducationVisible: true, + isEnglishVisible: true, + isEmailVisible: true, + isTelegramVisible: true, + isWhatsAppVisible: true, + isSkypeVisible: true, + isPhoneVisible: true, + isContactsNotesVisible: true, + isLinkedInVisible: true, + isPublicFeedbackVisible: true, + isMentorStatsVisible: true, + isStudentStatsVisible: true, + isStageInterviewFeedbackVisible: true, + isCoreJsFeedbackVisible: true, + isConsentsVisible: true, + isExpellingReasonVisible: true, + }); + }); + }); + + describe('requestor is a profile owner', () => { + it('should return object with values relevant to role "all" with restrictive role permissions', () => { + expect( + service.getAccessRights({ + isProfileOwner: true, + isAdmin: false, + role: 'all', + permissions: { + isProfileVisible: { all: false }, + isAboutVisible: { all: false, mentor: false, student: false }, + isEducationVisible: { all: false, mentor: false, student: false }, + isEnglishVisible: { all: false, student: false }, + isEmailVisible: { all: false, student: false }, + isTelegramVisible: { all: false, student: false }, + isSkypeVisible: { all: false, student: false }, + isPhoneVisible: { all: false, student: false }, + isContactsNotesVisible: { all: false, student: false }, + isLinkedInVisible: { all: false, mentor: false, student: false }, + isPublicFeedbackVisible: { all: false, mentor: false, student: false }, + isMentorStatsVisible: { all: false, mentor: false, student: false }, + isStudentStatsVisible: { all: false, student: false }, + }, + }), + ).toEqual({ + isProfileVisible: true, + isAboutVisible: true, + isEducationVisible: true, + isEnglishVisible: true, + isEmailVisible: true, + isTelegramVisible: true, + isWhatsAppVisible: true, + isSkypeVisible: true, + isPhoneVisible: true, + isContactsNotesVisible: true, + isLinkedInVisible: true, + isPublicFeedbackVisible: true, + isMentorStatsVisible: true, + isStudentStatsVisible: true, + isStageInterviewFeedbackVisible: false, + isCoreJsFeedbackVisible: false, + isConsentsVisible: true, + isExpellingReasonVisible: false, + }); + }); + }); + }); + }); + + describe('defineRole', () => { + let mockUserSession: IUserSession; + let mockRoles: Relations; + + beforeEach(() => { + mockUserSession = { + id: 1, + githubId: 'githubId', + isHirer: false, + isAdmin: false, + courses: { + '1': { + mentorId: 1, + studentId: 2, + roles: [], + }, + '2': { + mentorId: 1, + studentId: 2, + roles: [], + }, + '11': { + mentorId: 1, + studentId: 2, + roles: [], + }, + }, + }; + + mockRoles = { + student: 'dima', + mentors: ['andrey', 'dasha'], + interviewers: ['sasha', 'max'], + stageInterviewers: ['alex'], + checkers: ['masha', 'ivan'], + }; + }); + + it('should return "student" if user is a student', () => { + expect( + service.defineRole({ + relationsRoles: mockRoles, + mentorRegistryCourses: null, + studentCourses: null, + userSession: mockUserSession, + userGithubId: 'dima', + }), + ).toBe('student'); + }); + + it('should return "mentor" if user is an assigned mentor', () => { + expect( + service.defineRole({ + relationsRoles: mockRoles, + mentorRegistryCourses: null, + studentCourses: null, + userSession: mockUserSession, + userGithubId: 'andrey', + }), + ).toBe('mentor'); + }); + + it('should return "mentor" if user is an interviewer', () => { + expect( + service.defineRole({ + relationsRoles: mockRoles, + mentorRegistryCourses: null, + studentCourses: null, + userSession: mockUserSession, + userGithubId: 'max', + }), + ).toBe('mentor'); + }); + + it('should return "mentor" if user is a stage-interviewer', () => { + expect( + service.defineRole({ + relationsRoles: mockRoles, + mentorRegistryCourses: null, + studentCourses: null, + userSession: mockUserSession, + userGithubId: 'alex', + }), + ).toBe('mentor'); + }); + + it('should return "mentor" if user is assigned for checking a task', () => { + expect( + service.defineRole({ + relationsRoles: mockRoles, + mentorRegistryCourses: null, + studentCourses: null, + userSession: mockUserSession, + userGithubId: 'masha', + }), + ).toBe('mentor'); + }); + + it('should return "coursementor" if user is a mentor of the is a student\'s course', () => { + expect( + service.defineRole({ + relationsRoles: null, + mentorRegistryCourses: null, + studentCourses: [{ courseId: 1 }, { courseId: 11 }], + userSession: mockUserSession, + userGithubId: 'denis', + }), + ).toBe('coursementor'); + }); + + it('should return "coursemanager" if user is coursemanager of the course where mentor is waiting for confirmation', () => { + expect( + service.defineRole({ + relationsRoles: null, + mentorRegistryCourses: [{ courseId: 1 }], + studentCourses: null, + userSession: { ...mockUserSession, courses: { 1: { roles: [CourseRole.Manager] } } }, + userGithubId: 'denis', + }), + ).toBe('coursemanager'); + }); + + it('should return "all" if user is not a mentor at the course where requested user is a student', () => { + expect( + service.defineRole({ + relationsRoles: null, + mentorRegistryCourses: null, + studentCourses: [{ courseId: 1 }], + userSession: { ...mockUserSession, courses: { 1: { roles: [] } } }, + userGithubId: 'denis', + }), + ).toBe('all'); + }); + + it('should return "all" if user is not registered to any of courses', () => { + expect( + service.defineRole({ + relationsRoles: null, + mentorRegistryCourses: null, + studentCourses: null, + userSession: mockUserSession, + userGithubId: 'denis', + }), + ).toBe('all'); + }); + }); + + describe('getProfilePermissionsSettings', () => { + it('should not mutate provided permissions', () => { + const permissions = { + isProfileVisible: { all: true }, + }; + + const permissionsSettings = service.getProfilePermissionsSettings(permissions); + + expect(permissions).toEqual({ isProfileVisible: { all: true } }); + expect(permissionsSettings).not.toEqual({ isProfileVisible: { all: true } }); + }); + }); + + it('should return default permissions settings for the permission value that are not provided', () => { + const permissions = { + isProfileVisible: { all: false }, + isAboutVisible: { all: true, mentor: true, student: true }, + isEducationVisible: { all: true, mentor: true, student: true }, + }; + const permissionsSettings = service.getProfilePermissionsSettings(permissions); + + expect(permissionsSettings).toEqual({ + isProfileVisible: { all: false }, + isAboutVisible: { all: true, mentor: true, student: true }, + isEducationVisible: { all: true, mentor: true, student: true }, + isEnglishVisible: { all: false, student: false }, + isEmailVisible: { all: false, student: true }, + isTelegramVisible: { all: false, student: true }, + isSkypeVisible: { all: false, student: true }, + isPhoneVisible: { all: false, student: true }, + isContactsNotesVisible: { all: false, student: true }, + isLinkedInVisible: { all: false, mentor: false, student: false }, + isPublicFeedbackVisible: { all: false, mentor: false, student: false }, + isMentorStatsVisible: { all: false, mentor: false, student: false }, + isStudentStatsVisible: { all: false, student: false }, + }); + }); +}); diff --git a/nestjs/src/profile/permissions.service.ts b/nestjs/src/profile/permissions.service.ts index 8b0e89ae68..eebffdb1f8 100644 --- a/nestjs/src/profile/permissions.service.ts +++ b/nestjs/src/profile/permissions.service.ts @@ -19,7 +19,7 @@ import { defaultProfilePermissionsSettings } from '@entities/profilePermissions' type RelationRole = 'student' | 'mentor' | 'coursementor' | 'coursesupervisor' | 'coursemanager' | 'all'; -interface Relations { +export interface Relations { student: string; mentors: string[]; interviewers: string[]; From b048487ea0edfffb1d0457a681de5fcce6460868 Mon Sep 17 00:00:00 2001 From: Stanislau Laniuk Date: Thu, 8 Feb 2024 21:16:09 +0200 Subject: [PATCH 4/4] refactor: remove unused profile/info koa route handling --- client/src/services/user.ts | 6 - .../profile/__test__/permissions.test.ts | 463 ------------------ server/src/routes/profile/index.ts | 17 - server/src/routes/profile/info.ts | 85 ---- server/src/routes/profile/mentor-stats.ts | 52 -- server/src/routes/profile/permissions.ts | 322 ------------ server/src/routes/profile/public-feedback.ts | 28 -- .../profile/stage-interview-feedback.ts | 116 ----- server/src/routes/profile/student-stats.ts | 184 ------- server/src/routes/profile/user-info.ts | 126 ----- 10 files changed, 1399 deletions(-) delete mode 100644 server/src/routes/profile/__test__/permissions.test.ts delete mode 100644 server/src/routes/profile/info.ts delete mode 100644 server/src/routes/profile/mentor-stats.ts delete mode 100644 server/src/routes/profile/permissions.ts delete mode 100644 server/src/routes/profile/public-feedback.ts delete mode 100644 server/src/routes/profile/stage-interview-feedback.ts delete mode 100644 server/src/routes/profile/student-stats.ts delete mode 100644 server/src/routes/profile/user-info.ts diff --git a/client/src/services/user.ts b/client/src/services/user.ts index 985b86865c..ea97f3ed3c 100644 --- a/client/src/services/user.ts +++ b/client/src/services/user.ts @@ -103,12 +103,6 @@ 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 }, - // }); - // return response.data.data; } async sendEmailConfirmationLink() { diff --git a/server/src/routes/profile/__test__/permissions.test.ts b/server/src/routes/profile/__test__/permissions.test.ts deleted file mode 100644 index b5ac9199a5..0000000000 --- a/server/src/routes/profile/__test__/permissions.test.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { CourseRole, IUserSession } from '../../../models'; -import { getPermissions, defineRole, getProfilePermissionsSettings } from '../permissions'; - -const mockSession = { - id: 1, - githubId: 'githubId', - isHirer: false, - isAdmin: false, - courses: { - '1': { - mentorId: 1, - studentId: 2, - roles: [], - }, - '2': { - mentorId: 1, - studentId: 2, - roles: [], - }, - '11': { - mentorId: 1, - studentId: 2, - roles: [], - }, - }, -} as IUserSession; - -describe('getPermissions', () => { - it('Should be an instance of Function', () => { - expect(getPermissions).toBeInstanceOf(Function); - }); - describe('Should return permissions object with all keys equal "false"', () => { - it('if "isProfileOwner" is "false" and no "role" and "permissions" have passed', () => { - expect( - getPermissions({ - isProfileOwner: false, - isAdmin: false, - }), - ).toEqual({ - isProfileVisible: false, - isAboutVisible: false, - isEducationVisible: false, - isEnglishVisible: false, - isEmailVisible: false, - isTelegramVisible: false, - isWhatsAppVisible: false, - isSkypeVisible: false, - isPhoneVisible: false, - isContactsNotesVisible: false, - isLinkedInVisible: false, - isPublicFeedbackVisible: false, - isMentorStatsVisible: false, - isStudentStatsVisible: false, - isStageInterviewFeedbackVisible: false, - isCoreJsFeedbackVisible: false, - isConsentsVisible: false, - isExpellingReasonVisible: false, - }); - }); - }); - describe('Should return permissions object depends on "role" and "permissions" have passed', () => { - describe('if "isProfileOwner" is "false"', () => { - it('"role" is "all" and some "permissions" set with "all" = "true"', () => { - expect( - getPermissions({ - isProfileOwner: false, - isAdmin: false, - role: 'all', - permissions: { - isProfileVisible: { all: true }, - isAboutVisible: { all: true, mentor: true, student: true }, - isEducationVisible: { all: true, mentor: true, student: true }, - isEnglishVisible: { all: false, student: false }, - isEmailVisible: { all: true, student: true }, - isTelegramVisible: { all: false, student: false }, - isSkypeVisible: { all: true, student: true }, - isPhoneVisible: { all: false, student: false }, - isContactsNotesVisible: { all: true, student: true }, - isLinkedInVisible: { all: false, mentor: false, student: false }, - isPublicFeedbackVisible: { all: true, mentor: true, student: true }, - isMentorStatsVisible: { all: true, mentor: true, student: true }, - isStudentStatsVisible: { all: true, student: true }, - }, - }), - ).toEqual({ - isProfileVisible: true, - isAboutVisible: true, - isEducationVisible: true, - isEnglishVisible: false, - isEmailVisible: true, - isTelegramVisible: false, - isWhatsAppVisible: false, - isSkypeVisible: true, - isPhoneVisible: false, - isContactsNotesVisible: true, - isLinkedInVisible: false, - isPublicFeedbackVisible: true, - isMentorStatsVisible: true, - isStudentStatsVisible: true, - isStageInterviewFeedbackVisible: false, - isCoreJsFeedbackVisible: false, - isConsentsVisible: false, - isExpellingReasonVisible: false, - }); - }); - it('"role" is "mentor" and some "permissions" set with "mentor" = "true"', () => { - expect( - getPermissions({ - isProfileOwner: false, - isAdmin: false, - role: 'mentor', - permissions: { - isProfileVisible: { all: true }, - isAboutVisible: { all: false, mentor: true, student: false }, - isEducationVisible: { all: false, mentor: false, student: true }, - isEnglishVisible: { all: false, student: false }, - isEmailVisible: { all: false, student: true }, - isTelegramVisible: { all: false, student: false }, - isSkypeVisible: { all: false, student: true }, - isPhoneVisible: { all: false, student: false }, - isContactsNotesVisible: { all: true, student: true }, - isLinkedInVisible: { all: false, mentor: false, student: false }, - isPublicFeedbackVisible: { all: false, mentor: true, student: true }, - isMentorStatsVisible: { all: false, mentor: true, student: true }, - isStudentStatsVisible: { all: false, student: true }, - }, - }), - ).toEqual({ - isProfileVisible: true, - isAboutVisible: true, - isEducationVisible: false, - isEnglishVisible: true, - isEmailVisible: true, - isTelegramVisible: true, - isWhatsAppVisible: true, - isSkypeVisible: true, - isPhoneVisible: true, - isContactsNotesVisible: true, - isLinkedInVisible: false, - isPublicFeedbackVisible: true, - isMentorStatsVisible: true, - isStudentStatsVisible: true, - isStageInterviewFeedbackVisible: true, - isCoreJsFeedbackVisible: true, - isConsentsVisible: false, - isExpellingReasonVisible: true, - }); - }); - it('"role" is "student" and some "permissions" set with "student" = "true"', () => { - expect( - getPermissions({ - isProfileOwner: false, - isAdmin: false, - role: 'student', - permissions: { - isProfileVisible: { all: true }, - isAboutVisible: { all: false, mentor: true, student: true }, - isEducationVisible: { all: false, mentor: false, student: false }, - isEnglishVisible: { all: false, student: false }, - isEmailVisible: { all: false, student: false }, - isTelegramVisible: { all: false, student: true }, - isSkypeVisible: { all: false, student: true }, - isPhoneVisible: { all: false, student: false }, - isContactsNotesVisible: { all: true, student: true }, - isLinkedInVisible: { all: false, mentor: false, student: false }, - isPublicFeedbackVisible: { all: false, mentor: true, student: true }, - isMentorStatsVisible: { all: false, mentor: true, student: true }, - isStudentStatsVisible: { all: false, student: true }, - }, - }), - ).toEqual({ - isProfileVisible: true, - isAboutVisible: true, - isEducationVisible: false, - isEnglishVisible: false, - isEmailVisible: false, - isTelegramVisible: true, - isWhatsAppVisible: true, - isSkypeVisible: true, - isPhoneVisible: false, - isContactsNotesVisible: true, - isLinkedInVisible: false, - isPublicFeedbackVisible: true, - isMentorStatsVisible: true, - isStudentStatsVisible: true, - isStageInterviewFeedbackVisible: false, - isCoreJsFeedbackVisible: false, - isConsentsVisible: false, - isExpellingReasonVisible: false, - }); - }); - it('"role" is "coursemanager" and some "permissions" set with "coursemanager" = "true"', () => { - expect( - getPermissions({ - isProfileOwner: false, - isAdmin: false, - role: 'coursemanager', - permissions: { - isProfileVisible: { all: true }, - isAboutVisible: { all: false, mentor: true, student: true }, - isEducationVisible: { all: false, mentor: false, student: false }, - isEnglishVisible: { all: false, student: false }, - isEmailVisible: { all: false, student: false }, - isTelegramVisible: { all: false, student: true }, - isSkypeVisible: { all: false, student: true }, - isPhoneVisible: { all: false, student: false }, - isContactsNotesVisible: { all: true, student: true }, - isLinkedInVisible: { all: false, mentor: false, student: false }, - isPublicFeedbackVisible: { all: false, mentor: true, student: true }, - isMentorStatsVisible: { all: false, mentor: true, student: true }, - isStudentStatsVisible: { all: false, student: true }, - }, - }), - ).toEqual({ - isProfileVisible: true, - isAboutVisible: true, - isEducationVisible: true, - isEnglishVisible: true, - isEmailVisible: true, - isTelegramVisible: true, - isWhatsAppVisible: true, - isSkypeVisible: true, - isPhoneVisible: true, - isContactsNotesVisible: true, - isLinkedInVisible: true, - isPublicFeedbackVisible: true, - isMentorStatsVisible: true, - isStudentStatsVisible: true, - isStageInterviewFeedbackVisible: true, - isCoreJsFeedbackVisible: true, - isConsentsVisible: true, - isExpellingReasonVisible: true, - }); - }); - }); - describe('if "isProfileOwner" is "true"', () => { - it('"role" is "all" and all "permissions" set with "all" = "false"', () => { - expect( - getPermissions({ - isProfileOwner: true, - isAdmin: false, - role: 'all', - permissions: { - isProfileVisible: { all: false }, - isAboutVisible: { all: false, mentor: false, student: false }, - isEducationVisible: { all: false, mentor: false, student: false }, - isEnglishVisible: { all: false, student: false }, - isEmailVisible: { all: false, student: false }, - isTelegramVisible: { all: false, student: false }, - isSkypeVisible: { all: false, student: false }, - isPhoneVisible: { all: false, student: false }, - isContactsNotesVisible: { all: false, student: false }, - isLinkedInVisible: { all: false, mentor: false, student: false }, - isPublicFeedbackVisible: { all: false, mentor: false, student: false }, - isMentorStatsVisible: { all: false, mentor: false, student: false }, - isStudentStatsVisible: { all: false, student: false }, - }, - }), - ).toEqual({ - isProfileVisible: true, - isAboutVisible: true, - isEducationVisible: true, - isEnglishVisible: true, - isEmailVisible: true, - isTelegramVisible: true, - isWhatsAppVisible: true, - isSkypeVisible: true, - isPhoneVisible: true, - isContactsNotesVisible: true, - isLinkedInVisible: true, - isPublicFeedbackVisible: true, - isMentorStatsVisible: true, - isStudentStatsVisible: true, - isStageInterviewFeedbackVisible: false, - isCoreJsFeedbackVisible: false, - isConsentsVisible: true, - isExpellingReasonVisible: false, - }); - }); - }); - }); -}); - -describe('defineRole', () => { - it('Should be an instance of Function', () => { - expect(defineRole).toBeInstanceOf(Function); - }); - - describe('Should return user role', () => { - it('"student", if user is a student', () => { - expect( - defineRole({ - relationsRoles: { - student: 'dima', - mentors: ['andrey', 'dasha'], - interviewers: ['sasha', 'max'], - stageInterviewers: ['alex'], - checkers: ['masha', 'ivan'], - }, - registryCourses: null, - studentCourses: null, - session: mockSession, - userGithubId: 'dima', - }), - ).toBe('student'); - }); - it('"mentor", if user is an assigned mentor', () => { - expect( - defineRole({ - relationsRoles: { - student: 'dima', - mentors: ['andrey', 'dasha'], - interviewers: ['sasha', 'max'], - stageInterviewers: ['alex'], - checkers: ['masha', 'ivan'], - }, - registryCourses: null, - studentCourses: null, - session: mockSession, - userGithubId: 'andrey', - }), - ).toBe('mentor'); - }); - it('"mentor", if user is an interviewer', () => { - expect( - defineRole({ - relationsRoles: { - student: 'dima', - mentors: ['andrey', 'dasha'], - interviewers: ['sasha', 'max'], - stageInterviewers: ['alex'], - checkers: ['masha', 'ivan'], - }, - registryCourses: null, - studentCourses: null, - session: mockSession, - userGithubId: 'max', - }), - ).toBe('mentor'); - }); - it('"mentor", if user is a stage-interviewer', () => { - expect( - defineRole({ - relationsRoles: { - student: 'dima', - mentors: ['andrey', 'dasha'], - interviewers: ['sasha', 'max'], - stageInterviewers: ['alex'], - checkers: ['masha', 'ivan'], - }, - registryCourses: null, - studentCourses: null, - session: mockSession, - userGithubId: 'alex', - }), - ).toBe('mentor'); - }); - it('"mentor", if user is assigned for checking a task', () => { - expect( - defineRole({ - relationsRoles: { - student: 'dima', - mentors: ['andrey', 'dasha'], - interviewers: ['sasha', 'max'], - stageInterviewers: ['alex'], - checkers: ['masha', 'ivan'], - }, - registryCourses: null, - studentCourses: null, - session: mockSession, - userGithubId: 'masha', - }), - ).toBe('mentor'); - }); - it('"coursementor", if user is a mentor at the same course where requested user is a student', () => { - expect( - defineRole({ - relationsRoles: null, - registryCourses: null, - studentCourses: [{ courseId: 1 }, { courseId: 11 }], - session: mockSession, - userGithubId: 'denis', - }), - ).toBe('coursementor'); - }); - it('"coursemanager", if user is mentor waiting confirmation and current user is coursemanager', () => { - expect( - defineRole({ - relationsRoles: null, - registryCourses: [{ courseId: 1 }], - studentCourses: null, - session: { - courses: { 1: { roles: [CourseRole.Manager] } }, - } as unknown as IUserSession, - userGithubId: 'denis', - }), - ).toBe('coursemanager'); - }); - it('"all", if user is not a mentor at the same course where requested user is a student', () => { - expect( - defineRole({ - relationsRoles: null, - registryCourses: null, - studentCourses: [{ courseId: 1 }], - session: { ...mockSession, courses: { 1: { roles: [] } } }, - userGithubId: 'denis', - }), - ).toBe('all'); - }); - it('"all", if user if student has not registered to any course', () => { - expect( - defineRole({ - relationsRoles: null, - registryCourses: null, - studentCourses: null, - session: mockSession, - userGithubId: 'denis', - }), - ).toBe('all'); - }); - }); -}); - -describe('getProfilePermissionsSettings', () => { - it('Should be an instance of Function', () => { - expect(defineRole).toBeInstanceOf(Function); - }); - - it('Should not mutate param "permissions"', () => { - const permissions = { - isProfileVisible: { all: true }, - }; - const permissionsSettings = getProfilePermissionsSettings(permissions); - - expect(permissions).toEqual({ isProfileVisible: { all: true } }); - expect(permissionsSettings).not.toEqual({ isProfileVisible: { all: true } }); - }); - - it('Should return permissions settings with defaults if all have not been passed', () => { - const permissions = { - isProfileVisible: { all: false }, - isAboutVisible: { all: true, mentor: true, student: true }, - isEducationVisible: { all: true, mentor: true, student: true }, - }; - const permissionsSettings = getProfilePermissionsSettings(permissions); - - expect(permissionsSettings).toEqual({ - isProfileVisible: { all: false }, - isAboutVisible: { all: true, mentor: true, student: true }, - isEducationVisible: { all: true, mentor: true, student: true }, - isEnglishVisible: { all: false, student: false }, - isEmailVisible: { all: false, student: true }, - isTelegramVisible: { all: false, student: true }, - isSkypeVisible: { all: false, student: true }, - isPhoneVisible: { all: false, student: true }, - isContactsNotesVisible: { all: false, student: true }, - isLinkedInVisible: { all: false, mentor: false, student: false }, - isPublicFeedbackVisible: { all: false, mentor: false, student: false }, - isMentorStatsVisible: { all: false, mentor: false, student: false }, - isStudentStatsVisible: { all: false, student: false }, - }); - }); -}); diff --git a/server/src/routes/profile/index.ts b/server/src/routes/profile/index.ts index 935a4ba3ad..b43664fc1b 100644 --- a/server/src/routes/profile/index.ts +++ b/server/src/routes/profile/index.ts @@ -1,28 +1,11 @@ import Router from '@koa/router'; import { ILogger } from '../../logger'; import { guard } from '../guards'; -import { getProfileInfo } from './info'; import { getMyProfile, updateMyProfile } from './me'; export function profileRoute(logger: ILogger) { const router = new Router({ prefix: '/profile' }); - /** - * @swagger - * - * /profile: - * get: - * description: get user profile - * security: - * - cookieAuth: [] - * produces: - * - application/json - * responses: - * 200: - * description: profile - */ - router.get('/info', guard, getProfileInfo(logger)); - /** * @swagger * diff --git a/server/src/routes/profile/info.ts b/server/src/routes/profile/info.ts deleted file mode 100644 index 6cfbe56379..0000000000 --- a/server/src/routes/profile/info.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { NOT_FOUND, OK, FORBIDDEN } from 'http-status-codes'; -import Router from '@koa/router'; -import { ILogger } from '../../logger'; -import { setResponse } from '../utils'; -import { IUserSession } from '../../models'; -import { ConfigurableProfilePermissions } from '../../../../common/models/profile'; -import { getMentorStats } from './mentor-stats'; -import { getPublicFeedback } from './public-feedback'; -import { getStageInterviewFeedback } from './stage-interview-feedback'; -import { getStudentStats } from './student-stats'; -import { getUserInfo } from './user-info'; -import { - getProfilePermissionsSettings, - getConfigurableProfilePermissions, - getRelationsRoles, - getStudentCourses, - getPermissions, - getMentorCourses, - defineRole, - RelationRole, - Permissions, -} from './permissions'; - -export const getProfileInfo = (_: ILogger) => async (ctx: Router.RouterContext) => { - const session = ctx.state!.user as IUserSession; - const { githubId: userGithubId, isAdmin } = ctx.state!.user as IUserSession; - const { githubId: requestedGithubId = userGithubId } = ctx.query as { githubId: string | undefined }; - - if (!requestedGithubId) { - return setResponse(ctx, NOT_FOUND); - } - - const isProfileOwner = requestedGithubId === userGithubId; - - const profilePermissions = await getConfigurableProfilePermissions(requestedGithubId); - - let role: RelationRole; - let permissions: Permissions; - let permissionsSettings: ConfigurableProfilePermissions | undefined; - if (isProfileOwner) { - role = 'all'; - permissions = getPermissions({ isProfileOwner, isAdmin }); - permissionsSettings = getProfilePermissionsSettings(profilePermissions); - } else { - const relationsRoles = await getRelationsRoles(userGithubId, requestedGithubId); - const [studentCourses, registryCourses] = !relationsRoles - ? await Promise.all([getStudentCourses(requestedGithubId), getMentorCourses(requestedGithubId)]) - : [null, null]; - role = defineRole({ relationsRoles, studentCourses, registryCourses, session, userGithubId }); - permissions = getPermissions({ isAdmin, isProfileOwner, role, permissions: profilePermissions }); - } - - const { - isProfileVisible, - isPublicFeedbackVisible, - isMentorStatsVisible, - isStudentStatsVisible, - isStageInterviewFeedbackVisible, - } = permissions; - - if (!isProfileVisible && !isProfileOwner) { - return setResponse(ctx, FORBIDDEN); - } - - const { generalInfo, contacts, discord } = await getUserInfo(requestedGithubId, permissions); - const publicFeedback = isPublicFeedbackVisible ? await getPublicFeedback(requestedGithubId) : undefined; - const mentorStats = isMentorStatsVisible ? await getMentorStats(requestedGithubId) : undefined; - const studentStats = isStudentStatsVisible ? await getStudentStats(requestedGithubId, permissions) : undefined; - const stageInterviewFeedback = isStageInterviewFeedbackVisible - ? await getStageInterviewFeedback(requestedGithubId) - : undefined; - - const profileInfo = { - permissionsSettings, - generalInfo, - contacts, - discord, - mentorStats, - publicFeedback, - stageInterviewFeedback, - studentStats, - }; - - setResponse(ctx, OK, profileInfo); -}; diff --git a/server/src/routes/profile/mentor-stats.ts b/server/src/routes/profile/mentor-stats.ts deleted file mode 100644 index 391719a6dc..0000000000 --- a/server/src/routes/profile/mentor-stats.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getRepository } from 'typeorm'; -import { MentorStats } from '../../../../common/models/profile'; -import { getFullName } from '../../rules'; -import { User, Mentor, Student, Course } from '../../models'; -import { RepositoryService } from '../../services'; - -export const getMentorStats = async (githubId: string): Promise => { - const rawData = await getRepository(Mentor) - .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: getFullName(studentFirstNames[idx], studentLastNames[idx], githubId), - isExpelled: studentIsExpelledStatuses[idx], - totalScore: studentTotalScores[idx], - repoUrl: `https://github.com/rolling-scopes-school/${RepositoryService.getRepoName(githubId, { - alias: courseAlias, - })}`, - })) - : undefined; - return { courseLocationName, courseName, students }; - }, - ); -}; diff --git a/server/src/routes/profile/permissions.ts b/server/src/routes/profile/permissions.ts deleted file mode 100644 index 7baa64bd9d..0000000000 --- a/server/src/routes/profile/permissions.ts +++ /dev/null @@ -1,322 +0,0 @@ -import get from 'lodash/get'; -import mapValues from 'lodash/mapValues'; -import mergeWith from 'lodash/mergeWith'; -import cloneDeep from 'lodash/cloneDeep'; -import uniqBy from 'lodash/uniqBy'; -import { In, getRepository } from 'typeorm'; -import { - User, - Student, - Mentor, - ProfilePermissions, - TaskChecker, - TaskInterviewResult, - StageInterview, - isManager, - IUserSession, - isSupervisor, - MentorRegistry, - Discipline, - Course, -} from '../../models'; -import { defaultProfilePermissionsSettings } from '../../models/profilePermissions'; -import { ConfigurableProfilePermissions } from '../../../../common/models/profile'; - -interface Relations { - student: string; - mentors: string[]; - interviewers: string[]; - stageInterviewers: string[]; - checkers: string[]; -} - -export type RelationRole = 'student' | 'mentor' | 'coursementor' | 'coursesupervisor' | 'coursemanager' | 'all'; - -interface PermissionsSetup { - isProfileOwner: boolean; - isAdmin: boolean; - role?: RelationRole; - permissions?: ConfigurableProfilePermissions; -} - -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; -} - -export const getStudentCourses = async (githubId: string): Promise<{ courseId: number }[] | null> => { - const result = await getRepository(User) - .createQueryBuilder('user') - .select('"student"."courseId" AS "courseId"') - .leftJoin(Student, 'student', '"student"."userId" = "user"."id"') - .where('"user"."githubId" = :githubId', { githubId }) - .getRawMany(); - return result ?? null; -}; - -export const getMentorCourses = async (githubId: string): Promise<{ courseId: number }[] | null> => { - const [registerdCourseIds, registryCourseIds] = await Promise.all([ - getRegisteredMentorsCourseIds(githubId), - getMentorsFromRegistryCourseIds(githubId), - ]); - - const mentorsCourses = registerdCourseIds.concat(registryCourseIds); - - return mentorsCourses.length ? mentorsCourses : null; -}; - -const getRegisteredMentorsCourseIds = async (githubId: string) => { - const result: { courseId: number }[] = await getRepository(Mentor) - .createQueryBuilder('mentor') - .select(['mentor.courseId']) - .leftJoin('mentor.user', 'user') - .where('user.githubId = :githubId', { githubId }) - .getMany(); - - return result.length ? result : []; -}; - -const getMentorsFromRegistryCourseIds = async (githubId: string) => { - const result = await getRepository(MentorRegistry) - .createQueryBuilder('mentorRegistry') - .select(['mentorRegistry.preferedCourses', 'mentorRegistry.technicalMentoring']) - .leftJoin('mentorRegistry.user', 'user') - .where('user.githubId = :githubId', { githubId }) - .andWhere('"mentorRegistry".canceled = false') - .getOne(); - - const disciplines = await getRepository(Discipline).find({ where: { name: In(result?.technicalMentoring ?? []) } }); - const disciplinesIds = disciplines.map(({ id }) => id); - const coursesByDisciplines = await getRepository(Course).find({ where: { disciplineId: In(disciplinesIds) } }); - - const preferredCourseIds = result?.preferedCourses?.map(courseId => ({ courseId: Number(courseId) })) ?? []; - const courseIdsByDisciplines = coursesByDisciplines.map(({ id }) => ({ courseId: id })); - - const courseIds = uniqBy(preferredCourseIds.concat(courseIdsByDisciplines), ({ courseId }) => courseId); - - return courseIds; -}; - -export const getConfigurableProfilePermissions = async (githubId: string): Promise => - (await getRepository(ProfilePermissions) - .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()) || {}; - -export const getRelationsRoles = async (userGithubId: string, requestedGithubId: string): Promise => - (await getRepository(Student) - .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; - -export const defineRole = ({ - relationsRoles, - studentCourses, - registryCourses, - session, - userGithubId, -}: { - relationsRoles: Relations | null; - registryCourses: { courseId: number }[] | null; - studentCourses: { courseId: number }[] | null; - session: IUserSession; - userGithubId: string; -}): RelationRole => { - if (registryCourses?.some(({ courseId }) => isManager(session, courseId))) { - return 'coursemanager'; - } else if (registryCourses?.some(({ courseId }) => isSupervisor(session, courseId))) { - return 'coursesupervisor'; - } else if (studentCourses?.some(({ courseId }) => isManager(session, courseId))) { - return 'coursemanager'; - } else if (studentCourses?.some(({ courseId }) => isSupervisor(session, 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 }) => !!session?.courses?.[courseId]?.mentorId)) { - return 'coursementor'; - } - - return 'all'; -}; - -export const getPermissions = ({ isAdmin, isProfileOwner, role, permissions }: PermissionsSetup): 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 accessToContacts = (permission: string, role?: RelationRole) => { - return ( - [ - 'isEmailVisible', - 'isTelegramVisible', - 'isSkypeVisible', - 'isPhoneVisible', - 'isWhatsAppVisible', - 'isContactsNodesVisible', - 'isEnglishVisible', - ].includes(permission) && - role && - ['mentor', 'coursemanager', 'coursesupervisor'].includes(role) - ); - }; - - const defaultAccessToContacts = (permission: string, role?: RelationRole) => { - return ( - [ - 'isEmailVisible', - 'isWhatsAppVisible', - 'isTelegramVisible', - 'isSkypeVisible', - 'isPhoneVisible', - 'isContactsNodesVisible', - ].includes(permission) && - role && - ['student'].includes(role) - ); - }; - - const accessToFeedbacks = (permission: string, role?: RelationRole) => { - return ( - [ - 'isStageInterviewFeedbackVisible', - 'isStudentStatsVisible', - 'isCoreJsFeedbackVisible', - 'isProfileVisible', - 'isExpellingReasonVisible', - ].includes(permission) && - role && - ['mentor', 'coursementor', 'coursemanager'].includes(role) - ); - }; - - const accessToProfile = (permission: string, role?: RelationRole) => - ['isProfileVisible'].includes(permission) && role && ['student'].includes(role); - - return mapValues(defaultPermissions, (_, permission) => { - if (isAdmin || role === 'coursemanager') { - return true; - } - if (role === 'coursesupervisor' && permission === 'isProfileVisible') { - return true; - } - if (accessToFeedbacks(permission, role)) { - return true; - } - if (accessToContacts(permission, role)) { - return true; - } - if (accessToProfile(permission, role)) { - return true; - } - // do not show own feedbacks - if ( - isProfileOwner && - !['isStageInterviewFeedbackVisible', 'isCoreJsFeedbackVisible', 'isExpellingReasonVisible'].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 && defaultAccessToContacts(permission, role)) { - return true; - } - return false; - }); -}; - -export const getProfilePermissionsSettings = (permissions: ConfigurableProfilePermissions) => { - const newPermissions = cloneDeep(permissions); - - mergeWith(newPermissions, defaultProfilePermissionsSettings, (setting, defaultSetting) => - mapValues(defaultSetting, (value, key) => get(setting, key, value)), - ); - - return newPermissions; -}; diff --git a/server/src/routes/profile/public-feedback.ts b/server/src/routes/profile/public-feedback.ts deleted file mode 100644 index 3fdf63fbf7..0000000000 --- a/server/src/routes/profile/public-feedback.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getRepository } from 'typeorm'; -import { PublicFeedback } from '../../../../common/models/profile'; -import { getFullName } from '../../rules'; -import { User, Feedback } from '../../models'; - -export const getPublicFeedback = async (githubId: string): Promise => - ( - await getRepository(Feedback) - .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() - ).map(({ feedbackDate, badgeId, comment, fromUserFirstName, fromUserLastName, fromUserGithubId }: any) => ({ - feedbackDate, - badgeId, - comment, - fromUser: { - name: getFullName(fromUserFirstName, fromUserLastName, fromUserGithubId), - githubId: fromUserGithubId, - }, - })); diff --git a/server/src/routes/profile/stage-interview-feedback.ts b/server/src/routes/profile/stage-interview-feedback.ts deleted file mode 100644 index 366ef896a8..0000000000 --- a/server/src/routes/profile/stage-interview-feedback.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { getRepository } from 'typeorm'; -import { StageInterviewDetailedFeedback } from '../../../../common/models/profile'; -import { getFullName } from '../../rules'; -import { User, Mentor, Student, Course, StageInterview, StageInterviewFeedback, CourseTask } from '../../models'; -import { stageInterviewService } from '../../services'; -import { StageInterviewFeedbackJson } from '../../../../common/models'; - -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; -}; - -export const getStageInterviewFeedback = async (githubId: string): Promise => { - const data = await getRepository(StageInterview) - .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((data: FeedbackData) => { - const { - feedbackVersion, - decision, - interviewFeedbackDate, - interviewerFirstName, - courseFullName, - courseName, - interviewerLastName, - interviewerGithubId, - isGoodCandidate, - interviewScore, - interviewResultJson, - maxScore, - } = data; - const feedbackTemplate = JSON.parse(interviewResultJson) as any; - - const { score, feedback } = !feedbackVersion - ? parseLegacyFeedback(feedbackTemplate) - : { - feedback: feedbackTemplate, - score: interviewScore ?? 0, - }; - - return { - version: feedbackVersion ?? 0, - date: interviewFeedbackDate, - decision, - isGoodCandidate, - courseName, - courseFullName, - feedback, - score, - interviewer: { - name: getFullName(interviewerFirstName, interviewerLastName, interviewerGithubId), - githubId: interviewerGithubId, - }, - maxScore, - }; - }) - .filter(Boolean); -}; - -// this is legacy form -function parseLegacyFeedback(interviewResult: StageInterviewFeedbackJson) { - const { english, programmingTask, resume } = interviewResult; - const { rating, htmlCss, common, dataStructures } = stageInterviewService.getInterviewRatings(interviewResult); - - return { - score: rating, - feedback: { - english: english.levelMentorOpinion ? english.levelMentorOpinion : english.levelStudentOpinion, - programmingTask, - comment: resume.comment, - skills: { - htmlCss, - common, - dataStructures, - }, - }, - }; -} diff --git a/server/src/routes/profile/student-stats.ts b/server/src/routes/profile/student-stats.ts deleted file mode 100644 index 8ba11f7392..0000000000 --- a/server/src/routes/profile/student-stats.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { getRepository } from 'typeorm'; -import { StudentStats } from '../../../../common/models/profile'; -import { getFullName } from '../../rules'; -import { - User, - Mentor, - Student, - Course, - Task, - CourseTask, - TaskResult, - TaskInterviewResult, - Certificate, - StageInterview, - StageInterviewFeedback, -} from '../../models'; -import { Permissions } from './permissions'; -import omit from 'lodash/omit'; - -// use this as a mark for identifying self-expelled students. -const SELF_EXPELLED_MARK = 'Self expelled from the course'; - -const getStudentStatsWithPosition = async (githubId: string, permissions: Permissions): Promise => { - const { isCoreJsFeedbackVisible, isExpellingReasonVisible } = permissions; - - const query = await getRepository(Student) - .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: getFullName(interviewerFirstName[idx], 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_MARK), - isCourseCompleted, - totalScore, - tasks: orderedTasks, - certificateId, - rank, - mentor: { - githubId: mentorGithubId, - name: getFullName(mentorFirstName, mentorLastName, mentorGithubId), - }, - }; - }, - ); -}; - -export const getStudentStats = async (githubId: string, permissions: Permissions) => { - const studentStats = await getStudentStatsWithPosition(githubId, permissions); - return studentStats; -}; diff --git a/server/src/routes/profile/user-info.ts b/server/src/routes/profile/user-info.ts deleted file mode 100644 index 42605bbf5b..0000000000 --- a/server/src/routes/profile/user-info.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { getRepository } from 'typeorm'; -import { UserInfo } from '../../../../common/models/profile'; -import { getFullName } from '../../rules'; -import { User } from '../../models'; -import { Permissions } from './permissions'; - -export const getUserInfo = async (githubId: string, permissions: Permissions): Promise => { - const { - isAboutVisible, - isEducationVisible, - isEnglishVisible, - isPhoneVisible, - isEmailVisible, - isTelegramVisible, - isSkypeVisible, - isContactsNotesVisible, - isLinkedInVisible, - isWhatsAppVisible, - } = permissions; - - const query = await getRepository(User) - .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: getFullName(firstName, lastName, githubId), - languages, - }, - discord, - contacts: isContactsVisible - ? { - phone: contactsPhone, - email: contactsEmail, - epamEmail, - skype: contactsSkype, - telegram: contactsTelegram, - notes: contactsNotes, - linkedIn: contactsLinkedIn, - whatsApp: contactsWhatsApp, - } - : undefined, - }; -};