Skip to content

Commit 4ac6ae7

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

12 files changed

Lines changed: 1337 additions & 6 deletions

_bmad-output/implementation-artifacts/tech-spec-moodle-course-bulk-enhancement.md

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

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe('AdminFiltersController', () => {
99
let controller: AdminFiltersController;
1010
let filtersService: {
1111
GetCampuses: jest.Mock;
12+
GetSemesters: jest.Mock;
1213
GetDepartments: jest.Mock;
1314
GetPrograms: jest.Mock;
1415
GetRoles: jest.Mock;
@@ -17,6 +18,7 @@ describe('AdminFiltersController', () => {
1718
beforeEach(async () => {
1819
filtersService = {
1920
GetCampuses: jest.fn().mockResolvedValue([]),
21+
GetSemesters: jest.fn().mockResolvedValue([]),
2022
GetDepartments: jest.fn().mockResolvedValue([]),
2123
GetPrograms: jest.fn().mockResolvedValue([]),
2224
GetRoles: jest.fn().mockReturnValue(Object.values(UserRole)),
@@ -59,14 +61,29 @@ describe('AdminFiltersController', () => {
5961

6062
const result = await controller.GetDepartments({ campusId: 'c-1' });
6163

62-
expect(filtersService.GetDepartments).toHaveBeenCalledWith('c-1');
64+
expect(filtersService.GetDepartments).toHaveBeenCalledWith(
65+
'c-1',
66+
undefined,
67+
);
6368
expect(result).toEqual(departments);
6469
});
6570

6671
it('should pass undefined campusId when not provided', async () => {
6772
await controller.GetDepartments({});
6873

69-
expect(filtersService.GetDepartments).toHaveBeenCalledWith(undefined);
74+
expect(filtersService.GetDepartments).toHaveBeenCalledWith(
75+
undefined,
76+
undefined,
77+
);
78+
});
79+
80+
it('should pass semesterId to the filters service', async () => {
81+
await controller.GetDepartments({ semesterId: 's-1' });
82+
83+
expect(filtersService.GetDepartments).toHaveBeenCalledWith(
84+
undefined,
85+
's-1',
86+
);
7087
});
7188

7289
it('should delegate program listing to the filters service', async () => {

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

Lines changed: 18 additions & 1 deletion
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 { SemesterFilterResponseDto } from './dto/responses/semester-filter.response.dto';
2021

2122
@ApiTags('Admin')
2223
@Controller('admin/filters')
@@ -32,6 +33,16 @@ export class AdminFiltersController {
3233
return this.filtersService.GetCampuses();
3334
}
3435

36+
@Get('semesters')
37+
@ApiOperation({
38+
summary:
39+
'List all semesters with computed date ranges for filter dropdowns',
40+
})
41+
@ApiResponse({ status: 200, type: [SemesterFilterResponseDto] })
42+
async GetSemesters(): Promise<SemesterFilterResponseDto[]> {
43+
return this.filtersService.GetSemesters();
44+
}
45+
3546
@Get('departments')
3647
@ApiOperation({ summary: 'List departments for filter dropdowns' })
3748
@ApiQuery({
@@ -40,11 +51,17 @@ export class AdminFiltersController {
4051
type: String,
4152
description: 'Filter by campus UUID',
4253
})
54+
@ApiQuery({
55+
name: 'semesterId',
56+
required: false,
57+
type: String,
58+
description: 'Filter by semester UUID',
59+
})
4360
@ApiResponse({ status: 200, type: [FilterOptionResponseDto] })
4461
async GetDepartments(
4562
@Query() query: FilterDepartmentsQueryDto,
4663
): Promise<FilterOptionResponseDto[]> {
47-
return this.filtersService.GetDepartments(query.campusId);
64+
return this.filtersService.GetDepartments(query.campusId, query.semesterId);
4865
}
4966

5067
@Get('programs')

src/modules/admin/dto/requests/filter-departments-query.dto.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,9 @@ export class FilterDepartmentsQueryDto {
66
@IsUUID()
77
@IsOptional()
88
campusId?: string;
9+
10+
@ApiPropertyOptional({ description: 'Filter departments by semester UUID' })
11+
@IsUUID()
12+
@IsOptional()
13+
semesterId?: string;
914
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class SemesterFilterResponseDto {
4+
@ApiProperty({ description: 'Semester UUID' })
5+
id: string;
6+
7+
@ApiProperty({ description: 'Semester code', example: 'S12526' })
8+
code: string;
9+
10+
@ApiProperty({ description: 'Semester label', example: 'Semester 1' })
11+
label: string;
12+
13+
@ApiProperty({ description: 'Academic year', example: '2025-2026' })
14+
academicYear: string;
15+
16+
@ApiProperty({ description: 'Campus code', example: 'UCMN' })
17+
campusCode: string;
18+
19+
@ApiProperty({
20+
description: 'Computed start date (ISO 8601)',
21+
example: '2025-08-01',
22+
})
23+
startDate: string;
24+
25+
@ApiProperty({
26+
description: 'Computed end date (ISO 8601)',
27+
example: '2025-12-18',
28+
})
29+
endDate: string;
30+
}

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

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { FilterQuery } from '@mikro-orm/core';
22
import { EntityManager } from '@mikro-orm/postgresql';
3-
import { Injectable, NotFoundException } from '@nestjs/common';
3+
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
44
import { Campus } from 'src/entities/campus.entity';
55
import { Department } from 'src/entities/department.entity';
66
import { Enrollment } from 'src/entities/enrollment.entity';
77
import { Program } from 'src/entities/program.entity';
8+
import { Semester } from 'src/entities/semester.entity';
89
import { QuestionnaireType } from 'src/entities/questionnaire-type.entity';
910
import { QuestionnaireVersion } from 'src/entities/questionnaire-version.entity';
1011
import { User } from 'src/entities/user.entity';
@@ -14,9 +15,12 @@ import { FilterOptionResponseDto } from '../dto/responses/filter-option.response
1415
import { FilterFacultyResponseDto } from '../dto/responses/filter-faculty.response.dto';
1516
import { FilterCourseResponseDto } from '../dto/responses/filter-course.response.dto';
1617
import { FilterVersionResponseDto } from '../dto/responses/filter-version.response.dto';
18+
import { SemesterFilterResponseDto } from '../dto/responses/semester-filter.response.dto';
1719

1820
@Injectable()
1921
export class AdminFiltersService {
22+
private readonly logger = new Logger(AdminFiltersService.name);
23+
2024
constructor(private readonly em: EntityManager) {}
2125

2226
async GetCampuses(): Promise<FilterOptionResponseDto[]> {
@@ -28,9 +32,60 @@ export class AdminFiltersService {
2832
return campuses.map((c) => FilterOptionResponseDto.Map(c));
2933
}
3034

31-
async GetDepartments(campusId?: string): Promise<FilterOptionResponseDto[]> {
35+
async GetSemesters(): Promise<SemesterFilterResponseDto[]> {
36+
const semesters = await this.em.find(
37+
Semester,
38+
{},
39+
{ populate: ['campus'], orderBy: { code: 'DESC' } },
40+
);
41+
42+
const results: SemesterFilterResponseDto[] = [];
43+
44+
for (const sem of semesters) {
45+
const match = sem.code.match(/^S([12])(\d{2})(\d{2})$/);
46+
if (!match) {
47+
this.logger.warn(
48+
`Skipping semester with malformed code: "${sem.code}" (id=${sem.id})`,
49+
);
50+
continue;
51+
}
52+
53+
const semesterNum = match[1];
54+
const fullStartYear = '20' + match[2];
55+
const fullEndYear = '20' + match[3];
56+
57+
let startDate: string;
58+
let endDate: string;
59+
if (semesterNum === '1') {
60+
startDate = `${fullStartYear}-08-01`;
61+
endDate = `${fullStartYear}-12-18`;
62+
} else {
63+
startDate = `${fullEndYear}-01-20`;
64+
endDate = `${fullEndYear}-06-01`;
65+
}
66+
67+
results.push({
68+
id: sem.id,
69+
code: sem.code,
70+
label: sem.label ?? `Semester ${semesterNum}`,
71+
academicYear: sem.academicYear ?? `${fullStartYear}-${fullEndYear}`,
72+
campusCode: sem.campus.code,
73+
startDate,
74+
endDate,
75+
});
76+
}
77+
78+
return results;
79+
}
80+
81+
async GetDepartments(
82+
campusId?: string,
83+
semesterId?: string,
84+
): Promise<FilterOptionResponseDto[]> {
3285
const filter: FilterQuery<Department> = {};
33-
if (campusId) {
86+
if (semesterId) {
87+
filter.semester = semesterId;
88+
} else if (campusId) {
3489
filter.semester = { campus: campusId };
3590
}
3691
const departments = await this.em.find(Department, filter, {

src/modules/audit/audit-action.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const AuditAction = {
1515
MOODLE_PROVISION_COURSES: 'moodle.provision.courses',
1616
MOODLE_PROVISION_QUICK_COURSE: 'moodle.provision.quick-course',
1717
MOODLE_PROVISION_USERS: 'moodle.provision.users',
18+
MOODLE_BULK_PROVISION_COURSES: 'moodle.provision.bulk-courses',
1819
} as const;
1920

2021
export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];

src/modules/moodle/controllers/moodle-provisioning.controller.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import { MoodleProvisioningService } from '../services/moodle-provisioning.servi
3535
import { ProvisionCategoriesRequestDto } from '../dto/requests/provision-categories.request.dto';
3636
import { SeedCoursesContextDto } from '../dto/requests/seed-courses.request.dto';
3737
import { ExecuteCoursesRequestDto } from '../dto/requests/execute-courses.request.dto';
38+
import { BulkCoursePreviewRequestDto } from '../dto/requests/bulk-course-preview.request.dto';
39+
import { BulkCourseExecuteRequestDto } from '../dto/requests/bulk-course-execute.request.dto';
3840
import { QuickCourseRequestDto } from '../dto/requests/quick-course.request.dto';
3941
import { SeedUsersRequestDto } from '../dto/requests/seed-users.request.dto';
4042
import { ProvisionResultDto } from '../dto/responses/provision-result.response.dto';
@@ -192,6 +194,46 @@ export class MoodleProvisioningController {
192194
);
193195
}
194196

197+
@Post('courses/bulk/preview')
198+
@HttpCode(HttpStatus.OK)
199+
@UseJwtGuard(UserRole.SUPER_ADMIN)
200+
@ApiBearerAuth()
201+
@ApiOperation({ summary: 'Preview bulk course creation from JSON input' })
202+
@ApiResponse({ status: 200, type: CoursePreviewResultDto })
203+
async PreviewBulkCourses(
204+
@Body() dto: BulkCoursePreviewRequestDto,
205+
): Promise<CoursePreviewResultDto> {
206+
return await this.provisioningService.PreviewBulkCourses(dto);
207+
}
208+
209+
@Post('courses/bulk/execute')
210+
@HttpCode(HttpStatus.OK)
211+
@UseJwtGuard(UserRole.SUPER_ADMIN)
212+
@ApiBearerAuth()
213+
@Audited({
214+
action: AuditAction.MOODLE_BULK_PROVISION_COURSES,
215+
resource: 'MoodleCourse',
216+
})
217+
@UseInterceptors(
218+
MetaDataInterceptor,
219+
CurrentUserInterceptor,
220+
AuditInterceptor,
221+
)
222+
@ApiOperation({ summary: 'Execute bulk course creation in Moodle' })
223+
@ApiResponse({ status: 200, type: ProvisionResultDto })
224+
async ExecuteBulkCourses(
225+
@Body() dto: BulkCourseExecuteRequestDto,
226+
): Promise<ProvisionResultDto> {
227+
try {
228+
return await this.provisioningService.ExecuteBulkCourses(dto);
229+
} catch (e) {
230+
if (e instanceof MoodleConnectivityError) {
231+
throw new BadGatewayException('Moodle is unreachable');
232+
}
233+
throw e;
234+
}
235+
}
236+
195237
@Post('courses/quick/preview')
196238
@HttpCode(HttpStatus.OK)
197239
@UseJwtGuard(UserRole.SUPER_ADMIN)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { Type } from 'class-transformer';
3+
import {
4+
IsArray,
5+
IsDateString,
6+
IsInt,
7+
IsNotEmpty,
8+
IsString,
9+
IsUUID,
10+
Min,
11+
ArrayNotEmpty,
12+
ArrayMaxSize,
13+
ValidateNested,
14+
Validate,
15+
} from 'class-validator';
16+
import { IsBeforeEndDate } from '../validators/is-before-end-date.validator';
17+
18+
export class ConfirmedCourseEntryDto {
19+
@ApiProperty({ description: 'Course code', example: 'CS101' })
20+
@IsString()
21+
@IsNotEmpty()
22+
courseCode: string;
23+
24+
@ApiProperty({
25+
description: 'Descriptive title',
26+
example: 'Introduction to Computer Science',
27+
})
28+
@IsString()
29+
@IsNotEmpty()
30+
descriptiveTitle: string;
31+
32+
@ApiProperty({
33+
description: 'Moodle category ID from preview',
34+
example: 42,
35+
})
36+
@IsInt()
37+
@Min(1)
38+
categoryId: number;
39+
}
40+
41+
export class BulkCourseExecuteRequestDto {
42+
@ApiProperty({ description: 'Semester UUID' })
43+
@IsUUID()
44+
semesterId: string;
45+
46+
@ApiProperty({ description: 'Department UUID' })
47+
@IsUUID()
48+
departmentId: string;
49+
50+
@ApiProperty({ description: 'Program UUID' })
51+
@IsUUID()
52+
programId: string;
53+
54+
@ApiProperty({
55+
description: 'Course start date (ISO 8601)',
56+
example: '2025-08-01',
57+
})
58+
@IsDateString()
59+
@Validate(IsBeforeEndDate)
60+
startDate: string;
61+
62+
@ApiProperty({
63+
description: 'Course end date (ISO 8601)',
64+
example: '2025-12-18',
65+
})
66+
@IsDateString()
67+
endDate: string;
68+
69+
@ApiProperty({
70+
type: [ConfirmedCourseEntryDto],
71+
description: 'Confirmed courses to create',
72+
})
73+
@IsArray()
74+
@ArrayNotEmpty()
75+
@ArrayMaxSize(500)
76+
@ValidateNested({ each: true })
77+
@Type(() => ConfirmedCourseEntryDto)
78+
courses: ConfirmedCourseEntryDto[];
79+
}

0 commit comments

Comments
 (0)