Skip to content

Commit f206a21

Browse files
authored
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
1 parent 4ac6ae7 commit f206a21

6 files changed

Lines changed: 545 additions & 5 deletions

File tree

_bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md

Lines changed: 429 additions & 0 deletions
Large diffs are not rendered by default.

src/modules/admin/admin-filters.controller.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,21 @@ describe('AdminFiltersController', () => {
8787
});
8888

8989
it('should delegate program listing to the filters service', async () => {
90-
const programs = [{ id: 'p-1', code: 'BSCS', name: 'Computer Science' }];
90+
const programs = [
91+
{
92+
id: 'p-1',
93+
code: 'BSCS',
94+
name: 'Computer Science',
95+
moodleCategoryId: 42,
96+
},
97+
];
9198
filtersService.GetPrograms.mockResolvedValue(programs);
9299

93100
const result = await controller.GetPrograms({ departmentId: 'd-1' });
94101

95102
expect(filtersService.GetPrograms).toHaveBeenCalledWith('d-1');
96103
expect(result).toEqual(programs);
104+
expect(result[0].moodleCategoryId).toBe(42);
97105
});
98106

99107
it('should pass undefined departmentId when not provided', async () => {

src/modules/admin/admin-filters.controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { FilterOptionResponseDto } from './dto/responses/filter-option.response.
1717
import { FilterFacultyResponseDto } from './dto/responses/filter-faculty.response.dto';
1818
import { FilterCourseResponseDto } from './dto/responses/filter-course.response.dto';
1919
import { FilterVersionResponseDto } from './dto/responses/filter-version.response.dto';
20+
import { ProgramFilterOptionResponseDto } from './dto/responses/program-filter-option.response.dto';
2021
import { SemesterFilterResponseDto } from './dto/responses/semester-filter.response.dto';
2122

2223
@ApiTags('Admin')
@@ -72,10 +73,10 @@ export class AdminFiltersController {
7273
type: String,
7374
description: 'Filter by department UUID',
7475
})
75-
@ApiResponse({ status: 200, type: [FilterOptionResponseDto] })
76+
@ApiResponse({ status: 200, type: [ProgramFilterOptionResponseDto] })
7677
async GetPrograms(
7778
@Query() query: FilterProgramsQueryDto,
78-
): Promise<FilterOptionResponseDto[]> {
79+
): Promise<ProgramFilterOptionResponseDto[]> {
7980
return this.filtersService.GetPrograms(query.departmentId);
8081
}
8182

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
3+
export class ProgramFilterOptionResponseDto {
4+
@ApiProperty()
5+
id: string;
6+
7+
@ApiProperty()
8+
code: string;
9+
10+
@ApiPropertyOptional({ nullable: true })
11+
name: string | null;
12+
13+
@ApiProperty({ description: 'Moodle category ID for this program' })
14+
moodleCategoryId: number;
15+
16+
static MapProgram(entity: {
17+
id: string;
18+
code: string;
19+
name?: string;
20+
moodleCategoryId: number;
21+
}): ProgramFilterOptionResponseDto {
22+
const dto = new ProgramFilterOptionResponseDto();
23+
dto.id = entity.id;
24+
dto.code = entity.code;
25+
dto.name = entity.name ?? null;
26+
dto.moodleCategoryId = entity.moodleCategoryId;
27+
return dto;
28+
}
29+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { EntityManager } from '@mikro-orm/postgresql';
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
import { Program } from 'src/entities/program.entity';
4+
import { AdminFiltersService } from './admin-filters.service';
5+
import { ProgramFilterOptionResponseDto } from '../dto/responses/program-filter-option.response.dto';
6+
7+
describe('AdminFiltersService', () => {
8+
let service: AdminFiltersService;
9+
let em: { find: jest.Mock };
10+
11+
beforeEach(async () => {
12+
em = {
13+
find: jest.fn().mockResolvedValue([]),
14+
};
15+
16+
const module: TestingModule = await Test.createTestingModule({
17+
providers: [
18+
AdminFiltersService,
19+
{ provide: EntityManager, useValue: em },
20+
],
21+
}).compile();
22+
23+
service = module.get(AdminFiltersService);
24+
});
25+
26+
describe('GetPrograms', () => {
27+
it('should map moodleCategoryId via ProgramFilterOptionResponseDto', async () => {
28+
const programEntity = {
29+
id: 'p-1',
30+
code: 'BSCS',
31+
name: 'Computer Science',
32+
moodleCategoryId: 42,
33+
};
34+
em.find.mockResolvedValue([programEntity]);
35+
36+
const result = await service.GetPrograms('d-1');
37+
38+
expect(em.find).toHaveBeenCalledWith(
39+
Program,
40+
{ department: 'd-1' },
41+
{ orderBy: { code: 'ASC' } },
42+
);
43+
expect(result).toHaveLength(1);
44+
expect(result[0].moodleCategoryId).toBe(42);
45+
expect(result[0].id).toBe('p-1');
46+
expect(result[0].code).toBe('BSCS');
47+
expect(result[0].name).toBe('Computer Science');
48+
expect(result[0]).toBeInstanceOf(ProgramFilterOptionResponseDto);
49+
});
50+
51+
it('should map name to null when entity name is undefined', async () => {
52+
const programEntity = {
53+
id: 'p-2',
54+
code: 'BSIT',
55+
moodleCategoryId: 55,
56+
};
57+
em.find.mockResolvedValue([programEntity]);
58+
59+
const result = await service.GetPrograms();
60+
61+
expect(em.find).toHaveBeenCalledWith(
62+
Program,
63+
{},
64+
{ orderBy: { code: 'ASC' } },
65+
);
66+
expect(result[0].name).toBeNull();
67+
expect(result[0].moodleCategoryId).toBe(55);
68+
});
69+
});
70+
});

src/modules/admin/services/admin-filters.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { FilterOptionResponseDto } from '../dto/responses/filter-option.response
1515
import { FilterFacultyResponseDto } from '../dto/responses/filter-faculty.response.dto';
1616
import { FilterCourseResponseDto } from '../dto/responses/filter-course.response.dto';
1717
import { FilterVersionResponseDto } from '../dto/responses/filter-version.response.dto';
18+
import { ProgramFilterOptionResponseDto } from '../dto/responses/program-filter-option.response.dto';
1819
import { SemesterFilterResponseDto } from '../dto/responses/semester-filter.response.dto';
1920

2021
@Injectable()
@@ -94,15 +95,17 @@ export class AdminFiltersService {
9495
return departments.map((d) => FilterOptionResponseDto.Map(d));
9596
}
9697

97-
async GetPrograms(departmentId?: string): Promise<FilterOptionResponseDto[]> {
98+
async GetPrograms(
99+
departmentId?: string,
100+
): Promise<ProgramFilterOptionResponseDto[]> {
98101
const filter: FilterQuery<Program> = {};
99102
if (departmentId) {
100103
filter.department = departmentId;
101104
}
102105
const programs = await this.em.find(Program, filter, {
103106
orderBy: { code: 'ASC' },
104107
});
105-
return programs.map((p) => FilterOptionResponseDto.Map(p));
108+
return programs.map((p) => ProgramFilterOptionResponseDto.MapProgram(p));
106109
}
107110

108111
GetRoles(): UserRole[] {

0 commit comments

Comments
 (0)