From 3177df0701efb060f14f82eb91c81234f5e76293 Mon Sep 17 00:00:00 2001 From: billsedison Date: Sat, 6 Sep 2025 10:02:01 +0800 Subject: [PATCH] Add endpoint to get the review progress of a challenge --- src/api/review/review.controller.ts | 171 ++++++++++++++++++ src/dto/review.dto.ts | 45 +++++ src/shared/modules/global/resource.service.ts | 5 +- 3 files changed, 220 insertions(+), 1 deletion(-) diff --git a/src/api/review/review.controller.ts b/src/api/review/review.controller.ts index 456b167..0ed04bb 100644 --- a/src/api/review/review.controller.ts +++ b/src/api/review/review.controller.ts @@ -9,6 +9,7 @@ import { Param, Query, NotFoundException, + BadRequestException, InternalServerErrorException, } from '@nestjs/common'; import { @@ -31,6 +32,7 @@ import { ReviewResponseDto, ReviewItemRequestDto, ReviewItemResponseDto, + ReviewProgressResponseDto, mapReviewRequestToDto, mapReviewItemRequestToDto, } from 'src/dto/review.dto'; @@ -39,6 +41,7 @@ import { ScorecardStatus } from '../../dto/scorecard.dto'; import { LoggerService } from '../../shared/modules/global/logger.service'; import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto'; import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service'; +import { ResourceApiService } from '../../shared/modules/global/resource.service'; @ApiTags('Reviews') @ApiBearerAuth() @@ -49,6 +52,7 @@ export class ReviewController { constructor( private readonly prisma: PrismaService, private readonly prismaErrorService: PrismaErrorService, + private readonly resourceApiService: ResourceApiService, ) { this.logger = LoggerService.forRoot('ReviewController'); } @@ -603,4 +607,171 @@ export class ReviewController { }); } } + + @Get('/progress/:challengeId') + @Roles(UserRole.Admin, UserRole.Copilot, UserRole.Reviewer, UserRole.User) + @Scopes(Scope.ReadReview) + @ApiOperation({ + summary: 'Get review progress for a specific challenge', + description: + 'Calculate and return the review progress percentage for a challenge. Accessible to all authenticated users. | Scopes: read:review', + }) + @ApiParam({ + name: 'challengeId', + description: 'The ID of the challenge to calculate progress for', + example: 'challenge123', + }) + @ApiResponse({ + status: 200, + description: 'Review progress calculated successfully.', + type: ReviewProgressResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid challengeId parameter.', + }) + @ApiResponse({ + status: 404, + description: 'Challenge not found or no data available.', + }) + @ApiResponse({ + status: 500, + description: 'Server error during calculation.', + }) + async getReviewProgress( + @Param('challengeId') challengeId: string, + ): Promise { + this.logger.log( + `Calculating review progress for challenge: ${challengeId}`, + ); + + try { + // Validate challengeId parameter + if ( + !challengeId || + typeof challengeId !== 'string' || + challengeId.trim() === '' + ) { + throw new Error('Invalid challengeId parameter'); + } + + // Get reviewers from Resource API + this.logger.debug('Fetching reviewers from Resource API'); + const resources = await this.resourceApiService.getResources({ + challengeId, + }); + + // Get resource roles to filter by reviewer role + const resourceRoles = await this.resourceApiService.getResourceRoles(); + + // Filter resources to get only reviewers + const reviewers = resources.filter((resource) => { + const role = resourceRoles[resource.roleId]; + return role && role.name.toLowerCase().includes('reviewer'); + }); + + const totalReviewers = reviewers.length; + this.logger.debug( + `Found ${totalReviewers} reviewers for challenge ${challengeId}`, + ); + + // Get submissions for the challenge + this.logger.debug('Fetching submissions for the challenge'); + const submissions = await this.prisma.submission.findMany({ + where: { + challengeId, + status: 'ACTIVE', + }, + }); + + const submissionIds = submissions.map((s) => s.id); + const totalSubmissions = submissions.length; + this.logger.debug( + `Found ${totalSubmissions} submissions for challenge ${challengeId}`, + ); + + // Get submitted reviews for these submissions + this.logger.debug('Fetching submitted reviews'); + const submittedReviews = await this.prisma.review.findMany({ + where: { + submissionId: { in: submissionIds }, + committed: true, + }, + include: { + reviewItems: true, + }, + }); + + const totalSubmittedReviews = submittedReviews.length; + this.logger.debug(`Found ${totalSubmittedReviews} submitted reviews`); + + // Calculate progress percentage + let progressPercentage = 0; + + if (totalReviewers > 0 && totalSubmissions > 0) { + const expectedTotalReviews = totalSubmissions * totalReviewers; + progressPercentage = + (totalSubmittedReviews / expectedTotalReviews) * 100; + // Round to 2 decimal places + progressPercentage = Math.round(progressPercentage * 100) / 100; + } + + // Handle edge cases + if (progressPercentage > 100) { + progressPercentage = 100; + } + + const result: ReviewProgressResponseDto = { + challengeId, + totalReviewers, + totalSubmissions, + totalSubmittedReviews, + progressPercentage, + calculatedAt: new Date().toISOString(), + }; + + this.logger.log( + `Review progress calculated: ${progressPercentage}% for challenge ${challengeId}`, + ); + return result; + } catch (error) { + this.logger.error( + `Error calculating review progress for challenge ${challengeId}:`, + error, + ); + + if (error.message === 'Invalid challengeId parameter') { + throw new Error('Invalid challengeId parameter'); + } + + // Handle Resource API errors based on HTTP status codes + if (error.message === 'Cannot get data from Resource API.') { + const statusCode = (error as Error & { statusCode?: number }) + .statusCode; + if (statusCode === 400) { + throw new BadRequestException({ + message: `Challenge ID ${challengeId} is not in valid GUID format`, + code: 'INVALID_CHALLENGE_ID', + }); + } else if (statusCode === 404) { + throw new NotFoundException({ + message: `Challenge with ID ${challengeId} was not found`, + code: 'CHALLENGE_NOT_FOUND', + }); + } + } + + if (error.message && error.message.includes('not found')) { + throw new NotFoundException({ + message: `Challenge with ID ${challengeId} was not found or has no data available`, + code: 'CHALLENGE_NOT_FOUND', + }); + } + + throw new InternalServerErrorException({ + message: 'Failed to calculate review progress', + code: 'PROGRESS_CALCULATION_ERROR', + }); + } + } } diff --git a/src/dto/review.dto.ts b/src/dto/review.dto.ts index 96164fa..7ecd8ad 100644 --- a/src/dto/review.dto.ts +++ b/src/dto/review.dto.ts @@ -418,3 +418,48 @@ export function mapReviewItemRequestToDto(request: ReviewItemRequestDto) { }, }; } + +export class ReviewProgressResponseDto { + @ApiProperty({ + description: 'The ID of the challenge', + example: 'challenge123', + }) + @IsString() + @IsNotEmpty() + challengeId: string; + + @ApiProperty({ + description: 'Total number of reviewers for the challenge', + example: 2, + }) + @IsNumber() + totalReviewers: number; + + @ApiProperty({ + description: 'Total number of submissions for the challenge', + example: 4, + }) + @IsNumber() + totalSubmissions: number; + + @ApiProperty({ + description: 'Total number of submitted reviews', + example: 6, + }) + @IsNumber() + totalSubmittedReviews: number; + + @ApiProperty({ + description: 'Review progress percentage', + example: 75.0, + }) + @IsNumber() + progressPercentage: number; + + @ApiProperty({ + description: 'Timestamp when the progress was calculated', + example: '2025-01-15T10:30:00Z', + }) + @IsDateString() + calculatedAt: string; +} diff --git a/src/shared/modules/global/resource.service.ts b/src/shared/modules/global/resource.service.ts index 8c7d9c1..e96cc86 100644 --- a/src/shared/modules/global/resource.service.ts +++ b/src/shared/modules/global/resource.service.ts @@ -79,7 +79,10 @@ export class ResourceApiService { } catch (e) { if (e instanceof AxiosError) { this.logger.error(`Http Error: ${e.message}`, e.response?.data); - throw new Error('Cannot get data from Resource API.'); + const error = new Error('Cannot get data from Resource API.'); + (error as any).statusCode = e.response?.status; + (error as any).originalMessage = e.response?.data?.message; + throw error; } this.logger.error(`Data validation error: ${e}`); throw new Error('Malformed data returned from Resource API');