diff --git a/backend/typescript/graphql/index.ts b/backend/typescript/graphql/index.ts index 315bb6a..c05b3b9 100644 --- a/backend/typescript/graphql/index.ts +++ b/backend/typescript/graphql/index.ts @@ -51,6 +51,7 @@ const executableSchema = makeExecutableSchema({ adminCommentType, applicantRecordType, reviewPageType, + reviewedApplicantRecordTypes, ], resolvers: merge( authResolvers, @@ -62,6 +63,7 @@ const executableSchema = makeExecutableSchema({ adminCommentResolvers, applicantRecordResolvers, reviewPageResolvers, + reviewedApplicantRecordResolvers, ), }); @@ -93,6 +95,7 @@ const graphQLMiddlewares = { bulkCreateReviewedApplicantRecord: authorizedByAllRoles(), deleteReviewedApplicantRecord: authorizedByAllRoles(), bulkDeleteReviewedApplicantRecord: authorizedByAllRoles(), + updateReviewedApplicantRecord: authorizedByAllRoles(), createUser: authorizedByAdmin(), updateUser: authorizedByAdmin(), deleteUserById: authorizedByAdmin(), diff --git a/backend/typescript/graphql/resolvers/reviewedApplicantRecordResolver.ts b/backend/typescript/graphql/resolvers/reviewedApplicantRecordResolver.ts index afa3a96..4a95d54 100644 --- a/backend/typescript/graphql/resolvers/reviewedApplicantRecordResolver.ts +++ b/backend/typescript/graphql/resolvers/reviewedApplicantRecordResolver.ts @@ -3,6 +3,7 @@ import { ReviewedApplicantRecordDTO, CreateReviewedApplicantRecordDTO, DeleteReviewedApplicantRecordDTO, + UpdateReviewedApplicantRecordDTO, } from "../../types"; import { getErrorMessage } from "../../utilities/errorUtils"; @@ -61,6 +62,19 @@ const reviewedApplicantRecordResolvers = { throw new Error(getErrorMessage(error)); } }, + + updateReviewedApplicantRecord: async ( + _parent: undefined, + args: { input: UpdateReviewedApplicantRecordDTO }, + ): Promise => { + try { + return await reviewedApplicantRecordService.updateReviewedApplicantRecord( + args.input, + ); + } catch (error) { + throw new Error(getErrorMessage(error)); + } + }, }, }; diff --git a/backend/typescript/graphql/types/reviewedApplicantRecordTypes.ts b/backend/typescript/graphql/types/reviewedApplicantRecordTypes.ts index 6f43897..d0a3f6d 100644 --- a/backend/typescript/graphql/types/reviewedApplicantRecordTypes.ts +++ b/backend/typescript/graphql/types/reviewedApplicantRecordTypes.ts @@ -45,6 +45,13 @@ const reviewedApplicantRecordTypes = gql` reviewerId: Int! } + input UpdateReviewedApplicantRecordInput { + applicantRecordId: ID! + reviewerId: Int! + review: ReviewInput + status: String + } + extend type Mutation { createReviewedApplicantRecord( input: CreateReviewedApplicantRecordInput! @@ -61,6 +68,10 @@ const reviewedApplicantRecordTypes = gql` bulkDeleteReviewedApplicantRecord( inputs: [DeleteReviewedApplicantRecord!]! ): [ReviewedApplicantRecord!]! + + updateReviewedApplicantRecord( + input: UpdateReviewedApplicantRecordInput! + ): ReviewedApplicantRecord! } `; diff --git a/backend/typescript/services/implementations/reviewedApplicantRecordService.ts b/backend/typescript/services/implementations/reviewedApplicantRecordService.ts index 4b0f844..44f516e 100644 --- a/backend/typescript/services/implementations/reviewedApplicantRecordService.ts +++ b/backend/typescript/services/implementations/reviewedApplicantRecordService.ts @@ -1,9 +1,12 @@ import { sequelize } from "../../models"; import ReviewedApplicantRecord from "../../models/reviewedApplicantRecord.model"; +import ApplicantRecord from "../../models/applicantRecord.model"; import { ReviewedApplicantRecordDTO, CreateReviewedApplicantRecordDTO, DeleteReviewedApplicantRecordDTO, + UpdateReviewedApplicantRecordDTO, + Review, } from "../../types"; import { getErrorMessage } from "../../utilities/errorUtils"; import logger from "../../utilities/logger"; @@ -11,12 +14,32 @@ import IReviewApplicantRecordService from "../interfaces/IReviewedApplicantRecor const Logger = logger(__filename); +function validateReviewScores(review: Review | undefined): void { + if (!review) return; + + const scores = { + passionFSG: review.passionFSG, + teamPlayer: review.teamPlayer, + desireToLearn: review.desireToLearn, + skill: review.skill, + }; + + Object.entries(scores).forEach(([field, value]) => { + if (value !== undefined && (value < 1 || value > 5)) { + throw new Error( + `Invalid score for ${field}: ${value}. Scores must be between 1 and 5.`, + ); + } + }); +} + class ReviewedApplicantRecordService implements IReviewApplicantRecordService { /* eslint-disable class-methods-use-this */ async createReviewedApplicantRecord( dto: CreateReviewedApplicantRecordDTO, ): Promise { try { + validateReviewScores(dto.review); const record = await ReviewedApplicantRecord.create(dto); return record.toJSON() as ReviewedApplicantRecordDTO; } catch (error: unknown) { @@ -33,6 +56,10 @@ class ReviewedApplicantRecordService implements IReviewApplicantRecordService { createReviewedApplicantRecordDTOs: CreateReviewedApplicantRecordDTO[], ): Promise { try { + createReviewedApplicantRecordDTOs.forEach((dto) => { + validateReviewScores(dto.review); + }); + const reviewedApplicantRecords = await sequelize.transaction( async (t) => { const records = await ReviewedApplicantRecord.bulkCreate( @@ -121,6 +148,90 @@ class ReviewedApplicantRecordService implements IReviewApplicantRecordService { throw error; } } + + /* eslint-disable class-methods-use-this */ + async updateReviewedApplicantRecord({ + applicantRecordId, + reviewerId, + review, + status, + }: UpdateReviewedApplicantRecordDTO): Promise { + try { + const updatedRecord = await sequelize.transaction(async (t) => { + const reviewedRecord = await ReviewedApplicantRecord.findOne({ + where: { applicantRecordId, reviewerId }, + transaction: t, + }); + + if (!reviewedRecord) { + throw new Error( + `ReviewedApplicantRecord not found for applicantRecordId: ${applicantRecordId} and reviewerId: ${reviewerId}`, + ); + } + + const oldReviewedScore = reviewedRecord.score || 0; + + if (review !== undefined) { + validateReviewScores(review); + + reviewedRecord.review = { + ...reviewedRecord.review, + ...review, + }; + + const { passionFSG, teamPlayer, desireToLearn, skill } = + reviewedRecord.review; + + let calculatedScore = 0; + if (passionFSG !== undefined) calculatedScore += passionFSG; + if (teamPlayer !== undefined) calculatedScore += teamPlayer; + if (desireToLearn !== undefined) calculatedScore += desireToLearn; + if (skill !== undefined) calculatedScore += skill; + reviewedRecord.score = calculatedScore; + + if (review.skillCategory !== undefined) { + reviewedRecord.skillCategory = review.skillCategory; + } + } + + if (status !== undefined) { + reviewedRecord.status = status; + } + + await reviewedRecord.save({ transaction: t }); + + const newReviewedScore = reviewedRecord.score || 0; + + const applicantRecord = await ApplicantRecord.findOne({ + where: { id: applicantRecordId }, + transaction: t, + }); + + if (!applicantRecord) { + throw new Error( + `ApplicantRecord not found for applicantRecordId: ${applicantRecordId}`, + ); + } + + const oldCombinedScore = applicantRecord.combined_score || 0; + applicantRecord.combined_score = + oldCombinedScore - oldReviewedScore + newReviewedScore; + + await applicantRecord.save({ transaction: t }); + + return reviewedRecord; + }); + + return updatedRecord.toJSON() as ReviewedApplicantRecordDTO; + } catch (error: unknown) { + Logger.error( + `Failed to update reviewed applicant record. Reason = ${getErrorMessage( + error, + )}`, + ); + throw error; + } + } } export default ReviewedApplicantRecordService; diff --git a/backend/typescript/services/interfaces/IReviewedApplicantRecordService.ts b/backend/typescript/services/interfaces/IReviewedApplicantRecordService.ts index 646ffc6..77ca1ce 100644 --- a/backend/typescript/services/interfaces/IReviewedApplicantRecordService.ts +++ b/backend/typescript/services/interfaces/IReviewedApplicantRecordService.ts @@ -2,6 +2,7 @@ import { ReviewedApplicantRecordDTO, CreateReviewedApplicantRecordDTO, DeleteReviewedApplicantRecordDTO, + UpdateReviewedApplicantRecordDTO, } from "../../types"; interface IReviewApplicantRecordService { @@ -37,6 +38,15 @@ interface IReviewApplicantRecordService { bulkDeleteReviewedApplicantRecord( deleteReviewedApplicantRecords: DeleteReviewedApplicantRecordDTO[], ): Promise; + + /** + * Updates the review content and/or status of a ReviewedApplicantRecord + * Also updates the combined score in the ApplicantRecord table + * @Param updateReviewedApplicantRecordDTO data to update reviewed applicant record + */ + updateReviewedApplicantRecord( + updateReviewedApplicantRecordDTO: UpdateReviewedApplicantRecordDTO, + ): Promise; } export default IReviewApplicantRecordService; diff --git a/backend/typescript/types.ts b/backend/typescript/types.ts index a5a4964..c7584f9 100644 --- a/backend/typescript/types.ts +++ b/backend/typescript/types.ts @@ -222,6 +222,13 @@ export type DeleteReviewedApplicantRecordDTO = { reviewerId: number; }; +export type UpdateReviewedApplicantRecordDTO = { + applicantRecordId: string; + reviewerId: number; + review?: Review; + status?: ReviewStatus; +}; + export type ReviewDetails = { reviewerFirstName: string; reviewerLastName: string;