From f10603bc3edd493351e07eeb7ca2dd9045f4a2f9 Mon Sep 17 00:00:00 2001 From: Wanderson Soares Date: Mon, 22 Jun 2026 10:31:00 -0300 Subject: [PATCH 1/2] feat: implement validation endpoints and score updates Adiciona ValidationController e atualiza repository de CV Items para aceitar casas decimais --- src/app.module.ts | 3 ++ src/cv-scoring/cv-item.service.spec.ts | 40 +++++++++++++++++++ src/cv-scoring/cv-item.service.ts | 1 + src/cv-scoring/dto/update-cv-item.dto.ts | 11 ++++- .../persistence/cv-item.repository.ts | 1 + .../drizzle/cv-item.drizzle-repository.ts | 1 + 6 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/app.module.ts b/src/app.module.ts index 3f4861e..7b4c97a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,8 @@ import { CandidateModule } from './candidate/candidate.module'; import { LoggingModule } from './common/logging.module'; import { HttpLoggerMiddleware } from './common/middlewares/http-logger.middleware'; import { CvScoringModule } from './cv-scoring/cv-scoring.module'; +import { ValidationModule } from './validation/validation.module'; + import { DatabaseModule } from './database/database.module'; import { EnrollmentModule } from './enrollment/enrollment.module'; import { validate } from './env.validation'; @@ -55,6 +57,7 @@ import { UsersModule } from './users/users.module'; SystemModule, EnrollmentModule, CvScoringModule, + ValidationModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/cv-scoring/cv-item.service.spec.ts b/src/cv-scoring/cv-item.service.spec.ts index 237dfa6..5693c40 100644 --- a/src/cv-scoring/cv-item.service.spec.ts +++ b/src/cv-scoring/cv-item.service.spec.ts @@ -345,6 +345,46 @@ describe('CvItemService', () => { }), ).rejects.toThrow(BadRequestException); }); + + it('updates score field (including null)', async () => { + const updatedWithScore = { ...mockCvItem, score: 7.5 }; + const updatedWithNullScore = { ...mockCvItem, score: null }; + + // Test with numeric score + mockCvItemRepository.findEnrollmentById.mockResolvedValueOnce(mockEnrollment); // getAndValidateEnrollment + mockCvItemRepository.findById.mockResolvedValueOnce(mockCvItem); // findById + mockCvItemRepository.update.mockResolvedValueOnce(updatedWithScore); // update + mockCvItemRepository.findEnrollmentById.mockResolvedValueOnce(mockEnrollment); // recalculateScore → getEnrollment + mockCvItemRepository.findByEnrollment.mockResolvedValueOnce([updatedWithScore]); // recalculateScore → findByEnrollment + + let result = await service.update('user-uuid', 'enrollment-uuid', 'item-uuid', { + score: 7.5, + }); + + expect(result.score).toBe(7.5); + expect(mockCvItemRepository.update).toHaveBeenCalledWith( + 'item-uuid', + expect.objectContaining({ score: 7.5 }), + ); + + // Reset mock call counts + mockCvItemRepository.update.mockClear(); + + // Test with null score + mockCvItemRepository.findEnrollmentById.mockResolvedValueOnce(mockEnrollment); + mockCvItemRepository.findById.mockResolvedValueOnce(mockCvItem); + mockCvItemRepository.update.mockResolvedValueOnce(updatedWithNullScore); + mockCvItemRepository.findEnrollmentById.mockResolvedValueOnce(mockEnrollment); + mockCvItemRepository.findByEnrollment.mockResolvedValueOnce([updatedWithNullScore]); + + result = await service.update('user-uuid', 'enrollment-uuid', 'item-uuid', { score: null }); + + expect(result.score).toBeNull(); + expect(mockCvItemRepository.update).toHaveBeenCalledWith( + 'item-uuid', + expect.objectContaining({ score: null }), + ); + }); }); describe('remove', () => { diff --git a/src/cv-scoring/cv-item.service.ts b/src/cv-scoring/cv-item.service.ts index a1c283f..91989d4 100644 --- a/src/cv-scoring/cv-item.service.ts +++ b/src/cv-scoring/cv-item.service.ts @@ -111,6 +111,7 @@ export class CvItemService { if (dto.isInArea !== undefined) updateData.isInArea = dto.isInArea; if (dto.docenciaType !== undefined) updateData.docenciaType = dto.docenciaType; if (dto.eventoType !== undefined) updateData.eventoType = dto.eventoType; + if (dto.score !== undefined) updateData.score = dto.score; if (file) { if (existingItem.proofFileId) { diff --git a/src/cv-scoring/dto/update-cv-item.dto.ts b/src/cv-scoring/dto/update-cv-item.dto.ts index 4c1cfbd..d07c7f2 100644 --- a/src/cv-scoring/dto/update-cv-item.dto.ts +++ b/src/cv-scoring/dto/update-cv-item.dto.ts @@ -1,4 +1,13 @@ import { PartialType } from '@nestjs/swagger'; import { CreateCvItemDto } from './create-cv-item.dto'; -export class UpdateCvItemDto extends PartialType(CreateCvItemDto) {} +import { IsNumber, IsOptional } from 'class-validator'; + +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateCvItemDto extends PartialType(CreateCvItemDto) { + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + score?: number | null; +} diff --git a/src/cv-scoring/infrastructure/persistence/cv-item.repository.ts b/src/cv-scoring/infrastructure/persistence/cv-item.repository.ts index 329b6f4..cdec7a7 100644 --- a/src/cv-scoring/infrastructure/persistence/cv-item.repository.ts +++ b/src/cv-scoring/infrastructure/persistence/cv-item.repository.ts @@ -39,6 +39,7 @@ export interface UpdateCvItemData { correctedClassification?: string | null; verificationComment?: string | null; updatedAt: Date; + score?: number | null; } export abstract class CvItemRepository { diff --git a/src/cv-scoring/infrastructure/persistence/drizzle/cv-item.drizzle-repository.ts b/src/cv-scoring/infrastructure/persistence/drizzle/cv-item.drizzle-repository.ts index 0d69d38..7591b21 100644 --- a/src/cv-scoring/infrastructure/persistence/drizzle/cv-item.drizzle-repository.ts +++ b/src/cv-scoring/infrastructure/persistence/drizzle/cv-item.drizzle-repository.ts @@ -86,6 +86,7 @@ export class CvItemDrizzleRepository extends CvItemRepository { updateData.correctedClassification = data.correctedClassification; if (data.verificationComment !== undefined) updateData.verificationComment = data.verificationComment; + if (data.score !== undefined) updateData.score = data.score; const [row] = await this.db .update(cvItems) From ed308b3a716ae01edd73d09135f288b929532b40 Mon Sep 17 00:00:00 2001 From: Wanderson Soares Date: Mon, 22 Jun 2026 10:55:22 -0300 Subject: [PATCH 2/2] feat: create dashboard endpoints and statistics for secretary --- src/validation/validation.controller.spec.ts | 67 ++++++ src/validation/validation.controller.ts | 29 +++ src/validation/validation.module.ts | 9 + src/validation/validation.service.spec.ts | 219 +++++++++++++++++++ src/validation/validation.service.ts | 74 +++++++ 5 files changed, 398 insertions(+) create mode 100644 src/validation/validation.controller.spec.ts create mode 100644 src/validation/validation.controller.ts create mode 100644 src/validation/validation.module.ts create mode 100644 src/validation/validation.service.spec.ts create mode 100644 src/validation/validation.service.ts diff --git a/src/validation/validation.controller.spec.ts b/src/validation/validation.controller.spec.ts new file mode 100644 index 0000000..c6d4166 --- /dev/null +++ b/src/validation/validation.controller.spec.ts @@ -0,0 +1,67 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { SessionAuthGuard } from '../auth/guards/session-auth.guard'; +import { SessionLifecycleGuard } from '../auth/guards/session-lifecycle.guard'; +import { RolesGuard } from '../roles/roles.guard'; +import { ValidationController } from './validation.controller'; +import { ValidationService } from './validation.service'; + +describe('ValidationController', () => { + let controller: ValidationController; + let service: ValidationService; + + const mockValidationService = { + getCandidatesForDashboard: jest.fn(), + getValidationStats: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ValidationController], + providers: [ + { + provide: ValidationService, + useValue: mockValidationService, + }, + ], + }) + .overrideGuard(SessionAuthGuard) + .useValue({ canActivate: jest.fn().mockReturnValue(true) }) + .overrideGuard(SessionLifecycleGuard) + .useValue({ canActivate: jest.fn().mockReturnValue(true) }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: jest.fn().mockReturnValue(true) }) + .compile(); + + controller = module.get(ValidationController); + service = module.get(ValidationService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getCandidates', () => { + it('should call validationService.getCandidatesForDashboard', async () => { + const mockResult = [{ id: '1' }]; + mockValidationService.getCandidatesForDashboard.mockResolvedValue(mockResult); + + const result = await controller.getCandidates(); + + expect(mockValidationService.getCandidatesForDashboard).toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); + }); + + describe('getStats', () => { + it('should call validationService.getValidationStats', async () => { + const mockResult = { total: 10, validated: 5, pending: 3 }; + mockValidationService.getValidationStats.mockResolvedValue(mockResult); + + const result = await controller.getStats(); + + expect(mockValidationService.getValidationStats).toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); + }); +}); diff --git a/src/validation/validation.controller.ts b/src/validation/validation.controller.ts new file mode 100644 index 0000000..84cc1cc --- /dev/null +++ b/src/validation/validation.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; + +import { ApiCookieAuth, ApiTags } from '@nestjs/swagger'; + +import { SessionAuthGuard } from '../auth/guards/session-auth.guard'; +import { SessionLifecycleGuard } from '../auth/guards/session-lifecycle.guard'; +import { StaffOnly } from '../roles/roles.decorator'; + +import { ValidationService } from './validation.service'; + +@ApiTags('Validation') +@ApiCookieAuth() +@UseGuards(SessionAuthGuard, SessionLifecycleGuard) +@Controller({ path: 'validation', version: '1' }) +export class ValidationController { + constructor(private readonly validationService: ValidationService) {} + + @Get('candidates') + @StaffOnly() + async getCandidates() { + return this.validationService.getCandidatesForDashboard(); + } + + @Get('stats') + @StaffOnly() + async getStats() { + return this.validationService.getValidationStats(); + } +} diff --git a/src/validation/validation.module.ts b/src/validation/validation.module.ts new file mode 100644 index 0000000..f6e328a --- /dev/null +++ b/src/validation/validation.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ValidationController } from './validation.controller'; +import { ValidationService } from './validation.service'; + +@Module({ + controllers: [ValidationController], + providers: [ValidationService], +}) +export class ValidationModule {} diff --git a/src/validation/validation.service.spec.ts b/src/validation/validation.service.spec.ts new file mode 100644 index 0000000..9046379 --- /dev/null +++ b/src/validation/validation.service.spec.ts @@ -0,0 +1,219 @@ +import { Test } from '@nestjs/testing'; +import { ValidationService } from './validation.service'; + +import { DRIZZLE_TX } from '../database/drizzle.constants'; +import type { DrizzleDB } from '../database/drizzle.provider'; + +describe('ValidationService', () => { + let service: ValidationService; + let mockDb: DrizzleDB; + + const mockEnrollments = [ + { + id: 'enrollment-1', + candidateId: 'candidate-1', + undergradUniversity: 'University A', + ira: 8.5, + status: 'submitted', + primaryThemeId: 'theme-1', + createdAt: new Date(), + }, + { + id: 'enrollment-2', + candidateId: 'candidate-2', + undergradUniversity: 'University B', + ira: 7.0, + status: 'draft', + primaryThemeId: null, + createdAt: new Date(), + }, + { + id: 'enrollment-3', + candidateId: 'candidate-3', + undergradUniversity: 'University C', + ira: 9.0, + status: 'closed', + primaryThemeId: 'theme-2', + createdAt: new Date(), + }, + ]; + + const mockCandidates = [ + { + id: 'candidate-1', + userId: 'user-1', + universityOfOrigin: 'University A', + ira: 8.5, + poscomp: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'candidate-2', + userId: 'user-2', + universityOfOrigin: 'University B', + ira: 7.0, + poscomp: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'candidate-3', + userId: 'user-3', + universityOfOrigin: 'University C', + ira: 9.0, + poscomp: 2, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const mockCvItems = [ + { + id: 'cvitem-1', + enrollmentId: 'enrollment-1', + score: 8.5, + }, + { + id: 'cvitem-2', + enrollmentId: 'enrollment-1', + score: 7.0, + }, + { + id: 'cvitem-3', + enrollmentId: 'enrollment-2', + score: null, + }, + { + id: 'cvitem-4', + enrollmentId: 'enrollment-3', + score: 9.0, + }, + ]; + + const mockResearchThemes = [ + { + id: 'theme-1', + title: 'AI Research', + level: 'masters', + professorId: 'prof-1', + vacancies: 2, + references: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'theme-2', + title: 'Systems Research', + level: 'doctoral', + professorId: 'prof-2', + vacancies: 1, + references: [], + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + function createQueryBuilder(data: unknown) { + return { + from: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + then: (resolve: (value: unknown) => void) => resolve(data), + }; + } + + beforeEach(async () => { + mockDb = { + select: jest.fn(), + } as unknown as DrizzleDB; + + const module = await Test.createTestingModule({ + providers: [ + ValidationService, + { + provide: DRIZZLE_TX, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(ValidationService); + }); + + describe('getCandidatesForDashboard', () => { + it('should return enrollments with summed scores and research theme info', async () => { + // Prepare expected result + const expected = mockEnrollments + .map(e => { + const candidate = mockCandidates.find(c => c.id === e.candidateId); + const cvItemScores = mockCvItems + .filter(ci => ci.enrollmentId === e.id) + .map(ci => ci.score) + .filter(s => s !== null); + const totalScore = cvItemScores.reduce((sum, s) => sum + s, 0); + const theme = mockResearchThemes.find(t => t.id === e.primaryThemeId); + return { + enrollmentId: e.id, + candidateId: e.candidateId, + university: candidate?.universityOfOrigin ?? null, + ira: candidate?.ira ?? null, + status: e.status, + totalScore, + researchThemeTitle: theme?.title ?? null, + researchThemeLevel: theme?.level ?? null, + }; + }) + .filter(r => r !== undefined); + + const queryBuilder = createQueryBuilder(expected); + (mockDb.select as jest.Mock).mockReturnValue(queryBuilder); + + // Act + const result = await service.getCandidatesForDashboard(); + + // Assert + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ + enrollmentId: 'enrollment-1', + totalScore: 15.5, // 8.5 + 7.0 + researchThemeTitle: 'AI Research', + researchThemeLevel: 'masters', + }); + expect(result[1]).toMatchObject({ + enrollmentId: 'enrollment-2', + totalScore: 0, // null treated as 0 + researchThemeTitle: null, + researchThemeLevel: null, + }); + expect(result[2]).toMatchObject({ + enrollmentId: 'enrollment-3', + totalScore: 9.0, + researchThemeTitle: 'Systems Research', + researchThemeLevel: 'doctoral', + }); + }); + }); + + describe('getValidationStats', () => { + it('should return total, validated, and pending counts', async () => { + (mockDb.select as jest.Mock) + .mockReturnValueOnce(createQueryBuilder([{ count: 3 }])) // total + .mockReturnValueOnce(createQueryBuilder([{ count: 2 }])) // validated + .mockReturnValueOnce(createQueryBuilder([{ count: 1 }])); // pending + + // Act + const result = await service.getValidationStats(); + + // Assert + expect(result).toEqual({ + total: 3, + validated: 2, + pending: 1, + }); + }); + }); +}); diff --git a/src/validation/validation.service.ts b/src/validation/validation.service.ts new file mode 100644 index 0000000..8c17f2f --- /dev/null +++ b/src/validation/validation.service.ts @@ -0,0 +1,74 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DRIZZLE_TX } from '../database/drizzle.constants'; +import type { DrizzleDB } from '../database/drizzle.provider'; + +import { eq, or, sql } from 'drizzle-orm'; + +import { enrollments } from '../database/schema/enrollments'; + +import { cvItems } from '../database/schema/cv-items'; +import { researchThemes } from '../database/schema/research-themes'; + +@Injectable() +export class ValidationService { + constructor(@Inject(DRIZZLE_TX) private readonly db: DrizzleDB) {} + + async getCandidatesForDashboard() { + // Query to join enrollments, cv_items (to sum scores) and research_themes (via primaryThemeId) + // We'll group by enrollment to get the sum of scores per enrollment. + // We'll also get the research theme details (title, level) from the primary theme. + // The university and ira are taken from the enrollments table. + const results = await this.db + .select({ + enrollmentId: enrollments.id, + candidateId: enrollments.candidateId, + university: enrollments.undergradUniversity, + ira: enrollments.ira, + status: enrollments.status, + // Sum of scores from cv_items, treating null as 0 + totalScore: sql`coalesce(sum(${cvItems.score}), 0)`, + // Research theme details (from primaryThemeId) + researchThemeTitle: researchThemes.title, + researchThemeLevel: researchThemes.level, + }) + .from(enrollments) + .leftJoin(cvItems, eq(enrollments.id, cvItems.enrollmentId)) + .leftJoin(researchThemes, eq(enrollments.primaryThemeId, researchThemes.id)) + .groupBy( + enrollments.id, + enrollments.candidateId, + enrollments.undergradUniversity, + enrollments.ira, + enrollments.status, + researchThemes.title, + researchThemes.level, + ) + .orderBy(enrollments.createdAt); + + return results; + } + + async getValidationStats() { + // We'll count: + // total: count of all enrollments + // validated: count of enrollments with status in ['submitted', 'closed'] + // pending: count of enrollments with status 'draft' + const [totalResult, validatedResult, pendingResult] = await Promise.all([ + this.db.select({ count: sql`count(*)` }).from(enrollments), + this.db + .select({ count: sql`count(*)` }) + .from(enrollments) + .where(or(eq(enrollments.status, 'submitted'), eq(enrollments.status, 'closed'))), + this.db + .select({ count: sql`count(*)` }) + .from(enrollments) + .where(eq(enrollments.status, 'draft')), + ]); + + return { + total: Number(totalResult[0].count), + validated: Number(validatedResult[0].count), + pending: Number(pendingResult[0].count), + }; + } +}