From ae7e93ea47171bfb5eabfc11a33fa81deefe6892 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 29 Mar 2026 17:22:32 +0000 Subject: [PATCH] feat: add pagination to curriculum list endpoints (#209) Curriculum list endpoints (/departments, /programs, /courses) returned unbounded result sets. Add page/limit query params via PaginationQueryDto and return paginated { data, meta } responses using findAndCount, matching the pattern used by admin, faculty, and dimensions modules. https://claude.ai/code/session_01Ug6JLCMPhy42Skcq7tWyVV --- .../curriculum/curriculum.controller.ts | 18 +- .../dto/requests/list-courses-query.dto.ts | 3 +- .../requests/list-departments-query.dto.ts | 3 +- .../dto/requests/list-programs-query.dto.ts | 3 +- .../dto/responses/course-list.response.dto.ts | 11 + .../responses/department-list.response.dto.ts | 11 + .../responses/program-list.response.dto.ts | 11 + .../services/curriculum.service.spec.ts | 260 +++++++++++++----- .../curriculum/services/curriculum.service.ts | 93 ++++++- 9 files changed, 323 insertions(+), 90 deletions(-) create mode 100644 src/modules/curriculum/dto/responses/course-list.response.dto.ts create mode 100644 src/modules/curriculum/dto/responses/department-list.response.dto.ts create mode 100644 src/modules/curriculum/dto/responses/program-list.response.dto.ts diff --git a/src/modules/curriculum/curriculum.controller.ts b/src/modules/curriculum/curriculum.controller.ts index f9d1955..10c9989 100644 --- a/src/modules/curriculum/curriculum.controller.ts +++ b/src/modules/curriculum/curriculum.controller.ts @@ -7,9 +7,9 @@ import { CurriculumService } from './services/curriculum.service'; import { ListDepartmentsQueryDto } from './dto/requests/list-departments-query.dto'; import { ListProgramsQueryDto } from './dto/requests/list-programs-query.dto'; import { ListCoursesQueryDto } from './dto/requests/list-courses-query.dto'; -import { DepartmentItemResponseDto } from './dto/responses/department-item.response.dto'; -import { ProgramItemResponseDto } from './dto/responses/program-item.response.dto'; -import { CourseItemResponseDto } from './dto/responses/course-item.response.dto'; +import { DepartmentListResponseDto } from './dto/responses/department-list.response.dto'; +import { ProgramListResponseDto } from './dto/responses/program-list.response.dto'; +import { CourseListResponseDto } from './dto/responses/course-list.response.dto'; @ApiTags('Curriculum') @Controller('curriculum') @@ -20,28 +20,28 @@ export class CurriculumController { @Get('departments') @ApiOperation({ summary: 'List departments scoped to caller role' }) - @ApiResponse({ status: 200, type: [DepartmentItemResponseDto] }) + @ApiResponse({ status: 200, type: DepartmentListResponseDto }) async ListDepartments( @Query() query: ListDepartmentsQueryDto, - ): Promise { + ): Promise { return this.curriculumService.ListDepartments(query); } @Get('programs') @ApiOperation({ summary: 'List programs scoped to caller role' }) - @ApiResponse({ status: 200, type: [ProgramItemResponseDto] }) + @ApiResponse({ status: 200, type: ProgramListResponseDto }) async ListPrograms( @Query() query: ListProgramsQueryDto, - ): Promise { + ): Promise { return this.curriculumService.ListPrograms(query); } @Get('courses') @ApiOperation({ summary: 'List courses scoped to caller role' }) - @ApiResponse({ status: 200, type: [CourseItemResponseDto] }) + @ApiResponse({ status: 200, type: CourseListResponseDto }) async ListCourses( @Query() query: ListCoursesQueryDto, - ): Promise { + ): Promise { return this.curriculumService.ListCourses(query); } } diff --git a/src/modules/curriculum/dto/requests/list-courses-query.dto.ts b/src/modules/curriculum/dto/requests/list-courses-query.dto.ts index 49a8049..8d92178 100644 --- a/src/modules/curriculum/dto/requests/list-courses-query.dto.ts +++ b/src/modules/curriculum/dto/requests/list-courses-query.dto.ts @@ -6,8 +6,9 @@ import { MaxLength, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaginationQueryDto } from 'src/modules/common/dto/pagination-query.dto'; -export class ListCoursesQueryDto { +export class ListCoursesQueryDto extends PaginationQueryDto { @ApiProperty({ description: 'Semester UUID to scope course list' }) @IsUUID() @IsNotEmpty() diff --git a/src/modules/curriculum/dto/requests/list-departments-query.dto.ts b/src/modules/curriculum/dto/requests/list-departments-query.dto.ts index 030d7c2..cfb9a3b 100644 --- a/src/modules/curriculum/dto/requests/list-departments-query.dto.ts +++ b/src/modules/curriculum/dto/requests/list-departments-query.dto.ts @@ -6,8 +6,9 @@ import { MaxLength, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaginationQueryDto } from 'src/modules/common/dto/pagination-query.dto'; -export class ListDepartmentsQueryDto { +export class ListDepartmentsQueryDto extends PaginationQueryDto { @ApiProperty({ description: 'Semester UUID to scope department list' }) @IsUUID() @IsNotEmpty() diff --git a/src/modules/curriculum/dto/requests/list-programs-query.dto.ts b/src/modules/curriculum/dto/requests/list-programs-query.dto.ts index 3d0f95b..487d07d 100644 --- a/src/modules/curriculum/dto/requests/list-programs-query.dto.ts +++ b/src/modules/curriculum/dto/requests/list-programs-query.dto.ts @@ -6,8 +6,9 @@ import { MaxLength, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaginationQueryDto } from 'src/modules/common/dto/pagination-query.dto'; -export class ListProgramsQueryDto { +export class ListProgramsQueryDto extends PaginationQueryDto { @ApiProperty({ description: 'Semester UUID to scope program list' }) @IsUUID() @IsNotEmpty() diff --git a/src/modules/curriculum/dto/responses/course-list.response.dto.ts b/src/modules/curriculum/dto/responses/course-list.response.dto.ts new file mode 100644 index 0000000..50874d4 --- /dev/null +++ b/src/modules/curriculum/dto/responses/course-list.response.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationMeta } from 'src/modules/common/dto/pagination.dto'; +import { CourseItemResponseDto } from './course-item.response.dto'; + +export class CourseListResponseDto { + @ApiProperty({ type: [CourseItemResponseDto] }) + data: CourseItemResponseDto[]; + + @ApiProperty({ type: PaginationMeta }) + meta: PaginationMeta; +} diff --git a/src/modules/curriculum/dto/responses/department-list.response.dto.ts b/src/modules/curriculum/dto/responses/department-list.response.dto.ts new file mode 100644 index 0000000..2fdf206 --- /dev/null +++ b/src/modules/curriculum/dto/responses/department-list.response.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationMeta } from 'src/modules/common/dto/pagination.dto'; +import { DepartmentItemResponseDto } from './department-item.response.dto'; + +export class DepartmentListResponseDto { + @ApiProperty({ type: [DepartmentItemResponseDto] }) + data: DepartmentItemResponseDto[]; + + @ApiProperty({ type: PaginationMeta }) + meta: PaginationMeta; +} diff --git a/src/modules/curriculum/dto/responses/program-list.response.dto.ts b/src/modules/curriculum/dto/responses/program-list.response.dto.ts new file mode 100644 index 0000000..e2878c1 --- /dev/null +++ b/src/modules/curriculum/dto/responses/program-list.response.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationMeta } from 'src/modules/common/dto/pagination.dto'; +import { ProgramItemResponseDto } from './program-item.response.dto'; + +export class ProgramListResponseDto { + @ApiProperty({ type: [ProgramItemResponseDto] }) + data: ProgramItemResponseDto[]; + + @ApiProperty({ type: PaginationMeta }) + meta: PaginationMeta; +} diff --git a/src/modules/curriculum/services/curriculum.service.spec.ts b/src/modules/curriculum/services/curriculum.service.spec.ts index 68b4e78..65fe3c5 100644 --- a/src/modules/curriculum/services/curriculum.service.spec.ts +++ b/src/modules/curriculum/services/curriculum.service.spec.ts @@ -10,7 +10,7 @@ import { ScopeResolverService } from 'src/modules/common/services/scope-resolver describe('CurriculumService', () => { let service: CurriculumService; - let em: { findOne: jest.Mock; find: jest.Mock }; + let em: { findOne: jest.Mock; findAndCount: jest.Mock }; let scopeResolver: { ResolveDepartmentIds: jest.Mock }; const semesterId = 'semester-1'; @@ -22,7 +22,7 @@ describe('CurriculumService', () => { beforeEach(async () => { em = { findOne: jest.fn(), - find: jest.fn(), + findAndCount: jest.fn(), }; scopeResolver = { @@ -44,6 +44,14 @@ describe('CurriculumService', () => { em.findOne.mockResolvedValueOnce({ id: semesterId }); } + const emptyMeta = (page = 1, limit = 10) => ({ + totalItems: 0, + itemCount: 0, + itemsPerPage: limit, + totalPages: 0, + currentPage: page, + }); + // ─── ListDepartments ────────────────────────────────────────────── describe('ListDepartments', () => { @@ -55,14 +63,16 @@ describe('CurriculumService', () => { { id: deptId, code: 'CCS', name: 'College of Computer Studies' }, { id: deptId2, code: 'CBA', name: 'College of Business Admin' }, ]; - em.find.mockResolvedValue(departments); + em.findAndCount.mockResolvedValue([departments, 2]); const result = await service.ListDepartments({ semesterId }); - expect(result).toHaveLength(2); - expect(result[0].id).toBe(deptId); - expect(result[0].code).toBe('CCS'); - expect(result[0].name).toBe('College of Computer Studies'); + expect(result.data).toHaveLength(2); + expect(result.data[0].id).toBe(deptId); + expect(result.data[0].code).toBe('CCS'); + expect(result.data[0].name).toBe('College of Computer Studies'); + expect(result.meta.totalItems).toBe(2); + expect(result.meta.currentPage).toBe(1); expect(scopeResolver.ResolveDepartmentIds).toHaveBeenCalledWith( semesterId, ); @@ -75,37 +85,38 @@ describe('CurriculumService', () => { const departments = [ { id: deptId, code: 'CCS', name: 'College of Computer Studies' }, ]; - em.find.mockResolvedValue(departments); + em.findAndCount.mockResolvedValue([departments, 1]); const result = await service.ListDepartments({ semesterId }); - expect(result).toHaveLength(1); - expect(result[0].code).toBe('CCS'); + expect(result.data).toHaveLength(1); + expect(result.data[0].code).toBe('CCS'); // Verify scope filter was applied - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ id: { $in: [deptId] } }), ); }); - it('should return [] when dean has empty scope', async () => { + it('should return empty page when dean has empty scope', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue([]); const result = await service.ListDepartments({ semesterId }); - expect(result).toEqual([]); - expect(em.find).not.toHaveBeenCalled(); + expect(result.data).toEqual([]); + expect(result.meta).toEqual(emptyMeta()); + expect(em.findAndCount).not.toHaveBeenCalled(); }); it('should filter by search on code and name (OR, ILIKE)', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); await service.ListDepartments({ semesterId, search: 'Comp' }); - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ $and: [ @@ -123,11 +134,11 @@ describe('CurriculumService', () => { it('should escape LIKE wildcards in search', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); await service.ListDepartments({ semesterId, search: '%admin_test' }); - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ $and: [ @@ -150,24 +161,25 @@ describe('CurriculumService', () => { ); }); - it('should return [] when no departments match', async () => { + it('should return empty page when no departments match', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); const result = await service.ListDepartments({ semesterId }); - expect(result).toEqual([]); + expect(result.data).toEqual([]); + expect(result.meta.totalItems).toBe(0); }); it('should apply both scope restriction and search simultaneously', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue([deptId]); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); await service.ListDepartments({ semesterId, search: 'CCS' }); - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ id: { $in: [deptId] }, @@ -186,11 +198,64 @@ describe('CurriculumService', () => { it('should handle department with null name', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([{ id: deptId, code: 'CCS', name: undefined }]); + em.findAndCount.mockResolvedValue([ + [{ id: deptId, code: 'CCS', name: undefined }], + 1, + ]); const result = await service.ListDepartments({ semesterId }); - expect(result[0].name).toBeNull(); + expect(result.data[0].name).toBeNull(); + }); + + it('should pass limit and offset to findAndCount with default pagination', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); + em.findAndCount.mockResolvedValue([[], 0]); + + await service.ListDepartments({ semesterId }); + + const findCall = em.findAndCount.mock.calls[0] as unknown[]; + expect(findCall[2]).toEqual( + expect.objectContaining({ limit: 10, offset: 0 }), + ); + }); + + it('should pass custom page and limit to findAndCount', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); + em.findAndCount.mockResolvedValue([[], 0]); + + await service.ListDepartments({ semesterId, page: 3, limit: 5 }); + + const findCall = em.findAndCount.mock.calls[0] as unknown[]; + expect(findCall[2]).toEqual( + expect.objectContaining({ limit: 5, offset: 10 }), + ); + }); + + it('should compute pagination meta correctly', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); + + const departments = [ + { id: deptId, code: 'CCS', name: 'College of Computer Studies' }, + ]; + em.findAndCount.mockResolvedValue([departments, 25]); + + const result = await service.ListDepartments({ + semesterId, + page: 2, + limit: 10, + }); + + expect(result.meta).toEqual({ + totalItems: 25, + itemCount: 1, + itemsPerPage: 10, + totalPages: 3, + currentPage: 2, + }); }); }); @@ -215,27 +280,28 @@ describe('CurriculumService', () => { department: { id: deptId }, }, ]; - em.find.mockResolvedValue(programs); + em.findAndCount.mockResolvedValue([programs, 2]); const result = await service.ListPrograms({ semesterId }); - expect(result).toHaveLength(2); - expect(result[0].departmentId).toBe(deptId); + expect(result.data).toHaveLength(2); + expect(result.data[0].departmentId).toBe(deptId); + expect(result.meta.totalItems).toBe(2); }); - it('should return [] for super admin with non-existent departmentId', async () => { + it('should return empty page for super admin with non-existent departmentId', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); const result = await service.ListPrograms({ semesterId, departmentId: 'non-existent', }); - expect(result).toEqual([]); + expect(result.data).toEqual([]); // Verify filter includes the departmentId - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -256,21 +322,21 @@ describe('CurriculumService', () => { department: { id: deptId }, }, ]; - em.find.mockResolvedValue(programs); + em.findAndCount.mockResolvedValue([programs, 1]); const result = await service.ListPrograms({ semesterId }); - expect(result).toHaveLength(1); + expect(result.data).toHaveLength(1); }); it('should narrow results with departmentId within scope', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue([deptId, deptId2]); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); await service.ListPrograms({ semesterId, departmentId: deptId }); - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -291,11 +357,11 @@ describe('CurriculumService', () => { it('should filter by search on code and name (OR)', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); await service.ListPrograms({ semesterId, search: 'BS' }); - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ $and: [ @@ -307,24 +373,39 @@ describe('CurriculumService', () => { ); }); - it('should return [] when no programs match', async () => { + it('should return empty page when no programs match', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); const result = await service.ListPrograms({ semesterId }); - expect(result).toEqual([]); + expect(result.data).toEqual([]); + expect(result.meta.totalItems).toBe(0); }); - it('should return [] when dean has empty scope and no departmentId', async () => { + it('should return empty page when dean has empty scope and no departmentId', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue([]); const result = await service.ListPrograms({ semesterId }); - expect(result).toEqual([]); - expect(em.find).not.toHaveBeenCalled(); + expect(result.data).toEqual([]); + expect(result.meta).toEqual(emptyMeta()); + expect(em.findAndCount).not.toHaveBeenCalled(); + }); + + it('should pass limit and offset with custom pagination', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); + em.findAndCount.mockResolvedValue([[], 0]); + + await service.ListPrograms({ semesterId, page: 2, limit: 15 }); + + const findCall = em.findAndCount.mock.calls[0] as unknown[]; + expect(findCall[2]).toEqual( + expect.objectContaining({ limit: 15, offset: 15 }), + ); }); }); @@ -359,15 +440,16 @@ describe('CurriculumService', () => { isActive: false, }, ]; - em.find.mockResolvedValue(courses); + em.findAndCount.mockResolvedValue([courses, 2]); const result = await service.ListCourses({ semesterId, departmentId: deptId, }); - expect(result).toHaveLength(2); - expect(result[0].programId).toBe(programId); + expect(result.data).toHaveLength(2); + expect(result.data[0].programId).toBe(programId); + expect(result.meta.totalItems).toBe(2); }); it('should return courses for dean with programId within scope', async () => { @@ -388,14 +470,14 @@ describe('CurriculumService', () => { isActive: true, }, ]; - em.find.mockResolvedValue(courses); + em.findAndCount.mockResolvedValue([courses, 1]); const result = await service.ListCourses({ semesterId, programId, }); - expect(result).toHaveLength(1); + expect(result.data).toHaveLength(1); }); it('should throw 403 when dean provides programId outside scope', async () => { @@ -444,7 +526,7 @@ describe('CurriculumService', () => { it('should filter by search on shortname and fullname (OR)', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); await service.ListCourses({ semesterId, @@ -452,7 +534,7 @@ describe('CurriculumService', () => { search: 'NET', }); - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ $and: [ @@ -487,19 +569,19 @@ describe('CurriculumService', () => { isActive: false, }, ]; - em.find.mockResolvedValue(courses); + em.findAndCount.mockResolvedValue([courses, 2]); const result = await service.ListCourses({ semesterId, departmentId: deptId, }); - expect(result).toHaveLength(2); - expect(result[0].isActive).toBe(true); - expect(result[1].isActive).toBe(false); + expect(result.data).toHaveLength(2); + expect(result.data[0].isActive).toBe(true); + expect(result.data[1].isActive).toBe(false); // Verify no isActive filter was applied - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).not.toHaveProperty('isActive'); }); @@ -526,17 +608,18 @@ describe('CurriculumService', () => { ).rejects.toThrow(ForbiddenException); }); - it('should return [] when no courses match', async () => { + it('should return empty page when no courses match', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); - em.find.mockResolvedValue([]); + em.findAndCount.mockResolvedValue([[], 0]); const result = await service.ListCourses({ semesterId, departmentId: deptId, }); - expect(result).toEqual([]); + expect(result.data).toEqual([]); + expect(result.meta.totalItems).toBe(0); }); it('should throw 404 for non-existent semesterId', async () => { @@ -547,7 +630,7 @@ describe('CurriculumService', () => { ).rejects.toThrow(NotFoundException); }); - it('should return [] when dean has empty scope with departmentId', async () => { + it('should return empty page when dean has empty scope with departmentId', async () => { setupSemesterFound(); scopeResolver.ResolveDepartmentIds.mockResolvedValue([]); @@ -588,7 +671,7 @@ describe('CurriculumService', () => { isActive: true, }, ]; - em.find.mockResolvedValue(courses); + em.findAndCount.mockResolvedValue([courses, 1]); const result = await service.ListCourses({ semesterId, @@ -596,11 +679,11 @@ describe('CurriculumService', () => { programId, }); - expect(result).toHaveLength(1); - expect(result[0].shortname).toBe('FREAI'); + expect(result.data).toHaveLength(1); + expect(result.data[0].shortname).toBe('FREAI'); // Verify filter includes both constraints - const findCall = em.find.mock.calls[0] as unknown[]; + const findCall = em.findAndCount.mock.calls[0] as unknown[]; expect(findCall[1]).toEqual( expect.objectContaining({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -608,5 +691,54 @@ describe('CurriculumService', () => { }), ); }); + + it('should pass limit and offset with custom pagination', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); + em.findAndCount.mockResolvedValue([[], 0]); + + await service.ListCourses({ + semesterId, + departmentId: deptId, + page: 4, + limit: 25, + }); + + const findCall = em.findAndCount.mock.calls[0] as unknown[]; + expect(findCall[2]).toEqual( + expect.objectContaining({ limit: 25, offset: 75 }), + ); + }); + + it('should compute pagination meta correctly for courses', async () => { + setupSemesterFound(); + scopeResolver.ResolveDepartmentIds.mockResolvedValue(null); + + const courses = [ + { + id: 'c1', + shortname: 'FREAI', + fullname: 'Free Elective AI', + program: { id: programId }, + isActive: true, + }, + ]; + em.findAndCount.mockResolvedValue([courses, 50]); + + const result = await service.ListCourses({ + semesterId, + departmentId: deptId, + page: 3, + limit: 10, + }); + + expect(result.meta).toEqual({ + totalItems: 50, + itemCount: 1, + itemsPerPage: 10, + totalPages: 5, + currentPage: 3, + }); + }); }); }); diff --git a/src/modules/curriculum/services/curriculum.service.ts b/src/modules/curriculum/services/curriculum.service.ts index 64d4c01..b04562e 100644 --- a/src/modules/curriculum/services/curriculum.service.ts +++ b/src/modules/curriculum/services/curriculum.service.ts @@ -15,8 +15,11 @@ import { ListDepartmentsQueryDto } from '../dto/requests/list-departments-query. import { ListProgramsQueryDto } from '../dto/requests/list-programs-query.dto'; import { ListCoursesQueryDto } from '../dto/requests/list-courses-query.dto'; import { DepartmentItemResponseDto } from '../dto/responses/department-item.response.dto'; +import { DepartmentListResponseDto } from '../dto/responses/department-list.response.dto'; import { ProgramItemResponseDto } from '../dto/responses/program-item.response.dto'; +import { ProgramListResponseDto } from '../dto/responses/program-list.response.dto'; import { CourseItemResponseDto } from '../dto/responses/course-item.response.dto'; +import { CourseListResponseDto } from '../dto/responses/course-list.response.dto'; @Injectable() export class CurriculumService { @@ -27,9 +30,13 @@ export class CurriculumService { async ListDepartments( query: ListDepartmentsQueryDto, - ): Promise { + ): Promise { await this.ValidateSemester(query.semesterId); + const page = query.page ?? 1; + const limit = query.limit ?? 10; + const offset = (page - 1) * limit; + const departmentIds = await this.scopeResolverService.ResolveDepartmentIds( query.semesterId, ); @@ -40,25 +47,44 @@ export class CurriculumService { if (departmentIds !== null) { if (departmentIds.length === 0) { - return []; + return this.BuildEmptyPage(page, limit); } Object.assign(filter, { id: { $in: departmentIds } }); } this.ApplySearchFilter(filter, query.search, ['code', 'name']); - const departments = await this.em.find(Department, filter, { - orderBy: { name: QueryOrder.ASC_NULLS_LAST }, - }); + const [departments, totalItems] = await this.em.findAndCount( + Department, + filter, + { + orderBy: { name: QueryOrder.ASC_NULLS_LAST }, + limit, + offset, + }, + ); - return departments.map((d) => DepartmentItemResponseDto.Map(d)); + return { + data: departments.map((d) => DepartmentItemResponseDto.Map(d)), + meta: { + totalItems, + itemCount: departments.length, + itemsPerPage: limit, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }, + }; } async ListPrograms( query: ListProgramsQueryDto, - ): Promise { + ): Promise { await this.ValidateSemester(query.semesterId); + const page = query.page ?? 1; + const limit = query.limit ?? 10; + const offset = (page - 1) * limit; + const departmentIds = await this.scopeResolverService.ResolveDepartmentIds( query.semesterId, ); @@ -79,7 +105,7 @@ export class CurriculumService { departmentFilter.id = query.departmentId; } else if (departmentIds !== null) { if (departmentIds.length === 0) { - return []; + return this.BuildEmptyPage(page, limit); } departmentFilter.id = { $in: departmentIds }; } @@ -90,17 +116,28 @@ export class CurriculumService { this.ApplySearchFilter(filter, query.search, ['code', 'name']); - const programs = await this.em.find(Program, filter, { + const [programs, totalItems] = await this.em.findAndCount(Program, filter, { populate: ['department'], orderBy: { name: QueryOrder.ASC_NULLS_LAST }, + limit, + offset, }); - return programs.map((p) => ProgramItemResponseDto.Map(p)); + return { + data: programs.map((p) => ProgramItemResponseDto.Map(p)), + meta: { + totalItems, + itemCount: programs.length, + itemsPerPage: limit, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }, + }; } async ListCourses( query: ListCoursesQueryDto, - ): Promise { + ): Promise { await this.ValidateSemester(query.semesterId); if (!query.programId && !query.departmentId) { @@ -109,6 +146,10 @@ export class CurriculumService { ); } + const page = query.page ?? 1; + const limit = query.limit ?? 10; + const offset = (page - 1) * limit; + const departmentIds = await this.scopeResolverService.ResolveDepartmentIds( query.semesterId, ); @@ -161,7 +202,7 @@ export class CurriculumService { departmentFilter.id = query.departmentId; } else if (departmentIds !== null) { if (departmentIds.length === 0) { - return []; + return this.BuildEmptyPage(page, limit); } departmentFilter.id = { $in: departmentIds }; } @@ -180,12 +221,23 @@ export class CurriculumService { this.ApplySearchFilter(filter, query.search, ['shortname', 'fullname']); - const courses = await this.em.find(Course, filter, { + const [courses, totalItems] = await this.em.findAndCount(Course, filter, { populate: ['program'], orderBy: { shortname: QueryOrder.ASC }, + limit, + offset, }); - return courses.map((c) => CourseItemResponseDto.Map(c)); + return { + data: courses.map((c) => CourseItemResponseDto.Map(c)), + meta: { + totalItems, + itemCount: courses.length, + itemsPerPage: limit, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }, + }; } private async ValidateSemester(semesterId: string): Promise { @@ -221,4 +273,17 @@ export class CurriculumService { .replace(/%/g, '\\%') .replace(/_/g, '\\_'); } + + private BuildEmptyPage(page: number, limit: number) { + return { + data: [], + meta: { + totalItems: 0, + itemCount: 0, + itemsPerPage: limit, + totalPages: 0, + currentPage: page, + }, + }; + } }