diff --git a/apps/gateway/src/modules/evaluations/evaluations.controller.ts b/apps/gateway/src/modules/evaluations/evaluations.controller.ts index 9898808..f58c683 100644 --- a/apps/gateway/src/modules/evaluations/evaluations.controller.ts +++ b/apps/gateway/src/modules/evaluations/evaluations.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, ParseIntPipe, Body, Query } from '@nestjs/common'; +import { Controller, Get, Post, Param, ParseIntPipe, DefaultValuePipe, Body, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiSecurity, ApiBody, ApiQuery } from '@nestjs/swagger'; import { EvaluationsService } from './evaluations.service'; import { EvaluateProjectDto } from './dto/evaluate-project.dto'; @@ -77,9 +77,9 @@ export class EvaluationsController { @RequirePermission('manage:events') @Get('courses/:courseId/top-projects') @ApiOperation({ - summary: 'Get top 5 projects for a course', + summary: 'Get top N projects for a course', description: - 'Returns the top 5 projects for a specific course within an event, ranked by average evaluation grade. ' + + 'Returns the top N projects for a specific course within an event, ranked by average evaluation grade. ' + 'Each project includes complete project details along with average grade and evaluation count.', }) @ApiParam({ @@ -93,6 +93,12 @@ export class EvaluationsController { example: 1, required: true, }) + @ApiQuery({ + name: 'limit', + description: 'Number of top projects to return', + example: 5, + required: false, + }) @ApiResponse({ status: 200, description: 'Top projects retrieved successfully', @@ -104,8 +110,9 @@ export class EvaluationsController { async getTopProjectsByCourse( @Param('courseId', ParseIntPipe) courseId: number, @Query('eventId', ParseIntPipe) eventId: number, + @Query('limit', new DefaultValuePipe(5), ParseIntPipe) limit: number, ) { - return this.evaluationsService.getTopProjectsByCourse(courseId, eventId); + return this.evaluationsService.getTopProjectsByCourse(courseId, eventId, limit); } } diff --git a/apps/gateway/src/modules/evaluations/evaluations.module.ts b/apps/gateway/src/modules/evaluations/evaluations.module.ts index b159229..dfcf863 100644 --- a/apps/gateway/src/modules/evaluations/evaluations.module.ts +++ b/apps/gateway/src/modules/evaluations/evaluations.module.ts @@ -5,6 +5,7 @@ import { join } from 'path'; import { EVALUATION_SERVICE_NAME, CRITERIONS_SERVICE_NAME, + TIE_BREAK_SERVICE_NAME, protobufPackage as evaluationProtobufPackage, } from '@app/common/generated/evaluation'; import { @@ -49,6 +50,22 @@ import { EvaluationsService } from './evaluations.service'; }), inject: [ConfigService], }, + { + name: TIE_BREAK_SERVICE_NAME, + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + transport: Transport.GRPC, + options: { + package: evaluationProtobufPackage, + protoPath: join( + process.cwd(), + 'libs/common/src/protos/evaluation.proto', + ), + url: configService.get('EVALUATION_SERVICE_URL'), + }, + }), + inject: [ConfigService], + }, { name: PROJECTS_SERVICE_NAME, imports: [ConfigModule], diff --git a/apps/gateway/src/modules/evaluations/evaluations.service.ts b/apps/gateway/src/modules/evaluations/evaluations.service.ts index d0edd5e..957f3f7 100644 --- a/apps/gateway/src/modules/evaluations/evaluations.service.ts +++ b/apps/gateway/src/modules/evaluations/evaluations.service.ts @@ -4,8 +4,11 @@ import { lastValueFrom } from 'rxjs'; import { EVALUATION_SERVICE_NAME, CRITERIONS_SERVICE_NAME, + TIE_BREAK_SERVICE_NAME, EvaluationServiceClient, CriterionsServiceClient, + TieBreakServiceClient, + TieBreakProto, FindEvaluationsByEvaluatorResponse, FindCriterionsByCourseResponse, GetProjectStatsRequest, @@ -25,11 +28,13 @@ import { export class EvaluationsService implements OnModuleInit { private evaluationService: EvaluationServiceClient; private criterionsService: CriterionsServiceClient; + private tieBreakService: TieBreakServiceClient; private projectsService: ProjectsServiceClient; constructor( @Inject(EVALUATION_SERVICE_NAME) private evaluationClient: ClientGrpc, @Inject(CRITERIONS_SERVICE_NAME) private criterionsClient: ClientGrpc, + @Inject(TIE_BREAK_SERVICE_NAME) private tieBreakClient: ClientGrpc, @Inject(PROJECTS_SERVICE_NAME) private projectsClient: ClientGrpc, ) { } @@ -42,6 +47,10 @@ export class EvaluationsService implements OnModuleInit { this.criterionsClient.getService( CRITERIONS_SERVICE_NAME, ); + this.tieBreakService = + this.tieBreakClient.getService( + TIE_BREAK_SERVICE_NAME, + ); this.projectsService = this.projectsClient.getService( PROJECTS_SERVICE_NAME, @@ -154,7 +163,7 @@ export class EvaluationsService implements OnModuleInit { ); } - async getTopProjectsByCourse(courseId: number, eventId: number) { + async getTopProjectsByCourse(courseId: number, eventId: number, limit: number = 5) { // 1. Fetch all project IDs for the course and event from project service const projectsResponse = await lastValueFrom( this.projectsService.listProjectsByEvent({ @@ -184,56 +193,92 @@ export class EvaluationsService implements OnModuleInit { return { items: [], courseId, eventId }; } - // 3. Sort by averageGrade descending and take top 5 - const topProjects = projectStats - .sort((a, b) => b.averageGrade - a.averageGrade) - .slice(0, 5); + // 3. Sort by averageGrade descending, applying tiebreak order if there are ties + const sorted = projectStats.sort((a, b) => b.averageGrade - a.averageGrade); + + const hasTies = sorted.some((p, i, arr) => i > 0 && arr[i - 1].averageGrade === p.averageGrade); + + let appliedTiebreaks: TieBreakProto[] = []; + + if (hasTies) { + const tiebreaksResponse = await lastValueFrom( + this.tieBreakService.listTieBreaks({ eventId, categoryId: courseId }), + ); + appliedTiebreaks = tiebreaksResponse.tiebreaks; + const tiebreakMap = new Map( + appliedTiebreaks.map(tb => [tb.projectId, tb.tiebreakOrder]), + ); + sorted.sort((a, b) => { + if (b.averageGrade !== a.averageGrade) return b.averageGrade - a.averageGrade; + return (tiebreakMap.get(a.projectId) ?? Infinity) - (tiebreakMap.get(b.projectId) ?? Infinity); + }); + } + + const tiebreakProjectIds = new Set(appliedTiebreaks.map(tb => tb.projectId)); + + const topProjects = sorted.slice(0, limit); - // 4. Fetch full project details for the top 5 + // Detect disputed: projects outside the limit tied with the last position and without a tiebreak record + const disputedStats = topProjects.length > 0 + ? sorted.slice(limit).filter(p => + p.averageGrade === topProjects[topProjects.length - 1].averageGrade && + !tiebreakProjectIds.has(p.projectId) + ) + : []; + + // 4. Fetch full project details for the top N and disputed projects in parallel const topProjectIds = topProjects.map(tp => tp.projectId); - const projectsPromises = topProjectIds.map(id => - lastValueFrom(this.projectsService.getProjectComplete({ id })) - .catch(() => null) // Handle deleted projects gracefully - ); + const disputedProjectIds = disputedStats.map(dp => dp.projectId); - const projectsResponses = await Promise.all(projectsPromises); + const [projectsResponses, disputedResponses] = await Promise.all([ + Promise.all(topProjectIds.map(id => + lastValueFrom(this.projectsService.getProjectComplete({ id })).catch(() => null) + )), + Promise.all(disputedProjectIds.map(id => + lastValueFrom(this.projectsService.getProjectComplete({ id })).catch(() => null) + )), + ]); + + const buildEnrichedProject = (tp: { projectId: number; averageGrade: number; evaluationCount: number }, response: any) => { + if (!response || !response.items || response.items.length === 0) return null; + const project = response.items[0]; + return { + id: project.id, + eventId: project.eventId, + name: project.name, + description: project.description, + eventNumber: project.eventNumber, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + courseId: project.courseId, + state: project.state, + reason: project.reason, + participants: project.participants, + documents: project.documents, + pendingParticipants: project.pendingParticipants, + averageGrade: tp.averageGrade, + evaluationCount: tp.evaluationCount, + }; + }; // 5. Merge data const enrichedProjects = topProjects - .map((tp, idx) => { - const projectResponse = projectsResponses[idx]; - - // Skip if project is deleted or response is null - if (!projectResponse || !projectResponse.items || projectResponse.items.length === 0) { - return null; - } - - const project = projectResponse.items[0]; - - return { - id: project.id, - eventId: project.eventId, - name: project.name, - description: project.description, - eventNumber: project.eventNumber, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - courseId: project.courseId, - state: project.state, - reason: project.reason, - participants: project.participants, - documents: project.documents, - pendingParticipants: project.pendingParticipants, - averageGrade: tp.averageGrade, - evaluationCount: tp.evaluationCount, - }; - }) + .map((tp, idx) => buildEnrichedProject(tp, projectsResponses[idx])) + .filter(p => p !== null); + + const disputedProjects = disputedStats + .map((dp, idx) => buildEnrichedProject(dp, disputedResponses[idx])) .filter(p => p !== null); + const enrichedProjectIds = new Set(enrichedProjects.map(p => p.id)); + const relevantTiebreaks = appliedTiebreaks.filter(tb => enrichedProjectIds.has(tb.projectId)); + return { items: enrichedProjects, courseId, eventId, + ...(relevantTiebreaks.length > 0 && { tiebreaks: relevantTiebreaks }), + ...(disputedProjects.length > 0 && { disputedProjects }), }; } } diff --git a/apps/gateway/src/modules/events/use-cases/fetch-confirmed-juror-members.use-case.ts b/apps/gateway/src/modules/events/use-cases/fetch-confirmed-juror-members.use-case.ts index bfc2574..6de4803 100644 --- a/apps/gateway/src/modules/events/use-cases/fetch-confirmed-juror-members.use-case.ts +++ b/apps/gateway/src/modules/events/use-cases/fetch-confirmed-juror-members.use-case.ts @@ -16,9 +16,6 @@ import { InvitationServiceClient, InvitationStatus, } from '@app/common/generated/invitation'; - -import { Logger } from '@nestjs/common'; - export type EventMember = NonNullable< ListEventMembersResponse['members'] >[number]; @@ -28,8 +25,6 @@ export class FetchConfirmedJurorMembersUseCase { private eventService: EventServiceClient; private authService: AuthServiceClient; private invitationService: InvitationServiceClient; - private logger = new Logger(FetchConfirmedJurorMembersUseCase.name); - constructor( @Inject(EVENT_SERVICE_NAME) private readonly eventClient: ClientGrpc, @Inject(AUTH_SERVICE_NAME) private readonly authClient: ClientGrpc, @@ -46,28 +41,39 @@ export class FetchConfirmedJurorMembersUseCase { ); } + // NOTE: The original implementation filtered jury members based on accepted invitations. + // I'm making changes just 10 hours before the event while studying for my final networking exam. + // The jury members haven't confirmed yet, and we need to assign them to the projects. + // Pure joy :) async execute(eventId: number): Promise { - const [members, acceptedInvitationUserIds] = await Promise.all([ - this.fetchAllEventMembers(eventId), - this.fetchAcceptedInvitationUserIds(eventId), - ]); - - if ( - !members || - members.length === 0 || - acceptedInvitationUserIds.size === 0 - ) { + // const [members, acceptedInvitationUserIds] = await Promise.all([ + // this.fetchAllEventMembers(eventId), + // this.fetchAcceptedInvitationUserIds(eventId), + // ]); + const members = await this.fetchAllEventMembers(eventId); + + // if ( + // !members || + // members.length === 0 || + // acceptedInvitationUserIds.size === 0 + // ) { + // return []; + // } + if (!members || members.length === 0) { return []; } const uniqueRoleIds = [...new Set(members.map((m) => m.roleId))]; const jurorRoleIds = await this.resolveJurorRoleIds(uniqueRoleIds); + // const result = members.filter( + // (member) => + // member.active && + // jurorRoleIds.has(member.roleId) && + // acceptedInvitationUserIds.has(member.userId), + // ); const result = members.filter( - (member) => - member.active && - jurorRoleIds.has(member.roleId) && - acceptedInvitationUserIds.has(member.userId), + (member) => member.active && jurorRoleIds.has(member.roleId), ); return result;