From 78e2802fb1661d5c6a87bcf97a90ec97f292f65c Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:01:54 +0800 Subject: [PATCH 1/4] FAC-113 feat: scoped program filters for analytics attention endpoint (#269) (#270) Add programCode filter to GET /analytics/attention with scope validation. Fix chairperson program scoping in GET /curriculum/programs. Add scope validation for programCode on GET /analytics/overview. --- .../analytics/analytics.controller.spec.ts | 23 ++- src/modules/analytics/analytics.controller.ts | 3 +- .../analytics/analytics.service.spec.ts | 176 +++++++++++++++++- src/modules/analytics/analytics.service.ts | 95 ++++++++-- .../analytics/dto/analytics-query.dto.ts | 18 +- .../services/scope-resolver.service.spec.ts | 74 ++++++++ .../common/services/scope-resolver.service.ts | 65 +++++-- .../services/curriculum.service.spec.ts | 57 +++++- .../curriculum/services/curriculum.service.ts | 10 + 9 files changed, 483 insertions(+), 38 deletions(-) diff --git a/src/modules/analytics/analytics.controller.spec.ts b/src/modules/analytics/analytics.controller.spec.ts index cc4d702..60c9e8a 100644 --- a/src/modules/analytics/analytics.controller.spec.ts +++ b/src/modules/analytics/analytics.controller.spec.ts @@ -77,7 +77,7 @@ describe('AnalyticsController', () => { }); describe('GetAttentionList', () => { - it('should delegate to AnalyticsService with semesterId', async () => { + it('should delegate to AnalyticsService with semesterId and query', async () => { const query = { semesterId: '550e8400-e29b-41d4-a716-446655440000', }; @@ -91,6 +91,27 @@ describe('AnalyticsController', () => { expect(mockAnalyticsService.GetAttentionList).toHaveBeenCalledWith( query.semesterId, + query, + ); + expect(result).toEqual(expectedResult); + }); + + it('should pass programCode to service', async () => { + const query = { + semesterId: '550e8400-e29b-41d4-a716-446655440000', + programCode: 'BSCS', + }; + const expectedResult = { + items: [], + lastRefreshedAt: null, + }; + mockAnalyticsService.GetAttentionList.mockResolvedValue(expectedResult); + + const result = await controller.GetAttentionList(query); + + expect(mockAnalyticsService.GetAttentionList).toHaveBeenCalledWith( + query.semesterId, + query, ); expect(result).toEqual(expectedResult); }); diff --git a/src/modules/analytics/analytics.controller.ts b/src/modules/analytics/analytics.controller.ts index 9bacb00..34b917e 100644 --- a/src/modules/analytics/analytics.controller.ts +++ b/src/modules/analytics/analytics.controller.ts @@ -47,11 +47,12 @@ export class AnalyticsController { summary: 'Get attention list — faculty flagged for review', }) @ApiQuery({ name: 'semesterId', required: true, type: String }) + @ApiQuery({ name: 'programCode', required: false, type: String }) @ApiResponse({ status: 200, type: AttentionListResponseDto }) async GetAttentionList( @Query() query: AttentionListQueryDto, ): Promise { - return this.analyticsService.GetAttentionList(query.semesterId); + return this.analyticsService.GetAttentionList(query.semesterId, query); } @Get('trends') diff --git a/src/modules/analytics/analytics.service.spec.ts b/src/modules/analytics/analytics.service.spec.ts index d04bf67..4e1b1d1 100644 --- a/src/modules/analytics/analytics.service.spec.ts +++ b/src/modules/analytics/analytics.service.spec.ts @@ -8,7 +8,10 @@ import { QuestionnaireSchemaSnapshot } from 'src/modules/questionnaires/lib/ques describe('AnalyticsService', () => { let service: AnalyticsService; let mockExecute: jest.Mock; - let mockScopeResolver: { ResolveDepartmentIds: jest.Mock }; + let mockScopeResolver: { + ResolveDepartmentIds: jest.Mock; + ResolveProgramCodes: jest.Mock; + }; beforeEach(async () => { mockExecute = jest.fn().mockResolvedValue([]); @@ -20,6 +23,7 @@ describe('AnalyticsService', () => { mockScopeResolver = { ResolveDepartmentIds: jest.fn().mockResolvedValue(null), + ResolveProgramCodes: jest.fn().mockResolvedValue(null), }; const module: TestingModule = await Test.createTestingModule({ @@ -183,7 +187,9 @@ describe('AnalyticsService', () => { // GetLastRefreshedAt .mockResolvedValueOnce([]); - const result = await service.GetAttentionList(semesterId); + const result = await service.GetAttentionList(semesterId, { + semesterId, + }); expect(result.items).toHaveLength(1); expect(result.items[0].flags).toHaveLength(1); @@ -211,7 +217,9 @@ describe('AnalyticsService', () => { // GetLastRefreshedAt .mockResolvedValueOnce([]); - const result = await service.GetAttentionList(semesterId); + const result = await service.GetAttentionList(semesterId, { + semesterId, + }); expect(result.items).toHaveLength(1); expect(result.items[0].flags[0].type).toBe('quant_qual_gap'); @@ -236,7 +244,9 @@ describe('AnalyticsService', () => { // GetLastRefreshedAt .mockResolvedValueOnce([]); - const result = await service.GetAttentionList(semesterId); + const result = await service.GetAttentionList(semesterId, { + semesterId, + }); expect(result.items).toHaveLength(1); expect(result.items[0].flags[0].type).toBe('low_coverage'); @@ -274,7 +284,9 @@ describe('AnalyticsService', () => { // GetLastRefreshedAt .mockResolvedValueOnce([]); - const result = await service.GetAttentionList(semesterId); + const result = await service.GetAttentionList(semesterId, { + semesterId, + }); // Same faculty should be deduplicated into one item with 2 flags expect(result.items).toHaveLength(1); @@ -295,13 +307,165 @@ describe('AnalyticsService', () => { // GetLastRefreshedAt .mockResolvedValueOnce([]); - const result = await service.GetAttentionList(semesterId); + const result = await service.GetAttentionList(semesterId, { + semesterId, + }); expect(result.items).toHaveLength(0); expect(mockScopeResolver.ResolveDepartmentIds).toHaveBeenCalledWith( semesterId, ); }); + + it('should filter attention by programCode on quant-qual gap and low coverage', async () => { + // Super admin: no dept scope call + mockExecute + // Declining trends + .mockResolvedValueOnce([]) + // Quant-qual gap + .mockResolvedValueOnce([ + { + faculty_id: 'f1', + faculty_name: 'Dr. Smith', + department_code: 'CCS', + avg_normalized_score: 90, + positive_count: 5, + analyzed_count: 20, + divergence: 0.65, + }, + ]) + // Low coverage + .mockResolvedValueOnce([]) + // GetLastRefreshedAt + .mockResolvedValueOnce([]); + + await service.GetAttentionList(semesterId, { + semesterId, + programCode: 'BSCS', + }); + + // Quant-qual gap (call[1]) should include program_code_snapshot filter + const qqCall = mockExecute.mock.calls[1] as [string, unknown[]]; + expect(qqCall[0]).toContain('program_code_snapshot = ?'); + expect(qqCall[1]).toContain('BSCS'); + + // Low coverage (call[2]) should include program_code_snapshot filter + const lcCall = mockExecute.mock.calls[2] as [string, unknown[]]; + expect(lcCall[0]).toContain('program_code_snapshot = ?'); + expect(lcCall[1]).toContain('BSCS'); + }); + + it('should join-back declining trends to stats MV when programCode provided', async () => { + mockExecute + // Declining trends (with join) + .mockResolvedValueOnce([]) + // Quant-qual gap + .mockResolvedValueOnce([]) + // Low coverage + .mockResolvedValueOnce([]) + // GetLastRefreshedAt + .mockResolvedValueOnce([]); + + await service.GetAttentionList(semesterId, { + semesterId, + programCode: 'BSCS', + }); + + // Declining trends (call[0]) should JOIN mv_faculty_semester_stats + const dtCall = mockExecute.mock.calls[0] as [string, unknown[]]; + expect(dtCall[0]).toContain('JOIN mv_faculty_semester_stats'); + expect(dtCall[0]).toContain('s.semester_id = ?'); + expect(dtCall[0]).toContain('s.program_code_snapshot = ?'); + }); + + it('should correctly expand params when both programCode and deptCodes are present', async () => { + mockScopeResolver.ResolveDepartmentIds.mockResolvedValue(['dept-uuid-1']); + // ResolveDepartmentCodes + mockExecute + .mockResolvedValueOnce([{ code: 'CCS' }]) + // Declining trends + .mockResolvedValueOnce([]) + // Quant-qual gap + .mockResolvedValueOnce([]) + // Low coverage + .mockResolvedValueOnce([]) + // GetLastRefreshedAt + .mockResolvedValueOnce([]); + + await service.GetAttentionList(semesterId, { + semesterId, + programCode: 'BSCS', + }); + + // Declining trends (call[1]): params should be + // [minSemesters, minR2, minR2, semesterId, programCode, deptCodes] + const dtCall = mockExecute.mock.calls[1] as [string, unknown[]]; + expect(dtCall[0]).toContain('JOIN mv_faculty_semester_stats'); + expect(dtCall[0]).toContain('t.department_code_snapshot = ANY(?)'); + expect(dtCall[1]).toEqual([3, 0.5, 0.5, semesterId, 'BSCS', '{CCS}']); + }); + + it('should return empty when programCode is out of scope for chairperson', async () => { + mockScopeResolver.ResolveProgramCodes.mockResolvedValue(['BSCS']); + mockScopeResolver.ResolveDepartmentIds.mockResolvedValue(['dept-1']); + // ResolveDepartmentCodes + mockExecute.mockResolvedValueOnce([{ code: 'CCS' }]); + // GetLastRefreshedAt + mockExecute.mockResolvedValueOnce([ + { value: '2026-03-22T10:00:00.000Z' }, + ]); + + const result = await service.GetAttentionList(semesterId, { + semesterId, + programCode: 'BSIT', + }); + + expect(result.items).toEqual([]); + expect(result.lastRefreshedAt).toBe('2026-03-22T10:00:00.000Z'); + }); + + it('should allow any programCode for super admin (unrestricted)', async () => { + mockScopeResolver.ResolveProgramCodes.mockResolvedValue(null); + mockExecute + // Declining trends + .mockResolvedValueOnce([]) + // Quant-qual gap + .mockResolvedValueOnce([]) + // Low coverage + .mockResolvedValueOnce([]) + // GetLastRefreshedAt + .mockResolvedValueOnce([]); + + const result = await service.GetAttentionList(semesterId, { + semesterId, + programCode: 'ANY_CODE', + }); + + // All 3 sub-queries should have been called (4 total with GetLastRefreshedAt) + expect(mockExecute).toHaveBeenCalledTimes(4); + expect(result.items).toHaveLength(0); + }); + }); + + describe('GetDepartmentOverview — scope validation', () => { + const semesterId = '550e8400-e29b-41d4-a716-446655440000'; + + it('should return empty overview when programCode is out of scope', async () => { + mockScopeResolver.ResolveProgramCodes.mockResolvedValue(['BSCS']); + // GetLastRefreshedAt + mockExecute.mockResolvedValueOnce([ + { value: '2026-03-22T10:00:00.000Z' }, + ]); + + const result = await service.GetDepartmentOverview(semesterId, { + semesterId, + programCode: 'BSIT', + }); + + expect(result.summary.totalFaculty).toBe(0); + expect(result.faculty).toEqual([]); + expect(result.lastRefreshedAt).toBe('2026-03-22T10:00:00.000Z'); + }); }); describe('GetFacultyTrends', () => { diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts index fe2f268..d4924ec 100644 --- a/src/modules/analytics/analytics.service.ts +++ b/src/modules/analytics/analytics.service.ts @@ -12,6 +12,7 @@ import { import { getInterpretation } from './lib/interpretation.util'; import { DepartmentOverviewQueryDto, + AttentionListQueryDto, FacultyTrendsQueryDto, FacultyReportQueryDto, FacultyReportCommentsQueryDto, @@ -76,6 +77,22 @@ export class AnalyticsService { ): Promise { const deptCodes = await this.ResolveDepartmentCodes(semesterId); + if (!(await this.IsProgramCodeInScope(semesterId, query.programCode))) { + const lastRefreshedAt = await this.GetLastRefreshedAt(); + return { + summary: { + totalFaculty: 0, + totalSubmissions: 0, + totalAnalyzed: 0, + positiveCount: 0, + negativeCount: 0, + neutralCount: 0, + }, + faculty: [], + lastRefreshedAt, + }; + } + const prevSemRows: { id: string }[] = await this.em.execute( `SELECT s2.id FROM semester s2 WHERE s2.campus_id = (SELECT s1.campus_id FROM semester s1 WHERE s1.id = ?) @@ -171,9 +188,16 @@ export class AnalyticsService { async GetAttentionList( semesterId: string, + query: AttentionListQueryDto, ): Promise { + const { programCode } = query; const deptCodes = await this.ResolveDepartmentCodes(semesterId); + if (!(await this.IsProgramCodeInScope(semesterId, programCode))) { + const lastRefreshedAt = await this.GetLastRefreshedAt(); + return { items: [], lastRefreshedAt }; + } + const flagMap = new Map< string, { @@ -211,25 +235,49 @@ export class AnalyticsService { ATTENTION_THRESHOLDS.MIN_R2_FOR_TREND, ]; + let fromClause: string; + let colPrefix: string; + let deptPrefix: string; + let extraFilters = ''; + + if (programCode) { + fromClause = `FROM mv_faculty_trends t + JOIN mv_faculty_semester_stats s + ON s.faculty_id = t.faculty_id + AND s.department_code_snapshot = t.department_code_snapshot`; + colPrefix = 't.'; + deptPrefix = 't.'; + extraFilters = ` AND s.semester_id = ? AND s.program_code_snapshot = ?`; + params.push(semesterId, programCode); + } else { + fromClause = 'FROM mv_faculty_trends'; + colPrefix = ''; + deptPrefix = ''; + } + let deptFilter = ''; if (deptCodes !== null) { - deptFilter = ` AND department_code_snapshot = ANY(?)`; + deptFilter = ` AND ${deptPrefix}department_code_snapshot = ANY(?)`; params.push(pgArray(deptCodes)); } const sql = ` - SELECT faculty_id, faculty_name_snapshot AS faculty_name, - department_code_snapshot AS department_code, - score_slope, score_r2, sentiment_slope, sentiment_r2 - FROM mv_faculty_trends - WHERE semester_count >= ? + SELECT ${colPrefix}faculty_id, + ${colPrefix}faculty_name_snapshot AS faculty_name, + ${colPrefix}department_code_snapshot AS department_code, + ${colPrefix}score_slope, ${colPrefix}score_r2, + ${colPrefix}sentiment_slope, ${colPrefix}sentiment_r2 + ${fromClause} + WHERE ${colPrefix}semester_count >= ? AND ( - (score_slope < 0 AND score_r2 >= ?) - OR (sentiment_slope < 0 AND sentiment_r2 >= ?) - )${deptFilter} + (${colPrefix}score_slope < 0 AND ${colPrefix}score_r2 >= ?) + OR (${colPrefix}sentiment_slope < 0 AND ${colPrefix}sentiment_r2 >= ?) + )${extraFilters}${deptFilter} `; - // The R2 threshold is used twice in the SQL + // PARAM CONTRACT: params[0] = minSemesters, params[1] = minR2 (used twice in SQL). + // params[2..] = optional filters pushed conditionally: [semesterId?, programCode?, deptCodes?]. + // Expansion duplicates minR2 for the two R2 comparisons, then appends the rest. const sqlParams = [params[0], params[1], params[1], ...params.slice(2)]; const rows = await this.em.execute(sql, sqlParams); @@ -287,6 +335,12 @@ export class AnalyticsService { params.push(pgArray(deptCodes)); } + let programFilter = ''; + if (programCode) { + programFilter = ` AND program_code_snapshot = ?`; + params.push(programCode); + } + const sql = ` SELECT faculty_id, faculty_name_snapshot AS faculty_name, department_code_snapshot AS department_code, @@ -295,7 +349,7 @@ export class AnalyticsService { FROM mv_faculty_semester_stats WHERE semester_id = ? AND analyzed_count >= ? - AND ABS((avg_normalized_score / 100.0) - (positive_count::float / analyzed_count)) > ?${deptFilter} + AND ABS((avg_normalized_score / 100.0) - (positive_count::float / analyzed_count)) > ?${deptFilter}${programFilter} `; const rows = await this.em.execute(sql, params); @@ -333,6 +387,12 @@ export class AnalyticsService { params.push(pgArray(deptCodes)); } + let programFilter = ''; + if (programCode) { + programFilter = ` AND program_code_snapshot = ?`; + params.push(programCode); + } + const sql = ` SELECT faculty_id, faculty_name_snapshot AS faculty_name, department_code_snapshot AS department_code, @@ -340,7 +400,7 @@ export class AnalyticsService { FROM mv_faculty_semester_stats WHERE semester_id = ? AND submission_count > 0 - AND (analyzed_count::float / submission_count) < 0.5${deptFilter} + AND (analyzed_count::float / submission_count) < 0.5${deptFilter}${programFilter} `; const rows = await this.em.execute(sql, params); @@ -998,6 +1058,17 @@ export class AnalyticsService { return rows[0]?.value ?? null; } + private async IsProgramCodeInScope( + semesterId: string, + programCode?: string, + ): Promise { + if (!programCode) return true; + const allowedCodes = + await this.scopeResolver.ResolveProgramCodes(semesterId); + if (allowedCodes === null) return true; + return allowedCodes.includes(programCode); + } + private async ResolveDepartmentCodes( semesterId: string, ): Promise { diff --git a/src/modules/analytics/dto/analytics-query.dto.ts b/src/modules/analytics/dto/analytics-query.dto.ts index 0fece42..08f630c 100644 --- a/src/modules/analytics/dto/analytics-query.dto.ts +++ b/src/modules/analytics/dto/analytics-query.dto.ts @@ -8,8 +8,9 @@ import { IsInt, Min, Max, + MaxLength, } from 'class-validator'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; export class DepartmentOverviewQueryDto { @ApiProperty({ description: 'Semester UUID to query analytics for' }) @@ -19,7 +20,12 @@ export class DepartmentOverviewQueryDto { @ApiPropertyOptional({ description: 'Optional program code filter', }) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) @IsString() + @IsNotEmpty() + @MaxLength(20) @IsOptional() programCode?: string; } @@ -28,6 +34,16 @@ export class AttentionListQueryDto { @ApiProperty({ description: 'Semester UUID to query analytics for' }) @IsUUID() semesterId!: string; + + @ApiPropertyOptional({ description: 'Optional program code filter' }) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) + @IsString() + @IsNotEmpty() + @MaxLength(20) + @IsOptional() + programCode?: string; } export class FacultyTrendsQueryDto { diff --git a/src/modules/common/services/scope-resolver.service.spec.ts b/src/modules/common/services/scope-resolver.service.spec.ts index 419d3f1..d6ae3a7 100644 --- a/src/modules/common/services/scope-resolver.service.spec.ts +++ b/src/modules/common/services/scope-resolver.service.spec.ts @@ -192,4 +192,78 @@ describe('ScopeResolverService', () => { expect(result).toBeNull(); expect(em.find).not.toHaveBeenCalled(); }); + + // ─── ResolveProgramCodes ───────────────────────────────────────── + + describe('ResolveProgramCodes', () => { + it('should return null for SUPER_ADMIN', async () => { + const user = createUser([UserRole.SUPER_ADMIN]); + currentUserService.getOrFail.mockReturnValue(user); + + const result = await service.ResolveProgramCodes(semesterId); + + expect(result).toBeNull(); + expect(em.find).not.toHaveBeenCalled(); + }); + + it('should return null for DEAN', async () => { + const user = createUser([UserRole.DEAN]); + currentUserService.getOrFail.mockReturnValue(user); + + const result = await service.ResolveProgramCodes(semesterId); + + expect(result).toBeNull(); + expect(em.find).not.toHaveBeenCalled(); + }); + + it('should return specific codes for CHAIRPERSON', async () => { + const user = createUser([UserRole.CHAIRPERSON]); + currentUserService.getOrFail.mockReturnValue(user); + + em.find + .mockResolvedValueOnce([{ moodleCategory: { name: 'BSCS' } }]) + .mockResolvedValueOnce([{ id: 'prog-1', code: 'BSCS' }]); + + const result = await service.ResolveProgramCodes(semesterId); + + expect(result).toEqual(['BSCS']); + }); + + it('should return empty array when chairperson has no institutional roles', async () => { + const user = createUser([UserRole.CHAIRPERSON]); + currentUserService.getOrFail.mockReturnValue(user); + + em.find.mockResolvedValueOnce([]); + + const result = await service.ResolveProgramCodes(semesterId); + + expect(result).toEqual([]); + }); + + it('should throw ForbiddenException for unsupported roles', async () => { + const user = createUser([UserRole.STUDENT]); + currentUserService.getOrFail.mockReturnValue(user); + + await expect(service.ResolveProgramCodes(semesterId)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + // ─── ResolveProgramIds (regression after refactor) ─────────────── + + describe('ResolveProgramIds', () => { + it('should still return UUIDs after refactor', async () => { + const user = createUser([UserRole.CHAIRPERSON]); + currentUserService.getOrFail.mockReturnValue(user); + + em.find + .mockResolvedValueOnce([{ moodleCategory: { name: 'BSCS' } }]) + .mockResolvedValueOnce([{ id: 'prog-uuid-1', code: 'BSCS' }]); + + const result = await service.ResolveProgramIds(semesterId); + + expect(result).toEqual(['prog-uuid-1']); + }); + }); }); diff --git a/src/modules/common/services/scope-resolver.service.ts b/src/modules/common/services/scope-resolver.service.ts index 9bdb43d..640a88e 100644 --- a/src/modules/common/services/scope-resolver.service.ts +++ b/src/modules/common/services/scope-resolver.service.ts @@ -54,26 +54,39 @@ export class ScopeResolverService { } if (user.roles.includes(UserRole.CHAIRPERSON)) { - const institutionalRoles = await this.em.find( - UserInstitutionalRole, - { user: user.id, role: UserRole.CHAIRPERSON }, - { populate: ['moodleCategory'] }, + const programs = await this.resolveChairpersonPrograms( + user.id, + semesterId, ); + return programs.map((p) => p.id); + } - const programCodes = institutionalRoles - .filter((ir) => ir.moodleCategory?.name != null) - .map((ir) => ir.moodleCategory.name); + throw new ForbiddenException( + 'User does not have a role with scope access.', + ); + } - if (programCodes.length === 0) { - return []; - } + /** + * Resolves program codes the user is allowed to access for a given semester. + * Returns `null` for unrestricted access (super admin, dean), or `string[]` of program codes. + */ + async ResolveProgramCodes(semesterId: string): Promise { + const user = this.currentUserService.getOrFail(); - const programs = await this.em.find(Program, { - code: { $in: programCodes }, - department: { semester: semesterId }, - }); + if (user.roles.includes(UserRole.SUPER_ADMIN)) { + return null; + } - return programs.map((p) => p.id); + if (user.roles.includes(UserRole.DEAN)) { + return null; + } + + if (user.roles.includes(UserRole.CHAIRPERSON)) { + const programs = await this.resolveChairpersonPrograms( + user.id, + semesterId, + ); + return programs.map((p) => p.code); } throw new ForbiddenException( @@ -81,6 +94,28 @@ export class ScopeResolverService { ); } + private async resolveChairpersonPrograms( + userId: string, + semesterId: string, + ): Promise { + const institutionalRoles = await this.em.find( + UserInstitutionalRole, + { user: userId, role: UserRole.CHAIRPERSON }, + { populate: ['moodleCategory'] }, + ); + + const programCodes = institutionalRoles + .filter((ir) => ir.moodleCategory?.name != null) + .map((ir) => ir.moodleCategory.name); + + if (programCodes.length === 0) return []; + + return this.em.find(Program, { + code: { $in: programCodes }, + department: { semester: semesterId }, + }); + } + private async resolveDeanDepartments( userId: string, semesterId: string, diff --git a/src/modules/curriculum/services/curriculum.service.spec.ts b/src/modules/curriculum/services/curriculum.service.spec.ts index 65fe3c5..ffe40a5 100644 --- a/src/modules/curriculum/services/curriculum.service.spec.ts +++ b/src/modules/curriculum/services/curriculum.service.spec.ts @@ -11,7 +11,10 @@ import { ScopeResolverService } from 'src/modules/common/services/scope-resolver describe('CurriculumService', () => { let service: CurriculumService; let em: { findOne: jest.Mock; findAndCount: jest.Mock }; - let scopeResolver: { ResolveDepartmentIds: jest.Mock }; + let scopeResolver: { + ResolveDepartmentIds: jest.Mock; + ResolveProgramIds: jest.Mock; + }; const semesterId = 'semester-1'; const deptId = 'dept-1'; @@ -27,6 +30,7 @@ describe('CurriculumService', () => { scopeResolver = { ResolveDepartmentIds: jest.fn(), + ResolveProgramIds: jest.fn().mockResolvedValue(null), }; const module: TestingModule = await Test.createTestingModule({ @@ -407,9 +411,58 @@ describe('CurriculumService', () => { expect.objectContaining({ limit: 15, offset: 15 }), ); }); + + it('should narrow programs for chairperson to assigned programs only', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue([deptId]); + scopeResolver.ResolveProgramIds.mockResolvedValue([programId]); + em.findAndCount.mockResolvedValue([ + [ + { + id: programId, + code: 'BSCS', + name: 'BS Computer Science', + department: { id: deptId }, + }, + ], + 1, + ]); + + const result = await service.ListPrograms({ semesterId }); + + expect(result.data).toHaveLength(1); + const findCall = em.findAndCount.mock.calls[0] as unknown[]; + expect(findCall[1]).toEqual( + expect.objectContaining({ id: { $in: [programId] } }), + ); + }); + + it('should not narrow programs for dean (unrestricted)', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue([deptId]); + scopeResolver.ResolveProgramIds.mockResolvedValue(null); + em.findAndCount.mockResolvedValue([[], 0]); + + await service.ListPrograms({ semesterId }); + + const findCall = em.findAndCount.mock.calls[0] as unknown[]; + expect(findCall[1]).not.toHaveProperty('id'); + }); + + it('should return empty page when chairperson has no assigned programs', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue([deptId]); + scopeResolver.ResolveProgramIds.mockResolvedValue([]); + + const result = await service.ListPrograms({ semesterId }); + + expect(result.data).toEqual([]); + expect(result.meta).toEqual(emptyMeta()); + expect(em.findAndCount).not.toHaveBeenCalled(); + }); }); - // ─── ListCourses ────────────────────────────────────────────────── + // ─── ListCourses ─────────────────────────────��──────────────────── describe('ListCourses', () => { it('should throw 400 when neither programId nor departmentId is provided', async () => { diff --git a/src/modules/curriculum/services/curriculum.service.ts b/src/modules/curriculum/services/curriculum.service.ts index b04562e..3d9b76f 100644 --- a/src/modules/curriculum/services/curriculum.service.ts +++ b/src/modules/curriculum/services/curriculum.service.ts @@ -114,6 +114,16 @@ export class CurriculumService { department: departmentFilter, }; + const programIds = await this.scopeResolverService.ResolveProgramIds( + query.semesterId, + ); + if (programIds !== null) { + if (programIds.length === 0) { + return this.BuildEmptyPage(page, limit); + } + filter.id = { $in: programIds }; + } + this.ApplySearchFilter(filter, query.search, ['code', 'name']); const [programs, totalItems] = await this.em.findAndCount(Program, filter, { From 3baacf9a80b96f484c157b383750b4d4a66a0a9f Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:44:33 +0800 Subject: [PATCH 2/4] [STAGING] FAC-114 feat: add CSV test submission generator (#273) (#274) * chore: initialize tech-spec WIP for CSV test submission generator https://claude.ai/code/session_01VbeiqCo7jDVMYYi9dgp5kr * chore: update tech-spec WIP with deep investigation findings https://claude.ai/code/session_01VbeiqCo7jDVMYYi9dgp5kr * chore: finalize tech-spec for CSV test submission generator Spec passed two rounds of adversarial review. Key decisions: - Direct submitQuestionnaire() calls instead of ingestion pipeline - Import QuestionnaireModule for QuestionnaireService access - em.clear() on failure for EM state recovery - OpenAI gpt-4o-mini for multilingual comment generation - Admin console builder UI with two-track selection flow https://claude.ai/code/session_01VbeiqCo7jDVMYYi9dgp5kr * feat: add CSV test submission generator API Add backend endpoints for generating realistic test questionnaire submissions with OpenAI-powered multilingual comments. New endpoints: - GET /admin/generate-submissions/status (lightweight pre-check) - POST /admin/generate-submissions/preview (generate with comments) - POST /admin/generate-submissions/commit (submit via QuestionnaireService) - GET /admin/filters/faculty, courses, questionnaire-types, versions Includes CommentGeneratorService with gpt-4o-mini integration and fallback, AdminGenerateService with preview/commit flow, and 31 unit tests across 3 test suites. --------- Co-authored-by: Claude --- ...tech-spec-csv-test-submission-generator.md | 899 ++++++++++++++++++ src/modules/admin/admin-filters.controller.ts | 52 + .../admin/admin-generate.controller.ts | 63 ++ src/modules/admin/admin.module.ts | 24 +- .../dto/requests/filter-courses-query.dto.ts | 9 + .../dto/requests/filter-versions-query.dto.ts | 9 + .../requests/generate-commit.request.dto.ts | 59 ++ .../requests/generate-preview.request.dto.ts | 19 + .../requests/submission-status-query.dto.ts | 19 + .../responses/commit-result.response.dto.ts | 35 + .../responses/filter-course.response.dto.ts | 21 + .../responses/filter-faculty.response.dto.ts | 21 + .../responses/filter-version.response.dto.ts | 21 + .../generate-preview.response.dto.ts | 72 ++ .../submission-status.response.dto.ts | 12 + src/modules/admin/lib/question-flattener.ts | 53 ++ .../__tests__/admin-filters.service.spec.ts | 126 +++ .../__tests__/admin-generate.service.spec.ts | 441 +++++++++ .../comment-generator.service.spec.ts | 174 ++++ .../admin/services/admin-filters.service.ts | 80 +- .../admin/services/admin-generate.service.ts | 357 +++++++ .../services/comment-generator.service.ts | 122 +++ 22 files changed, 2685 insertions(+), 3 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/tech-spec-csv-test-submission-generator.md create mode 100644 src/modules/admin/admin-generate.controller.ts create mode 100644 src/modules/admin/dto/requests/filter-courses-query.dto.ts create mode 100644 src/modules/admin/dto/requests/filter-versions-query.dto.ts create mode 100644 src/modules/admin/dto/requests/generate-commit.request.dto.ts create mode 100644 src/modules/admin/dto/requests/generate-preview.request.dto.ts create mode 100644 src/modules/admin/dto/requests/submission-status-query.dto.ts create mode 100644 src/modules/admin/dto/responses/commit-result.response.dto.ts create mode 100644 src/modules/admin/dto/responses/filter-course.response.dto.ts create mode 100644 src/modules/admin/dto/responses/filter-faculty.response.dto.ts create mode 100644 src/modules/admin/dto/responses/filter-version.response.dto.ts create mode 100644 src/modules/admin/dto/responses/generate-preview.response.dto.ts create mode 100644 src/modules/admin/dto/responses/submission-status.response.dto.ts create mode 100644 src/modules/admin/lib/question-flattener.ts create mode 100644 src/modules/admin/services/__tests__/admin-filters.service.spec.ts create mode 100644 src/modules/admin/services/__tests__/admin-generate.service.spec.ts create mode 100644 src/modules/admin/services/__tests__/comment-generator.service.spec.ts create mode 100644 src/modules/admin/services/admin-generate.service.ts create mode 100644 src/modules/admin/services/comment-generator.service.ts diff --git a/_bmad-output/implementation-artifacts/tech-spec-csv-test-submission-generator.md b/_bmad-output/implementation-artifacts/tech-spec-csv-test-submission-generator.md new file mode 100644 index 0000000..a2168fa --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-csv-test-submission-generator.md @@ -0,0 +1,899 @@ +--- +title: 'CSV Test Submission Generator' +slug: 'csv-test-submission-generator' +created: '2026-04-04' +status: 'completed' +stepsCompleted: [1, 2, 3, 4] +tech_stack: + [ + 'NestJS', + 'MikroORM', + 'PostgreSQL', + 'OpenAI SDK', + 'BullMQ', + 'Zod', + 'Jest', + 'React 19', + 'Vite', + 'TanStack Query v5', + 'Zustand', + 'shadcn/ui', + 'Tailwind CSS 4', + ] +files_to_modify: + - 'api: src/modules/admin/admin.module.ts' + - 'api: src/modules/admin/admin-filters.controller.ts' + - 'api: src/modules/admin/services/admin-filters.service.ts' + - 'api: src/modules/admin/admin-generate.controller.ts (NEW)' + - 'api: src/modules/admin/services/admin-generate.service.ts (NEW)' + - 'api: src/modules/admin/services/comment-generator.service.ts (NEW)' + - 'api: src/modules/admin/lib/question-flattener.ts (NEW)' + - 'api: src/modules/admin/dto/ (NEW DTOs)' + - 'admin: src/features/submission-generator/generator-page.tsx (NEW)' + - 'admin: src/features/submission-generator/components/selection-form.tsx (NEW)' + - 'admin: src/features/submission-generator/components/preview-panel.tsx (NEW)' + - 'admin: src/features/submission-generator/components/commit-result-dialog.tsx (NEW)' + - 'admin: src/features/submission-generator/use-generator-filters.ts (NEW)' + - 'admin: src/features/submission-generator/use-generate-submissions.ts (NEW)' + - 'admin: src/types/api.ts' + - 'admin: src/routes.tsx' + - 'admin: src/components/layout/app-shell.tsx' +code_patterns: + - 'EntityManager direct injection (admin services)' + - 'FilterOptionResponseDto for filter endpoints' + - '@UseJwtGuard(UserRole.SUPER_ADMIN) on all admin endpoints' + - 'MikroOrmModule.forFeature() for entity registration' + - 'OpenAI client: new OpenAI({ apiKey: env.OPENAI_API_KEY })' + - 'EnrollmentRole enum: STUDENT, EDITING_TEACHER' + - 'Admin console: feature-based folders with co-located hooks (use-*.ts)' + - 'Admin console: native fetch via apiClient() wrapper, no Axios' + - 'Admin console: React Query with 5-min staleTime, enabled flag for dependent queries' + - 'Admin console: shadcn/ui components, Lucide icons, sonner toasts' + - 'Admin console: useState for form state, no form library' +test_patterns: + - 'Mock EntityManager with jest.fn() methods' + - 'NestJS TestingModule with useValue mocks' + - 'Test files: *.spec.ts or __tests__/*.spec.ts' + - 'Admin console: no test runner configured' +--- + +# Tech-Spec: CSV Test Submission Generator + +**Created:** 2026-04-04 + +## Overview + +### Problem Statement + +Manually constructing CSV files with realistic submission data for questionnaire ingestion is too slow for rapid analytics testing. The team needs volume (up to ~50 submissions per course) with realistic, multilingual qualitative feedback to properly exercise analytics dashboards (sentiment analysis, topic modeling, etc.). + +### Solution + +Backend APIs that generate realistic test submissions for a given questionnaire version — pulling real identities from the DB (faculty, students, courses), generating varied numeric answers, and calling the OpenAI API to produce code-switched student feedback in Cebuano/Tagalog/English (English-heavy distribution). Two-phase flow: preview all available student submissions, then commit by calling `QuestionnaireService.submitQuestionnaire()` directly per row (bypassing the ingestion pipeline to avoid complex cross-module dependency chains). An admin console UI provides a builder flow for selecting the generation context and reviewing results before committing. + +### Scope + +**In Scope:** + +- 4 new filter endpoints for the admin console builder flow +- 2 new action endpoints (preview + commit) for submission generation +- Pull valid faculty, courses, students from enrollment data +- Answer generation with interesting distributions (not uniform random) +- OpenAI integration for multilingual comment generation (Cebuano, Tagalog, English, mixed — weighted English) +- Auto-count: generate for ALL available students (enrolled minus already submitted) +- Preview-then-commit flow: generate full preview → user reviews → commit all +- Commit via direct `QuestionnaireService.submitQuestionnaire()` calls (no ingestion pipeline dependency) +- Admin console UI: builder page with two-track selection, preview table, commit action + +**Out of Scope:** + +- Partial generation (subset of available students) +- Non-questionnaire data generation +- Semester selection (auto-derived from course hierarchy) + +## Context for Development + +### Codebase Patterns + +**Admin Module Pattern (API — `api.faculytics`):** + +- Controllers use `@UseJwtGuard(UserRole.SUPER_ADMIN)` for all endpoints +- Services inject `EntityManager` directly (not custom repositories) +- Filter endpoints return `FilterOptionResponseDto[]` with typed Query DTOs +- Module registers entities via `MikroOrmModule.forFeature([...])` +- Existing entities in admin module: Campus, Course, Department, Enrollment, Program, Semester, User + +**Admin Console Pattern (Frontend — `admin.faculytics`):** + +- Feature-based folder structure: `src/features//` with co-located components + hooks +- API calls via `apiClient(path, options)` — native fetch wrapper, auto-prefixes `/api/v1`, injects Bearer token, handles 401 refresh +- React Query hooks with `queryKey` including `activeEnvId`, `enabled` flag for dependent/cascading queries, 5-min staleTime +- Forms use raw `useState` — no form library (React Hook Form, Formik, etc.) +- UI: shadcn/ui (new-york style), Lucide icons, sonner toasts +- Mutations: `useMutation` with `onSuccess` → `toast.success()` + `queryClient.invalidateQueries()`, `onError` → `toast.error()` +- Cascading dropdowns pattern: parent selection resets child values, child queries use `enabled: !!parentValue` +- Data tables: shadcn `Table` components with optional pagination +- Detail views: shadcn `Sheet` (slide-over panel) + +**OpenAI Integration Pattern (from analysis module):** + +```typescript +constructor() { + this.openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }); +} +``` + +- Models in use: `gpt-5` (ChatKit), `gpt-4o-mini` (topic labels) +- For comment generation: `gpt-4o-mini` is appropriate (cheap, fast, sufficient quality) + +**submitQuestionnaire() — Direct Call Pattern:** +The commit path bypasses the ingestion pipeline (IngestionEngine, IngestionMapperService, SourceAdapters) entirely. Instead, it calls `QuestionnaireService.submitQuestionnaire()` directly per row. This avoids a deep transitive dependency chain (`IngestionEngine → IngestionMapperService → IngestionMappingLoader (REQUEST-scoped) → DataLoaderModule`) that would cause NestJS DI scope-resolution errors in the singleton AdminModule. + +`submitQuestionnaire()` validates: version active, enrollments exist (STUDENT + EDITING_TEACHER), unique constraint, answers in range `[1, maxScore]`, qualitative comment if required by schema. It also handles post-submission side effects: analysis job enqueuing (sentiment, embeddings), cache invalidation, score calculation. + +**Critical Constraint — Unique Submission:** + +``` +UNIQUE(respondent, faculty, questionnaireVersion, semester, course) +``` + +- Generator must exclude students who already have submissions for the given version+faculty+course+semester combo +- Available students = enrolled STUDENT users - already submitted users + +**Enrollment Query Patterns:** + +```typescript +// Faculty's courses +em.find( + Enrollment, + { user: facultyId, role: 'editingteacher', isActive: true }, + { populate: ['course'] }, +); + +// Course's students +em.find( + Enrollment, + { course: courseId, role: 'student', isActive: true }, + { populate: ['user'] }, +); +``` + +**Questionnaire Types:** + +- Existing endpoint: `GET /questionnaire-types` with optional `isSystem` filter +- Can reuse via `QuestionnaireTypeService.FindAll()` or query directly +- Three system types: FACULTY_IN_CLASSROOM, FACULTY_OUT_OF_CLASSROOM, FACULTY_FEEDBACK + +**submitQuestionnaire() Parameters** (called via `QuestionnaireService`): + +- `versionId: string` — questionnaire version UUID +- `respondentId: string` — student user UUID +- `facultyId: string` — faculty user UUID +- `semesterId: string` — semester UUID (resolved from course hierarchy) +- `courseId?: string` — course UUID (optional) +- `answers: Record` — `{ [questionId]: numericValue }`, all questions must be present, values in `[1, maxScore]` +- `qualitativeComment?: string` — must be non-empty if `schema.qualitativeFeedback.required === true` +- Returns: `SubmitQuestionnaireResponse { id: string }` +- Throws: `ConflictException` on unique constraint violation (caught per-row in commit loop) + +**GetAllQuestions() utility** is a method on `QuestionnaireService`. Recursively flattens `QuestionNode` instances from nested `schemaSnapshot.sections`. Returns `QuestionNode[]` with `{ id, text, type, dimensionCode, required, order }`. **Note: does NOT include sectionName** — a modified traversal is needed for the preview that tracks parent `SectionNode.title`. + +**qualitativeFeedback schema field:** + +```typescript +schema.qualitativeFeedback?: { enabled: boolean, required: boolean, maxLength: number } +``` + +- Comment generation should be conditional on `qualitativeFeedback.enabled === true` +- If `required === true`, fallback comments must be non-empty strings +- If not enabled, skip comment generation entirely (save OpenAI cost) + +### Files to Reference + +**API (`api.faculytics`):** + +| File | Purpose | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `src/modules/admin/admin.module.ts` | Admin module registration — add new entities/services/controllers here | +| `src/modules/admin/admin-filters.controller.ts` | Existing filter endpoints — pattern to follow for new filters | +| `src/modules/admin/services/admin-filters.service.ts` | Existing filter service — pattern to follow | +| `src/modules/questionnaires/questionnaire.controller.ts:331-480` | Existing csv-template + ingest endpoints | +| `src/modules/questionnaires/questionnaires.module.ts` | QuestionnaireModule — exports `QuestionnaireService`; import this module in AdminModule | +| `src/modules/questionnaires/services/questionnaire.service.ts:577+` | `submitQuestionnaire()` — full validation chain, called directly per row | +| `src/modules/questionnaires/services/questionnaire.service.ts:867-881` | `GetAllQuestions()` — schema flattening utility (returns `QuestionNode[]`, no sectionName) | +| `src/entities/questionnaire-version.entity.ts` | Version entity with schemaSnapshot | +| `src/entities/questionnaire-submission.entity.ts` | Submission entity — unique constraint, required fields | +| `src/entities/enrollment.entity.ts` | Enrollment entity — user+course+role+isActive | +| `src/modules/questionnaires/lib/questionnaire.types.ts` | EnrollmentRole enum, RespondentRole enum | +| `src/modules/questionnaires/services/questionnaire-type.service.ts` | QuestionnaireType queries | +| `src/modules/analysis/services/topic-label.service.ts` | OpenAI usage pattern to follow | +| `src/configurations/env/openai.env.ts` | OpenAI API key env config | +| `src/modules/questionnaires/ingestion/dto/raw-submission-data.dto.ts` | `RawSubmissionData` + `RawAnswerData` DTOs | + +**Admin Console (`../admin.faculytics`):** + +| File | Purpose | +| ------------------------------------------------- | ------------------------------------------------------------------- | +| `src/lib/api-client.ts` | Fetch wrapper — use `apiClient(path, options)` for all API calls | +| `src/features/admin/users-page.tsx:147-242` | Cascading dropdown pattern (campus → department → program) | +| `src/features/admin/use-admin-filters.ts` | React Query hooks for filter endpoints — pattern to replicate | +| `src/features/admin/role-action-dialog.tsx` | Multi-field form + preview summary pattern | +| `src/features/moodle-sync/sync-history-table.tsx` | Data table with pagination pattern | +| `src/features/admin/use-institutional-roles.ts` | `useMutation` with toast + query invalidation pattern | +| `src/types/api.ts` | Shared API type definitions — add new types here | +| `src/routes.tsx` | React Router config — add new route here | +| `src/components/layout/app-shell.tsx` | Main layout with sidebar — add nav item | + +### Technical Decisions + +- **OpenAI over Anthropic**: Reuse existing `OPENAI_API_KEY` env var; `gpt-4o-mini` for comment generation (cheap, fast) +- **Language distribution**: ~60% English, ~15% Tagalog, ~15% Cebuano, ~10% mixed/code-switched +- **Auto-count**: Generate for all available students (enrolled - already submitted), no manual count parameter in MVP +- **Preview all, commit all**: No partial generation — frontend holds full preview, sends back for commit +- **No server-side state**: Preview returns JSON rows, frontend POSTs them back for commit +- **Direct submitQuestionnaire() over ingestion pipeline**: Commit endpoint calls `QuestionnaireService.submitQuestionnaire()` directly per row in a loop with forked EntityManager. This avoids importing `IngestionEngine`/`IngestionMapperService` which have deep transitive dependencies (request-scoped `IngestionMappingLoader`, `DataLoaderModule`) that cause NestJS DI scope conflicts. Faculty/course/semester lookups are done once upfront; only student lookup varies per row. Results are aggregated manually into the same `CommitResultDto` shape. +- **Import QuestionnaireModule**: `QuestionnaireModule` exports `QuestionnaireService` and `QuestionnaireTypeService`. Import the whole module in `AdminModule` to get both services cleanly. +- **Semester auto-derived**: From course.program.department.semester — no user selection needed +- **Two-track builder flow**: Identity (faculty → course) + Instrument (type → version) are independent selections +- **Answer distribution**: Per-student tendency approach — pick a base tendency scaled to `[1, maxScore]` (e.g., `tendency = 1 + Math.random() * (maxScore - 1) * 0.6 + (maxScore - 1) * 0.3`), add noise per question, clamp to `[1, maxScore]`. Produces realistic inter-student variation with intra-student consistency. Works correctly for any maxScore (3, 4, 5, etc.). +- **Conditional comment generation**: Only generate comments if `schema.qualitativeFeedback.enabled === true`. If `required === true`, ensure fallback comments are non-empty. Skip OpenAI call entirely if not enabled. +- **Modified question flattener**: The existing `GetAllQuestions()` returns `QuestionNode[]` without section names. A local `GetAllQuestionsWithSections()` helper tracks parent `SectionNode.title` during traversal to return `{ ...QuestionNode, sectionName: string }[]`. This is not code duplication — it's an extension that adds sectionName tracking the original does not provide. + +### API Surface + +**Filter Endpoints (AdminFiltersController):** + +| Method | Path | Query Params | Returns | +| ------ | --------------------------------------- | ---------------------------- | ----------------------------------- | +| GET | `/admin/filters/faculty` | — | `{ id, username, fullName }[]` | +| GET | `/admin/filters/courses` | `facultyUsername` (required) | `{ id, shortname, fullname }[]` | +| GET | `/admin/filters/questionnaire-types` | — | `{ id, name, code }[]` | +| GET | `/admin/filters/questionnaire-versions` | `typeId` (required) | `{ id, versionNumber, isActive }[]` | + +**Generator Endpoints (new AdminGenerateController):** + +| Method | Path | Body | Returns | +| ------ | ------------------------------------- | ------------------------------------------------- | ---------------------------------------------- | +| POST | `/admin/generate-submissions/preview` | `{ versionId, facultyUsername, courseShortname }` | Preview response (metadata + questions + rows) | +| POST | `/admin/generate-submissions/commit` | `{ versionId, rows }` | `CommitResultDto` | + +**Preview Response Shape:** + +```typescript +{ + metadata: { + faculty: { username: string, fullName: string }, + course: { shortname: string, fullname: string }, + semester: { code: string, label: string, academicYear: string }, + version: { id: string, versionNumber: number }, + maxScore: number, + totalEnrolled: number, + alreadySubmitted: number, + availableStudents: number, + generatingCount: number, + }, + questions: [{ id: string, text: string, sectionName: string }], + rows: [{ + externalId: string, + username: string, + facultyUsername: string, + courseShortname: string, + answers: Record, + comment?: string, + }], +} +``` + +**Commit Request Shape:** + +```typescript +{ + versionId: string, + rows: [{ + externalId: string, + username: string, + facultyUsername: string, + courseShortname: string, + answers: Record, + comment?: string, + }], +} +``` + +## Implementation Plan + +### Tasks + +#### Phase 1: API Backend + +- [x] **Task 1: Create DTOs for generator endpoints** + - File: `api.faculytics/src/modules/admin/dto/generate-submissions.dto.ts` (NEW) + - Action: Create request/response DTOs: + - `GeneratePreviewRequestDto` — `{ versionId: string, facultyUsername: string, courseShortname: string }` with class-validator decorators + - `GeneratePreviewResponseDto` — metadata, questions, and rows as described in API Surface + - `GenerateCommitRequestDto` — `{ versionId: string, rows: GeneratedRowDto[] }` with nested validation + - `GeneratedRowDto` — `{ externalId, username, facultyUsername, courseShortname, answers: Record, comment?: string }` + - `CommitResultDto` — `{ commitId: string, total: number, successes: number, failures: number, dryRun: boolean, records: CommitRecordResult[] }`. This is a standalone DTO in the admin module — NOT imported from the ingestion pipeline's `IngestionResultDto`. Same shape but decoupled to avoid cross-module file coupling. + - `CommitRecordResult` — `{ externalId: string, success: boolean, error?: string, internalId?: string }` + - File: `api.faculytics/src/modules/admin/dto/filter-faculty.dto.ts` (NEW) + - Action: Create `FilterFacultyResponseDto` with `{ id, username, fullName }` and static `Map()` method. Create `FilterCoursesQueryDto` with required `facultyUsername` param. Create `FilterCourseResponseDto` with `{ id, shortname, fullname }`. Create `FilterQuestionnaireVersionsQueryDto` with required `typeId` param. Create `FilterVersionResponseDto` with `{ id, versionNumber }` and static `Map()` method. + - Notes: Follow existing `FilterOptionResponseDto` pattern with static `Map()` factory method. Use `@IsUUID()`, `@IsString()`, `@IsNotEmpty()` validators. For `GenerateCommitRequestDto.rows[].answers` (`Record`): use `@IsObject()` for basic shape validation — per-question answer validation is handled inside `submitQuestionnaire()`, so the DTO does not need to validate individual keys/values. + +- [x] **Task 2: Create GetAllQuestionsWithSections helper** + - File: `api.faculytics/src/modules/admin/lib/question-flattener.ts` (NEW) + - Action: Create a standalone helper function that extends the `GetAllQuestions()` pattern from `QuestionnaireService` (line 867-881): + + ```typescript + interface QuestionWithSection { + id: string; + text: string; + type: string; + dimensionCode: string; + required: boolean; + order: number; + sectionName: string; + } + export function GetAllQuestionsWithSections( + schema: QuestionnaireSchemaSnapshot, + ): QuestionWithSection[]; + ``` + + - Use the same stack-based depth-first traversal as the original + - Track the current `SectionNode.title` as the stack is processed + - Each yielded question includes `sectionName` from its parent section + - Import `QuestionnaireSchemaSnapshot`, `SectionNode`, `QuestionNode` types from `src/modules/questionnaires/lib/questionnaire.types.ts` + + - Notes: This is NOT duplication of `GetAllQuestions()` — it's an extension that adds sectionName tracking the original does not provide. The original returns `QuestionNode[]` without section context, which is insufficient for the preview response. + +- [x] **Task 3: Create CommentGeneratorService** + - File: `api.faculytics/src/modules/admin/services/comment-generator.service.ts` (NEW) + - Action: Create `@Injectable()` service that wraps OpenAI API for generating multilingual student feedback comments. + - Constructor: instantiate `new OpenAI({ apiKey: env.OPENAI_API_KEY })` following the pattern in `topic-label.service.ts` + - Method: `async GenerateComments(count: number, context: { courseName: string, facultyName: string, maxScore: number, maxLength?: number }): Promise` + - Single API call to `gpt-4o-mini` with a structured prompt requesting a JSON array of `count` student feedback comments + - Prompt should specify language distribution: ~60% English, ~15% Tagalog, ~15% Cebuano, ~10% mixed/code-switched + - Prompt should include course/faculty context for realistic feedback + - If `maxLength` is provided, include it in the prompt as a constraint (e.g., "each comment must be under {maxLength} characters") + - Parse response as `JSON.parse()` on the content, validate it's a string array of length `count` + - **Safety net**: After parsing, truncate any comment that exceeds `maxLength` (OpenAI may not always respect the constraint). `submitQuestionnaire()` validates `comment.length > maxLength` and throws `BadRequestException` if exceeded — truncation prevents silent commit failures. + - Fallback: if OpenAI call fails, times out, or returns invalid data, return array of generic fallback comments (e.g., `"Good teaching."`, `"Helpful instructor."`, varied — all under maxLength) so preview still works without error + - Notes: Use `response_format: { type: 'json_object' }` for reliable JSON output. Set a 60-second timeout. + +- [x] **Task 4: Create AdminGenerateService** + - File: `api.faculytics/src/modules/admin/services/admin-generate.service.ts` (NEW) + - Action: Create `@Injectable()` service with two methods: `GeneratePreview()` and `CommitSubmissions()`. + - Inject: `EntityManager`, `CommentGeneratorService`, `QuestionnaireService` (from imported `QuestionnaireModule`). + + **`GeneratePreview(dto: GeneratePreviewRequestDto): Promise`** + 1. Load version by `dto.versionId` with populate `['questionnaire.type']`. Throw `NotFoundException` if not found, `BadRequestException` if not active. + 2. Load faculty user by `dto.facultyUsername` (exact match on `userName`). Throw `NotFoundException` if not found. + 3. Load course by `dto.courseShortname` (exact match on `shortname`) with populate `['program.department.semester']`. Throw `NotFoundException` if not found. + 4. Verify faculty has active `EDITING_TEACHER` enrollment in the course. Throw `BadRequestException` if not. + 5. Resolve semester from `course.program.department.semester`. Throw `BadRequestException` if hierarchy is incomplete. + 6. Query all active `STUDENT` enrollments for the course, populate `['user']`. + 7. Query existing submissions for this `(faculty, version, course, semester)` combo to get already-submitted respondent IDs. + 8. Compute available students = enrolled students minus already-submitted students. + 9. If `availableStudents === 0`, throw `BadRequestException` with descriptive message. + 10. Extract questions using `GetAllQuestionsWithSections(version.schemaSnapshot)` from the local helper (Task 2). This returns `QuestionWithSection[]` with `sectionName` included. + 11. Read `maxScore` from `version.schemaSnapshot.meta.maxScore`. + 12. Generate answers using per-student tendency **scaled to maxScore**: for each student, pick a base tendency (e.g., `tendency = 1 + Math.random() * (maxScore - 1) * 0.6 + (maxScore - 1) * 0.3` — biases toward upper range), then for each question produce `Math.round(clamp(tendency + (Math.random() - 0.5) * 2, 1, maxScore))`. This works correctly for any maxScore (3, 4, 5, etc.). + 13. Check `version.schemaSnapshot.qualitativeFeedback?.enabled`. If enabled, call `CommentGeneratorService.GenerateComments(availableStudents, { courseName, facultyName, maxScore, maxLength: schema.qualitativeFeedback.maxLength })`. If not enabled, skip comment generation (set all comments to `undefined`). If enabled AND `required === true`, ensure fallback comments are non-empty strings. + 14. Build rows array: for each available student, create a `GeneratedRowDto` with `externalId` = `gen_{studentUsername}_{Date.now()}_{index}` (index prevents collision within batch), their username, faculty username, course shortname, generated answers, and assigned comment. + 15. Return `GeneratePreviewResponseDto` with metadata (counts, faculty/course/semester/version info, `maxScore`), questions (id + text + sectionName), and rows. + + **`CommitSubmissions(dto: GenerateCommitRequestDto): Promise`** + 1. Load version by `dto.versionId` with populate `['questionnaire.type']`. Throw `NotFoundException` if not found, `BadRequestException` if not active. + 2. Load faculty by finding the first row's `facultyUsername` (all rows share the same faculty). **Store `faculty.id` as a plain string** — not the entity reference. Throw `NotFoundException` if not found. + 3. Load course by finding the first row's `courseShortname` (all rows share the same course) with populate `['program.department.semester']`. **Store `course.id` and `semester.id` as plain strings.** Throw `NotFoundException` if not found. + 4. Resolve semester from `course.program.department.semester`. Throw `BadRequestException` if hierarchy incomplete. + 5. Loop over `dto.rows`, for each row: + a. Look up student user by `row.username` (exact match on `userName`). If not found, record as failure and continue. + b. Call `this.questionnaireService.submitQuestionnaire({ versionId: dto.versionId, respondentId: student.id, facultyId, semesterId, courseId, answers: row.answers, qualitativeComment: row.comment })`. + - **Note**: `submitQuestionnaire()` signature is `submitQuestionnaire(data: {...}, options?: { skipAnalysis?: boolean })` — it does NOT accept an EntityManager parameter. It uses `this.em` internally and does its own `findOneOrFail()` lookups + `em.persist()` + `em.flush()` per call. + - **Note on field mapping**: The preview row uses `comment`, but `submitQuestionnaire` expects `qualitativeComment`. Map explicitly: `qualitativeComment: row.comment`. + - **Note on answers format**: `submitQuestionnaire` accepts `answers: Record` — the preview row already uses this format, no conversion needed. + c. On success: record `{ externalId: row.externalId, success: true, internalId: response.id }` + d. On any `HttpException` (`ConflictException`, `ForbiddenException`, `BadRequestException`, `NotFoundException`): + - Record `{ externalId: row.externalId, success: false, error: err.message }` + - **Call `this.em.clear()`** to discard dirty EM state from the failed `flush()`. This is critical — MikroORM's EM enters an inconsistent state after a failed flush, and subsequent calls would re-attempt the failed entities. Since we pass IDs (not entity references), `em.clear()` is safe — `submitQuestionnaire()` re-fetches everything by ID internally on the next iteration. + e. On unexpected errors: record failure with `err.message`, call `this.em.clear()`. + 6. Aggregate results into `CommitResultDto` shape: `{ commitId: randomUUID(), total, successes, failures, dryRun: false, records }`. + 7. Return the result. + - Notes: No IngestionEngine, no ArrayAdapter, no IngestionMapperService. `submitQuestionnaire()` manages its own EM operations internally — we do NOT fork the EM or pass it as a parameter. On failure, `em.clear()` resets dirty state so the next row starts clean. All validation (enrollment checks, unique constraints, answer range, qualitative comment) is handled by `submitQuestionnaire()` itself. Post-submission side effects (analysis jobs, cache invalidation) also fire normally. + +- [x] **Task 5: Add filter endpoints to AdminFiltersController** + - File: `api.faculytics/src/modules/admin/admin-filters.controller.ts` + - Action: Add 4 new endpoints following the existing pattern: + - `GET /admin/filters/faculty` — delegates to `AdminFiltersService.GetFaculty()` + - `GET /admin/filters/courses` — accepts `FilterCoursesQueryDto` (required `facultyUsername`), delegates to `AdminFiltersService.GetCoursesForFaculty(facultyUsername)` + - `GET /admin/filters/questionnaire-types` — delegates to `AdminFiltersService.GetQuestionnaireTypes()` + - `GET /admin/filters/questionnaire-versions` — accepts `FilterQuestionnaireVersionsQueryDto` (required `typeId`), delegates to `AdminFiltersService.GetQuestionnaireVersions(typeId)` + - Notes: Each endpoint gets `@Get()`, `@ApiOperation()`, `@ApiResponse()`, and `@ApiQuery()` decorators matching the existing pattern. All inherit the class-level `@UseJwtGuard(UserRole.SUPER_ADMIN)`. + +- [x] **Task 6: Add filter service methods to AdminFiltersService** + - File: `api.faculytics/src/modules/admin/services/admin-filters.service.ts` + - Action: Add 4 new methods: + - `GetFaculty(): Promise` — Query distinct users who have at least one active `EDITING_TEACHER` enrollment. Return `{ id, username: user.userName, fullName: user.firstName + ' ' + user.lastName }`. Order by `fullName ASC`. + - `GetCoursesForFaculty(facultyUsername: string): Promise` — Find user by `userName`, then query active `EDITING_TEACHER` enrollments for that user, populate `['course']`. Map to `{ id: course.id, shortname: course.shortname, fullname: course.fullname }`. Throw `NotFoundException` if user not found. + - `GetQuestionnaireTypes(): Promise` — Query all `QuestionnaireType` entities, map via `FilterOptionResponseDto.Map()`. Order by `code ASC`. Note: `FilterOptionResponseDto.name` is `string | null` — the frontend `QuestionnaireTypeOption.name` is `string`; handle null safely in the Map method or ensure types always have names. + - `GetQuestionnaireVersions(typeId: string): Promise` — Query `QuestionnaireVersion` where `questionnaire.type.id = typeId` and `isActive = true`. Map via `FilterVersionResponseDto.Map()`. Throw `NotFoundException` if type not found. + - Notes: Import `QuestionnaireType` and `QuestionnaireVersion` entities. These need to be added to `MikroOrmModule.forFeature()` in the admin module (Task 8). + +- [x] **Task 7: Create AdminGenerateController** + - File: `api.faculytics/src/modules/admin/admin-generate.controller.ts` (NEW) + - Action: Create controller with prefix `admin/generate-submissions`, class-level `@UseJwtGuard(UserRole.SUPER_ADMIN)` and `@ApiBearerAuth()`: + - `POST /admin/generate-submissions/preview` — accepts `@Body() dto: GeneratePreviewRequestDto`, delegates to `AdminGenerateService.GeneratePreview(dto)`, returns `GeneratePreviewResponseDto` + - `POST /admin/generate-submissions/commit` — accepts `@Body() dto: GenerateCommitRequestDto`, delegates to `AdminGenerateService.CommitSubmissions(dto)`, returns `CommitResultDto` + - Notes: Add Swagger decorators (`@ApiTags('Admin')`, `@ApiOperation()`, `@ApiResponse()`). The commit endpoint may take time due to ingestion processing — document that the client should expect latency. + +- [x] **Task 8: Register new services and controllers in AdminModule** + - File: `api.faculytics/src/modules/admin/admin.module.ts` + - Action: + - Add `QuestionnaireModule` to the `imports` array — this provides `QuestionnaireService` and `QuestionnaireTypeService` + - Add `QuestionnaireType`, `QuestionnaireVersion`, `QuestionnaireSubmission` to the `MikroOrmModule.forFeature([...])` array + - Add `AdminGenerateController` to the `controllers` array + - Add `AdminGenerateService`, `CommentGeneratorService` to the `providers` array + - Notes: `QuestionnaireModule` already exports `QuestionnaireService` (needed for `submitQuestionnaire()`) and `QuestionnaireTypeService`. No need to import `IngestionEngine` or `IngestionMapperService` — we bypass the ingestion pipeline entirely. + - **Scope safety note**: `QuestionnaireModule` imports `DataLoaderModule` which provides `IngestionMappingLoader` (`Scope.REQUEST`). However, NestJS scope propagation follows the **injection graph**, not the module graph. `QuestionnaireService` does NOT inject `IngestionMappingLoader` — that's only injected by `IngestionMapperService`. Since `AdminGenerateService` only injects `QuestionnaireService`, the request-scoped chain does not propagate. The import is safe for singleton-scoped consumers. + +- [x] **Task 9: Unit tests for CommentGeneratorService** + - File: `api.faculytics/src/modules/admin/services/__tests__/comment-generator.service.spec.ts` (NEW) + - Action: Test: + - Successful generation: mock OpenAI to return valid JSON array of strings, verify count matches + - Fallback on API error: mock OpenAI to throw, verify fallback comments returned (not an error) + - Fallback on invalid JSON: mock OpenAI to return non-array, verify fallback + - Context passed to prompt: verify course/faculty name appear in the prompt sent to OpenAI + - Notes: Mock OpenAI client with `jest.fn()`. Don't test actual API calls. + +- [x] **Task 10: Unit tests for AdminGenerateService** + - File: `api.faculytics/src/modules/admin/services/__tests__/admin-generate.service.spec.ts` (NEW) + - Action: Test `GeneratePreview()`: + - Happy path: mock version (active), faculty, course (with semester hierarchy), enrollments (3 students), no existing submissions, mock comment generator. Verify response has 3 rows, correct metadata counts, all questions have answers in valid range. + - No available students: mock all students already submitted. Verify `BadRequestException`. + - Version not active: verify `BadRequestException`. + - Faculty not enrolled as EDITING_TEACHER: verify `BadRequestException`. + - Missing semester hierarchy: verify `BadRequestException`. + - Action: Test `CommitSubmissions()`: + - Happy path: mock version (active), faculty, course (with semester), student lookups. Verify `questionnaireService.submitQuestionnaire()` called once per row with correct args (especially `qualitativeComment` mapped from `comment`). Verify result aggregation (successes/failures counts). + - Partial failure (ConflictException): mock one row throwing `ConflictException`. Verify result shows correct success/failure split and `em.clear()` is called after the failure. + - Partial failure (ForbiddenException): mock one row throwing `ForbiddenException`. Verify it's caught and recorded as failure (not re-thrown as HTTP 403). + - Version not found: verify `NotFoundException`. + - Notes: Mock `EntityManager`, `CommentGeneratorService`, `QuestionnaireService`. Follow `admin.service.spec.ts` mocking pattern. + +- [x] **Task 11: Unit tests for new filter endpoints** + - File: `api.faculytics/src/modules/admin/services/__tests__/admin-filters.service.spec.ts` (update or NEW) + - Action: Test new methods: + - `GetFaculty()`: mock enrollment query returning users, verify response shape + - `GetCoursesForFaculty()`: mock user lookup + enrollment query, verify course mapping. Test `NotFoundException` for unknown username. + - `GetQuestionnaireTypes()`: mock type query, verify mapping + - `GetQuestionnaireVersions()`: mock version query with type filter, verify only active returned + - Notes: If existing test file exists, add tests there. Otherwise create new file. + +#### Phase 2: Admin Console Frontend + +**File structure:** + +``` +src/features/submission-generator/ +├── generator-page.tsx # Main page (route target, orchestrates view state) +├── components/ +│ ├── selection-form.tsx # Two-track selection panel with cascading selects +│ ├── preview-panel.tsx # Metadata card + scrollable table + commit action +│ └── commit-result-dialog.tsx # Post-commit results summary dialog +├── use-generator-filters.ts # React Query hooks for 4 filter endpoints +└── use-generate-submissions.ts # Preview + commit mutation hooks +``` + +- [x] **Task 12: Add API types for submission generator** + - File: `admin.faculytics/src/types/api.ts` + - Action: Add TypeScript interfaces at the end of the file: + + ```typescript + // --- Submission Generator --- + + // Filter response types + export interface FacultyFilterOption { + id: string; + username: string; + fullName: string; + } + export interface CourseFilterOption { + id: string; + shortname: string; + fullname: string; + } + export interface QuestionnaireTypeOption { + id: string; + name: string; + code: string; + } + export interface QuestionnaireVersionOption { + id: string; + versionNumber: number; + isActive: boolean; + } + + // Generator types + export interface GeneratePreviewRequest { + versionId: string; + facultyUsername: string; + courseShortname: string; + } + export interface GeneratedRow { + externalId: string; + username: string; + facultyUsername: string; + courseShortname: string; + answers: Record; + comment?: string; + } + export interface PreviewQuestion { + id: string; + text: string; + sectionName: string; + } + export interface GeneratePreviewResponse { + metadata: { + faculty: { username: string; fullName: string }; + course: { shortname: string; fullname: string }; + semester: { code: string; label: string; academicYear: string }; + version: { id: string; versionNumber: number }; + maxScore: number; + totalEnrolled: number; + alreadySubmitted: number; + availableStudents: number; + generatingCount: number; + }; + questions: PreviewQuestion[]; + rows: GeneratedRow[]; + } + export interface GenerateCommitRequest { + versionId: string; + rows: GeneratedRow[]; + } + export interface CommitResult { + commitId: string; + total: number; + successes: number; + failures: number; + records: { + externalId: string; + success: boolean; + error?: string; + internalId?: string; + }[]; + } + ``` + + - Notes: Follow existing export patterns in the file. All types are exported for use across the feature. + +- [x] **Task 13: Create React Query hooks for generator** + - File: `admin.faculytics/src/features/submission-generator/use-generator-filters.ts` (NEW) + - Action: Create filter hooks following the `use-admin-filters.ts` pattern: + + ```typescript + export function useFacultyFilter(): UseQueryResult; + // queryKey: ['generator-filters', 'faculty', activeEnvId] + // queryFn: apiClient('/admin/filters/faculty') + // enabled: !!activeEnvId && isAuth + // staleTime: 5 * 60_000 + + export function useCoursesFilter( + facultyUsername?: string, + ): UseQueryResult; + // queryKey: ['generator-filters', 'courses', facultyUsername, activeEnvId] + // queryFn: apiClient(`/admin/filters/courses?facultyUsername=${facultyUsername}`) + // enabled: !!activeEnvId && isAuth && !!facultyUsername + + export function useQuestionnaireTypesFilter(): UseQueryResult< + QuestionnaireTypeOption[] + >; + // queryKey: ['generator-filters', 'questionnaire-types', activeEnvId] + // queryFn: apiClient('/admin/filters/questionnaire-types') + + export function useVersionsFilter( + typeId?: string, + ): UseQueryResult; + // queryKey: ['generator-filters', 'versions', typeId, activeEnvId] + // queryFn: apiClient(`/admin/filters/questionnaire-versions?typeId=${typeId}`) + // enabled: !!activeEnvId && isAuth && !!typeId + ``` + + - Notes: Use `useEnvStore` for `activeEnvId` and auth check, matching the existing `use-admin-filters.ts` hook pattern. + + - File: `admin.faculytics/src/features/submission-generator/use-generate-submissions.ts` (NEW) + - Action: Create mutation hooks: + + ```typescript + export function useGeneratePreview(): UseMutationResult< + GeneratePreviewResponse, + ApiError, + GeneratePreviewRequest + >; + // mutationFn: apiClient('/admin/generate-submissions/preview', { + // method: 'POST', body: JSON.stringify(request) + // }) + // onError: toast specific messages for known status codes: + // 400 → parse body for descriptive message (e.g., "All students have already submitted") + // 404 → "Faculty, course, or version not found" + // default → "Failed to generate preview" + + export function useCommitSubmissions(): UseMutationResult< + CommitResult, + ApiError, + GenerateCommitRequest + >; + // mutationFn: apiClient('/admin/generate-submissions/commit', { + // method: 'POST', body: JSON.stringify(request) + // }) + // No onSuccess/onError here — handled by the component for dialog flow control + ``` + + - Notes: Preview is a mutation (not a query) because it triggers server-side AI generation and is not idempotent. Commit mutation delegates success/error handling to the component so it can control the result dialog. + +- [x] **Task 14: Create selection-form component** + - File: `admin.faculytics/src/features/submission-generator/components/selection-form.tsx` (NEW) + - Props: + ```typescript + interface SelectionFormProps { + onPreviewReady: ( + data: GeneratePreviewResponse, + versionId: string, + ) => void; + isGenerating: boolean; // from parent, to show loading state + } + ``` + - Action: Create two-track selection form: + - **Layout**: Two `Card` components side-by-side using `grid grid-cols-1 md:grid-cols-2 gap-4` + - **Left card — "Who's being evaluated?"**: + - Faculty `Select` — populated from `useFacultyFilter()`. Display `fullName` as label, store `username` as value. Show `Loader2` in trigger while loading. + - Course `Select` — populated from `useCoursesFilter(facultyUsername)`. Display `fullname` as label (with `shortname` in muted text), store `shortname` as value. Disabled until faculty selected. + - Cascading reset: when faculty changes, clear course selection. + - **Right card — "Using which form?"**: + - Questionnaire Type `Select` — populated from `useQuestionnaireTypesFilter()`. Display `name`, store `id` as value. + - Version `Select` — populated from `useVersionsFilter(typeId)`. Display `v{versionNumber}`, store `id` as value. Disabled until type selected. + - Cascading reset: when type changes, clear version selection. + - **Generate button** below the cards: + - Text: "Generate Preview" + - Disabled: `!facultyUsername || !courseShortname || !typeId || !versionId || isGenerating` + - Loading state: when `isGenerating`, show `Loader2 className="animate-spin"` + text "Generating comments..." + - onClick: call `useGeneratePreview().mutate({ versionId, facultyUsername, courseShortname })`, on success call `onPreviewReady(data, versionId)` + - Notes: Follow `users-page.tsx` cascading select pattern. Use `useState` for all 4 field values. shadcn `Select` + `Card` + `Button` components. Lucide `Loader2` for spinner. + +- [x] **Task 15: Create preview-panel component** + - File: `admin.faculytics/src/features/submission-generator/components/preview-panel.tsx` (NEW) + - Props: + ```typescript + interface PreviewPanelProps { + data: GeneratePreviewResponse; + versionId: string; + onBack: () => void; + onCommitSuccess: (result: CommitResult) => void; + } + ``` + - Action: Create preview display with metadata + table + commit action: + + **Metadata summary card:** + - `Card` at top with grid layout showing: + - Faculty: `{fullName} ({username})` + - Course: `{fullname} ({shortname})` + - Semester: `{label} ({academicYear})` + - Version: `v{versionNumber}` + - Count badges below: `Badge variant="secondary"` for `{totalEnrolled} enrolled`, `Badge variant="outline"` (yellow-ish) for `{alreadySubmitted} submitted`, `Badge variant="default"` (green) for `{generatingCount} generating` + + **Preview table:** + - Wrap in `ScrollArea` with `className="w-full"` for horizontal scrolling + - shadcn `Table` with columns: + - **Student** (first column, sticky/pinned left if possible via `sticky left-0 bg-background`) + - **Q1, Q2, ... QN** (one column per question) — header text truncated to ~20 chars, full text in `Tooltip`. `TooltipTrigger` wraps truncated text, `TooltipContent` shows full question text + section name. + - **Comment** (last column) — truncated to ~40 chars, full text in `Tooltip` + - Answer cell styling: centered text, color-coded background **relative to maxScore** (from `data.metadata.maxScore`): + - Bottom third (`value <= maxScore * 0.4`): `text-red-600 bg-red-50 dark:text-red-400 dark:bg-red-950/30` + - Middle third (`value <= maxScore * 0.7`): `text-yellow-600 bg-yellow-50 dark:text-yellow-400 dark:bg-yellow-950/30` + - Top third (`value > maxScore * 0.7`): `text-green-600 bg-green-50 dark:text-green-400 dark:bg-green-950/30` + - Example: maxScore=5 → red: 1-2, yellow: 3, green: 4-5. maxScore=3 → red: 1, yellow: 2, green: 3. + - Row count badge above table: `"Showing {rows.length} submissions"` + + **Action bar:** + - `div` with `flex justify-between items-center pt-4` + - Left: "Back" button (`variant="outline"`) — calls `onBack()`. **Disabled while commit is in-flight** (`commitMutation.isPending`). + - Right: "Commit {rows.length} Submissions" button (`variant="default"`) — calls `useCommitSubmissions().mutate({ versionId, rows: data.rows })`. + - Loading state: `Loader2 className="animate-spin"` + "Committing..." + - On success: call `onCommitSuccess(result)` + - On error: `toast.error('Failed to commit submissions')`, stay on preview (user can retry) + + **Navigation guard:** + - `useEffect` that registers `beforeunload` handler when `commitMutation.isPending` is true. Message: "Submissions are being committed. Leaving now may result in partial data without confirmation." + - Clean up handler when commit completes or component unmounts. + + - Notes: shadcn `Card`, `Table`, `Badge`, `Button`, `ScrollArea`, `Tooltip` components. Lucide `Loader2`, `ArrowLeft` icons. + +- [x] **Task 16: Create commit-result-dialog component** + - File: `admin.faculytics/src/features/submission-generator/components/commit-result-dialog.tsx` (NEW) + - Props: + ```typescript + interface CommitResultDialogProps { + open: boolean; + result: CommitResult | null; + metadata: GeneratePreviewResponse['metadata'] | null; + onGenerateMore: () => void; + onDone: () => void; + } + ``` + - Action: Create result dialog using shadcn `Dialog`: + - **Header**: "Submissions Committed" title + - **Body**: + - **All succeeded** (`failures === 0`): Green checkmark icon (`CheckCircle2`), text: "{successes} submissions committed successfully" + - **Partial failures** (`failures > 0 && successes > 0`): Yellow warning icon (`AlertTriangle`), text: "{successes} succeeded, {failures} failed", sub-text: "Some students may have submitted between preview and commit." + - **All failed** (`successes === 0`): Red error icon (`XCircle`), text: "All {failures} submissions failed", sub-text: "Data may have already been committed for these students." + - Context summary below: Faculty, Course, Version from `metadata` + - **Footer** with two buttons: + - "Generate More" (`variant="outline"`) — calls `onGenerateMore()`. Resets to selection form for another round. + - "Done" (`variant="default"`) — calls `onDone()`. Closes dialog, stays on preview as read-only. + - Notes: shadcn `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogFooter`. Lucide `CheckCircle2`, `AlertTriangle`, `XCircle` icons. + +- [x] **Task 17: Create generator-page orchestrator** + - File: `admin.faculytics/src/features/submission-generator/generator-page.tsx` (NEW) + - Action: Create the main page component that orchestrates view state and child components: + + ```typescript + type ViewState = 'selection' | 'preview'; + + // State + const [view, setView] = useState('selection'); + const [previewData, setPreviewData] = + useState(null); + const [previewVersionId, setPreviewVersionId] = useState(''); + const [commitResult, setCommitResult] = useState(null); + const [resultDialogOpen, setResultDialogOpen] = useState(false); + const generatePreview = useGeneratePreview(); + ``` + + - **Page header**: Title "Submission Generator", subtitle "Generate test submissions for analytics testing" + - **Selection view** (`view === 'selection'`): + - Render `` + - `handlePreviewReady`: store preview data + versionId in state, switch to `'preview'` view + - **Preview view** (`view === 'preview'`): + - Render `` + - `handleBack`: clear preview data, switch to `'selection'` view + - `handleCommitSuccess`: store result, open result dialog + - **Result dialog** (overlay on preview): + - Render `` + - `handleGenerateMore`: clear ALL state (preview, result, dialog), switch to `'selection'` + - `handleDone`: close dialog only, stay on preview as read-only + + **Navigation guard during preview generation:** + - `useEffect` that registers `beforeunload` handler when `generatePreview.isPending` is true. Message: "Preview is being generated, are you sure?" + - Clean up on unmount or when generation completes. + + - Notes: This is a thin orchestrator — all logic lives in child components. shadcn `Card` for page wrapper if desired. Follow the `users-page.tsx` page-level pattern. + +- [x] **Task 18: Add route and navigation** + - File: `admin.faculytics/src/routes.tsx` + - Action: Add route for the generator page. **CRITICAL: nest INSIDE the `AuthGuard` wrapper children**, not as a top-level route. The `AuthGuard` component protects routes and redirects unauthenticated users. Adding outside it would create an unprotected route. + ```typescript + { path: 'submission-generator', element: } + ``` + Import: `import { GeneratorPage } from './features/submission-generator/generator-page'` + - File: `admin.faculytics/src/components/layout/app-shell.tsx` + - Action: Add navigation item in the sidebar for "Submission Generator" with the `FlaskConical` Lucide icon. Place it after the existing nav items. The sidebar uses a `const navItems` array with shape `{ to: string, label: string, icon: LucideIcon }` — add `{ to: '/submission-generator', label: 'Submission Generator', icon: FlaskConical }`. **Note**: if `navItems` is declared with `as const`, remove the `as const` assertion or change to an explicitly typed mutable array (`const navItems: NavItem[] = [...]`) to allow adding the new entry. + - Notes: Import `FlaskConical` from `lucide-react`. Follow the exact sidebar nav item pattern used for existing items (icon + label, active state via route match). + +### Acceptance Criteria + +**Filter Endpoints:** + +- [ ] AC 1: Given the API is running, when `GET /admin/filters/faculty` is called by a SUPER_ADMIN, then it returns all users who have at least one active EDITING_TEACHER enrollment with `{ id, username, fullName }` shape. +- [ ] AC 2: Given a valid faculty username, when `GET /admin/filters/courses?facultyUsername=X` is called, then it returns only courses where that faculty has an active EDITING_TEACHER enrollment. +- [ ] AC 3: Given no faculty username, when `GET /admin/filters/courses` is called, then it returns 400 Bad Request. +- [ ] AC 4: Given the API is running, when `GET /admin/filters/questionnaire-types` is called, then it returns all questionnaire types with `{ id, name, code }`. +- [ ] AC 5: Given a valid type ID, when `GET /admin/filters/questionnaire-versions?typeId=X` is called, then it returns only active versions for that type. + +**Preview Endpoint:** + +- [ ] AC 6: Given a valid versionId + facultyUsername + courseShortname where 10 students are enrolled and 3 have already submitted, when `POST /admin/generate-submissions/preview` is called, then it returns 7 rows with `metadata.availableStudents = 7`, `metadata.alreadySubmitted = 3`, `metadata.totalEnrolled = 10`. +- [ ] AC 7: Given the preview response, when inspecting generated rows, then every row has answers for ALL questions in the schema with values in range `[1, maxScore]`. +- [ ] AC 8 (manual verification): Given the preview response, when inspecting comments, then comments include a mix of English, Tagalog, Cebuano, and code-switched text. Unit tests verify the prompt includes language distribution instructions and responses are parsed correctly. +- [ ] AC 9: Given an inactive version, when preview is called, then it returns 400 Bad Request. +- [ ] AC 10: Given a faculty who is NOT enrolled as EDITING_TEACHER in the specified course, when preview is called, then it returns 400 Bad Request. +- [ ] AC 11: Given all students have already submitted for this version+faculty+course+semester, when preview is called, then it returns 400 Bad Request with descriptive message. + +**Commit Endpoint:** + +- [ ] AC 12: Given a valid preview payload POSTed to `POST /admin/generate-submissions/commit`, when `submitQuestionnaire()` processes each row, then it returns `CommitResultDto` with `successes` matching the row count and `failures = 0`. +- [ ] AC 13: Given the commit has completed, when querying the database, then `QuestionnaireSubmission` records exist for each generated row with correct faculty, course, version, and semester linkage. +- [ ] AC 14: Given the same preview payload is committed twice, when the second commit runs, then it returns HTTP 200 with `CommitResultDto` showing `successes = 0` and `failures` matching row count (unique constraint violations per row, not an HTTP error). + +**Auth & Security:** + +- [ ] AC 15: Given a non-SUPER_ADMIN user, when any generator or filter endpoint is called, then it returns 403 Forbidden. + +**Resilience:** + +- [ ] AC 16: Given the OpenAI API is unreachable or returns an error, when preview is called, then it still returns rows with generic fallback comments instead of failing entirely. + +**Admin Console UI — Selection:** + +- [ ] AC 17: Given a SUPER_ADMIN is logged into the admin console, when they navigate to the Submission Generator page, then they see two-track selection (faculty+course and type+version) with cascading dropdowns. +- [ ] AC 18: Given a faculty is selected, when the user opens the Course dropdown, then only courses where that faculty has an active EDITING_TEACHER enrollment are shown. When the faculty selection changes, the course selection resets. +- [ ] AC 19: Given a questionnaire type is selected, when the user opens the Version dropdown, then only active versions for that type are shown. When the type selection changes, the version selection resets. +- [ ] AC 20: Given fewer than 4 fields are selected, when the user views the "Generate Preview" button, then it is disabled. + +**Admin Console UI — Preview:** + +- [ ] AC 21: Given all four fields are selected, when the user clicks "Generate Preview," then a loading state with "Generating comments..." text is shown and on success a preview table displays with metadata summary card, color-coded answer columns, and truncated question headers with tooltips. +- [ ] AC 22: Given the preview table is displayed, when inspecting answer cells, then color-coding is relative to `maxScore` from metadata: bottom third red, middle third yellow, top third green (e.g., for maxScore=5: 1-2 red, 3 yellow, 4-5 green). +- [ ] AC 23: Given the preview is displayed, when the user clicks "Back," then they return to the selection form with all selections cleared. + +**Admin Console UI — Commit & Results:** + +- [ ] AC 24: Given the preview is displayed, when the user clicks "Commit N Submissions," then a loading state with "Committing..." text is shown, the "Back" button is disabled, and a `beforeunload` browser guard is active. +- [ ] AC 25: Given the commit succeeds with all rows, when the result dialog appears, then it shows a green checkmark with "{N} submissions committed successfully" and buttons "Generate More" and "Done." +- [ ] AC 26: Given the commit has partial failures, when the result dialog appears, then it shows a yellow warning with "{N} succeeded, {M} failed" and a message about possible duplicate submissions. +- [ ] AC 27: Given the user clicks "Generate More" in the result dialog, then all state is reset and they return to the empty selection form. +- [ ] AC 28: Given the user clicks "Done" in the result dialog, then the dialog closes and the preview remains visible as read-only. + +## Additional Context + +### Dependencies + +**API:** + +- `openai` npm package (already installed — used by ChatKit and analysis modules) +- Existing entities: User, Course, Enrollment, Semester, QuestionnaireVersion, QuestionnaireSubmission, QuestionnaireType +- Existing services: `QuestionnaireService` (exported from `QuestionnaireModule`), `QuestionnaireTypeService` +- Existing infrastructure: `OPENAI_API_KEY` env var, `@UseJwtGuard` decorator, `FilterOptionResponseDto` + +**Admin Console:** + +- No new npm dependencies needed — all required packages already installed (React Query, shadcn/ui, sonner, Lucide) +- Depends on Phase 1 API endpoints being available + +### Testing Strategy + +**Unit Tests (API):** + +- `CommentGeneratorService`: mock OpenAI client, test success/fallback/invalid-response paths +- `AdminGenerateService`: mock EntityManager + CommentGenerator + QuestionnaireService, test preview logic (available students calculation, answer generation range scaled to maxScore, metadata shape, conditional comment generation based on qualitativeFeedback.enabled) and commit loop (per-row submitQuestionnaire calls, comment→qualitativeComment mapping, partial failure aggregation) +- `AdminFiltersService` (new methods): mock EntityManager, test query patterns and response mapping + +**Integration Tests (manual):** + +- Run `POST /admin/generate-submissions/preview` with real dev data, verify response shape and comment quality +- Run `POST /admin/generate-submissions/commit` with preview output, verify submissions appear in DB +- Verify analytics dashboards populate correctly with generated data +- Test full admin console flow: select → preview → commit → verify in DB + +**Not Required:** + +- Frontend unit tests (admin console has no test runner configured) +- E2E tests (internal tool, manual verification sufficient) +- Load testing (max ~50 records per course, well within pipeline capacity) + +### Notes + +- This is an internal developer tool — iterate fast, polish later +- ~50 students across 4 courses in current dev/staging data +- Comment generation is the only external API call — 60-second timeout with fallback to generic comments ensures preview never fails due to OpenAI issues +- Answer distribution uses per-student tendency scaled to maxScore for realistic variation — works for any Likert scale, not just 1-5 +- The commit endpoint calls `submitQuestionnaire()` directly per row, meaning all existing validation (enrollment, unique constraint, answer range, qualitative comment) is applied. The generator doesn't duplicate any validation. Post-submission side effects (analysis jobs, cache invalidation) fire normally. +- Admin console uses native fetch (no Axios), React state for forms (no form library), shadcn/ui for all components +- `beforeunload` guards protect against accidental refresh during preview generation (wasted OpenAI call) and commit processing (lost result visibility). In-app navigation blocking via React Router is intentionally omitted — not worth the complexity for an internal tool. +- Preview table color-coding uses maxScore-relative thresholds for correct visual feedback regardless of scale +- Post-commit flow offers "Generate More" (reset to selection for next course) or "Done" (close dialog, stay on read-only preview) — optimized for batch generation across multiple courses + +**Known Limitations (acceptable for internal tool):** + +- Race condition between preview and commit: if someone submits evaluations for some students between preview and commit, those rows will fail with unique constraint violations. The result dialog handles this gracefully (shows partial failure count). +- No retry path for partially failed commits — "commit all" design means you'd need to generate a new preview to fill remaining students. +- No rate limiting on preview endpoint — each call makes an OpenAI API request. The frontend disables the button during generation, but multiple admin users or browser tabs could trigger concurrent calls. +- `externalId` format `gen_{username}_{timestamp}_{index}` is unique within a batch but could theoretically collide across batches in the same millisecond. Not a practical concern for an internal tool with manual usage. +- `dryRun` field in `CommitResult` is always `false` — included for API shape compatibility but the generator does not support dry-run mode. + +## Review Notes + +- Adversarial review completed +- Findings: 14 total, 9 fixed, 5 skipped (by design or consistent with existing patterns) +- Resolution approach: auto-fix +- Fixed: empty rows crash (F1), dangerous-key validation (F2), respondent populate (F4), encodeURIComponent (F5), dryRun type mismatch (F7), rows array bounds (F9), duplicate mutation instance (F10), HTTP 200 for preview (F13), dialog Escape key (F14) +- Skipped: no transaction wrapper (F3, by design), answer distribution bias (F6, matches spec), em.clear scope (F8, by design), DFS order (F11, matches existing code), OpenAI DI bypass (F12, matches existing TopicLabelService pattern) diff --git a/src/modules/admin/admin-filters.controller.ts b/src/modules/admin/admin-filters.controller.ts index 71d34b1..2b2b010 100644 --- a/src/modules/admin/admin-filters.controller.ts +++ b/src/modules/admin/admin-filters.controller.ts @@ -11,7 +11,12 @@ import { UserRole } from '../auth/roles.enum'; import { AdminFiltersService } from './services/admin-filters.service'; import { FilterDepartmentsQueryDto } from './dto/requests/filter-departments-query.dto'; import { FilterProgramsQueryDto } from './dto/requests/filter-programs-query.dto'; +import { FilterCoursesQueryDto } from './dto/requests/filter-courses-query.dto'; +import { FilterVersionsQueryDto } from './dto/requests/filter-versions-query.dto'; import { FilterOptionResponseDto } from './dto/responses/filter-option.response.dto'; +import { FilterFacultyResponseDto } from './dto/responses/filter-faculty.response.dto'; +import { FilterCourseResponseDto } from './dto/responses/filter-course.response.dto'; +import { FilterVersionResponseDto } from './dto/responses/filter-version.response.dto'; @ApiTags('Admin') @Controller('admin/filters') @@ -74,4 +79,51 @@ export class AdminFiltersController { GetRoles(): { roles: UserRole[] } { return { roles: this.filtersService.GetRoles() }; } + + @Get('faculty') + @ApiOperation({ + summary: + 'List faculty members (users with active editing teacher enrollments)', + }) + @ApiResponse({ status: 200, type: [FilterFacultyResponseDto] }) + async GetFaculty(): Promise { + return this.filtersService.GetFaculty(); + } + + @Get('courses') + @ApiOperation({ summary: 'List courses for a specific faculty member' }) + @ApiQuery({ + name: 'facultyUsername', + required: true, + type: String, + description: 'Faculty username to filter courses by', + }) + @ApiResponse({ status: 200, type: [FilterCourseResponseDto] }) + async GetCourses( + @Query() query: FilterCoursesQueryDto, + ): Promise { + return this.filtersService.GetCoursesForFaculty(query.facultyUsername); + } + + @Get('questionnaire-types') + @ApiOperation({ summary: 'List all questionnaire types' }) + @ApiResponse({ status: 200, type: [FilterOptionResponseDto] }) + async GetQuestionnaireTypes(): Promise { + return this.filtersService.GetQuestionnaireTypes(); + } + + @Get('questionnaire-versions') + @ApiOperation({ summary: 'List active versions for a questionnaire type' }) + @ApiQuery({ + name: 'typeId', + required: true, + type: String, + description: 'Questionnaire type UUID', + }) + @ApiResponse({ status: 200, type: [FilterVersionResponseDto] }) + async GetQuestionnaireVersions( + @Query() query: FilterVersionsQueryDto, + ): Promise { + return this.filtersService.GetQuestionnaireVersions(query.typeId); + } } diff --git a/src/modules/admin/admin-generate.controller.ts b/src/modules/admin/admin-generate.controller.ts new file mode 100644 index 0000000..7081b3c --- /dev/null +++ b/src/modules/admin/admin-generate.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { UseJwtGuard } from 'src/security/decorators'; +import { UserRole } from '../auth/roles.enum'; +import { AdminGenerateService } from './services/admin-generate.service'; +import { GeneratePreviewRequestDto } from './dto/requests/generate-preview.request.dto'; +import { GenerateCommitRequestDto } from './dto/requests/generate-commit.request.dto'; +import { GeneratePreviewResponseDto } from './dto/responses/generate-preview.response.dto'; +import { CommitResultDto } from './dto/responses/commit-result.response.dto'; +import { SubmissionStatusResponseDto } from './dto/responses/submission-status.response.dto'; +import { SubmissionStatusQueryDto } from './dto/requests/submission-status-query.dto'; + +@ApiTags('Admin') +@Controller('admin/generate-submissions') +@UseJwtGuard(UserRole.SUPER_ADMIN) +@ApiBearerAuth() +export class AdminGenerateController { + constructor(private readonly generateService: AdminGenerateService) {} + + @Get('status') + @ApiOperation({ + summary: 'Check submission status for a faculty+course+version combination', + }) + @ApiQuery({ name: 'versionId', required: true, type: String }) + @ApiQuery({ name: 'facultyUsername', required: true, type: String }) + @ApiQuery({ name: 'courseShortname', required: true, type: String }) + @ApiResponse({ status: 200, type: SubmissionStatusResponseDto }) + async Status( + @Query() query: SubmissionStatusQueryDto, + ): Promise { + return this.generateService.GetSubmissionStatus(query); + } + + @Post('preview') + @HttpCode(200) + @ApiOperation({ + summary: 'Generate preview of test submissions for a questionnaire version', + }) + @ApiResponse({ status: 200, type: GeneratePreviewResponseDto }) + async Preview( + @Body() dto: GeneratePreviewRequestDto, + ): Promise { + return this.generateService.GeneratePreview(dto); + } + + @Post('commit') + @ApiOperation({ + summary: + 'Commit generated submissions (may take time due to per-row processing)', + }) + @ApiResponse({ status: 201, type: CommitResultDto }) + async Commit( + @Body() dto: GenerateCommitRequestDto, + ): Promise { + return this.generateService.CommitSubmissions(dto); + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 0c09b0d..96b8e3f 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -9,10 +9,17 @@ import { Program } from 'src/entities/program.entity'; import { Semester } from 'src/entities/semester.entity'; import { UserInstitutionalRole } from 'src/entities/user-institutional-role.entity'; import { User } from 'src/entities/user.entity'; +import { QuestionnaireType } from 'src/entities/questionnaire-type.entity'; +import { QuestionnaireVersion } from 'src/entities/questionnaire-version.entity'; +import { QuestionnaireSubmission } from 'src/entities/questionnaire-submission.entity'; +import { QuestionnaireModule } from 'src/modules/questionnaires/questionnaires.module'; import { AdminController } from './admin.controller'; import { AdminFiltersController } from './admin-filters.controller'; +import { AdminGenerateController } from './admin-generate.controller'; import { AdminService } from './services/admin.service'; import { AdminFiltersService } from './services/admin-filters.service'; +import { AdminGenerateService } from './services/admin-generate.service'; +import { CommentGeneratorService } from './services/comment-generator.service'; @Module({ imports: [ @@ -26,9 +33,22 @@ import { AdminFiltersService } from './services/admin-filters.service'; Semester, UserInstitutionalRole, User, + QuestionnaireType, + QuestionnaireVersion, + QuestionnaireSubmission, ]), + QuestionnaireModule, + ], + controllers: [ + AdminController, + AdminFiltersController, + AdminGenerateController, + ], + providers: [ + AdminService, + AdminFiltersService, + AdminGenerateService, + CommentGeneratorService, ], - controllers: [AdminController, AdminFiltersController], - providers: [AdminService, AdminFiltersService], }) export class AdminModule {} diff --git a/src/modules/admin/dto/requests/filter-courses-query.dto.ts b/src/modules/admin/dto/requests/filter-courses-query.dto.ts new file mode 100644 index 0000000..8a3650a --- /dev/null +++ b/src/modules/admin/dto/requests/filter-courses-query.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class FilterCoursesQueryDto { + @ApiProperty({ description: 'Faculty username to filter courses by' }) + @IsString() + @IsNotEmpty() + facultyUsername: string; +} diff --git a/src/modules/admin/dto/requests/filter-versions-query.dto.ts b/src/modules/admin/dto/requests/filter-versions-query.dto.ts new file mode 100644 index 0000000..3d46d86 --- /dev/null +++ b/src/modules/admin/dto/requests/filter-versions-query.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class FilterVersionsQueryDto { + @ApiProperty({ description: 'Questionnaire type UUID' }) + @IsUUID() + @IsNotEmpty() + typeId: string; +} diff --git a/src/modules/admin/dto/requests/generate-commit.request.dto.ts b/src/modules/admin/dto/requests/generate-commit.request.dto.ts new file mode 100644 index 0000000..e8d74c7 --- /dev/null +++ b/src/modules/admin/dto/requests/generate-commit.request.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayNotEmpty, + IsArray, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GeneratedRowDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + externalId: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + username: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + facultyUsername: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + courseShortname: string; + + @ApiProperty({ description: 'Map of questionId -> numeric value' }) + @IsObject() + answers: Record; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + comment?: string; +} + +export class GenerateCommitRequestDto { + @ApiProperty({ description: 'Questionnaire version UUID' }) + @IsUUID() + @IsNotEmpty() + versionId: string; + + @ApiProperty({ type: [GeneratedRowDto] }) + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(200) + @ValidateNested({ each: true }) + @Type(() => GeneratedRowDto) + rows: GeneratedRowDto[]; +} diff --git a/src/modules/admin/dto/requests/generate-preview.request.dto.ts b/src/modules/admin/dto/requests/generate-preview.request.dto.ts new file mode 100644 index 0000000..ba2d1c2 --- /dev/null +++ b/src/modules/admin/dto/requests/generate-preview.request.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +export class GeneratePreviewRequestDto { + @ApiProperty({ description: 'Questionnaire version UUID' }) + @IsUUID() + @IsNotEmpty() + versionId: string; + + @ApiProperty({ description: 'Faculty username (exact match)' }) + @IsString() + @IsNotEmpty() + facultyUsername: string; + + @ApiProperty({ description: 'Course shortname (exact match)' }) + @IsString() + @IsNotEmpty() + courseShortname: string; +} diff --git a/src/modules/admin/dto/requests/submission-status-query.dto.ts b/src/modules/admin/dto/requests/submission-status-query.dto.ts new file mode 100644 index 0000000..57ff74c --- /dev/null +++ b/src/modules/admin/dto/requests/submission-status-query.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +export class SubmissionStatusQueryDto { + @ApiProperty({ description: 'Questionnaire version UUID' }) + @IsUUID() + @IsNotEmpty() + versionId: string; + + @ApiProperty({ description: 'Faculty username (exact match)' }) + @IsString() + @IsNotEmpty() + facultyUsername: string; + + @ApiProperty({ description: 'Course shortname (exact match)' }) + @IsString() + @IsNotEmpty() + courseShortname: string; +} diff --git a/src/modules/admin/dto/responses/commit-result.response.dto.ts b/src/modules/admin/dto/responses/commit-result.response.dto.ts new file mode 100644 index 0000000..39f3cb6 --- /dev/null +++ b/src/modules/admin/dto/responses/commit-result.response.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CommitRecordResultDto { + @ApiProperty() + externalId: string; + + @ApiProperty() + success: boolean; + + @ApiPropertyOptional() + error?: string; + + @ApiPropertyOptional() + internalId?: string; +} + +export class CommitResultDto { + @ApiProperty() + commitId: string; + + @ApiProperty() + total: number; + + @ApiProperty() + successes: number; + + @ApiProperty() + failures: number; + + @ApiProperty() + dryRun: boolean; + + @ApiProperty({ type: [CommitRecordResultDto] }) + records: CommitRecordResultDto[]; +} diff --git a/src/modules/admin/dto/responses/filter-course.response.dto.ts b/src/modules/admin/dto/responses/filter-course.response.dto.ts new file mode 100644 index 0000000..8f550d9 --- /dev/null +++ b/src/modules/admin/dto/responses/filter-course.response.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Course } from 'src/entities/course.entity'; + +export class FilterCourseResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + shortname: string; + + @ApiProperty() + fullname: string; + + static Map(course: Course): FilterCourseResponseDto { + return { + id: course.id, + shortname: course.shortname, + fullname: course.fullname, + }; + } +} diff --git a/src/modules/admin/dto/responses/filter-faculty.response.dto.ts b/src/modules/admin/dto/responses/filter-faculty.response.dto.ts new file mode 100644 index 0000000..70aa6d7 --- /dev/null +++ b/src/modules/admin/dto/responses/filter-faculty.response.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from 'src/entities/user.entity'; + +export class FilterFacultyResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + username: string; + + @ApiProperty() + fullName: string; + + static Map(user: User): FilterFacultyResponseDto { + return { + id: user.id, + username: user.userName, + fullName: user.fullName ?? `${user.firstName} ${user.lastName}`, + }; + } +} diff --git a/src/modules/admin/dto/responses/filter-version.response.dto.ts b/src/modules/admin/dto/responses/filter-version.response.dto.ts new file mode 100644 index 0000000..300f276 --- /dev/null +++ b/src/modules/admin/dto/responses/filter-version.response.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { QuestionnaireVersion } from 'src/entities/questionnaire-version.entity'; + +export class FilterVersionResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + versionNumber: number; + + @ApiProperty() + isActive: boolean; + + static Map(version: QuestionnaireVersion): FilterVersionResponseDto { + return { + id: version.id, + versionNumber: version.versionNumber, + isActive: version.isActive, + }; + } +} diff --git a/src/modules/admin/dto/responses/generate-preview.response.dto.ts b/src/modules/admin/dto/responses/generate-preview.response.dto.ts new file mode 100644 index 0000000..c44a1b1 --- /dev/null +++ b/src/modules/admin/dto/responses/generate-preview.response.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class PreviewQuestionDto { + @ApiProperty() + id: string; + + @ApiProperty() + text: string; + + @ApiProperty() + sectionName: string; +} + +export class PreviewRowDto { + @ApiProperty() + externalId: string; + + @ApiProperty() + username: string; + + @ApiProperty() + facultyUsername: string; + + @ApiProperty() + courseShortname: string; + + @ApiProperty({ description: 'Map of questionId -> numeric value' }) + answers: Record; + + @ApiPropertyOptional() + comment?: string; +} + +export class PreviewMetadataDto { + @ApiProperty() + faculty: { username: string; fullName: string }; + + @ApiProperty() + course: { shortname: string; fullname: string }; + + @ApiProperty() + semester: { code: string; label: string; academicYear: string }; + + @ApiProperty() + version: { id: string; versionNumber: number }; + + @ApiProperty() + maxScore: number; + + @ApiProperty() + totalEnrolled: number; + + @ApiProperty() + alreadySubmitted: number; + + @ApiProperty() + availableStudents: number; + + @ApiProperty() + generatingCount: number; +} + +export class GeneratePreviewResponseDto { + @ApiProperty({ type: PreviewMetadataDto }) + metadata: PreviewMetadataDto; + + @ApiProperty({ type: [PreviewQuestionDto] }) + questions: PreviewQuestionDto[]; + + @ApiProperty({ type: [PreviewRowDto] }) + rows: PreviewRowDto[]; +} diff --git a/src/modules/admin/dto/responses/submission-status.response.dto.ts b/src/modules/admin/dto/responses/submission-status.response.dto.ts new file mode 100644 index 0000000..4a8f2b2 --- /dev/null +++ b/src/modules/admin/dto/responses/submission-status.response.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SubmissionStatusResponseDto { + @ApiProperty() + totalEnrolled: number; + + @ApiProperty() + alreadySubmitted: number; + + @ApiProperty() + availableStudents: number; +} diff --git a/src/modules/admin/lib/question-flattener.ts b/src/modules/admin/lib/question-flattener.ts new file mode 100644 index 0000000..0ad9569 --- /dev/null +++ b/src/modules/admin/lib/question-flattener.ts @@ -0,0 +1,53 @@ +import type { + QuestionnaireSchemaSnapshot, + SectionNode, +} from 'src/modules/questionnaires/lib/questionnaire.types'; + +export interface QuestionWithSection { + id: string; + text: string; + type: string; + dimensionCode: string; + required: boolean; + order: number; + sectionName: string; +} + +/** + * Iterative traversal that flattens all questions from a schema snapshot, + * tracking the parent section title for each question. + * Extends the pattern from QuestionnaireService.GetAllQuestions(). + */ +export function GetAllQuestionsWithSections( + schema: QuestionnaireSchemaSnapshot, +): QuestionWithSection[] { + const questions: QuestionWithSection[] = []; + const stack: { section: SectionNode; sectionName: string }[] = + schema.sections.map((s) => ({ section: s, sectionName: s.title })); + + while (stack.length > 0) { + const { section, sectionName } = stack.pop()!; + + if (section.questions) { + for (const q of section.questions) { + questions.push({ + id: q.id, + text: q.text, + type: q.type, + dimensionCode: q.dimensionCode, + required: q.required, + order: q.order, + sectionName, + }); + } + } + + if (section.sections) { + stack.push( + ...section.sections.map((s) => ({ section: s, sectionName: s.title })), + ); + } + } + + return questions; +} diff --git a/src/modules/admin/services/__tests__/admin-filters.service.spec.ts b/src/modules/admin/services/__tests__/admin-filters.service.spec.ts new file mode 100644 index 0000000..7067985 --- /dev/null +++ b/src/modules/admin/services/__tests__/admin-filters.service.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { NotFoundException } from '@nestjs/common'; +import { AdminFiltersService } from '../admin-filters.service'; + +describe('AdminFiltersService', () => { + let service: AdminFiltersService; + let em: { findOne: jest.Mock; find: jest.Mock }; + + beforeEach(async () => { + em = { + findOne: jest.fn(), + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminFiltersService, + { provide: EntityManager, useValue: em }, + ], + }).compile(); + + service = module.get(AdminFiltersService); + }); + + describe('GetFaculty', () => { + it('should return deduplicated faculty members sorted by fullName', async () => { + const user1 = { + id: 'u1', + userName: 'faculty1', + firstName: 'Ana', + lastName: 'Cruz', + fullName: 'Ana Cruz', + }; + const user2 = { + id: 'u2', + userName: 'faculty2', + firstName: 'Ben', + lastName: 'Reyes', + fullName: 'Ben Reyes', + }; + + em.find.mockResolvedValue([ + { user: user1, role: 'editingteacher', isActive: true }, + { user: user2, role: 'editingteacher', isActive: true }, + { user: user1, role: 'editingteacher', isActive: true }, // duplicate + ] as any); + + const result = await service.GetFaculty(); + + expect(result).toHaveLength(2); + expect(result[0].username).toBe('faculty1'); // Ana before Ben + expect(result[1].username).toBe('faculty2'); + expect(result[0].fullName).toBe('Ana Cruz'); + }); + }); + + describe('GetCoursesForFaculty', () => { + it('should return courses for a valid faculty username', async () => { + const user = { id: 'u1', userName: 'prof.santos' }; + const course = { + id: 'c1', + shortname: 'CS101', + fullname: 'Intro to Programming', + }; + + em.findOne.mockResolvedValue(user as any); + em.find.mockResolvedValue([{ course }] as any); + + const result = await service.GetCoursesForFaculty('prof.santos'); + + expect(result).toHaveLength(1); + expect(result[0].shortname).toBe('CS101'); + expect(result[0].fullname).toBe('Intro to Programming'); + }); + + it('should throw NotFoundException for unknown username', async () => { + em.findOne.mockResolvedValue(null); + + await expect(service.GetCoursesForFaculty('unknown')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('GetQuestionnaireTypes', () => { + it('should return all types mapped via FilterOptionResponseDto', async () => { + const types = [ + { id: 't1', code: 'FIC', name: 'Faculty In Classroom' }, + { id: 't2', code: 'FOC', name: 'Faculty Out of Classroom' }, + ]; + + em.find.mockResolvedValue(types as any); + + const result = await service.GetQuestionnaireTypes(); + + expect(result).toHaveLength(2); + expect(result[0].code).toBe('FIC'); + expect(result[0].name).toBe('Faculty In Classroom'); + }); + }); + + describe('GetQuestionnaireVersions', () => { + it('should return active versions for a given type', async () => { + em.findOne.mockResolvedValue({ id: 't1' } as any); + em.find.mockResolvedValue([ + { id: 'v1', versionNumber: 1, isActive: true }, + { id: 'v2', versionNumber: 2, isActive: true }, + ] as any); + + const result = await service.GetQuestionnaireVersions('t1'); + + expect(result).toHaveLength(2); + expect(result[0].versionNumber).toBe(1); + expect(result[0].isActive).toBe(true); + }); + + it('should throw NotFoundException for unknown type ID', async () => { + em.findOne.mockResolvedValue(null); + + await expect(service.GetQuestionnaireVersions('bad-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/admin/services/__tests__/admin-generate.service.spec.ts b/src/modules/admin/services/__tests__/admin-generate.service.spec.ts new file mode 100644 index 0000000..ab57ebd --- /dev/null +++ b/src/modules/admin/services/__tests__/admin-generate.service.spec.ts @@ -0,0 +1,441 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { AdminGenerateService } from '../admin-generate.service'; +import { CommentGeneratorService } from '../comment-generator.service'; +import { QuestionnaireService } from 'src/modules/questionnaires/services/questionnaire.service'; +import type { QuestionnaireSchemaSnapshot } from 'src/modules/questionnaires/lib/questionnaire.types'; +import { QuestionType } from 'src/modules/questionnaires/lib/questionnaire.types'; + +const mockSchema: QuestionnaireSchemaSnapshot = { + meta: { + questionnaireType: 'FACULTY_IN_CLASSROOM', + scoringModel: 'SECTION_WEIGHTED', + version: 1, + maxScore: 5, + }, + sections: [ + { + id: 'sec-1', + title: 'Teaching Quality', + order: 1, + weight: 100, + questions: [ + { + id: 'q1', + text: 'The instructor explains clearly.', + type: QuestionType.LIKERT_1_5, + dimensionCode: 'TEACH', + required: true, + order: 1, + }, + { + id: 'q2', + text: 'The instructor is prepared.', + type: QuestionType.LIKERT_1_5, + dimensionCode: 'TEACH', + required: true, + order: 2, + }, + ], + }, + ], + qualitativeFeedback: { enabled: true, required: true, maxLength: 500 }, +}; + +const mockVersion = { + id: 'version-1', + versionNumber: 1, + isActive: true, + schemaSnapshot: mockSchema, + questionnaire: { type: { id: 'type-1' } }, +}; + +const mockFaculty = { + id: 'faculty-1', + userName: 'prof.santos', + firstName: 'Juan', + lastName: 'Santos', + fullName: 'Juan Santos', +}; + +const mockSemester = { + id: 'sem-1', + code: 'S22526', + label: '2nd Semester', + academicYear: '2025-2026', +}; + +const mockCourse = { + id: 'course-1', + shortname: 'CS101', + fullname: 'Intro to Programming', + program: { + department: { + semester: mockSemester, + }, + }, +}; + +const makeStudent = (index: number) => ({ + id: `student-${index}`, + userName: `student${index}`, + firstName: `First${index}`, + lastName: `Last${index}`, + fullName: `First${index} Last${index}`, +}); + +const mockStudentEnrollments = [1, 2, 3].map((i) => ({ + user: makeStudent(i), + course: mockCourse, + role: 'student', + isActive: true, +})); + +describe('AdminGenerateService', () => { + let service: AdminGenerateService; + let em: { findOne: jest.Mock; find: jest.Mock; clear: jest.Mock }; + let commentGenerator: { GenerateComments: jest.Mock }; + let questionnaireService: { submitQuestionnaire: jest.Mock }; + + beforeEach(async () => { + em = { + findOne: jest.fn(), + find: jest.fn(), + clear: jest.fn(), + }; + + commentGenerator = { + GenerateComments: jest.fn(), + }; + + questionnaireService = { + submitQuestionnaire: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminGenerateService, + { provide: EntityManager, useValue: em }, + { provide: CommentGeneratorService, useValue: commentGenerator }, + { provide: QuestionnaireService, useValue: questionnaireService }, + ], + }).compile(); + + service = module.get(AdminGenerateService); + }); + + describe('GeneratePreview', () => { + const dto = { + versionId: 'version-1', + facultyUsername: 'prof.santos', + courseShortname: 'CS101', + }; + + function setupHappyPath() { + em.findOne + .mockResolvedValueOnce(mockVersion as any) // version + .mockResolvedValueOnce(mockFaculty as any) // faculty + .mockResolvedValueOnce(mockCourse as any) // course + .mockResolvedValueOnce({ id: 'enroll-1' } as any); // faculty enrollment + + em.find + .mockResolvedValueOnce(mockStudentEnrollments as any) // student enrollments + .mockResolvedValueOnce([] as any); // existing submissions (none) + + commentGenerator.GenerateComments.mockResolvedValue([ + 'Great class!', + 'Maganda.', + 'Very helpful.', + ]); + } + + it('should return preview with correct metadata and rows for happy path', async () => { + setupHappyPath(); + + const result = await service.GeneratePreview(dto); + + expect(result.metadata.totalEnrolled).toBe(3); + expect(result.metadata.alreadySubmitted).toBe(0); + expect(result.metadata.availableStudents).toBe(3); + expect(result.metadata.generatingCount).toBe(3); + expect(result.metadata.maxScore).toBe(5); + expect(result.metadata.faculty.username).toBe('prof.santos'); + expect(result.metadata.course.shortname).toBe('CS101'); + expect(result.rows).toHaveLength(3); + expect(result.questions).toHaveLength(2); + }); + + it('should generate answers in valid range [1, maxScore]', async () => { + setupHappyPath(); + + const result = await service.GeneratePreview(dto); + + for (const row of result.rows) { + for (const val of Object.values(row.answers)) { + expect(val).toBeGreaterThanOrEqual(1); + expect(val).toBeLessThanOrEqual(5); + expect(Number.isInteger(val)).toBe(true); + } + } + }); + + it('should include questions with section names', async () => { + setupHappyPath(); + + const result = await service.GeneratePreview(dto); + + expect(result.questions[0].sectionName).toBe('Teaching Quality'); + expect(result.questions[0].id).toBe('q1'); + }); + + it('should generate comments when qualitativeFeedback is enabled', async () => { + setupHappyPath(); + + const result = await service.GeneratePreview(dto); + + expect(commentGenerator.GenerateComments).toHaveBeenCalledWith(3, { + courseName: 'Intro to Programming', + facultyName: 'Juan Santos', + maxScore: 5, + maxLength: 500, + }); + expect(result.rows[0].comment).toBe('Great class!'); + }); + + it('should throw NotFoundException when version not found', async () => { + em.findOne.mockResolvedValueOnce(null); + + await expect(service.GeneratePreview(dto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException for inactive version', async () => { + em.findOne.mockResolvedValueOnce({ + ...mockVersion, + isActive: false, + } as any); + + await expect(service.GeneratePreview(dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when faculty not enrolled as editing teacher', async () => { + em.findOne + .mockResolvedValueOnce(mockVersion as any) + .mockResolvedValueOnce(mockFaculty as any) + .mockResolvedValueOnce(mockCourse as any) + .mockResolvedValueOnce(null); // no enrollment + + await expect(service.GeneratePreview(dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when semester hierarchy is incomplete', async () => { + em.findOne + .mockResolvedValueOnce(mockVersion as any) + .mockResolvedValueOnce(mockFaculty as any) + .mockResolvedValueOnce({ + ...mockCourse, + program: { department: { semester: null } }, + } as any) + .mockResolvedValueOnce({ id: 'enroll-1' } as any); + + await expect(service.GeneratePreview(dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when all students have already submitted', async () => { + em.findOne + .mockResolvedValueOnce(mockVersion as any) + .mockResolvedValueOnce(mockFaculty as any) + .mockResolvedValueOnce(mockCourse as any) + .mockResolvedValueOnce({ id: 'enroll-1' } as any); + + em.find + .mockResolvedValueOnce(mockStudentEnrollments as any) // student enrollments + .mockResolvedValueOnce( + // all 3 students already submitted + [1, 2, 3].map((i) => ({ respondent: { id: `student-${i}` } })) as any, + ); + + await expect(service.GeneratePreview(dto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should skip comment generation when qualitativeFeedback is not enabled', async () => { + const versionNoComments = { + ...mockVersion, + schemaSnapshot: { + ...mockSchema, + qualitativeFeedback: { + enabled: false, + required: false, + maxLength: 500, + }, + }, + }; + + em.findOne + .mockResolvedValueOnce(versionNoComments as any) + .mockResolvedValueOnce(mockFaculty as any) + .mockResolvedValueOnce(mockCourse as any) + .mockResolvedValueOnce({ id: 'enroll-1' } as any); + + em.find + .mockResolvedValueOnce(mockStudentEnrollments as any) + .mockResolvedValueOnce([] as any); + + const result = await service.GeneratePreview(dto); + + expect(commentGenerator.GenerateComments).not.toHaveBeenCalled(); + expect(result.rows[0].comment).toBeUndefined(); + }); + }); + + describe('CommitSubmissions', () => { + const rows = [ + { + externalId: 'gen_student1_123_0', + username: 'student1', + facultyUsername: 'prof.santos', + courseShortname: 'CS101', + answers: { q1: 4, q2: 5 }, + comment: 'Great class!', + }, + { + externalId: 'gen_student2_123_1', + username: 'student2', + facultyUsername: 'prof.santos', + courseShortname: 'CS101', + answers: { q1: 3, q2: 4 }, + comment: 'Good.', + }, + ]; + + const dto = { versionId: 'version-1', rows }; + + function setupCommitHappyPath() { + em.findOne + .mockResolvedValueOnce(mockVersion as any) // version + .mockResolvedValueOnce(mockFaculty as any) // faculty + .mockResolvedValueOnce(mockCourse as any) // course + .mockResolvedValueOnce(makeStudent(1) as any) // student1 + .mockResolvedValueOnce(makeStudent(2) as any); // student2 + + questionnaireService.submitQuestionnaire + .mockResolvedValueOnce({ id: 'sub-1' } as any) + .mockResolvedValueOnce({ id: 'sub-2' } as any); + } + + it('should commit all rows successfully', async () => { + setupCommitHappyPath(); + + const result = await service.CommitSubmissions(dto); + + expect(result.total).toBe(2); + expect(result.successes).toBe(2); + expect(result.failures).toBe(0); + expect(result.dryRun).toBe(false); + expect(result.records).toHaveLength(2); + expect(result.records[0].success).toBe(true); + expect(result.records[0].internalId).toBe('sub-1'); + }); + + it('should map comment to qualitativeComment in submitQuestionnaire call', async () => { + setupCommitHappyPath(); + + await service.CommitSubmissions(dto); + + expect(questionnaireService.submitQuestionnaire).toHaveBeenCalledWith( + expect.objectContaining({ + qualitativeComment: 'Great class!', + versionId: 'version-1', + facultyId: 'faculty-1', + semesterId: 'sem-1', + courseId: 'course-1', + }), + ); + }); + + it('should handle partial failure with ConflictException', async () => { + em.findOne + .mockResolvedValueOnce(mockVersion as any) + .mockResolvedValueOnce(mockFaculty as any) + .mockResolvedValueOnce(mockCourse as any) + .mockResolvedValueOnce(makeStudent(1) as any) + .mockResolvedValueOnce(makeStudent(2) as any); + + questionnaireService.submitQuestionnaire + .mockRejectedValueOnce(new ConflictException('Duplicate submission')) + .mockResolvedValueOnce({ id: 'sub-2' } as any); + + const result = await service.CommitSubmissions(dto); + + expect(result.successes).toBe(1); + expect(result.failures).toBe(1); + expect(result.records[0].success).toBe(false); + expect(result.records[0].error).toContain('Duplicate submission'); + expect(result.records[1].success).toBe(true); + expect(em.clear).toHaveBeenCalled(); + }); + + it('should handle partial failure with ForbiddenException', async () => { + em.findOne + .mockResolvedValueOnce(mockVersion as any) + .mockResolvedValueOnce(mockFaculty as any) + .mockResolvedValueOnce(mockCourse as any) + .mockResolvedValueOnce(makeStudent(1) as any) + .mockResolvedValueOnce(makeStudent(2) as any); + + questionnaireService.submitQuestionnaire + .mockRejectedValueOnce(new ForbiddenException('Not enrolled')) + .mockResolvedValueOnce({ id: 'sub-2' } as any); + + const result = await service.CommitSubmissions(dto); + + expect(result.successes).toBe(1); + expect(result.failures).toBe(1); + expect(result.records[0].success).toBe(false); + expect(em.clear).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when version not found', async () => { + em.findOne.mockResolvedValueOnce(null); + + await expect(service.CommitSubmissions(dto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should record failure when student not found', async () => { + em.findOne + .mockResolvedValueOnce(mockVersion as any) + .mockResolvedValueOnce(mockFaculty as any) + .mockResolvedValueOnce(mockCourse as any) + .mockResolvedValueOnce(null) // student1 not found + .mockResolvedValueOnce(makeStudent(2) as any); + + questionnaireService.submitQuestionnaire.mockResolvedValueOnce({ + id: 'sub-2', + } as any); + + const result = await service.CommitSubmissions(dto); + + expect(result.successes).toBe(1); + expect(result.failures).toBe(1); + expect(result.records[0].success).toBe(false); + expect(result.records[0].error).toContain('student1'); + }); + }); +}); diff --git a/src/modules/admin/services/__tests__/comment-generator.service.spec.ts b/src/modules/admin/services/__tests__/comment-generator.service.spec.ts new file mode 100644 index 0000000..40a2150 --- /dev/null +++ b/src/modules/admin/services/__tests__/comment-generator.service.spec.ts @@ -0,0 +1,174 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CommentGeneratorService } from '../comment-generator.service'; + +// Mock env before importing the service +jest.mock('src/configurations/env', () => ({ + env: { OPENAI_API_KEY: 'test-key' }, +})); + +interface MockMessage { + role: string; + content: string; +} + +interface MockCreateArgs { + messages: MockMessage[]; + response_format: { type: string }; +} + +const mockCreate = jest.fn(); +jest.mock('openai', () => { + return jest.fn().mockImplementation(() => ({ + chat: { completions: { create: mockCreate } }, + })); +}); + +function getCallArgs(): MockCreateArgs { + const calls = mockCreate.mock.calls as MockCreateArgs[][]; + return calls[0][0]; +} + +describe('CommentGeneratorService', () => { + let service: CommentGeneratorService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CommentGeneratorService], + }).compile(); + + service = module.get(CommentGeneratorService); + mockCreate.mockReset(); + }); + + const context = { + courseName: 'CS101 Intro to Programming', + facultyName: 'Prof. Santos', + maxScore: 5, + }; + + it('should return parsed comments on successful generation', async () => { + const comments = ['Great class!', 'Maganda ang turo.', 'Very helpful.']; + mockCreate.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify({ comments }) } }], + }); + + const result = await service.GenerateComments(3, context); + + expect(result).toEqual(comments); + expect(result).toHaveLength(3); + }); + + it('should include course and faculty name in the prompt', async () => { + const comments = ['Comment 1']; + mockCreate.mockResolvedValue({ + choices: [{ message: { content: JSON.stringify({ comments }) } }], + }); + + await service.GenerateComments(1, context); + + const args = getCallArgs(); + const userMessage = args.messages.find((m) => m.role === 'user'); + expect(userMessage?.content).toContain('CS101 Intro to Programming'); + expect(userMessage?.content).toContain('Prof. Santos'); + }); + + it('should include language distribution in the system prompt', async () => { + mockCreate.mockResolvedValue({ + choices: [ + { message: { content: JSON.stringify({ comments: ['test'] }) } }, + ], + }); + + await service.GenerateComments(1, context); + + const args = getCallArgs(); + const systemMessage = args.messages.find((m) => m.role === 'system'); + expect(systemMessage?.content).toContain('English'); + expect(systemMessage?.content).toContain('Tagalog'); + expect(systemMessage?.content).toContain('Cebuano'); + }); + + it('should return fallback comments on API error', async () => { + mockCreate.mockRejectedValue(new Error('API timeout')); + + const result = await service.GenerateComments(3, context); + + expect(result).toHaveLength(3); + result.forEach((c) => expect(typeof c).toBe('string')); + result.forEach((c) => expect(c.length).toBeGreaterThan(0)); + }); + + it('should return fallback comments when response is not a valid array', async () => { + mockCreate.mockResolvedValue({ + choices: [ + { message: { content: JSON.stringify({ comments: 'not-an-array' }) } }, + ], + }); + + const result = await service.GenerateComments(3, context); + + expect(result).toHaveLength(3); + result.forEach((c) => expect(typeof c).toBe('string')); + }); + + it('should pad with fallback when count is short', async () => { + mockCreate.mockResolvedValue({ + choices: [ + { message: { content: JSON.stringify({ comments: ['only one'] }) } }, + ], + }); + + const result = await service.GenerateComments(3, context); + + expect(result).toHaveLength(3); + expect(result[0]).toBe('only one'); + // remaining are fallback strings + expect(typeof result[1]).toBe('string'); + expect(typeof result[2]).toBe('string'); + }); + + it('should truncate comments exceeding maxLength', async () => { + const longComment = 'A'.repeat(300); + mockCreate.mockResolvedValue({ + choices: [ + { + message: { + content: JSON.stringify({ comments: [longComment] }), + }, + }, + ], + }); + + const result = await service.GenerateComments(1, { + ...context, + maxLength: 100, + }); + + expect(result[0].length).toBeLessThanOrEqual(100); + }); + + it('should include maxLength constraint in prompt when provided', async () => { + mockCreate.mockResolvedValue({ + choices: [ + { message: { content: JSON.stringify({ comments: ['test'] }) } }, + ], + }); + + await service.GenerateComments(1, { ...context, maxLength: 200 }); + + const args = getCallArgs(); + const userMessage = args.messages.find((m) => m.role === 'user'); + expect(userMessage?.content).toContain('200'); + }); + + it('should return fallback when OpenAI returns no content', async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { content: null } }], + }); + + const result = await service.GenerateComments(3, context); + + expect(result).toHaveLength(3); + result.forEach((c) => expect(typeof c).toBe('string')); + }); +}); diff --git a/src/modules/admin/services/admin-filters.service.ts b/src/modules/admin/services/admin-filters.service.ts index 1ff2aa5..8654d10 100644 --- a/src/modules/admin/services/admin-filters.service.ts +++ b/src/modules/admin/services/admin-filters.service.ts @@ -1,11 +1,19 @@ import { FilterQuery } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/postgresql'; -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { Campus } from 'src/entities/campus.entity'; import { Department } from 'src/entities/department.entity'; +import { Enrollment } from 'src/entities/enrollment.entity'; import { Program } from 'src/entities/program.entity'; +import { QuestionnaireType } from 'src/entities/questionnaire-type.entity'; +import { QuestionnaireVersion } from 'src/entities/questionnaire-version.entity'; +import { User } from 'src/entities/user.entity'; import { UserRole } from 'src/modules/auth/roles.enum'; +import { EnrollmentRole } from 'src/modules/questionnaires/lib/questionnaire.types'; import { FilterOptionResponseDto } from '../dto/responses/filter-option.response.dto'; +import { FilterFacultyResponseDto } from '../dto/responses/filter-faculty.response.dto'; +import { FilterCourseResponseDto } from '../dto/responses/filter-course.response.dto'; +import { FilterVersionResponseDto } from '../dto/responses/filter-version.response.dto'; @Injectable() export class AdminFiltersService { @@ -45,4 +53,74 @@ export class AdminFiltersService { GetRoles(): UserRole[] { return Object.values(UserRole); } + + async GetFaculty(): Promise { + const enrollments = await this.em.find( + Enrollment, + { role: EnrollmentRole.EDITING_TEACHER, isActive: true }, + { populate: ['user'] }, + ); + + // Deduplicate by user ID + const userMap = new Map(); + for (const e of enrollments) { + if (!userMap.has(e.user.id)) { + userMap.set(e.user.id, e.user); + } + } + + return Array.from(userMap.values()) + .sort((a, b) => { + const nameA = a.fullName ?? `${a.firstName} ${a.lastName}`; + const nameB = b.fullName ?? `${b.firstName} ${b.lastName}`; + return nameA.localeCompare(nameB); + }) + .map((u) => FilterFacultyResponseDto.Map(u)); + } + + async GetCoursesForFaculty( + facultyUsername: string, + ): Promise { + const user = await this.em.findOne(User, { userName: facultyUsername }); + if (!user) { + throw new NotFoundException( + `User with username "${facultyUsername}" not found.`, + ); + } + + const enrollments = await this.em.find( + Enrollment, + { user, role: EnrollmentRole.EDITING_TEACHER, isActive: true }, + { populate: ['course'] }, + ); + + return enrollments.map((e) => FilterCourseResponseDto.Map(e.course)); + } + + async GetQuestionnaireTypes(): Promise { + const types = await this.em.find( + QuestionnaireType, + {}, + { orderBy: { code: 'ASC' } }, + ); + return types.map((t) => FilterOptionResponseDto.Map(t)); + } + + async GetQuestionnaireVersions( + typeId: string, + ): Promise { + const type = await this.em.findOne(QuestionnaireType, typeId); + if (!type) { + throw new NotFoundException( + `Questionnaire type with ID "${typeId}" not found.`, + ); + } + + const versions = await this.em.find(QuestionnaireVersion, { + questionnaire: { type: typeId }, + isActive: true, + }); + + return versions.map((v) => FilterVersionResponseDto.Map(v)); + } } diff --git a/src/modules/admin/services/admin-generate.service.ts b/src/modules/admin/services/admin-generate.service.ts new file mode 100644 index 0000000..644e1d3 --- /dev/null +++ b/src/modules/admin/services/admin-generate.service.ts @@ -0,0 +1,357 @@ +import { + BadRequestException, + HttpException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { randomUUID } from 'crypto'; +import { QuestionnaireVersion } from 'src/entities/questionnaire-version.entity'; +import { User } from 'src/entities/user.entity'; +import { Course } from 'src/entities/course.entity'; +import { Enrollment } from 'src/entities/enrollment.entity'; +import { QuestionnaireSubmission } from 'src/entities/questionnaire-submission.entity'; +import { EnrollmentRole } from 'src/modules/questionnaires/lib/questionnaire.types'; +import { QuestionnaireService } from 'src/modules/questionnaires/services/questionnaire.service'; +import { CommentGeneratorService } from './comment-generator.service'; +import { GetAllQuestionsWithSections } from '../lib/question-flattener'; +import { GeneratePreviewRequestDto } from '../dto/requests/generate-preview.request.dto'; +import { GeneratePreviewResponseDto } from '../dto/responses/generate-preview.response.dto'; +import { GenerateCommitRequestDto } from '../dto/requests/generate-commit.request.dto'; +import { + CommitResultDto, + CommitRecordResultDto, +} from '../dto/responses/commit-result.response.dto'; +import { SubmissionStatusResponseDto } from '../dto/responses/submission-status.response.dto'; + +@Injectable() +export class AdminGenerateService { + private readonly logger = new Logger(AdminGenerateService.name); + + constructor( + private readonly em: EntityManager, + private readonly commentGenerator: CommentGeneratorService, + private readonly questionnaireService: QuestionnaireService, + ) {} + + private async ResolveGenerationContext(dto: GeneratePreviewRequestDto) { + const version = await this.em.findOne(QuestionnaireVersion, dto.versionId, { + populate: ['questionnaire.type'], + }); + if (!version) { + throw new NotFoundException( + `Questionnaire version with ID ${dto.versionId} not found.`, + ); + } + if (!version.isActive) { + throw new BadRequestException( + 'Cannot generate submissions for an inactive questionnaire version.', + ); + } + + const faculty = await this.em.findOne(User, { + userName: dto.facultyUsername, + }); + if (!faculty) { + throw new NotFoundException( + `Faculty with username "${dto.facultyUsername}" not found.`, + ); + } + + const course = await this.em.findOne( + Course, + { shortname: dto.courseShortname }, + { populate: ['program.department.semester'] }, + ); + if (!course) { + throw new NotFoundException( + `Course with shortname "${dto.courseShortname}" not found.`, + ); + } + + const facultyEnrollment = await this.em.findOne(Enrollment, { + user: faculty, + course, + role: EnrollmentRole.EDITING_TEACHER, + isActive: true, + }); + if (!facultyEnrollment) { + throw new BadRequestException( + `Faculty "${dto.facultyUsername}" is not enrolled as editing teacher in course "${dto.courseShortname}".`, + ); + } + + const semester = course.program?.department?.semester; + if (!semester) { + throw new BadRequestException( + 'Course hierarchy is incomplete — cannot resolve semester from course → program → department → semester.', + ); + } + + const studentEnrollments = await this.em.find( + Enrollment, + { course, role: EnrollmentRole.STUDENT, isActive: true }, + { populate: ['user'] }, + ); + + const existingSubmissions = await this.em.find( + QuestionnaireSubmission, + { faculty, questionnaireVersion: version, course, semester }, + { populate: ['respondent'] }, + ); + const submittedUserIds = new Set( + existingSubmissions.map((s) => s.respondent.id), + ); + + const availableStudents = studentEnrollments.filter( + (e) => !submittedUserIds.has(e.user.id), + ); + + return { + version, + faculty, + course, + semester, + studentEnrollments, + submittedUserIds, + availableStudents, + }; + } + + async GetSubmissionStatus( + dto: GeneratePreviewRequestDto, + ): Promise { + const { studentEnrollments, submittedUserIds, availableStudents } = + await this.ResolveGenerationContext(dto); + + return { + totalEnrolled: studentEnrollments.length, + alreadySubmitted: submittedUserIds.size, + availableStudents: availableStudents.length, + }; + } + + async GeneratePreview( + dto: GeneratePreviewRequestDto, + ): Promise { + const { + version, + faculty, + course, + semester, + studentEnrollments, + submittedUserIds, + availableStudents, + } = await this.ResolveGenerationContext(dto); + + if (availableStudents.length === 0) { + throw new BadRequestException( + 'All enrolled students have already submitted for this version, faculty, course, and semester combination.', + ); + } + + // 9. Extract questions + const questions = GetAllQuestionsWithSections(version.schemaSnapshot); + + // 10. Read maxScore + const maxScore = version.schemaSnapshot.meta.maxScore; + + // 11. Generate answers + const answersPerStudent = availableStudents.map(() => { + const tendency = + 1 + Math.random() * (maxScore - 1) * 0.6 + (maxScore - 1) * 0.3; + const answers: Record = {}; + for (const q of questions) { + const raw = tendency + (Math.random() - 0.5) * 2; + answers[q.id] = Math.round(Math.max(1, Math.min(maxScore, raw))); + } + return answers; + }); + + // 12. Generate comments (conditional) + let comments: (string | undefined)[] = availableStudents.map( + () => undefined, + ); + const qf = version.schemaSnapshot.qualitativeFeedback; + if (qf?.enabled) { + const generated = await this.commentGenerator.GenerateComments( + availableStudents.length, + { + courseName: course.fullname, + facultyName: + faculty.fullName ?? `${faculty.firstName} ${faculty.lastName}`, + maxScore, + maxLength: qf.maxLength, + }, + ); + comments = generated; + } + + // 13. Build rows + const now = Date.now(); + const rows = availableStudents.map((enrollment, index) => ({ + externalId: `gen_${enrollment.user.userName}_${now}_${index}`, + username: enrollment.user.userName, + facultyUsername: dto.facultyUsername, + courseShortname: dto.courseShortname, + answers: answersPerStudent[index], + comment: comments[index], + })); + + // 14. Return response + return { + metadata: { + faculty: { + username: faculty.userName, + fullName: + faculty.fullName ?? `${faculty.firstName} ${faculty.lastName}`, + }, + course: { shortname: course.shortname, fullname: course.fullname }, + semester: { + code: semester.code, + label: semester.label ?? '', + academicYear: semester.academicYear ?? '', + }, + version: { id: version.id, versionNumber: version.versionNumber }, + maxScore, + totalEnrolled: studentEnrollments.length, + alreadySubmitted: submittedUserIds.size, + availableStudents: availableStudents.length, + generatingCount: availableStudents.length, + }, + questions: questions.map((q) => ({ + id: q.id, + text: q.text, + sectionName: q.sectionName, + })), + rows, + }; + } + + async CommitSubmissions( + dto: GenerateCommitRequestDto, + ): Promise { + // 1. Load version + const version = await this.em.findOne(QuestionnaireVersion, dto.versionId, { + populate: ['questionnaire.type'], + }); + if (!version) { + throw new NotFoundException( + `Questionnaire version with ID ${dto.versionId} not found.`, + ); + } + if (!version.isActive) { + throw new BadRequestException( + 'Cannot commit submissions for an inactive questionnaire version.', + ); + } + + // 2. Load faculty (all rows share the same faculty) + const faculty = await this.em.findOne(User, { + userName: dto.rows[0].facultyUsername, + }); + if (!faculty) { + throw new NotFoundException( + `Faculty with username "${dto.rows[0].facultyUsername}" not found.`, + ); + } + const facultyId = faculty.id; + + // 3. Load course (all rows share the same course) + const course = await this.em.findOne( + Course, + { shortname: dto.rows[0].courseShortname }, + { populate: ['program.department.semester'] }, + ); + if (!course) { + throw new NotFoundException( + `Course with shortname "${dto.rows[0].courseShortname}" not found.`, + ); + } + const courseId = course.id; + + // 4. Resolve semester + const semester = course.program?.department?.semester; + if (!semester) { + throw new BadRequestException( + 'Course hierarchy is incomplete — cannot resolve semester.', + ); + } + const semesterId = semester.id; + + // 5. Validate answers keys (reject dangerous keys) + const dangerousKeys = new Set(['__proto__', 'constructor', 'prototype']); + for (const row of dto.rows) { + const badKey = Object.keys(row.answers).find((k) => dangerousKeys.has(k)); + if (badKey) { + throw new BadRequestException( + `Invalid answer key "${badKey}" in row "${row.externalId}".`, + ); + } + } + + // 6. Process rows + const records: CommitRecordResultDto[] = []; + let successes = 0; + let failures = 0; + + for (const row of dto.rows) { + try { + // Look up student + const student = await this.em.findOne(User, { userName: row.username }); + if (!student) { + records.push({ + externalId: row.externalId, + success: false, + error: `Student with username "${row.username}" not found.`, + }); + failures++; + continue; + } + + const result = await this.questionnaireService.submitQuestionnaire({ + versionId: dto.versionId, + respondentId: student.id, + facultyId, + semesterId, + courseId, + answers: row.answers, + qualitativeComment: row.comment, + }); + + records.push({ + externalId: row.externalId, + success: true, + internalId: result.id, + }); + successes++; + } catch (error) { + if (error instanceof HttpException) { + records.push({ + externalId: row.externalId, + success: false, + error: error.message, + }); + } else { + records.push({ + externalId: row.externalId, + success: false, + error: (error as Error).message, + }); + } + failures++; + this.em.clear(); + } + } + + return { + commitId: randomUUID(), + total: dto.rows.length, + successes, + failures, + dryRun: false, + records, + }; + } +} diff --git a/src/modules/admin/services/comment-generator.service.ts b/src/modules/admin/services/comment-generator.service.ts new file mode 100644 index 0000000..2fac765 --- /dev/null +++ b/src/modules/admin/services/comment-generator.service.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger } from '@nestjs/common'; +import OpenAI from 'openai'; +import { env } from 'src/configurations/env'; + +const FALLBACK_COMMENTS = [ + 'Good teaching.', + 'Helpful instructor.', + 'The class was informative.', + 'I learned a lot.', + 'Very supportive faculty.', + 'Clear explanations.', + 'Engaging lectures.', + 'Well-organized course.', + 'Responsive to student questions.', + 'Fair grading practices.', +]; + +@Injectable() +export class CommentGeneratorService { + private readonly logger = new Logger(CommentGeneratorService.name); + private readonly openai: OpenAI; + + constructor() { + this.openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }); + } + + async GenerateComments( + count: number, + context: { + courseName: string; + facultyName: string; + maxScore: number; + maxLength?: number; + }, + ): Promise { + try { + const maxLengthInstruction = context.maxLength + ? `Each comment must be under ${context.maxLength} characters.` + : ''; + + const response = await this.openai.chat.completions.create( + { + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: + 'You generate realistic student feedback comments for faculty evaluations. ' + + 'Return a JSON object with a "comments" key containing an array of strings. ' + + 'Language distribution: ~60% English, ~15% Tagalog, ~15% Cebuano, ~10% mixed/code-switched (e.g., Taglish or Bisaya-English). ' + + 'Comments should be varied in tone (positive, constructive, mixed) and length. ' + + 'They should sound like real Filipino college students evaluating their professors.', + }, + { + role: 'user', + content: + `Generate exactly ${count} student feedback comments for the course "${context.courseName}" ` + + `taught by "${context.facultyName}". The course uses a ${context.maxScore}-point scale. ` + + `${maxLengthInstruction} ` + + `Return JSON: { "comments": ["comment1", "comment2", ...] }`, + }, + ], + response_format: { type: 'json_object' }, + }, + { timeout: 60_000 }, + ); + + const content = response.choices[0]?.message?.content; + if (!content) { + this.logger.warn('OpenAI returned no content for comment generation'); + return this.getFallbackComments(count, context.maxLength); + } + + const parsed = JSON.parse(content) as { comments?: unknown[] }; + const comments = parsed.comments; + + if (!Array.isArray(comments) || comments.length === 0) { + this.logger.warn( + `OpenAI returned invalid comment array (expected ${count}, got ${Array.isArray(comments) ? comments.length : 'non-array'})`, + ); + return this.getFallbackComments(count, context.maxLength); + } + + // Normalize to exact count: pad with fallback if short, truncate if long + const fallback = this.getFallbackComments(count, context.maxLength); + const result: string[] = []; + for (let i = 0; i < count; i++) { + const raw = i < comments.length ? comments[i] : undefined; + let str = typeof raw === 'string' ? raw : fallback[i]; + if (context.maxLength && str.length > context.maxLength) { + str = str.slice(0, context.maxLength); + } + result.push(str); + } + + if (comments.length !== count) { + this.logger.warn( + `OpenAI returned ${comments.length} comments instead of ${count}, padded with fallback`, + ); + } + + return result; + } catch (error) { + this.logger.warn( + `Failed to generate comments via OpenAI, using fallback: ${(error as Error).message}`, + ); + return this.getFallbackComments(count, context.maxLength); + } + } + + private getFallbackComments(count: number, maxLength?: number): string[] { + const comments: string[] = []; + for (let i = 0; i < count; i++) { + let comment = FALLBACK_COMMENTS[i % FALLBACK_COMMENTS.length]; + if (maxLength && comment.length > maxLength) { + comment = comment.slice(0, maxLength); + } + comments.push(comment); + } + return comments; + } +} From 1c146f0facbc73b7c120b2cfb57723b79d02c0ef Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:18:07 +0800 Subject: [PATCH 3/4] FAC-115 fix: scope recommendation supporting evidence to pipeline submissions (#276) (#277) TopicAssignment query now filters by submissionIds, preventing cross-faculty evidence contamination. Replaced Topic.docCount with scoped assignment count for accurate commentCount in evidence and confidence computation. --- .../recommendation-generation.service.spec.ts | 125 ++++++++++++++++++ .../recommendation-generation.service.ts | 13 +- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/modules/analysis/services/recommendation-generation.service.spec.ts b/src/modules/analysis/services/recommendation-generation.service.spec.ts index 21812de..9a5bb3a 100644 --- a/src/modules/analysis/services/recommendation-generation.service.spec.ts +++ b/src/modules/analysis/services/recommendation-generation.service.spec.ts @@ -4,7 +4,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityManager } from '@mikro-orm/postgresql'; import { RecommendationGenerationService } from './recommendation-generation.service'; +import { TopicAssignment } from 'src/entities/topic-assignment.entity'; import { RECOMMENDATION_THRESHOLDS } from '../constants'; +import type { TopicSource } from '../dto/recommendations.dto'; // Mock OpenAI const mockParse = jest.fn(); @@ -238,6 +240,12 @@ describe('RecommendationGenerationService', () => { const result = await service.Generate('pipeline-1'); expect(result[0].supportingEvidence.confidenceLevel).toBe('LOW'); + // Scoped count = 1 assignment (not docCount 3) + const topicSource = result[0].supportingEvidence.sources.find( + (s) => s.type === 'topic', + ); + expect(topicSource).toBeDefined(); + expect((topicSource as TopicSource).commentCount).toBe(1); }); it('should compute HIGH confidence when commentCount >= 10 and agreement > 0.7', async () => { @@ -299,6 +307,12 @@ describe('RecommendationGenerationService', () => { const result = await service.Generate('pipeline-1'); expect(result[0].supportingEvidence.confidenceLevel).toBe('HIGH'); + // Scoped count = 14 assignments (not docCount 15) + const topicSource = result[0].supportingEvidence.sources.find( + (s) => s.type === 'topic', + ); + expect(topicSource).toBeDefined(); + expect((topicSource as TopicSource).commentCount).toBe(14); }); it('should compute MEDIUM confidence for >= 10 comments with <= 0.7 agreement', async () => { @@ -361,6 +375,12 @@ describe('RecommendationGenerationService', () => { const result = await service.Generate('pipeline-1'); expect(result[0].supportingEvidence.confidenceLevel).toBe('MEDIUM'); + // Scoped count = 12 assignments (matches docCount 12 in this case) + const topicSource = result[0].supportingEvidence.sources.find( + (s) => s.type === 'topic', + ); + expect(topicSource).toBeDefined(); + expect((topicSource as TopicSource).commentCount).toBe(12); }); it('should throw on OpenAI API failure', async () => { @@ -495,6 +515,111 @@ describe('RecommendationGenerationService', () => { expect(result[0].supportingEvidence.confidenceLevel).toBe('HIGH'); }); + it('should scope TopicAssignment query to pipeline submissionIds', async () => { + const topics = makeTopics(); + mockFork.findOneOrFail.mockResolvedValue(makePipeline()); + mockFork.findOne + .mockResolvedValueOnce({ id: 'sr1' }) + .mockResolvedValueOnce({ id: 'tmr1' }); + mockFork.find + .mockResolvedValueOnce([{ id: 'sub1' }, { id: 'sub2' }]) // submissions + .mockResolvedValueOnce([]) // sentiment results + .mockResolvedValueOnce(topics) // topics + .mockResolvedValueOnce([]); // topic assignments (empty = no quote sub queries) + + mockParse.mockResolvedValue(makeLlmResponse([])); + + await service.Generate('pipeline-1'); + + const assignmentCall = mockFork.find.mock.calls.find( + (call) => call[0] === TopicAssignment, + ); + expect(assignmentCall).toBeDefined(); + expect(assignmentCall![1]).toEqual( + expect.objectContaining({ + topic: { $in: ['t1', 't2'] }, + submission: { $in: ['sub1', 'sub2'] }, + }), + ); + }); + + it('should produce accurate evidence from scoped assignments', async () => { + const topics = [ + { + id: 't1', + topicIndex: 0, + rawLabel: 'raw_topic_1', + label: 'Teaching Quality', + keywords: ['teaching', 'quality'], + docCount: 50, // intentionally large to prove scoped count is used + }, + ]; + const sentimentResults = [ + { + id: 'sr1', + label: 'positive', + submission: { id: 'sub1' }, + positiveScore: 0.9, + negativeScore: 0.1, + }, + { + id: 'sr2', + label: 'negative', + submission: { id: 'sub2' }, + positiveScore: 0.1, + negativeScore: 0.8, + }, + ]; + + mockFork.findOneOrFail.mockResolvedValue(makePipeline()); + mockFork.findOne + .mockResolvedValueOnce({ id: 'sr1' }) + .mockResolvedValueOnce({ id: 'tmr1' }); + mockFork.find + .mockResolvedValueOnce([{ id: 'sub1' }, { id: 'sub2' }]) // submissions + .mockResolvedValueOnce(sentimentResults) // sentiment results + .mockResolvedValueOnce(topics) // topics + .mockResolvedValueOnce([ + // topic assignments — only in-scope + { topic: { id: 't1' }, submission: { id: 'sub1' }, isDominant: true }, + { topic: { id: 't1' }, submission: { id: 'sub2' }, isDominant: false }, + ]) + .mockResolvedValueOnce([ + { id: 'sub1', cleanedComment: 'Great teaching methods' }, + ]); // quote subs for dominant sub1 + + const llmRecs = [ + { + category: 'STRENGTH' as const, + headline: 'Strong Teaching', + description: 'Students appreciate teaching.', + actionPlan: 'Continue current methods.', + priority: 'HIGH' as const, + topicReference: 'Teaching Quality', + }, + ]; + mockParse.mockResolvedValue(makeLlmResponse(llmRecs)); + + const result = await service.Generate('pipeline-1'); + + expect(result.length).toBe(1); + + const topicSource = result[0].supportingEvidence.sources.find( + (s) => s.type === 'topic', + ); + expect(topicSource).toBeDefined(); + expect((topicSource as TopicSource).commentCount).toBe(2); // scoped, NOT docCount 50 + expect((topicSource as TopicSource).sampleQuotes).toEqual([ + 'Great teaching methods', + ]); + expect((topicSource as TopicSource).sentimentBreakdown).toEqual({ + positive: 1, + neutral: 0, + negative: 1, + }); + expect(result[0].supportingEvidence.confidenceLevel).toBe('LOW'); // 2 < 5 + }); + it('should attach dimension_scores evidence to every recommendation', async () => { mockFork.findOneOrFail.mockResolvedValue(makePipeline()); mockFork.findOne diff --git a/src/modules/analysis/services/recommendation-generation.service.ts b/src/modules/analysis/services/recommendation-generation.service.ts index b45f01f..21e7b67 100644 --- a/src/modules/analysis/services/recommendation-generation.service.ts +++ b/src/modules/analysis/services/recommendation-generation.service.ts @@ -24,6 +24,7 @@ import { interface TopicData { topic: Topic; + scopedCommentCount: number; sentimentBreakdown: { positive: number; neutral: number; negative: number }; sampleQuotes: string[]; } @@ -96,7 +97,10 @@ export class RecommendationGenerationService { const topicIds = topics.map((t) => t.id); const allAssignments = topicIds.length > 0 - ? await fork.find(TopicAssignment, { topic: { $in: topicIds } }) + ? await fork.find(TopicAssignment, { + topic: { $in: topicIds }, + submission: { $in: submissionIds }, + }) : []; // Build a Set for sentiment result lookups @@ -157,6 +161,7 @@ export class RecommendationGenerationService { const label = topic.label ?? topic.rawLabel; topicDataMap.set(label, { topic, + scopedCommentCount: assignments.length, sentimentBreakdown: breakdown, sampleQuotes, }); @@ -213,7 +218,7 @@ export class RecommendationGenerationService { const sentiment = data ? `positive=${data.sentimentBreakdown.positive}, neutral=${data.sentimentBreakdown.neutral}, negative=${data.sentimentBreakdown.negative}` : 'N/A'; - return `- "${label}" (keywords: [${t.keywords.join(', ')}], comments: ${t.docCount}, sentiment: ${sentiment})`; + return `- "${label}" (keywords: [${t.keywords.join(', ')}], comments: ${data!.scopedCommentCount}, sentiment: ${sentiment})`; }) .join('\n'); @@ -303,7 +308,7 @@ ${commentsDesc || 'No sample comments available.'} const topicSource: TopicSource = { type: 'topic', topicLabel: rec.topicReference, - commentCount: topicData.topic.docCount, + commentCount: topicData.scopedCommentCount, sentimentBreakdown: topicData.sentimentBreakdown, sampleQuotes: topicData.sampleQuotes, }; @@ -357,7 +362,7 @@ ${commentsDesc || 'No sample comments available.'} if (rec.topicReference && topicDataMap.has(rec.topicReference)) { const topicData = topicDataMap.get(rec.topicReference)!; - commentCount = topicData.topic.docCount; + commentCount = topicData.scopedCommentCount; sentimentBreakdown = topicData.sentimentBreakdown; } else { // Fallback to pipeline-level data From 28f0c84ca8b9d63aac120c68f0c516f7b3ff33eb Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sun, 12 Apr 2026 03:28:36 +0800 Subject: [PATCH 4/4] [STAGING] FAC-116 to FAC-121 feat: Moodle seeding toolkit, tree explorer, audit trail, semester fix, bulk course provisioning, program filter enhancements (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FAC-116 feat: add Moodle seeding toolkit API (#279) * FAC-116 feat: add Moodle seeding toolkit API Add provisioning endpoints for creating Moodle categories, courses, and users via REST API, replacing the manual Rust CLI workflow. Closes #278 * chore: add bmad artifacts for Moodle seeding toolkit * FAC-117 feat: add Moodle tree explorer API endpoints (#281) * FAC-117 feat: add Moodle tree explorer API endpoints Add read-only endpoints for browsing live Moodle category hierarchy and course listings to support admin provisioning visibility. * chore: add tech spec for moodle tree explorer * FAC-118 feat: add audit trail query endpoints (#282) * feat: add audit trail query endpoints Add GET /audit-logs (paginated, filtered list) and GET /audit-logs/:id (single record) endpoints for superadmin audit log visibility. https://claude.ai/code/session_01D6jVaVQiXM5y8P8XmsmzG5 * fix: startup issue --------- Co-authored-by: Claude * FAC-119 fix: correct semester year derivation and add category preview endpoint (#284) Fix wrong semester tag generation in category provisioning when a single semester is selected (e.g., S22626 instead of S22526). Add ComputeSchoolYears utility for school-year-aware year computation. Add POST categories/preview endpoint with read-only hierarchy walk. Improve webservice_access_exception error message with actionable hint. * FAC-120 feat: enhance bulk course provisioning with cascading dropdowns (#286) * FAC-120 feat: enhance bulk course provisioning with cascading dropdowns Replace free-text inputs and CSV upload with cascading dropdown selectors (Semester → Department → Program) and JSON-based bulk preview/execute endpoints for course provisioning. * FAC-120 chore: add tech spec for bulk course provisioning enhancement * FAC-121 feat: add ProgramFilterOptionResponseDto with moodleCategoryId (#288) * FAC-121 feat: add ProgramFilterOptionResponseDto with moodleCategoryId Create standalone ProgramFilterOptionResponseDto that exposes moodleCategoryId in the program filter response, enabling the admin frontend to derive Moodle category IDs for course fetching via cascading dropdowns. - Add ProgramFilterOptionResponseDto with static MapProgram() mapper - Update GetPrograms() service and controller return types - Add service-level spec for mapping verification - Update controller spec with moodleCategoryId assertions * chore: added tech spec --------- Co-authored-by: Claude --- ...ch-spec-enhance-seed-users-provision-ux.md | 429 +++++++ ...-spec-fix-moodle-semester-category-bugs.md | 566 +++++++++ ...ech-spec-moodle-course-bulk-enhancement.md | 610 ++++++++++ .../tech-spec-moodle-seeding-toolkit.md | 661 +++++++++++ .../tech-spec-moodle-tree-explorer.md | 574 +++++++++ _bmad/_config/agent-manifest.csv | 1 + .../bmm-moodle-integrator.customize.yaml | 41 + _bmad/bmm/agents/moodle-integrator.md | 103 ++ package-lock.json | 17 + package.json | 3 +- src/configurations/env/moodle.env.ts | 2 + .../admin/admin-filters.controller.spec.ts | 31 +- src/modules/admin/admin-filters.controller.ts | 24 +- .../requests/filter-departments-query.dto.ts | 5 + .../program-filter-option.response.dto.ts | 29 + .../responses/semester-filter.response.dto.ts | 30 + .../services/admin-filters.service.spec.ts | 70 ++ .../admin/services/admin-filters.service.ts | 68 +- src/modules/audit/audit-action.enum.ts | 5 + src/modules/audit/audit-query.service.spec.ts | 360 ++++++ src/modules/audit/audit-query.service.ts | 104 ++ src/modules/audit/audit.controller.spec.ts | 68 ++ src/modules/audit/audit.controller.ts | 117 ++ src/modules/audit/audit.module.ts | 13 +- .../dto/requests/list-audit-logs-query.dto.ts | 81 ++ .../audit-log-detail.response.dto.ts | 53 + .../responses/audit-log-item.response.dto.ts | 53 + .../responses/audit-log-list.response.dto.ts | 11 + .../moodle-provisioning.controller.spec.ts | 255 ++++ .../moodle-provisioning.controller.ts | 360 ++++++ .../bulk-course-execute.request.dto.ts | 79 ++ .../bulk-course-preview.request.dto.ts | 66 ++ .../requests/execute-courses.request.dto.ts | 66 ++ .../provision-categories.request.dto.ts | 73 ++ .../dto/requests/quick-course.request.dto.ts | 48 + .../dto/requests/seed-courses.request.dto.ts | 28 + .../dto/requests/seed-users.request.dto.ts | 44 + .../responses/course-preview.response.dto.ts | 41 + .../moodle-course-preview.response.dto.ts | 52 + .../dto/responses/moodle-tree.response.dto.ts | 47 + .../provision-result.response.dto.ts | 35 + .../seed-users-result.response.dto.ts | 18 + .../is-before-end-date.validator.ts | 17 + src/modules/moodle/lib/moodle.client.spec.ts | 48 + src/modules/moodle/lib/moodle.client.ts | 75 +- src/modules/moodle/lib/moodle.constants.ts | 4 + src/modules/moodle/lib/moodle.types.ts | 55 + src/modules/moodle/lib/provisioning.types.ts | 115 ++ src/modules/moodle/moodle.module.ts | 10 +- src/modules/moodle/moodle.service.ts | 55 +- .../moodle-course-transform.service.spec.ts | 228 ++++ .../moodle-course-transform.service.ts | 164 +++ .../moodle-csv-parser.service.spec.ts | 80 ++ .../services/moodle-csv-parser.service.ts | 107 ++ .../moodle-provisioning.service.spec.ts | 1028 +++++++++++++++++ .../services/moodle-provisioning.service.ts | 994 ++++++++++++++++ 56 files changed, 8303 insertions(+), 18 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md create mode 100644 _bmad-output/implementation-artifacts/tech-spec-fix-moodle-semester-category-bugs.md create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-course-bulk-enhancement.md create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-seeding-toolkit.md create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-tree-explorer.md create mode 100644 _bmad/_config/agents/bmm-moodle-integrator.customize.yaml create mode 100644 _bmad/bmm/agents/moodle-integrator.md create mode 100644 src/modules/admin/dto/responses/program-filter-option.response.dto.ts create mode 100644 src/modules/admin/dto/responses/semester-filter.response.dto.ts create mode 100644 src/modules/admin/services/admin-filters.service.spec.ts create mode 100644 src/modules/audit/audit-query.service.spec.ts create mode 100644 src/modules/audit/audit-query.service.ts create mode 100644 src/modules/audit/audit.controller.spec.ts create mode 100644 src/modules/audit/audit.controller.ts create mode 100644 src/modules/audit/dto/requests/list-audit-logs-query.dto.ts create mode 100644 src/modules/audit/dto/responses/audit-log-detail.response.dto.ts create mode 100644 src/modules/audit/dto/responses/audit-log-item.response.dto.ts create mode 100644 src/modules/audit/dto/responses/audit-log-list.response.dto.ts create mode 100644 src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts create mode 100644 src/modules/moodle/controllers/moodle-provisioning.controller.ts create mode 100644 src/modules/moodle/dto/requests/bulk-course-execute.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/bulk-course-preview.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/execute-courses.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/provision-categories.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/quick-course.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/seed-courses.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/seed-users.request.dto.ts create mode 100644 src/modules/moodle/dto/responses/course-preview.response.dto.ts create mode 100644 src/modules/moodle/dto/responses/moodle-course-preview.response.dto.ts create mode 100644 src/modules/moodle/dto/responses/moodle-tree.response.dto.ts create mode 100644 src/modules/moodle/dto/responses/provision-result.response.dto.ts create mode 100644 src/modules/moodle/dto/responses/seed-users-result.response.dto.ts create mode 100644 src/modules/moodle/dto/validators/is-before-end-date.validator.ts create mode 100644 src/modules/moodle/lib/moodle.client.spec.ts create mode 100644 src/modules/moodle/lib/provisioning.types.ts create mode 100644 src/modules/moodle/services/moodle-course-transform.service.spec.ts create mode 100644 src/modules/moodle/services/moodle-course-transform.service.ts create mode 100644 src/modules/moodle/services/moodle-csv-parser.service.spec.ts create mode 100644 src/modules/moodle/services/moodle-csv-parser.service.ts create mode 100644 src/modules/moodle/services/moodle-provisioning.service.spec.ts create mode 100644 src/modules/moodle/services/moodle-provisioning.service.ts diff --git a/_bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md b/_bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md new file mode 100644 index 0000000..706063a --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md @@ -0,0 +1,429 @@ +--- +title: 'Enhance Seed Users Provision UX' +slug: 'enhance-seed-users-provision-ux' +created: '2026-04-12' +status: 'ready-for-dev' +stepsCompleted: [1, 2, 3, 4] +tech_stack: + [ + 'React 19', + 'Vite', + 'TanStack Query', + 'shadcn/ui', + 'Radix Select', + 'Tailwind 4', + 'NestJS 11', + 'MikroORM 6', + 'Zod', + ] +files_to_modify: + - 'api: src/modules/admin/dto/responses/program-filter-option.response.dto.ts (NEW)' + - 'api: src/modules/admin/services/admin-filters.service.ts' + - 'api: src/modules/admin/services/admin-filters.service.spec.ts (NEW)' + - 'api: src/modules/admin/admin-filters.controller.ts' + - 'api: src/modules/admin/admin-filters.controller.spec.ts' + - 'admin: src/features/moodle-provision/components/seed-users-tab.tsx' + - 'admin: src/features/moodle-provision/use-seed-users.ts' + - 'admin: src/features/moodle-provision/use-programs-by-department.ts' + - 'admin: src/features/moodle-provision/provision-page.tsx' + - 'admin: src/types/api.ts' +code_patterns: + - 'Cascading dropdowns: useSemesters → useDepartmentsBySemester → useProgramsByDepartment with reset-on-change' + - 'View state machine: type View = "input" | "preview"' + - 'Category course fetch: useCategoryCourses(categoryId) with keepPreviousData' + - 'Checkbox selection: checked Set with toggleRow/toggleAll' + - 'onBrowse prop pattern for MoodleTreeSheet integration' + - 'Standalone dedicated DTOs per entity (SemesterFilterResponseDto pattern): flat class, own @ApiProperty decorators, static mapper' + - 'PascalCase public service methods' + - 'Swagger decorators on all DTO properties' +test_patterns: + - 'Jest with NestJS TestingModule' + - 'Controller spec mocks service methods as jest.fn()' + - 'admin-filters.controller.spec.ts exists; no service spec for admin-filters (service spec added in this work)' +--- + +# Tech-Spec: Enhance Seed Users Provision UX + +**Created:** 2026-04-12 + +## Overview + +### Problem Statement + +The Seed Users tab in the admin Moodle provisioning feature has a bare-bones UX compared to the recently enhanced Bulk Course Insert tab. Users must type raw comma-separated Moodle course IDs into a text input, pick campus from a static dropdown with no relationship to course selection, and execute with only a basic AlertDialog confirmation. There is no visual course selection, no preview step, and results are displayed as inline badges. This is friction-heavy, error-prone, and inconsistent with the improved UX patterns established in the bulk course flow. + +### Solution + +Rebuild the Seed Users tab with cascading dropdowns (Semester > Department > Program) that scope the user to a specific Moodle category, a visual course picker table fetched from the Moodle category tree API, a client-side preview/confirm step showing exactly what will happen, and a dedicated result panel. One API change: create a standalone `ProgramFilterOptionResponseDto` (same pattern as `SemesterFilterResponseDto`) that includes `moodleCategoryId`, keeping `FilterOptionResponseDto` untouched. Retain a small "Add by ID" escape hatch for power users. + +### Scope + +**In Scope:** + +- `admin.faculytics` — Rewrite `seed-users-tab.tsx` with cascading dropdowns, course picker table, preview view, and result panel +- `admin.faculytics` — Wire `onBrowse` prop to `SeedUsersTab` in `provision-page.tsx` +- `admin.faculytics` — Add `ProgramFilterOption` type in `api.ts`; update `useProgramsByDepartment` return type +- `admin.faculytics` — Remove `onSuccess` toast from `useSeedUsers` hook (result panel replaces it) +- `api.faculytics` — Create standalone `ProgramFilterOptionResponseDto` with `moodleCategoryId` +- Small "Add by ID" input for manual course entry as an escape hatch + +**Out of Scope:** + +- New backend preview endpoint (client-side preview only for this iteration) +- Multi-program selection (single program per operation; run twice for cross-program seeding) +- Changes to the `POST /moodle/provision/users` API request/response contract +- Changes to other provisioning tabs (categories, bulk courses, quick course) +- Changes to `FilterOptionResponseDto` — it stays untouched to avoid polluting campus/department responses + +## Context for Development + +### Codebase Patterns + +**Cascading Dropdowns (established in `courses-bulk-tab.tsx`):** + +- Three hooks chained: `useSemesters()` → `useDepartmentsBySemester(semesterId)` → `useProgramsByDepartment(departmentId)` +- Each hook uses `useQuery` with `enabled: !!parentId && isAuth` for conditional fetching +- Semester change resets department + program; department change resets program +- Semester selection auto-fills `startDate`/`endDate` from `SemesterFilterOption` and derives `campusCode` +- All hooks depend on `activeEnvId` from `useEnvStore` and `isAuthenticated` from `useAuthStore` + +**Category Course Fetching:** + +- `useCategoryCourses(categoryId: number | null)` fetches `GET /moodle/provision/tree/:categoryId/courses` +- Returns `MoodleCategoryCoursesResponse { categoryId, courses: MoodleCoursePreview[] }` +- `MoodleCoursePreview` has: `id`, `shortname`, `fullname`, `enrolledusercount?`, `visible`, `startdate`, `enddate` +- Uses `keepPreviousData` and 3-minute stale time +- **Important**: The hook uses `keepPreviousData`, meaning stale data from the previous category persists during fetch transitions. Components must snapshot courses into local state and eagerly clear the snapshot on program change to prevent showing stale courses with a new program label. + +**View State Machine:** + +- `type View = 'input' | 'preview'` pattern used by bulk courses +- Input view: form with cascade + data entry +- Preview view: read-only summary + execute button + back button +- Result shown inline after execution within the preview view + +**Checkbox Selection:** + +- `checked` as `Set` (indices into a **stable local snapshot**, not the live query data) +- `toggleRow(idx)` and `toggleAll()` handlers +- Select-all checkbox in table header + +**Standalone Dedicated Filter DTO Pattern (precedent: `SemesterFilterResponseDto`):** + +- When an entity needs fields beyond `{ id, code, name }`, a **standalone flat DTO class** is created with its own `@ApiProperty` decorators and static mapper +- `SemesterFilterResponseDto` is a standalone class — it does NOT extend `FilterOptionResponseDto`. It has its own `id`, `code`, `label`, `academicYear`, `campusCode`, `startDate`, `endDate` properties and decorators +- `FilterOptionResponseDto` stays unchanged for campuses and departments +- New `ProgramFilterOptionResponseDto` follows this exact same pattern: standalone flat class, own decorators, own `MapProgram()` static mapper +- **Why standalone**: NestJS Swagger metadata scanner relies on class prototypes. Spreading a plain object from `FilterOptionResponseDto.Map()` into a return value strips the prototype, making `@ApiProperty` decorators invisible. Standalone classes avoid this entirely. + +**Seed Users API (unchanged):** + +- `POST /moodle/provision/users` accepts `{ count, role, campus, courseIds }` +- `campus` is a plain string (e.g., `'UCMN'`) — the API calls `.toLowerCase()` internally in `GenerateFakeUser()` +- API generates users via `GenerateFakeUser(campus, role)`: + - Student username: `campus-YYMMDD####` (date + 4-digit random) + - Faculty username: `campus-t-#####` (5-digit random) + - Email: `username@faculytics.seed`, password: `User123#` +- Enrollments use Moodle role IDs from env: `MOODLE_ROLE_ID_STUDENT` / `MOODLE_ROLE_ID_EDITING_TEACHER` +- Batch size: 50 (users and enrolments) +- Operation guard prevents concurrent seed operations + +**`useSeedUsers` Hook Behavior:** + +- The hook defines both `onSuccess` (toast) and `onError` (409 check + generic toast) callbacks at the hook level +- TanStack Query executes hook-level AND component-level `onSuccess` callbacks. With the new result panel as primary success feedback, the hook-level `onSuccess` toast must be removed to avoid double feedback. + +### Files to Reference + +| File | Purpose | +| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `admin: src/features/moodle-provision/components/seed-users-tab.tsx` | **PRIMARY TARGET** — current seed users component to rewrite | +| `admin: src/features/moodle-provision/components/courses-bulk-tab.tsx` | Reference implementation for cascading dropdowns + preview pattern | +| `admin: src/features/moodle-provision/provision-page.tsx` | Tab orchestrator — needs `onBrowse` wired to SeedUsersTab | +| `admin: src/features/moodle-provision/use-seed-users.ts` | Mutation hook — remove `onSuccess` toast (result panel replaces it) | +| `admin: src/features/moodle-provision/use-semesters.ts` | Cascade level 1 hook — reuse as-is | +| `admin: src/features/moodle-provision/use-departments-by-semester.ts` | Cascade level 2 hook — reuse as-is | +| `admin: src/features/moodle-provision/use-programs-by-department.ts` | Cascade level 3 hook — return type changes to `ProgramFilterOption[]` | +| `admin: src/features/moodle-provision/use-category-courses.ts` | Course fetcher by category — reuse as-is | +| `admin: src/features/moodle-provision/components/moodle-tree-sheet.tsx` | Browse existing tree — wired via `onBrowse` prop | +| `admin: src/types/api.ts` | Add `ProgramFilterOption` type with required `moodleCategoryId: number` | +| `admin: src/features/admin/use-admin-filters.ts` | **NOT MODIFIED** — contains `usePrograms()` typed as `FilterOption[]`. This is a separate consumer for admin user management and does not need `moodleCategoryId`. The type divergence is intentional (structural typing makes it safe at runtime). | +| `api: src/modules/admin/dto/responses/filter-option.response.dto.ts` | **UNCHANGED** — existing DTO stays clean | +| `api: src/modules/admin/dto/responses/program-filter-option.response.dto.ts` | **NEW** — standalone flat class with `moodleCategoryId` | +| `api: src/modules/admin/dto/responses/semester-filter.response.dto.ts` | **Pattern reference** — standalone flat DTO with own decorators and mapper | +| `api: src/modules/admin/services/admin-filters.service.ts` | `GetPrograms()` — return type changes to `ProgramFilterOptionResponseDto[]` | +| `api: src/modules/admin/admin-filters.controller.ts` | Programs endpoint — return type annotation changes | +| `api: src/modules/admin/admin-filters.controller.spec.ts` | Controller test — needs updated mock and assertion | +| `api: src/entities/program.entity.ts` | Source of truth: `moodleCategoryId` is a required `number` field on `Program` | +| `api: src/modules/moodle/services/moodle-provisioning.service.ts:608-714` | `SeedUsers()` method — unchanged, for reference | + +### Technical Decisions + +- **Standalone `ProgramFilterOptionResponseDto` (not extending `FilterOptionResponseDto`)**: NestJS Swagger metadata scanner relies on class prototypes. Spreading a plain object from `Map()` strips the prototype, making `@ApiProperty` decorators invisible. `SemesterFilterResponseDto` uses a standalone flat class — `ProgramFilterOptionResponseDto` follows the same pattern. (Adversarial review R1-F1, R2-F1, R2-F2) +- **Client-side preview over server-side**: The course picker already shows live Moodle data (fetched via `useCategoryCourses`), so the preview is a confirmation of user selections, not a validation step. If server-side preview is needed later, the view structure supports swapping the data source. +- **Course list snapshot for stable checkbox indices**: The `useCategoryCourses` hook uses `keepPreviousData`, meaning stale data persists during transitions. Indexing `checked: Set` directly into live query data creates a race condition. Snapshot courses into `useState` on load, clear eagerly on program change via `handleProgramChange`. (Adversarial review R1-F7, R2-F4, R2-F5) +- **Eager clearing on program change**: A dedicated `handleProgramChange` handler clears `courseSnapshot` and `checked` immediately, before the async fetch completes. This prevents stale courses from appearing under a new program label during the `keepPreviousData` transition. (Adversarial review R2-F5) +- **Remove hook-level success toast**: `useSeedUsers` defines `onSuccess: toast.success(...)` at hook level. TanStack Query fires both hook-level and component-level `onSuccess`. With the result panel as primary success feedback, the hook toast causes double feedback. Remove it. (Adversarial review R2-F3) +- **Deduplication via `Set`**: All course IDs (from picker + manual input) are merged into a `new Set()` before submission to prevent duplicate enrollments. (Adversarial review R1-F3) +- **Campus sent as uppercase**: `SemesterFilterOption.campusCode` is uppercase (e.g., `'UCMN'`). Send it as-is to the API — the API's `GenerateFakeUser()` calls `.toLowerCase()` internally. Matching the current behavior avoids any contract ambiguity. (Adversarial review R1-F2) +- **Single-program scoping**: Matches the Rust script's directory-based scoping (`enrolments/ucmn/ccs/bscs/`). The "Add by ID" escape hatch covers cross-program edge cases. +- **Dedicated result panel over shared dialog**: `SeedUsersResponse` (`usersCreated`, `usersFailed`, `enrolmentsCreated`, `warnings[]`, `durationMs`) doesn't map to `ProvisionResultResponse` (`created`, `skipped`, `errors`, `details[]`, `durationMs`). An inline panel avoids conditional rendering complexity. +- **`mutation.reset()` on form reset**: TanStack Query mutations hold internal state (`isSuccess`, `data`, `error`). The reset handler must call `mutation.reset()` to clear stale mutation state before the user starts a new form session. (Adversarial review R2-F12) + +## Implementation Plan + +### Tasks + +#### Task 1: Create `ProgramFilterOptionResponseDto` + +- File: `api: src/modules/admin/dto/responses/program-filter-option.response.dto.ts` **(NEW)** +- Action: + 1. Create a **standalone flat DTO class** `ProgramFilterOptionResponseDto` (do NOT extend `FilterOptionResponseDto`) + 2. Add properties with Swagger decorators: + - `@ApiProperty()` `id: string` + - `@ApiProperty()` `code: string` + - `@ApiPropertyOptional({ nullable: true })` `name: string | null` + - `@ApiProperty({ description: 'Moodle category ID for this program' })` `moodleCategoryId: number` + 3. Add a static mapper method: + ```typescript + static MapProgram(entity: { + id: string; + code: string; + name?: string; + moodleCategoryId: number; + }): ProgramFilterOptionResponseDto { + const dto = new ProgramFilterOptionResponseDto(); + dto.id = entity.id; + dto.code = entity.code; + dto.name = entity.name ?? null; + dto.moodleCategoryId = entity.moodleCategoryId; + return dto; + } + ``` +- Notes: `FilterOptionResponseDto` is **not modified**. This follows the exact same pattern as `SemesterFilterResponseDto` — standalone flat class, own decorators, own mapper. Do NOT use extends or spread. + +#### Task 2: Use `ProgramFilterOptionResponseDto` in `GetPrograms()` + +- **Depends on**: Task 1 +- File: `api: src/modules/admin/services/admin-filters.service.ts` +- Action: + 1. Import `ProgramFilterOptionResponseDto` + 2. Change `GetPrograms()` return type from `Promise` to `Promise` + 3. Change the mapper call from `FilterOptionResponseDto.Map(p)` to `ProgramFilterOptionResponseDto.MapProgram(p)` — the `Program` entity already has `moodleCategoryId` loaded from `em.find()` + +#### Task 3: Add service-level test for `GetPrograms()` mapping + +- **Depends on**: Task 1, Task 2 +- File: `api: src/modules/admin/services/admin-filters.service.spec.ts` **(NEW)** +- Action: + 1. Create a new spec file for `AdminFiltersService` + 2. Add a test: `'GetPrograms should map moodleCategoryId via ProgramFilterOptionResponseDto'` + 3. Mock `EntityManager.find()` to return a program entity with `{ id: 'p-1', code: 'BSCS', name: 'Computer Science', moodleCategoryId: 42 }` + 4. Assert `result[0].moodleCategoryId` equals `42` + 5. Assert `result[0]` is an instance of `ProgramFilterOptionResponseDto` (verifies real mapper, not mock passthrough) +- Notes: This is the critical mapping that the entire feature depends on. The controller test bypasses the mapper via mock; this test exercises the real `MapProgram()` method. + +#### Task 4: Update controller return type and test + +- **Depends on**: Task 1 +- File: `api: src/modules/admin/admin-filters.controller.ts` +- Action: + 1. Import `ProgramFilterOptionResponseDto` + 2. Change the `GetPrograms()` method return type annotation from `Promise` to `Promise` + 3. Update `@ApiResponse` decorator from `{ status: 200, type: [FilterOptionResponseDto] }` to `{ status: 200, type: [ProgramFilterOptionResponseDto] }` +- File: `api: src/modules/admin/admin-filters.controller.spec.ts` +- Action: + 1. Update the mock program data to include `moodleCategoryId`: `{ id: 'p-1', code: 'BSCS', name: 'Computer Science', moodleCategoryId: 42 }` + 2. Update assertion to expect `moodleCategoryId: 42` in the result + +#### Task 5: Add `ProgramFilterOption` type on frontend + +- File: `admin: src/types/api.ts` +- Action: + 1. Add a new interface below `FilterOption`: + ```typescript + export interface ProgramFilterOption extends FilterOption { + moodleCategoryId: number; + } + ``` + 2. `FilterOption` is **not modified**. + +#### Task 6: Update `useProgramsByDepartment` return type + +- **Depends on**: Task 5 +- File: `admin: src/features/moodle-provision/use-programs-by-department.ts` +- Action: + 1. Import `ProgramFilterOption` instead of `FilterOption` + 2. Change the `apiClient` generic from `FilterOption[]` to `ProgramFilterOption[]` +- Notes: This is the only provision hook that changes. `useSemesters` and `useDepartmentsBySemester` remain unchanged. A separate `usePrograms()` hook in `use-admin-filters.ts` also calls the programs endpoint but is typed as `FilterOption[]` — this is intentional. That hook serves admin user management which does not need `moodleCategoryId`. TypeScript structural typing makes the runtime safe; the type divergence is acceptable. + +#### Task 7: Remove `onSuccess` toast from `useSeedUsers` hook + +- File: `admin: src/features/moodle-provision/use-seed-users.ts` +- Action: + 1. Remove the `onSuccess` callback from the `useMutation` options (lines 13-15: `onSuccess: (data) => { toast.success(...) }`) + 2. Keep the `onError` callback unchanged — error toasts (409 and generic) are still appropriate +- Notes: The result panel (Task 10) replaces the success toast as the sole success feedback. TanStack Query fires both hook-level and component-level `onSuccess` callbacks; removing the hook-level one prevents double feedback. + +#### Task 8: Wire `onBrowse` to `SeedUsersTab` in provision page + +- File: `admin: src/features/moodle-provision/provision-page.tsx` +- Action: + 1. Change `` to `` + +#### Task 9: Rewrite `seed-users-tab.tsx` — Input View + +- File: `admin: src/features/moodle-provision/components/seed-users-tab.tsx` +- Action: Full rewrite of the component. This task covers the **input view**: + 1. **Component signature**: Accept `{ onBrowse: () => void }` prop + 2. **State**: + - Cascade: `semesterId`, `departmentId`, `programId` (all `string | undefined`) + - View: `type View = 'input' | 'preview'` + - Course snapshot: `courseSnapshot: MoodleCoursePreview[]` (local state, not live query) + - Selection: `checked: Set` (indices into `courseSnapshot`) + - Manual IDs: `manualIdsInput: string` (default: `''`), `manualIdsExpanded: boolean` (default: `false`) + - Form: `role: 'student' | 'faculty' | ''`, `count: string` + - Result: `result: SeedUsersResponse | null` + 3. **Cascade dropdowns section** (full-width semester, then 2-col department + program): + - Import and use `useSemesters()`, `useDepartmentsBySemester(semesterId)`, `useProgramsByDepartment(departmentId)` + - `handleSemesterChange(id)`: set semesterId, reset departmentId + programId + courseSnapshot + checked + - `handleDepartmentChange(id)`: set departmentId, reset programId + courseSnapshot + checked + - `handleProgramChange(id)`: set programId, **immediately clear `courseSnapshot` to `[]` and `checked` to `new Set()`** — this prevents stale courses from appearing under a new program label during the `keepPreviousData` transition + - Disable department until semester selected; disable program until department selected + 4. **Role + Count row** (2-col grid below cascade): + - Role: `Select` with `student` / `faculty` options (same as current) + - Count: `Input type="number"` min=1 max=200 (same as current) + 5. **Course picker section** (appears when programId is set): + - Derive `moodleCategoryId` from selected program: `programs?.find(p => p.id === programId)?.moodleCategoryId ?? null` + - Call `useCategoryCourses(moodleCategoryId)` + - **Snapshot pattern**: When `categoryCourses` data arrives with a new `categoryId` (different from the current snapshot's source), copy `categoryCourses.courses` into `courseSnapshot` via `useEffect` and reset `checked` to empty `Set`. Dependency: `categoryCourses?.categoryId` — but note the snapshot is already eagerly cleared by `handleProgramChange`, so this effect only populates, never clears. + - **Loading state**: Show `Loader2` spinner while `useCategoryCourses` is loading AND `courseSnapshot` is empty + - **Error state**: If `useCategoryCourses` returns an error, show a bordered error box: "Failed to load courses from Moodle" with a "Retry" button that calls `refetch()` + - **Empty state**: Show "No courses found in this category" if fetch succeeded and `courseSnapshot.length === 0` + - **Course table**: Render checkbox table from `courseSnapshot` with columns: Checkbox, ID, Shortname, Fullname, Enrolled + - Select-all checkbox in header + - `toggleRow(idx)` and `toggleAll()` handlers using `checked: Set` indexing into `courseSnapshot` + 6. **"Add by ID" escape hatch** (below course table, visible only when programId is set): + - Collapsible section toggled by a text link: "Add courses by ID" + - **Initial state**: collapsed (`manualIdsExpanded: false`) + - **Collapse behavior**: collapsing does NOT clear the input (preserves typed IDs) + - **State persistence**: `manualIdsInput` and `manualIdsExpanded` persist across view transitions (preview and back) + - When expanded: text `Input` for comma-separated IDs + - Parsing logic: + ``` + parsedManualIds = input.split(',').map(s => s.trim()).filter(Boolean).map(s => parseInt(s, 10)) + invalidIds = input.split(',').map(s => s.trim()).filter(s => s && isNaN(parseInt(s, 10))) + validManualIds = parsedManualIds.filter(n => !isNaN(n)) + ``` + - Show inline error listing ALL invalid IDs: `Invalid course IDs: {invalidIds.join(', ')}` when `invalidIds.length > 0` + 7. **Derived values for submission**: + - `pickerIds = checked indices mapped to courseSnapshot[i].id` + - `allCourseIds = [...new Set([...pickerIds, ...validManualIds])]` — **deduplicated via Set** + - `campusCode = semesters?.find(s => s.id === semesterId)?.campusCode` + 8. **Action buttons row**: + - "Preview" button — enabled when ALL of: role is set, `parsedCount >= 1 && parsedCount <= 200`, `allCourseIds.length > 0`, **`invalidIds.length === 0`** (manual IDs must be valid or empty) + - On click: set `view = 'preview'` + - "Browse existing" button with `FolderTree` icon — calls `onBrowse()` + 9. **Remove**: `CAMPUSES` import, static campus dropdown, raw courseIdsInput text field as the sole input, `AlertDialog` confirmation, inline badge result display + +#### Task 10: Rewrite `seed-users-tab.tsx` — Preview View + +- File: `admin: src/features/moodle-provision/components/seed-users-tab.tsx` +- Action: Add the **preview view** (rendered when `view === 'preview'` and `result` is null): + 1. **Back button**: `