Skip to content
15 changes: 11 additions & 4 deletions apps/gateway/src/modules/evaluations/evaluations.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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({
Expand All @@ -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',
Expand All @@ -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);
}

}
17 changes: 17 additions & 0 deletions apps/gateway/src/modules/evaluations/evaluations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>('EVALUATION_SERVICE_URL'),
},
}),
inject: [ConfigService],
},
{
name: PROJECTS_SERVICE_NAME,
imports: [ConfigModule],
Expand Down
123 changes: 84 additions & 39 deletions apps/gateway/src/modules/evaluations/evaluations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
) { }

Expand All @@ -42,6 +47,10 @@ export class EvaluationsService implements OnModuleInit {
this.criterionsClient.getService<CriterionsServiceClient>(
CRITERIONS_SERVICE_NAME,
);
this.tieBreakService =
this.tieBreakClient.getService<TieBreakServiceClient>(
TIE_BREAK_SERVICE_NAME,
);
this.projectsService =
this.projectsClient.getService<ProjectsServiceClient>(
PROJECTS_SERVICE_NAME,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<number, number>(
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 }),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ import {
InvitationServiceClient,
InvitationStatus,
} from '@app/common/generated/invitation';

import { Logger } from '@nestjs/common';

export type EventMember = NonNullable<
ListEventMembersResponse['members']
>[number];
Expand All @@ -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,
Expand All @@ -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<EventMember[]> {
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;
Expand Down
Loading