diff --git a/client/src/modules/CrossCheck/UploadCriteriaJSON.tsx b/client/src/modules/CrossCheck/UploadCriteriaJSON.tsx index 1cc1c9279f..7ea97ac6ef 100644 --- a/client/src/modules/CrossCheck/UploadCriteriaJSON.tsx +++ b/client/src/modules/CrossCheck/UploadCriteriaJSON.tsx @@ -24,18 +24,19 @@ export const UploadCriteriaJSON = ({ onLoad }: IUploadCriteriaJSON) => { fileReader.readAsText(info.file.originFileObj as Blob, 'UTF-8'); fileReader.onload = (e: Event) => { const target = e.target as Element & { result: string }; - const { criteria } = JSON.parse(target.result); + const { criteria } = JSON.parse(target.result) as { criteria: CriteriaDto[] }; const transformedCriteria = criteria?.map((item: CriteriaJSONType) => { if (item.type === TaskType.Title) { return { type: item.type, text: item.title }; - } else return item; + } + return item; }); if (!transformedCriteria?.length) { message.warning(`There is no criteria for downloading`); return; } message.success(`${info.file.name} file uploaded successfully`); - onLoad(transformedCriteria); + onLoad(transformedCriteria as CriteriaDto[]); }; } }; diff --git a/client/src/pages/admin/tasks.tsx b/client/src/pages/admin/tasks.tsx index f637af9d91..387ffc6ae3 100644 --- a/client/src/pages/admin/tasks.tsx +++ b/client/src/pages/admin/tasks.tsx @@ -181,7 +181,7 @@ function createRecord(values: any) { tags: values.tags, skills: values.skills?.map((skill: string) => skill.toLowerCase()), disciplineId: values.discipline, - attributes: JSON.parse(values.attributes ?? '{}'), + attributes: JSON.parse(values.attributes ?? '{}') as Record, }; return data; } diff --git a/client/src/pages/course/mentor/dashboard.tsx b/client/src/pages/course/mentor/dashboard.tsx index 4b31246f18..b8a6378b09 100644 --- a/client/src/pages/course/mentor/dashboard.tsx +++ b/client/src/pages/course/mentor/dashboard.tsx @@ -16,7 +16,7 @@ export interface MentorDashboardProps extends CoursePageProps { } function parseToken(token: string): Session { - return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()) as Session; } export const getServerSideProps: GetServerSideProps<{ course: ProfileCourseDto }> = async ctx => { diff --git a/client/src/reset.d.ts b/client/src/reset.d.ts new file mode 100644 index 0000000000..12bd3edc94 --- /dev/null +++ b/client/src/reset.d.ts @@ -0,0 +1 @@ +import '@total-typescript/ts-reset'; diff --git a/client/src/services/user.ts b/client/src/services/user.ts index be5df383d7..ac23564afa 100644 --- a/client/src/services/user.ts +++ b/client/src/services/user.ts @@ -50,7 +50,11 @@ export class UserService { }, }); - const { username, discriminator, id } = await response.json(); + const { username, discriminator, id } = (await response.json()) as { + username: string; + discriminator: string; + id: string; + }; return { username, diff --git a/common/models/stage-interview-feedback.ts b/common/models/stage-interview-feedback.ts index ac262e4d14..47d2b47dca 100644 --- a/common/models/stage-interview-feedback.ts +++ b/common/models/stage-interview-feedback.ts @@ -9,7 +9,7 @@ interface StageInterviewFeedback { otherAchievements: string | null; militaryService: 'served' | 'liable' | 'notLiable' | null; }; - skills: { + skills?: { [index: string]: any; htmlCss: { level: number | null; diff --git a/docker-compose.yml b/docker-compose.yml index 2679ea7735..13cd82d453 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,7 @@ services: RSSHCOOL_USERS_CLOUD_USERNAME: ${RSSHCOOL_USERS_CLOUD_USERNAME} RSSHCOOL_USERS_CLOUD_PASSWORD: ${RSSHCOOL_USERS_CLOUD_PASSWORD} RSSHCOOL_OPENAI_API_KEY: ${RSSHCOOL_OPENAI_API_KEY} + SENTRY_DSN: ${SENTRY_DSN} restart: on-failure networks: - shared-network diff --git a/nestjs/package.json b/nestjs/package.json index 03320e1229..ee65e25698 100644 --- a/nestjs/package.json +++ b/nestjs/package.json @@ -75,7 +75,7 @@ "@nestjs/schematics": "10.0.2", "@nestjs/testing": "10.1.3", "@openapitools/openapi-generator-cli": "2.7.0", - "@total-typescript/ts-reset": "0.4.2", + "@sentry/node": "7.68.0", "@types/cache-manager": "4.0.2", "@types/cookie-parser": "1.4.3", "@types/express": "4.17.17", diff --git a/nestjs/src/core/filters/index.ts b/nestjs/src/core/filters/index.ts index 87dfbd430e..e88952fff6 100644 --- a/nestjs/src/core/filters/index.ts +++ b/nestjs/src/core/filters/index.ts @@ -1,2 +1,2 @@ export * from './entity-not-found.filter'; -export * from './unhandled-exceptions.filter'; +export * from './sentry.filter'; diff --git a/nestjs/src/core/filters/sentry.filter.ts b/nestjs/src/core/filters/sentry.filter.ts new file mode 100644 index 0000000000..10ee0e2ecd --- /dev/null +++ b/nestjs/src/core/filters/sentry.filter.ts @@ -0,0 +1,11 @@ +import { ArgumentsHost, Catch } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import * as Sentry from '@sentry/node'; + +@Catch() +export class SentryFilter extends BaseExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + Sentry.captureException(exception); + super.catch(exception, host); + } +} diff --git a/nestjs/src/core/filters/unhandled-exceptions.filter.ts b/nestjs/src/core/filters/unhandled-exceptions.filter.ts deleted file mode 100644 index e560dac2b8..0000000000 --- a/nestjs/src/core/filters/unhandled-exceptions.filter.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - HttpException, - InternalServerErrorException, - HttpStatus, -} from '@nestjs/common'; -import { HttpAdapterHost } from '@nestjs/core'; -import { ConfigService } from 'src/config'; -import { CloudApiService } from 'src/cloud-api/cloud-api.service'; - -@Catch() -export class UnhandledExceptionsFilter implements ExceptionFilter { - constructor( - private readonly httpAdapterHost: HttpAdapterHost, - private readonly cloudApiService: CloudApiService, - private readonly configService: ConfigService, - ) {} - - public catch(exception: unknown, host: ArgumentsHost) { - const { httpAdapter } = this.httpAdapterHost; - const ctx = host.switchToHttp(); - const normalizedException = exception instanceof HttpException ? exception : new InternalServerErrorException(); - const status = normalizedException.getStatus(); - - if (status === HttpStatus.INTERNAL_SERVER_ERROR || status === HttpStatus.BAD_REQUEST) { - this.sendError({ message: (exception as any)?.message, cause: exception }); - } - - httpAdapter.reply(ctx.getResponse(), normalizedException.getResponse(), normalizedException.getStatus()); - } - - /** - * Sends errors to a remote queue for futher analysis - * @param data error - */ - private async sendError(data: { message: string; cause: unknown }) { - if (this.configService.isDev || messagesToIgnore.includes(data.message)) { - return; - } - const stack = (data.cause as Error)?.stack ?? ''; - await this.cloudApiService.logErrors([{ ...data, stack }]); - } -} - -const messagesToIgnore = ['Failed to obtain access token', 'Failed to fetch user emails']; diff --git a/nestjs/src/courses/interviews/interviews.service.ts b/nestjs/src/courses/interviews/interviews.service.ts index 0e6652f3b6..790797a0d8 100644 --- a/nestjs/src/courses/interviews/interviews.service.ts +++ b/nestjs/src/courses/interviews/interviews.service.ts @@ -162,10 +162,10 @@ export class InterviewsService { * @deprecated - should be removed once Artsiom A. makes migration of the legacy feedback format */ private static getInterviewRatings({ skills, programmingTask, resume }: StageInterviewFeedbackJson) { - const commonSkills = Object.values(skills.common).filter(Boolean) as number[]; - const dataStructuresSkills = Object.values(skills.dataStructures).filter(Boolean) as number[]; + const commonSkills = Object.values(skills?.common ?? {}).filter(Boolean) as number[]; + const dataStructuresSkills = Object.values(skills?.dataStructures ?? {}).filter(Boolean) as number[]; - const htmlCss = skills.htmlCss.level; + const htmlCss = skills?.htmlCss.level; const common = commonSkills.reduce((acc, cur) => acc + cur, 0) / commonSkills.length; const dataStructures = dataStructuresSkills.reduce((acc, cur) => acc + cur, 0) / dataStructuresSkills.length; diff --git a/nestjs/src/setup.ts b/nestjs/src/setup.ts index 77e9182dfd..db34659458 100644 --- a/nestjs/src/setup.ts +++ b/nestjs/src/setup.ts @@ -1,10 +1,8 @@ import { BadRequestException, INestApplication, ValidationError, ValidationPipe } from '@nestjs/common'; -import { HttpAdapterHost } from '@nestjs/core'; +import * as Sentry from '@sentry/node'; import * as cookieParser from 'cookie-parser'; import { Logger } from 'nestjs-pino'; -import { CloudApiService } from './cloud-api/cloud-api.service'; -import { ConfigService } from './config'; -import { EntityNotFoundFilter, UnhandledExceptionsFilter } from './core/filters'; +import { EntityNotFoundFilter, SentryFilter } from './core/filters'; import { ValidationFilter } from './core/validation'; export function setupApp(app: INestApplication) { @@ -13,11 +11,11 @@ export function setupApp(app: INestApplication) { app.useLogger(logger); app.use(cookieParser()); - const httpAdapterHost = app.get(HttpAdapterHost); - app.useGlobalFilters( - new UnhandledExceptionsFilter(httpAdapterHost, app.get(CloudApiService), app.get(ConfigService)), - new EntityNotFoundFilter(), - ); + if (process.env.SENTRY_DSN) { + Sentry.init({ dsn: process.env.SENTRY_DSN }); + } + + app.useGlobalFilters(new EntityNotFoundFilter(), new SentryFilter()); app.useGlobalPipes( new ValidationPipe({ whitelist: true, diff --git a/package-lock.json b/package-lock.json index e24a3c12e8..a6d297bc99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "client" ], "devDependencies": { + "@total-typescript/ts-reset": "^0.5.1", "prettier": "3.0.2", "turbo": "1.10.13" } @@ -149,7 +150,7 @@ "@nestjs/schematics": "10.0.2", "@nestjs/testing": "10.1.3", "@openapitools/openapi-generator-cli": "2.7.0", - "@total-typescript/ts-reset": "0.4.2", + "@sentry/node": "7.68.0", "@types/cache-manager": "4.0.2", "@types/cookie-parser": "1.4.3", "@types/express": "4.17.17", @@ -5899,9 +5900,9 @@ } }, "node_modules/@total-typescript/ts-reset": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.4.2.tgz", - "integrity": "sha512-vqd7ZUDSrXFVT1n8b2kc3LnklncDQFPvR58yUS1kEP23/nHPAO9l1lMjUfnPrXYYk4Hj54rrLKMW5ipwk7k09A==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.5.1.tgz", + "integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==", "dev": true }, "node_modules/@tsconfig/node10": { @@ -28198,9 +28199,9 @@ "dev": true }, "@total-typescript/ts-reset": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.4.2.tgz", - "integrity": "sha512-vqd7ZUDSrXFVT1n8b2kc3LnklncDQFPvR58yUS1kEP23/nHPAO9l1lMjUfnPrXYYk4Hj54rrLKMW5ipwk7k09A==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.5.1.tgz", + "integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==", "dev": true }, "@tsconfig/node10": { @@ -36458,9 +36459,9 @@ "@nestjs/testing": "10.1.3", "@nestjs/typeorm": "10.0.0", "@openapitools/openapi-generator-cli": "2.7.0", + "@sentry/node": "7.68.0", "@swc/cli": "^0.1.62", "@swc/core": "^1.3.77", - "@total-typescript/ts-reset": "0.4.2", "@types/cache-manager": "4.0.2", "@types/cookie-parser": "1.4.3", "@types/express": "4.17.17", diff --git a/package.json b/package.json index cf1c9f88bb..bd8573960d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "db:down": "docker-compose -f ./setup/docker-compose.yml down" }, "devDependencies": { + "@total-typescript/ts-reset": "^0.5.1", "prettier": "3.0.2", "turbo": "1.10.13" } diff --git a/server/src/reset.d.ts b/server/src/reset.d.ts new file mode 100644 index 0000000000..12bd3edc94 --- /dev/null +++ b/server/src/reset.d.ts @@ -0,0 +1 @@ +import '@total-typescript/ts-reset'; diff --git a/server/src/routes/profile/stage-interview-feedback.ts b/server/src/routes/profile/stage-interview-feedback.ts index 00e309dd90..366ef896a8 100644 --- a/server/src/routes/profile/stage-interview-feedback.ts +++ b/server/src/routes/profile/stage-interview-feedback.ts @@ -20,38 +20,38 @@ type FeedbackData = { maxScore: number; }; -export const getStageInterviewFeedback = async (githubId: string): Promise => - ( - 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() - ) +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, @@ -67,7 +67,8 @@ export const getStageInterviewFeedback = async (githubId: string): Promise acc + cur, 0) / commonSkills.length; const dataStructures = dataStructuresSkills.reduce((acc, cur) => acc + cur, 0) / dataStructuresSkills.length; @@ -28,7 +28,7 @@ export const getStageInterviewRating = (stageInterviews: StageInterview[]) => { stageInterviewFeedbacks.map((feedback: StageInterviewFeedback) => ({ date: feedback.updatedDate, // interviews in new template should have score precalculated - rating: score ?? getInterviewRatings(JSON.parse(feedback.json)).rating, + rating: score ?? getInterviewRatings(JSON.parse(feedback.json) as StageInterviewFeedbackJson).rating, })), ) .reduce((acc, cur) => acc.concat(cur), []) diff --git a/turbo.json b/turbo.json index b9336740d4..df18c57c4a 100644 --- a/turbo.json +++ b/turbo.json @@ -46,7 +46,8 @@ "RSSHCOOL_PG_PASSWORD", "RSSHCOOL_PG_USERNAME", "RSSHCOOL_UI_GCP_MAPS_API_KEY", - "RSSHCOOL_OPENAI_API_KEY" + "RSSHCOOL_OPENAI_API_KEY", + "SENTRY_DSN" ] }, "lint": {},