Skip to content

Commit 55d46e8

Browse files
authored
Merge pull request #39 from topcoder-platform/feat/review-progress-calc-endpoint
Add endpoint to get the review progress of a challenge
2 parents d41fc4a + 3177df0 commit 55d46e8

File tree

3 files changed

+220
-1
lines changed

3 files changed

+220
-1
lines changed

src/api/review/review.controller.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Param,
1010
Query,
1111
NotFoundException,
12+
BadRequestException,
1213
InternalServerErrorException,
1314
} from '@nestjs/common';
1415
import {
@@ -31,6 +32,7 @@ import {
3132
ReviewResponseDto,
3233
ReviewItemRequestDto,
3334
ReviewItemResponseDto,
35+
ReviewProgressResponseDto,
3436
mapReviewRequestToDto,
3537
mapReviewItemRequestToDto,
3638
} from 'src/dto/review.dto';
@@ -39,6 +41,7 @@ import { ScorecardStatus } from '../../dto/scorecard.dto';
3941
import { LoggerService } from '../../shared/modules/global/logger.service';
4042
import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto';
4143
import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service';
44+
import { ResourceApiService } from '../../shared/modules/global/resource.service';
4245

4346
@ApiTags('Reviews')
4447
@ApiBearerAuth()
@@ -49,6 +52,7 @@ export class ReviewController {
4952
constructor(
5053
private readonly prisma: PrismaService,
5154
private readonly prismaErrorService: PrismaErrorService,
55+
private readonly resourceApiService: ResourceApiService,
5256
) {
5357
this.logger = LoggerService.forRoot('ReviewController');
5458
}
@@ -603,4 +607,171 @@ export class ReviewController {
603607
});
604608
}
605609
}
610+
611+
@Get('/progress/:challengeId')
612+
@Roles(UserRole.Admin, UserRole.Copilot, UserRole.Reviewer, UserRole.User)
613+
@Scopes(Scope.ReadReview)
614+
@ApiOperation({
615+
summary: 'Get review progress for a specific challenge',
616+
description:
617+
'Calculate and return the review progress percentage for a challenge. Accessible to all authenticated users. | Scopes: read:review',
618+
})
619+
@ApiParam({
620+
name: 'challengeId',
621+
description: 'The ID of the challenge to calculate progress for',
622+
example: 'challenge123',
623+
})
624+
@ApiResponse({
625+
status: 200,
626+
description: 'Review progress calculated successfully.',
627+
type: ReviewProgressResponseDto,
628+
})
629+
@ApiResponse({
630+
status: 400,
631+
description: 'Invalid challengeId parameter.',
632+
})
633+
@ApiResponse({
634+
status: 404,
635+
description: 'Challenge not found or no data available.',
636+
})
637+
@ApiResponse({
638+
status: 500,
639+
description: 'Server error during calculation.',
640+
})
641+
async getReviewProgress(
642+
@Param('challengeId') challengeId: string,
643+
): Promise<ReviewProgressResponseDto> {
644+
this.logger.log(
645+
`Calculating review progress for challenge: ${challengeId}`,
646+
);
647+
648+
try {
649+
// Validate challengeId parameter
650+
if (
651+
!challengeId ||
652+
typeof challengeId !== 'string' ||
653+
challengeId.trim() === ''
654+
) {
655+
throw new Error('Invalid challengeId parameter');
656+
}
657+
658+
// Get reviewers from Resource API
659+
this.logger.debug('Fetching reviewers from Resource API');
660+
const resources = await this.resourceApiService.getResources({
661+
challengeId,
662+
});
663+
664+
// Get resource roles to filter by reviewer role
665+
const resourceRoles = await this.resourceApiService.getResourceRoles();
666+
667+
// Filter resources to get only reviewers
668+
const reviewers = resources.filter((resource) => {
669+
const role = resourceRoles[resource.roleId];
670+
return role && role.name.toLowerCase().includes('reviewer');
671+
});
672+
673+
const totalReviewers = reviewers.length;
674+
this.logger.debug(
675+
`Found ${totalReviewers} reviewers for challenge ${challengeId}`,
676+
);
677+
678+
// Get submissions for the challenge
679+
this.logger.debug('Fetching submissions for the challenge');
680+
const submissions = await this.prisma.submission.findMany({
681+
where: {
682+
challengeId,
683+
status: 'ACTIVE',
684+
},
685+
});
686+
687+
const submissionIds = submissions.map((s) => s.id);
688+
const totalSubmissions = submissions.length;
689+
this.logger.debug(
690+
`Found ${totalSubmissions} submissions for challenge ${challengeId}`,
691+
);
692+
693+
// Get submitted reviews for these submissions
694+
this.logger.debug('Fetching submitted reviews');
695+
const submittedReviews = await this.prisma.review.findMany({
696+
where: {
697+
submissionId: { in: submissionIds },
698+
committed: true,
699+
},
700+
include: {
701+
reviewItems: true,
702+
},
703+
});
704+
705+
const totalSubmittedReviews = submittedReviews.length;
706+
this.logger.debug(`Found ${totalSubmittedReviews} submitted reviews`);
707+
708+
// Calculate progress percentage
709+
let progressPercentage = 0;
710+
711+
if (totalReviewers > 0 && totalSubmissions > 0) {
712+
const expectedTotalReviews = totalSubmissions * totalReviewers;
713+
progressPercentage =
714+
(totalSubmittedReviews / expectedTotalReviews) * 100;
715+
// Round to 2 decimal places
716+
progressPercentage = Math.round(progressPercentage * 100) / 100;
717+
}
718+
719+
// Handle edge cases
720+
if (progressPercentage > 100) {
721+
progressPercentage = 100;
722+
}
723+
724+
const result: ReviewProgressResponseDto = {
725+
challengeId,
726+
totalReviewers,
727+
totalSubmissions,
728+
totalSubmittedReviews,
729+
progressPercentage,
730+
calculatedAt: new Date().toISOString(),
731+
};
732+
733+
this.logger.log(
734+
`Review progress calculated: ${progressPercentage}% for challenge ${challengeId}`,
735+
);
736+
return result;
737+
} catch (error) {
738+
this.logger.error(
739+
`Error calculating review progress for challenge ${challengeId}:`,
740+
error,
741+
);
742+
743+
if (error.message === 'Invalid challengeId parameter') {
744+
throw new Error('Invalid challengeId parameter');
745+
}
746+
747+
// Handle Resource API errors based on HTTP status codes
748+
if (error.message === 'Cannot get data from Resource API.') {
749+
const statusCode = (error as Error & { statusCode?: number })
750+
.statusCode;
751+
if (statusCode === 400) {
752+
throw new BadRequestException({
753+
message: `Challenge ID ${challengeId} is not in valid GUID format`,
754+
code: 'INVALID_CHALLENGE_ID',
755+
});
756+
} else if (statusCode === 404) {
757+
throw new NotFoundException({
758+
message: `Challenge with ID ${challengeId} was not found`,
759+
code: 'CHALLENGE_NOT_FOUND',
760+
});
761+
}
762+
}
763+
764+
if (error.message && error.message.includes('not found')) {
765+
throw new NotFoundException({
766+
message: `Challenge with ID ${challengeId} was not found or has no data available`,
767+
code: 'CHALLENGE_NOT_FOUND',
768+
});
769+
}
770+
771+
throw new InternalServerErrorException({
772+
message: 'Failed to calculate review progress',
773+
code: 'PROGRESS_CALCULATION_ERROR',
774+
});
775+
}
776+
}
606777
}

src/dto/review.dto.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,48 @@ export function mapReviewItemRequestToDto(request: ReviewItemRequestDto) {
418418
},
419419
};
420420
}
421+
422+
export class ReviewProgressResponseDto {
423+
@ApiProperty({
424+
description: 'The ID of the challenge',
425+
example: 'challenge123',
426+
})
427+
@IsString()
428+
@IsNotEmpty()
429+
challengeId: string;
430+
431+
@ApiProperty({
432+
description: 'Total number of reviewers for the challenge',
433+
example: 2,
434+
})
435+
@IsNumber()
436+
totalReviewers: number;
437+
438+
@ApiProperty({
439+
description: 'Total number of submissions for the challenge',
440+
example: 4,
441+
})
442+
@IsNumber()
443+
totalSubmissions: number;
444+
445+
@ApiProperty({
446+
description: 'Total number of submitted reviews',
447+
example: 6,
448+
})
449+
@IsNumber()
450+
totalSubmittedReviews: number;
451+
452+
@ApiProperty({
453+
description: 'Review progress percentage',
454+
example: 75.0,
455+
})
456+
@IsNumber()
457+
progressPercentage: number;
458+
459+
@ApiProperty({
460+
description: 'Timestamp when the progress was calculated',
461+
example: '2025-01-15T10:30:00Z',
462+
})
463+
@IsDateString()
464+
calculatedAt: string;
465+
}

src/shared/modules/global/resource.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ export class ResourceApiService {
7979
} catch (e) {
8080
if (e instanceof AxiosError) {
8181
this.logger.error(`Http Error: ${e.message}`, e.response?.data);
82-
throw new Error('Cannot get data from Resource API.');
82+
const error = new Error('Cannot get data from Resource API.');
83+
(error as any).statusCode = e.response?.status;
84+
(error as any).originalMessage = e.response?.data?.message;
85+
throw error;
8386
}
8487
this.logger.error(`Data validation error: ${e}`);
8588
throw new Error('Malformed data returned from Resource API');

0 commit comments

Comments
 (0)